subagent handling + 1m context resume
This commit is contained in:
156
CLAUDE.md
156
CLAUDE.md
@@ -36,7 +36,12 @@ src/proxy.rs axum fallback handler: buffers request body (for session metadata
|
||||
response back unbuffered, tees SSE
|
||||
src/sse.rs incremental SSE parser; tolerant of chunk splits mid-event/mid-UTF-8
|
||||
src/app.rs Arc<Mutex<App>> shared state; Tap = one in-flight tapped request,
|
||||
translates SSE events → session Entries (Drop closes it out)
|
||||
translates SSE events → session Entries (Drop closes it out).
|
||||
Each Tap belongs to a *lane* (`Lane`/`LaneId`): lane 0 is the
|
||||
main chain, every subagent gets its own. Entries stay in one
|
||||
append-only Vec tagged with `Entry::lane`; per-agent state
|
||||
(model, tokens, tool count, system/tools signatures, label,
|
||||
parent, finished) lives on `Lane`
|
||||
src/ui.rs ratatui rendering @ ~30fps; session list + scrollable feed
|
||||
(FeedCache: per-entry rendered lines + wrapped heights, only
|
||||
changed entries re-render; the viewport window of lines is
|
||||
@@ -58,6 +63,16 @@ src/ui.rs ratatui rendering @ ~30fps; session list + scrollable feed
|
||||
minimap: `*` markers show where each user message sits in the
|
||||
whole conversation, with the scroll thumb drawn on top where they
|
||||
coincide.
|
||||
Subagents never touch this feed: it renders lane
|
||||
`MAIN_LANE` only, at full width, whatever the agents are doing.
|
||||
They live in the `A` popup (`popup_rect` = 80% of the *feed*
|
||||
rect, centred): `draw_agent_list` is the picker,
|
||||
`draw_feed` the chosen agent's own stream — same function as the
|
||||
main feed, own FeedCache from the `FeedCaches` pool, own
|
||||
scroll/follow from `App::lane_cols`, so it follows its own tail
|
||||
and the border title carries the identity (`⟳ Explore · find the
|
||||
retry helper · sonnet · out 2.1k · 2/3`). See the
|
||||
subagent-popup invariant.
|
||||
Sessions panel is a uniform 50% of the main area: each session
|
||||
is a multi-line item — full white title (live = first user
|
||||
prompt via `live_title`, stub = disk label, word-wrapped by
|
||||
@@ -67,7 +82,8 @@ src/ui.rs ratatui rendering @ ~30fps; session list + scrollable feed
|
||||
src/markdown.rs wraps tui-markdown: renders GFM tables itself (box-drawing,
|
||||
width-fitted wrapped columns) and strips heading `#` markers —
|
||||
the pinned tui-markdown 0.3.5 does neither
|
||||
src/sessions.rs on-disk session history: background scanner thread keeps
|
||||
src/sessions.rs on-disk session history (main chain *and* subagents):
|
||||
background scanner thread keeps
|
||||
App::disk_sessions fresh (~1/s poll, `read_meta` re-read only on
|
||||
mtime change — one pass yields the label *and* the session's
|
||||
last main-chain model, which `App::resume_model` turns into the
|
||||
@@ -75,14 +91,21 @@ src/sessions.rs on-disk session history: background scanner thread keeps
|
||||
from a JSONL transcript (lazily, on first view); build_tree
|
||||
parses uuid/parentUuid chains into a TurnTree (one node per
|
||||
real user prompt; rewinds leave fork points); materialize
|
||||
writes a new session file from a chosen set of turns
|
||||
writes a new session file from a chosen set of turns.
|
||||
`scan_agents` reads the `<session>/subagents/agent-*.meta.json`
|
||||
sidecars (cheap: the transcripts themselves can be MBs) and
|
||||
`splice_agents` inserts each agent's entries into its own lane
|
||||
right after the `Agent` tool call that spawned it
|
||||
src/term.rs embedded claude pane: spawns `claude --session-id <uuid>` in a
|
||||
portable-pty routed through the proxy; wezterm-term models the
|
||||
screen (and answers terminal queries); renderer paints cells
|
||||
into the ratatui buffer. Each spawn injects a fresh per-pane
|
||||
token via `ANTHROPIC_CUSTOM_HEADERS` (`PANE_TOKEN_HEADER` =
|
||||
`x-claude-cloak-pane`), the correlation handle the proxy uses to
|
||||
recognise the pane's own traffic (see the embed-identity invariant)
|
||||
recognise the pane's own traffic (see the embed-identity invariant).
|
||||
`cc_default_model` reads Claude Code's *own* configured default
|
||||
model out of its settings — the only persisted record of a
|
||||
`[1m]` pick (see the 1M-context invariant)
|
||||
```
|
||||
|
||||
Data flow: proxy task parses SSE chunks → `Tap::handle()` mutates shared state →
|
||||
@@ -106,6 +129,80 @@ UI thread redraws on its own tick (no channel; just the mutex).
|
||||
builds `user_…_session_<uuid>`; `proxy::session_key` handles both.
|
||||
Concurrent requests (subagents) share a session but each `Tap` tracks its own
|
||||
current entry index — entries/sessions are append-only, so indices stay stable.
|
||||
`session_key` tolerates whitespace around the JSON colon (a pretty-printed
|
||||
blob used to fall through to the legacy `session_` split and yield `id": "…`).
|
||||
- **Subagent identity comes from Claude Code's own header, never a heuristic.**
|
||||
A subagent's request reports the *parent's* `session_id` and no agent id in
|
||||
`metadata`, but Claude Code stamps `x-claude-code-agent-id` (and, from spawn
|
||||
depth 2, `x-claude-code-parent-agent-id`) on every one of them. That id is
|
||||
unique per agent — **including byte-identical sibling prompts**, which do
|
||||
occur and which a prompt hash cannot separate — stable across the agent's
|
||||
inner-loop turns, and equal to the `agentId` of its on-disk
|
||||
`subagents/agent-<id>.jsonl`. `proxy.rs` reads both headers (`AGENT_ID_HEADER`
|
||||
/ `PARENT_AGENT_ID_HEADER`) and **forwards them untouched** — they are Claude
|
||||
Code's, not ours; only `x-claude-cloak-pane` is ours to consume.
|
||||
`Session::lane_for` maps the id to a lane (appended on first sight, so a
|
||||
`LaneId` stays valid forever).
|
||||
Labels come from a *separate*, later fact: `Session::label_lane_from_prompt`
|
||||
matches the subagent's opening prompt against an unclaimed `Agent` tool call's
|
||||
`prompt` (byte-identical on the wire) to learn subagent_type/description/
|
||||
parent, and `close_lane_from_result` scrapes `agentId: <hex>` out of the
|
||||
`Agent` tool_result to tie the lane to that call and mark it finished. A lane
|
||||
must never wait for either: a synchronous agent's result only lands when it
|
||||
has already finished, and the child's first request can beat the parent's next
|
||||
one, so lanes are born anonymous and adopted later.
|
||||
- **One entry vec, tagged with lanes.** Per-lane vecs would double every index
|
||||
site (`Tap::cur`), break `FeedCache`'s positional alignment with
|
||||
`Session::entries`, turn the viewport window into a k-way merge inside the
|
||||
mutex the tap shares, and lose global wire order. Lane membership is a field;
|
||||
showing one lane is a filter (`e.lane == args.lane`), which is also why there
|
||||
is no "show everything interleaved" mode.
|
||||
- **"The agent finished" is a `<task-notification>`, not the tool_result.**
|
||||
Claude Code launches *every* `Agent` call asynchronously: the tool_result
|
||||
comes back immediately and says so (`Async agent launched successfully… \
|
||||
agentId: <hex>`), and the real completion is injected into the parent's next
|
||||
user turn as `<task-notification>` … `<task-id><agent id></task-id>`. So
|
||||
`close_lane_from_result` only ties the lane to its tool call (and finishes it
|
||||
in the non-async wording, kept for older builds), while
|
||||
`Session::finish_lanes_from_notifications` — called from
|
||||
`record_user_prompt` on the trailing user run, before its early returns — is
|
||||
what stamps `Lane::finished_at`. Background *bash* tasks share the
|
||||
notification shape with a short id that matches no lane. Reading `finished`
|
||||
off the tool_result alone is why a finished agent used to keep reading as
|
||||
running.
|
||||
- **Subagents live in a popup; they never share the feed.** The main feed
|
||||
always renders `MAIN_LANE` at full width, so how many agents run changes
|
||||
nothing about reading the main chain — no split, no rows, no reserved space,
|
||||
no interleaved entries (`draw_feed` filters `e.lane == args.lane`). `A`
|
||||
(`App::toggle_agent_popup` → `App::agent_popup`) opens the one place they are
|
||||
shown: `AgentPopup::List` picks an agent, `AgentPopup::Feed` gives one agent
|
||||
the whole popup (80% of the feed rect, `ui::popup_rect`). Opening takes the
|
||||
shortest path — a lone agent goes straight to its stream, several land on the
|
||||
picker with the first *running* one preselected — and `A` closes whatever is
|
||||
open. The popup is **modal**: while it is up it takes every key (and the
|
||||
wheel), which is why it needs no focus/column model at all. Esc unwinds one
|
||||
layer (feed → picker → closed), `[`/`]` step between agents from inside a
|
||||
feed. `App::agent_list_of` orders it: running first (`Lane::running`), then
|
||||
idle/finished, each group in spawn order — but **every** lane is listed,
|
||||
disk lanes included, because this popup is the only way to read a finished
|
||||
agent's output. State is session-local: `draw` clears `agent_popup` and
|
||||
`lane_cols` when the displayed session changes, and `validate_agent_popup`
|
||||
drops a popup whose lane the displayed session doesn't have (a rebuilt
|
||||
on-disk view), so the render path never sees a dangling `LaneId`.
|
||||
- **`Lane::running` is a sort key, never a gate**: streaming (`active > 0`), or
|
||||
no finish signal and quiet for less than `LANE_IDLE_MAX` (60s); a
|
||||
`finished_at` (the `<task-notification>`) or no traffic at all (a lane read
|
||||
from disk) means not running. The long idle net matters because a gap between
|
||||
an agent's turns (a slow local tool call) looks exactly like "done"; only the
|
||||
notification distinguishes them. Being wrong therefore costs an ordering and
|
||||
a `⟳`/`·` mark — never a hidden stream, which is what the old row-collapse
|
||||
timers could do.
|
||||
- **Only the main lane drives the pane and the session header.** `embed_grow`,
|
||||
the ctrl-l wipe scheduled in `Tap::drop`, the prompt minimap, `n`/`N` and
|
||||
`Session::model`/context are gated on `MAIN_LANE`; `last_system_len` and
|
||||
`last_tools_sig` live per lane (a subagent's system prompt and restricted tool
|
||||
set differ, so session-wide state re-emitted both lines on every
|
||||
main↔subagent alternation), and the prompt dedup is scoped to the lane.
|
||||
- **Embed identity is learned from traffic, never assumed from `--session-id`.**
|
||||
Claude Code's interactive `--session-id` is *not* guaranteed to equal the id
|
||||
it reports in request metadata (and a `--resume` can mint a fresh one), so the
|
||||
@@ -153,11 +250,25 @@ UI thread redraws on its own tick (no channel; just the mutex).
|
||||
(`sonnet`, `opus`, … from `App::model_choices`) wins over the dated snapshot
|
||||
id, so a retired snapshot can't pin the pane; an id with no alias inside is
|
||||
passed through verbatim (`--model` takes full names too). The transcript is
|
||||
authoritative, so a mid-session `/model` switch is honoured. `[1m]`
|
||||
(1M-context) picks are the one thing it cannot see — the transcript records
|
||||
the same base id either way — so `Session::spawn_model` remembers the exact
|
||||
argument the pane was spawned with in-process and wins **only** while it
|
||||
still names the model the transcript reports.
|
||||
authoritative, so a mid-session `/model` switch is honoured.
|
||||
- **The 1M context window is a header, and a resume must keep it.**
|
||||
`--model opus[1m]` differs from `opus` only by `anthropic-beta:
|
||||
…,context-1m-…` — same body `model`, same transcript record — so no amount of
|
||||
transcript reading can tell them apart. The proxy is the only place that
|
||||
sees it: `proxy.rs` reads `BETA_HEADER` on **main-chain turn requests only**
|
||||
(a side/title call runs haiku without the flag, a subagent runs its own
|
||||
model) and `app::record_long_context` stores it as `Session::long_context`.
|
||||
`App::resume_arg` then picks the window: the wire observation wins, else the
|
||||
`[1m]` in `Session::spawn_model` (our own spawn, while it still names the
|
||||
same model), and with **neither** — a session that predates this process —
|
||||
it falls back to `term::cc_default_model()`, Claude Code's configured default
|
||||
(`ANTHROPIC_MODEL`, then local/project/user `settings.json`), which is the
|
||||
one place a `[1m]` pick is persisted (`/model` writes it there). When that
|
||||
default names the same base model the resume passes **no `--model` at all**
|
||||
and inherits it whole, window included; any explicit knowledge overrides it,
|
||||
including "this session ran the *short* window", which is why an observed
|
||||
non-1m session is resumed with an explicit `--model opus`. The suffix is only
|
||||
ever added for an alias that `model_choices` says has a `[1m]` variant.
|
||||
- **Turn tree / branching** (lazygit/yazi-style, all in the sessions panel):
|
||||
`space` (or `→`/`l`) expands the selected session's turn tree — one row per
|
||||
real user prompt, abandoned rewind branches indented `⑂` under their fork
|
||||
@@ -283,6 +394,14 @@ UI thread redraws on its own tick (no channel; just the mutex).
|
||||
quit). n/N jump the feed scroll to the next/previous user prompt
|
||||
(`App::prompt_jump`, applied in `draw` where entry heights are cached). The
|
||||
feed scrolls only via wheel / PgUp / PgDn / g / G / n / N.
|
||||
`A` toggles the subagent popup (the footer leads with `A agents (N)` when
|
||||
the displayed session has any — it is the only route to them). Inside it:
|
||||
j/k move the picker or scroll the agent feed one line, enter/→ opens the
|
||||
highlighted agent, `[`/`]` step to the previous/next agent, PgUp/PgDn/g/G
|
||||
scroll, Esc goes feed → picker → closed, `A`/`q` closes outright. Being modal
|
||||
it also owns the wheel (`ui::wheel`), so no pointer hit-testing is involved.
|
||||
Switching the displayed session clears `App::lane_cols` and closes the popup,
|
||||
so a lane id can't inherit another session's scroll offset.
|
||||
`CT_DEBUG_KEYS=1` shows raw key events in the status bar.
|
||||
- Mouse is captured: wheel always scrolls the feed (regardless of focus), and
|
||||
left-drag selects screen text, copied on release via OSC 52 (like Claude
|
||||
@@ -321,7 +440,8 @@ UI thread redraws on its own tick (no channel; just the mutex).
|
||||
fully offline end-to-end tests with zero API usage. That fake server is
|
||||
`dev/fake_upstream.py`: it answers every request with canned SSE, so a **real
|
||||
`claude` child** can be made to render its client-side tool UIs on demand
|
||||
(`dev/.fake_scenario` = `ask | plan | todo | taskupdate | text`, switchable
|
||||
(`dev/.fake_scenario` = `ask | plan | todo | taskupdate | agent | text`,
|
||||
switchable
|
||||
mid-run) — this is how the pane's frame detector is developed against what
|
||||
Ink actually draws. Drive it through tmux (`.claude/skills/tui-verify`) and
|
||||
obey that skill's safety rule: **never `pkill`/`killall`**, tear down only
|
||||
@@ -331,6 +451,22 @@ UI thread redraws on its own tick (no channel; just the mutex).
|
||||
## Not yet handled (known MVP limits)
|
||||
|
||||
- Non-streaming requests pass through untapped (e.g. `count_tokens`).
|
||||
- Subagent popup: no per-lane prompt minimap (a subagent has no user prompts),
|
||||
and lanes loaded from disk have no token counts (a transcript records no
|
||||
usage) — the picker shows tool counts too. Only one agent is readable at a
|
||||
time (a modal popup, by design: the alternative was the split feed this
|
||||
replaced). A lane is never closed, only
|
||||
marked `finished`: a background agent (`x-app: cli-bg`) can wake up again
|
||||
long after its launch result landed, and `SendMessage` can revive a finished
|
||||
one. An agent transcript over `MAX_AGENT_BYTES` (8 MB) is summarised instead
|
||||
of parsed, because the view is built while the app mutex is held.
|
||||
- Materialized branch files carry no subagent transcripts: the `Agent`
|
||||
tool_results in them still hold the reports the parent model saw, and copying
|
||||
`subagents/` would duplicate `agentId`s across two sessions and contradict
|
||||
the agent files' own `sessionId`. Deliberate — don't "fix" it by copying.
|
||||
- A subagent's *first* turn is what labels its lane, so if we attach mid-run
|
||||
(the parent's `Agent` call never passed through us) it stays listed as
|
||||
`agent <id-prefix>` until its result lands.
|
||||
- Sessions are never pruned (entry memory grows for the process lifetime);
|
||||
the same goes for viewed disk transcripts (`App::history`).
|
||||
- Request bodies are fully buffered (up to 512 MB) before forwarding — needed
|
||||
|
||||
Reference in New Issue
Block a user