trail-boss/docs/research/claude-code-mechanics.md
jedarden c89b315aa7 feat(trail-boss): phase 1-2 - PermissionRequest probe and emitter
Phase 1: Probed PermissionRequest hook, confirmed firing + payload shape.
Documented in docs/research/claude-code-mechanics.md.

Phase 2: Built trailboss-emit.sh that forwards hook payloads to collector
and injects $TMUX_PANE. Wired all hooks in .claude/settings.json.

Verified: stub collector logs POSTs with session_id, cwd, and pane_id.
Demonstrated bare curl drops $TMUX_PANE, proving wrapper required.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 22:04:03 -04:00

10 KiB

Claude Code mechanics: detect, correlate, deliver

The three primitives Trail Boss stands on, with what's confirmed and what's uncertain. Anything marked (verify) should be probed empirically before depending on it — Claude Code's hook surface evolves and some semantics are undocumented.


1. DETECT — which hook says a session is blocked

Hooks live in ~/.claude/settings.json (user-global) or .claude/settings.json (per-project). Each command hook receives the event JSON on stdin. Every hook routes through the same trailboss-emit.sh — a thin wrapper that forwards stdin and injects $TMUX_PANE from the environment (the pane is not in the payload; see §2). A bare curl of stdin alone is not enough, because it would drop the env-only pane mapping emitter test. SessionStart is not special — it uses the same emitter; the registry self-heals on every event:

{
  "hooks": {
    "PermissionRequest": [ { "hooks": [ { "type": "command", "command": "~/.claude/trailboss-emit.sh" } ] } ],
    "Stop":              [ { "hooks": [ { "type": "command", "command": "~/.claude/trailboss-emit.sh" } ] } ],
    "UserPromptSubmit":  [ { "hooks": [ { "type": "command", "command": "~/.claude/trailboss-emit.sh" } ] } ],
    "SessionStart":      [ { "hooks": [ { "type": "command", "command": "~/.claude/trailboss-emit.sh" } ] } ],
    "SessionEnd":        [ { "hooks": [ { "type": "command", "command": "~/.claude/trailboss-emit.sh" } ] } ]
  }
}

trailboss-emit.sh is essentially: curl -s -X POST http://localhost:4000/event --data-binary @- -H "X-Tmux-Pane: $TMUX_PANE" (or it merges $TMUX_PANE into the JSON body before POSTing).

Events relevant to "needs a human":

Event Meaning for the queue Confidence
Stop Turn finished; session waiting for the next instruction. Enqueue a stuck item. Confirmed firing (probe)
PermissionRequest Session blocked mid-turn on approval. Emits no Stop, so it is the only signal for the permission case. Enqueue a stuck item. Confirmed firing + payload (probe 2026-05-25)
SubagentStop A subagent finished — not a human-input point; ignored. Exists; not probed (ignored regardless)
UserPromptSubmit Human submitted input → block resolved → dequeue. Confirmed
SessionStart Register the session; capture $TMUX_PANE. Confirmed firing (probe)
SessionEnd Retire the session. Exists; firing not yet probed
PreToolUse / PostToolUse Activity telemetry (the "running" state), not blocks. Confirmed

Detection model (settled): the two enqueue triggers are Stop (turn finished, waiting) and PermissionRequest (blocked mid-turn). Both are required — a permission-blocked session is mid-turn and emits no Stop, so without PermissionRequest it would never be detected. They are treated identically (a flat stuck item; reason is display-only, never a priority). Notification was evaluated and droppedStop + PermissionRequest cover every stuck case. See ../plan/plan.md ("Detection model" and the resolved-questions list).


2. CORRELATE — tie an event to a session, repo, and pane

Every hook payload includes stable identifiers:

{
  "session_id":      "abc-123-uuid",
  "transcript_path": "~/.claude/projects/<project-slug>/<session-id>.jsonl",
  "cwd":             "/path/to/repo",
  "hook_event_name": "PermissionRequest",
  "permission_mode": "default|plan|acceptEdits|bypassPermissions"
}
  • permission_mode — captured as-is for display only; Trail Boss does not branch on it (the plan value can appear in the payload even though vanilla plan mode is a non-goal).
  • session_id — primary key; stable across resume/fork.
  • cwd — derive the project/repo label.
  • transcript_path — the JSONL to tail for context.
  • tmux panenot in the payload, but available in the hook's environment as $TMUX_PANE (confirmed by probe 2026-05-25, in both interactive and -p modes). Capture it on every emit and POST it alongside session_id, so the session_id → pane registry self-heals across resume / pane reuse / window moves. Pane ids (%446) are tmux-server-global and addressable by any tmux command from outside tmux.

Confirmed environment available to hook commands (probe 2026-05-25): TMUX_PANE, TMUX, CLAUDE_CODE_SESSION_ID, CLAUDE_PROJECT_DIR, CLAUDE_CODE_ENTRYPOINT (cli interactive vs sdk-cli for -p), CLAUDECODE=1, TERM_PROGRAM=tmux, CLAUDE_ENV_FILE (per-session state dir). So identity is available both as env vars and in the payload.


