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

195 lines
10 KiB
Markdown

# 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:
```json
{
"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 **dropped** — `Stop` + `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:
```json
{
"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 pane** — *not* 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`:
```json
{
"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)
```bash
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:
```bash
# 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 |