3. CONTEXT — reconstruct what the session is asking

From the Stop payload directly (primary, no transcript needed)

Confirmed (probe 2026-05-25): the Stop payload includes last_assistant_message — what the agent just said — so the queue can render context straight from the hook. It also carries permission_mode, effort, stop_hook_active, background_tasks, session_crons. This makes transcript tailing an enhancement, not a requirement, for the stopped case.

From the PermissionRequest payload directly (primary for permission blocks)

Confirmed (probe 2026-05-25): the PermissionRequest payload includes the proposed tool operation in tool_name + tool_input:

{
  "session_id": "3247c672-a84c-4907-87e6-a7997ea2a0e3",
  "transcript_path": "/home/coding/.claude/projects/-home-coding-scratch-trail-boss-probe/3247c672-a84c-4907-87e6-a7997ea2a0e3.jsonl",
  "cwd": "/home/coding/scratch/trail-boss-probe",
  "permission_mode": "default",
  "effort": {"level": "high"},
  "hook_event_name": "PermissionRequest",
  "tool_name": "Edit",
  "tool_input": {
    "file_path": "/home/coding/scratch/trail-boss-probe/test-file.txt",
    "old_string": "This is a test file for probing PermissionRequest.",
    "new_string": "This is a test file for probing PermissionRequest.\nPermission probe test",
    "replace_all": false
  },
  "permission_suggestions": [
    {"type": "setMode", "mode": "acceptEdits", "destination": "session"}
  ]
}

The tool_name indicates the tool type (Edit, Bash, Write, etc.) and tool_input carries the full parameters — exactly what the queue needs to display "what the session is asking." permission_suggestions is present but not needed for Trail Boss (display-only, no branch logic).

Transcript JSONL (deeper context)

transcript_path is an append-only JSONL — one object per line, tailable in real time. The last assistant / tool-use entry holds the permission request or the question body in full. The collector tails from the last seen offset and extracts the trailing decision context. It is also the ground truth for the reconcile loop (see the plan): if the transcript has advanced past the last Stop, the session progressed → dequeue.

tmux capture-pane (fallback / literal view)

tmux capture-pane -t <pane> -p        # plain text of the visible pane

Use when the on-screen menu is needed verbatim, or when the transcript write lags the rendered prompt. Cheap; poll on demand.


4. DELIVER — route the operator to the live session (navigation, not relay)

Trail Boss does not inject answers. It navigates the operator to the live pane, where they interact with the real prompt directly (see ../plan/plan.md, "Navigator, not relay" and the delivery decision in ../notes/decisions.md). The relevant primitives, all tmux-server-global so they work from outside tmux:

# bring the operator's client to the stuck pane (pane ids like %446 are global)
tmux switch-client -t "$(tmux display -p -t %446 '#{session_name}')"
tmux select-window -t %446
tmux select-pane   -t %446
# optional co-display: link the target window into a Trail Boss view, then unlink
tmux link-window -s <src-session>:<window> -t trailboss: ; tmux unlink-window -t trailboss:<n>
  • Primary delivery is navigation — the operator types into the genuine CLI, so there is no keystroke-fidelity problem and "edit before allow" is native.
  • Secondary (optional): tmux send-keys -t %446 -l '<text>' then send-keys -t %446 Enter for plain-text submission (basic submission confirmed in the probe). Not the primary path; the daemon never sends synthesized input — only human-authored text, and only if this path is enabled.

Rejected delivery alternatives

  • Resume-to-deliver (claude --resume <id> in a second process) does not reach the original live pane — a live interactive CLI holds in-memory state and does not re-read its transcript; concurrent attach risks divergence. --fork-session confirms --resume reuses the session. Only viable in a no-resident-process model, which is rejected for v1.
  • Agent SDK canUseTool + streaming input would allow programmatic permission gating with updatedInput, but requires running sessions under the SDK instead of the terminal — deferred; the tmux-navigator model fits the existing workflow.
  • claude --remote-control routes to the claude.ai / desktop / mobile surface, not a local channel — useless for a same-host tool.
  • Headless claude -p is one-shot and cannot stream input into a running session.

Summary

Need Primitive Identifier / flag Confidence
Detect waiting for next instruction Stop hook stdin JSON confirmed firing (probe)
Detect permission block PermissionRequest hook stdin JSON confirmed firing + payload (probe 2026-05-25)
Detect resolved block UserPromptSubmit hook session_id confirmed
Correlate to session/repo any hook payload + env session_id, cwd, transcript_path, CLAUDE_CODE_SESSION_ID confirmed (probe)
Correlate to tmux pane any emit hook $TMUX_PANE (in hook env) confirmed (probe)
Read the question Stop payload last_assistant_message / transcript / capture-pane payload / transcript_path / pane confirmed (probe)
Deliver (primary) tmux navigation (switch-client/select-window/select-pane) pane id confirmed primitives
Deliver (secondary, optional) tmux send-keys -t <pane> (human-authored text only) pane id basic submission confirmed
Rejected: resume / SDK / remote-control / -p see "Rejected" above