subagent handling + 1m context resume

This commit is contained in:
Jonas H
2026-08-26 11:17:43 +02:00
parent a7d7940be3
commit c7eddf3bde
7 changed files with 2824 additions and 457 deletions

156
CLAUDE.md
View File

@@ -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

View File

@@ -7,7 +7,8 @@ lets us make Claude Code render its client-side tool UIs (AskUserQuestion,
ExitPlanMode, TodoWrite/Task*) on demand, so the pane's frame detector in
`src/term.rs` can be developed against what Ink actually draws.
Scenario is picked per turn from `CT_FAKE_SCENARIO` (ask | plan | todo | text).
Scenario is picked per turn from `CT_FAKE_SCENARIO`
(ask | plan | todo | taskupdate | agent | text).
Each incoming request is logged to `dev/fake_upstream.log` (declared tool names
+ the trailing user text) so we can see what CC sends.
"""
@@ -136,6 +137,19 @@ TASK_INPUTS = [
]
# Two parallel subagents with the *same* type and byte-identical prompts: the
# worst case for correlation (only Claude Code's `x-claude-code-agent-id` tells
# them apart), and what makes the subagent rows worth having.
AGENT_INPUTS = [
{"subagent_type": "Explore", "description": "find the retry helper",
"prompt": "Locate the retry helper and report back.", "run_in_background": False},
{"subagent_type": "Explore", "description": "find the retry helper",
"prompt": "Locate the retry helper and report back.", "run_in_background": False},
{"subagent_type": "oracle", "description": "review the lane design",
"prompt": "Review the lane design and report back.", "run_in_background": True},
]
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
@@ -161,7 +175,12 @@ class Handler(BaseHTTPRequestHandler):
has_result = bool(blocks) and all(
b.get("type") == "tool_result" for b in blocks if isinstance(b, dict)
) and "toolu_fake" in json.dumps(blocks)
log(f"[{time.strftime('%H:%M:%S')}] {self.path} tools={tools} tail={tail}")
# `model` + `anthropic-beta` show how a `--model opus[1m]` pick reaches
# the wire (the 1M context window is a beta header, not a model id).
log(
f"[{time.strftime('%H:%M:%S')}] {self.path} model={body.get('model')} "
f"betas={self.headers.get('anthropic-beta', '')} tools={tools} tail={tail}"
)
if "count_tokens" in self.path:
out = json.dumps({"input_tokens": 100}).encode()
@@ -190,6 +209,12 @@ class Handler(BaseHTTPRequestHandler):
gen = stream_tools(
[("TaskCreate", t) for t in TASK_INPUTS], "Setting up the task list."
)
elif scenario == "agent":
# Spawn subagents. A real child then issues their requests itself,
# each carrying `x-claude-code-agent-id`.
gen = stream_tools(
[("Agent", a) for a in AGENT_INPUTS], "Delegating this."
)
elif scenario == "taskupdate":
gen = stream_tool("TaskUpdate", {"taskId": "1", "status": "in_progress"},
"Starting the first task.")

1582
src/app.rs

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,36 @@
use crate::app::{attach_tool_results, lock_app, record_user_prompt, SharedApp, Tap};
use crate::app::{
AgentTag, SharedApp, Tap, attach_tool_results, lock_app, record_long_context,
record_user_prompt,
};
use crate::sse::SseParser;
use axum::Router;
use axum::body::Body;
use axum::extract::{Request, State};
use axum::http::Method;
use axum::response::Response;
use axum::Router;
use futures_util::StreamExt;
use serde_json::Value;
const UPSTREAM: &str = "https://api.anthropic.com";
/// Claude Code stamps a subagent's requests with the agent's own id (and its
/// parent's, from spawn depth 2). These are *its* headers, so they are read and
/// forwarded untouched — only `x-claude-cloak-pane` is ours to consume. This is
/// the exact correlation that separates a subagent's stream from the main
/// chain: unique per agent even for byte-identical sibling prompts, stable
/// across the agent's inner turns, and equal to the `agentId` in its on-disk
/// `subagents/agent-<id>.jsonl`.
pub const AGENT_ID_HEADER: &str = "x-claude-code-agent-id";
pub const PARENT_AGENT_ID_HEADER: &str = "x-claude-code-parent-agent-id";
/// Claude Code asks for the **1M-context window** with a beta flag, not a
/// different model: `--model opus[1m]` sends `anthropic-beta: …,context-1m-…`
/// while plain `opus` does not, and both report the same `model` in the body
/// (and in the transcript). So this header is the only place the window is
/// observable — `App::resume_model` needs it to resume a session the way it ran.
pub const BETA_HEADER: &str = "anthropic-beta";
pub const LONG_CONTEXT_BETA: &str = "context-1m";
/// Upstream base URL; `CT_UPSTREAM` overrides for offline testing against a
/// fake server (the relay itself is identical either way).
fn upstream() -> String {
@@ -20,8 +41,17 @@ fn upstream() -> String {
/// sends a JSON-ish blob containing `"session_id":"<uuid>"`; older builds
/// used `user_<hash>_account_<uuid>_session_<uuid>`.
fn session_key(u: &str) -> Option<&str> {
if let Some(rest) = u.split(r#"session_id":""#).nth(1) {
return rest.split('"').next().filter(|s| !s.is_empty());
// Tolerate whitespace around the colon: Claude Code sends compact JSON,
// but a pretty-printed blob would otherwise fall through to the legacy
// `session_` split and yield a garbage key (`id": "…`).
if let Some(rest) = u.split(r#"session_id""#).nth(1) {
let rest = rest.trim_start();
if let Some(rest) = rest.strip_prefix(':') {
let rest = rest.trim_start();
if let Some(rest) = rest.strip_prefix('"') {
return rest.split('"').next().filter(|s| !s.is_empty());
}
}
}
// `.filter`: a trailing "session_" would otherwise yield Some("") — an
// empty key that every malformed user_id would then collide on.
@@ -98,12 +128,36 @@ async fn forward_inner(ctx: Ctx, req: Request) -> anyhow::Result<Response> {
.get(crate::term::PANE_TOKEN_HEADER)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let header = |name: &str| {
parts
.headers
.get(name)
.and_then(|v| v.to_str().ok())
.filter(|v| !v.is_empty())
.map(str::to_string)
};
let agent = AgentTag {
id: header(AGENT_ID_HEADER),
parent: header(PARENT_AGENT_ID_HEADER),
};
// Tool results ride along in the request body; surface them
// on the tool entries from the previous turn.
attach_tool_results(&ctx.app, &key, &v);
tap = Some(Tap::new(ctx.app.clone(), key.clone(), model, pane_token));
// After Tap::new: the session must exist for the entry to land.
record_user_prompt(&ctx.app, &key, &v);
let t = Tap::new(ctx.app.clone(), key.clone(), model, pane_token, &agent);
// After Tap::new: the session (and the lane) must exist for the entry
// to land.
record_user_prompt(&ctx.app, &key, t.lane(), &v);
// Context window of the *main chain*, from this request's betas. Only a
// turn-starting main-chain request counts: a side/title call runs haiku
// without the flag and a subagent runs its own model, so either would
// report a window that is not the session's.
if t.lane() == crate::app::MAIN_LANE
&& v.get("tools").and_then(Value::as_array).is_some_and(|t| !t.is_empty())
{
let long = header(BETA_HEADER).is_some_and(|b| b.contains(LONG_CONTEXT_BETA));
record_long_context(&ctx.app, &key, long);
}
tap = Some(t);
}
let mut rb = ctx.client.request(parts.method.clone(), &url);
@@ -196,5 +250,10 @@ mod tests {
// Degenerate inputs must not produce an empty (colliding) key.
assert_eq!(session_key("user_x_session_"), None);
assert_eq!(session_key(r#"{"session_id":""}"#), None);
// Pretty-printed metadata must not fall through to the legacy split.
assert_eq!(
session_key(r#"{"session_id": "29bd3436-aaaa-bbbb-cccc-111122223333"}"#),
Some("29bd3436-aaaa-bbbb-cccc-111122223333")
);
}
}

View File

@@ -10,8 +10,8 @@
//! spaces … all become `-`) — see `encode_cwd`.
use crate::app::{
flatten_result_content, lock_app, strip_injected, Entry, Kind, Session, SharedApp,
ToolResult,
Entry, Kind, LaneId, MAIN_LANE, Session, SharedApp, ToolResult, flatten_result_content,
lock_app, strip_injected,
};
use serde_json::Value;
use std::collections::HashMap;
@@ -22,6 +22,11 @@ use std::time::SystemTime;
#[derive(Clone, PartialEq)]
pub struct DiskSession {
pub uuid: String,
/// Subagent transcripts recorded alongside this session, shown as `⑂N` in
/// the list. Counted in the same cached pass as the label (a subagent run
/// always writes the parent's `Agent` records too, so the parent's mtime
/// moves whenever this can change).
pub agents: usize,
/// Best human-readable label: ai-title > last-prompt text > uuid prefix.
pub label: String,
/// API model id of the session's last main-chain assistant message
@@ -38,9 +43,9 @@ pub struct DiskSession {
/// only taken when the list actually changed.
pub fn spawn_scanner(app: SharedApp) {
std::thread::spawn(move || {
// uuid → (mtime when read, label, model): skip re-parsing unchanged
// files (one pass yields both — see `read_meta`).
let mut meta: HashMap<String, (SystemTime, String, String)> = HashMap::new();
// uuid → (mtime when read, label, model, subagent count): skip
// re-parsing unchanged files (one pass yields all — see `read_meta`).
let mut meta: HashMap<String, (SystemTime, String, String, usize)> = HashMap::new();
let mut last: Vec<DiskSession> = Vec::new();
loop {
let list = scan(&mut meta).unwrap_or_default();
@@ -55,7 +60,7 @@ pub fn spawn_scanner(app: SharedApp) {
/// One scan of the project directory, newest first.
fn scan(
meta: &mut HashMap<String, (SystemTime, String, String)>,
meta: &mut HashMap<String, (SystemTime, String, String, usize)>,
) -> Result<Vec<DiskSession>, String> {
let dir = project_dir()?;
let rd = std::fs::read_dir(&dir)
@@ -69,15 +74,25 @@ fn scan(
}
let uuid = path.file_stem()?.to_str()?.to_string();
let modified = e.metadata().ok()?.modified().ok()?;
let (label, model) = match meta.get(&uuid) {
Some((m, l, md)) if *m == modified => (l.clone(), md.clone()),
let (label, model, agents) = match meta.get(&uuid) {
Some((m, l, md, n)) if *m == modified => (l.clone(), md.clone(), *n),
_ => {
let read = read_meta(&path, &uuid);
meta.insert(uuid.clone(), (modified, read.0.clone(), read.1.clone()));
read
let (label, model) = read_meta(&path, &uuid);
let agents = scan_agents(&uuid).len();
meta.insert(
uuid.clone(),
(modified, label.clone(), model.clone(), agents),
);
(label, model, agents)
}
};
Some(DiskSession { uuid, label, model, modified })
Some(DiskSession {
uuid,
label,
model,
agents,
modified,
})
})
.collect();
sessions.sort_by(|a, b| b.modified.cmp(&a.modified));
@@ -320,6 +335,82 @@ fn materialize_in(
Ok(new_uuid)
}
/// One subagent transcript on disk. Claude Code ≥2.1.2x writes each subagent
/// to `<project>/<session-uuid>/subagents/agent-<agentId>.jsonl` with a small
/// `agent-<agentId>.meta.json` sidecar; the sidecar alone reconstructs the whole
/// lane tree, so a multi-megabyte transcript is only read when its lane is
/// actually rendered.
pub struct DiskAgent {
pub agent_id: String,
pub agent_type: String,
pub description: String,
/// The parent's `Agent` tool_use id — where this lane is spliced in.
pub tool_use_id: String,
/// Set from spawn depth 2 (a subagent of a subagent).
pub parent_agent_id: Option<String>,
pub spawn_depth: u8,
pub path: PathBuf,
}
/// An agent transcript larger than this is summarised instead of parsed: the
/// view is built synchronously while the app mutex is held, and a few MB of
/// JSONL would stall the UI (and the proxy tap that shares the mutex).
const MAX_AGENT_BYTES: u64 = 8 * 1024 * 1024;
/// Subagent transcripts recorded for `uuid`, in spawn order. Reads only the
/// `.meta.json` sidecars.
pub fn scan_agents(uuid: &str) -> Vec<DiskAgent> {
let Ok(dir) = project_dir() else {
return Vec::new();
};
scan_agents_in(&dir.join(uuid).join("subagents"))
}
/// `scan_agents` against an explicit directory (the I/O seam tests use).
pub(crate) fn scan_agents_in(subdir: &std::path::Path) -> Vec<DiskAgent> {
let Ok(rd) = std::fs::read_dir(subdir) else {
return Vec::new();
};
let mut out: Vec<DiskAgent> = rd
.flatten()
.filter_map(|e| {
let meta_path = e.path();
let name = meta_path.file_name()?.to_str()?;
let agent_id = name.strip_prefix("agent-")?.strip_suffix(".meta.json")?;
let v: Value = serde_json::from_str(&std::fs::read_to_string(&meta_path).ok()?).ok()?;
let field = |k: &str| {
v.get(k)
.and_then(Value::as_str)
.unwrap_or_default()
.to_string()
};
Some(DiskAgent {
agent_id: agent_id.to_string(),
agent_type: field("agentType"),
description: field("description"),
tool_use_id: field("toolUseId"),
parent_agent_id: v
.get("parentAgentId")
.and_then(Value::as_str)
.map(str::to_string),
spawn_depth: v
.get("spawnDepth")
.and_then(Value::as_u64)
.unwrap_or(1)
.try_into()
.unwrap_or(1),
path: subdir.join(format!("agent-{agent_id}.jsonl")),
})
})
.collect();
out.sort_by(|a, b| {
a.spawn_depth
.cmp(&b.spawn_depth)
.then_with(|| a.agent_id.cmp(&b.agent_id))
});
out
}
/// A rendered transcript: the feed `Session` plus which tree path it shows.
pub struct HistoryView {
pub session: Session,
@@ -341,10 +432,13 @@ pub fn load_history(uuid: &str) -> Option<Session> {
/// preamble + the root→leaf chain is rendered (dead branches excluded) and
/// per-turn entry offsets are recorded; with None, the whole file.
pub fn load_view(uuid: &str, path: Option<(&TurnTree, usize)>) -> Option<HistoryView> {
// Subagent transcripts live in their own files next to the session's, so
// they are spliced in after the main chain is parsed (see `splice_agents`).
let agents = scan_agents(uuid);
match path {
None => {
let fpath = project_dir().ok()?.join(format!("{uuid}.jsonl"));
load_file_view(&fpath, uuid)
load_file_view(&fpath, uuid, &agents)
}
Some((tree, leaf)) => {
let mut p = EntryParser::new();
@@ -358,20 +452,195 @@ pub fn load_view(uuid: &str, path: Option<(&TurnTree, usize)>) -> Option<History
p.line(l);
}
}
p.into_view(uuid, Some(leaf), turn_entries)
let anchors = std::mem::take(&mut p.agent_tools);
let mut view = p.into_view(uuid, Some(leaf), turn_entries)?;
splice_agents(&mut view, anchors, &agents);
Some(view)
}
}
}
/// Whole-file transcript view from an explicit path (the I/O-location seam:
/// tests parse temp files without touching `HOME`).
pub(crate) fn load_file_view(path: &std::path::Path, uuid: &str) -> Option<HistoryView> {
pub(crate) fn load_file_view(
path: &std::path::Path,
uuid: &str,
agents: &[DiskAgent],
) -> Option<HistoryView> {
let f = std::fs::File::open(path).ok()?;
let mut p = EntryParser::new();
for line in std::io::BufReader::new(f).lines().map_while(Result::ok) {
p.line(&line);
}
p.into_view(uuid, None, Vec::new())
let anchors = std::mem::take(&mut p.agent_tools);
let mut view = p.into_view(uuid, None, Vec::new())?;
splice_agents(&mut view, anchors, agents);
Some(view)
}
/// Parse one subagent transcript into its own lane. Oversized files are
/// summarised rather than parsed: the view is built while the app mutex is
/// held (the proxy tap shares it), so a few MB of JSONL must not stall it.
fn parse_agent_file(path: &std::path::Path, lane: LaneId) -> (Vec<Entry>, HashMap<String, usize>) {
let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
if size > MAX_AGENT_BYTES {
let mb = size / (1024 * 1024);
let note =
Entry::meta(format!("(subagent transcript too large to show: {mb} MB)")).in_lane(lane);
return (vec![note], HashMap::new());
}
let Ok(f) = std::fs::File::open(path) else {
return (Vec::new(), HashMap::new());
};
let mut p = EntryParser::new_lane(lane);
for line in std::io::BufReader::new(f).lines().map_while(Result::ok) {
p.line(&line);
}
(p.entries, p.agent_tools)
}
/// Tool calls per lane, for the agent picker's summary line.
fn count_tools(entries: &[Entry]) -> HashMap<LaneId, usize> {
let mut out: HashMap<LaneId, usize> = HashMap::new();
for e in entries {
if matches!(e.kind, Kind::Tool { .. }) {
*out.entry(e.lane).or_default() += 1;
}
}
out
}
/// Insert `add` at `at`, keeping the anchor map pointing at the same entries.
/// Anchors *at* the insertion point shift; the spawning tool call itself sits
/// at `at - 1` and stays put.
fn insert_entries(
entries: &mut Vec<Entry>,
anchors: &mut HashMap<String, usize>,
at: usize,
add: Vec<Entry>,
) {
let n = add.len();
let at = at.min(entries.len());
entries.splice(at..at, add);
for v in anchors.values_mut() {
if *v >= at {
*v += n;
}
}
}
/// Splice each subagent transcript into the view right after the `Agent` tool
/// call that spawned it, as its own lane.
///
/// Deepest agents go first, so a depth-2 transcript is nested into its parent's
/// entry list *before* that list is spliced into the main chain; within a level
/// the highest anchor goes first. A lane whose spawn point is not in this view
/// (a path view can exclude that turn) keeps no entries, so it never shows up
/// in the agent popup.
fn splice_agents(
view: &mut HistoryView,
mut anchors: HashMap<String, usize>,
agents: &[DiskAgent],
) {
if agents.is_empty() {
return;
}
struct Loaded {
id: String,
lane: LaneId,
entries: Vec<Entry>,
anchors: HashMap<String, usize>,
tool_use_id: String,
parent: Option<String>,
depth: u8,
spliced: bool,
}
let mut loaded: Vec<Loaded> = agents
.iter()
.map(|a| {
let lane = view.session.add_lane(
a.agent_id.clone(),
a.agent_type.clone(),
a.description.clone(),
Some(a.tool_use_id.clone()),
None,
a.spawn_depth,
);
let (entries, anchors) = parse_agent_file(&a.path, lane);
Loaded {
id: a.agent_id.clone(),
lane,
entries,
anchors,
tool_use_id: a.tool_use_id.clone(),
parent: a.parent_agent_id.clone(),
depth: a.spawn_depth,
spliced: false,
}
})
.collect();
// Nest children into their parents, deepest first.
let mut order: Vec<usize> = (0..loaded.len()).collect();
order.sort_by_key(|&i| std::cmp::Reverse(loaded[i].depth));
for ci in order {
let Some(pid) = loaded[ci].parent.clone() else {
continue;
};
let Some(pi) = loaded.iter().position(|l| l.id == pid) else {
continue;
};
if pi == ci || loaded[ci].entries.is_empty() {
continue;
}
let child = std::mem::take(&mut loaded[ci].entries);
let tuid = loaded[ci].tool_use_id.clone();
let parent_lane = loaded[pi].lane;
{
let p = &mut loaded[pi];
match p.anchors.get(&tuid).copied() {
Some(at) => insert_entries(&mut p.entries, &mut p.anchors, at + 1, child),
// The parent's transcript doesn't contain the spawn point:
// keep the entries rather than lose them.
None => p.entries.extend(child),
}
}
loaded[ci].spliced = true;
let l = &mut view.session.lanes[loaded[ci].lane as usize];
l.parent = Some(parent_lane);
}
// Splice what's left into the main chain, highest anchor first.
let mut top: Vec<usize> = (0..loaded.len()).filter(|&i| !loaded[i].spliced).collect();
top.sort_by_key(|&i| {
std::cmp::Reverse(anchors.get(&loaded[i].tool_use_id).copied().unwrap_or(0))
});
for i in top {
let entries = std::mem::take(&mut loaded[i].entries);
if entries.is_empty() {
continue;
}
let Some(at) = anchors.get(&loaded[i].tool_use_id).copied() else {
continue; // spawn point outside this view
};
let n = entries.len();
insert_entries(&mut view.session.entries, &mut anchors, at + 1, entries);
// Turn offsets after the insertion point move with it.
for (_, e) in view.turn_entries.iter_mut() {
if *e > at {
*e += n;
}
}
let l = &mut view.session.lanes[loaded[i].lane as usize];
l.anchor = Some(at);
l.parent = Some(MAIN_LANE);
}
view.session.reindex_lanes();
// Tool counts for the agent picker (a transcript carries no usage, so
// token totals stay zero for on-disk lanes).
for (lane, n) in count_tools(&view.session.entries) {
view.session.lanes[lane as usize].tool_calls = n;
}
}
/// Incremental JSONL-record → feed-`Entry` translation (shared by the whole
/// file and path views).
@@ -380,6 +649,16 @@ struct EntryParser {
model: String,
/// tool_use id → entry index, to attach results from later user lines.
tool_idx: HashMap<String, usize>,
/// `Agent`/`Task` tool_use id → entry index. Kept separately from
/// `tool_idx`, which is *drained* as results attach — the anchor is still
/// needed afterwards to splice the subagent's transcript in.
agent_tools: HashMap<String, usize>,
/// Lane every parsed entry is tagged with (0 = the main chain).
lane: LaneId,
/// Keep `isSidechain` records instead of skipping them. A subagent file
/// (`subagents/agent-<id>.jsonl`) consists *entirely* of such records, so
/// the main-chain skip would yield an empty lane.
keep_sidechain: bool,
}
impl EntryParser {
@@ -388,6 +667,19 @@ impl EntryParser {
entries: Vec::new(),
model: String::from("(resumed)"),
tool_idx: HashMap::new(),
agent_tools: HashMap::new(),
lane: MAIN_LANE,
keep_sidechain: false,
}
}
/// Parser for one subagent transcript: entries land in `lane` and the
/// sidechain records that make up the file are kept.
fn new_lane(lane: LaneId) -> Self {
Self {
lane,
keep_sidechain: true,
..Self::new()
}
}
@@ -401,33 +693,31 @@ impl EntryParser {
if self.entries.is_empty() {
return None;
}
// One construction site for a `Session`, so a new field only needs a
// default in `Session::new`.
let mut session = Session::new(uuid.to_string(), self.model);
session.entries = self.entries;
Some(HistoryView {
session: Session {
key: uuid.to_string(),
model: self.model,
entries: self.entries,
active: 0,
input_tokens: 0,
output_tokens: 0,
last_activity: std::time::Instant::now(),
tool_ids: HashMap::new(),
last_system_len: None,
last_tools_sig: None,
spawn_model: None,
},
session,
leaf,
turn_entries,
})
}
fn line(&mut self, line: &str) {
let lane = self.lane;
let entries = &mut self.entries;
let tool_idx = &mut self.tool_idx;
let agent_tools = &mut self.agent_tools;
let Ok(v) = serde_json::from_str::<Value>(line) else {
return;
};
// Skip subagent transcripts and synthetic/meta user lines.
if v.get("isSidechain").and_then(Value::as_bool) == Some(true)
// Skip synthetic/meta user lines, and — for the main chain — subagent
// records. `new_lane` parsers keep the latter: an agent file is made of
// nothing else. (Claude Code ≥2.1.2x writes subagents to their own
// files, so the main-chain guard is also belt-and-braces for older
// transcripts that inlined them.)
if (!self.keep_sidechain && v.get("isSidechain").and_then(Value::as_bool) == Some(true))
|| v.get("isMeta").and_then(Value::as_bool) == Some(true)
{
return;
@@ -443,57 +733,44 @@ impl EntryParser {
};
for b in blocks {
let entry = match b.get("type").and_then(Value::as_str) {
Some("thinking") => Some(Entry {
kind: Kind::Thinking,
content: text_of(b, "thinking"),
done: true,
result: None,
}),
Some("redacted_thinking") => Some(Entry {
kind: Kind::Thinking,
content: "[redacted]".into(),
done: true,
result: None,
}),
Some("text") => Some(Entry {
kind: Kind::Text,
content: text_of(b, "text"),
done: true,
result: None,
}),
Some("thinking") => {
Some(Entry::done(Kind::Thinking, text_of(b, "thinking")))
}
Some("redacted_thinking") => {
Some(Entry::done(Kind::Thinking, "[redacted]".into()))
}
Some("text") => Some(Entry::done(Kind::Text, text_of(b, "text"))),
Some("tool_use" | "server_tool_use" | "mcp_tool_use") => {
let tool_name = b.get("name").and_then(Value::as_str).unwrap_or("");
if let Some(id) = b.get("id").and_then(Value::as_str) {
tool_idx.insert(id.to_string(), entries.len());
if crate::app::is_agent_tool(tool_name) {
agent_tools.insert(id.to_string(), entries.len());
}
}
let input = b.get("input").map_or(String::new(), |i| {
serde_json::to_string_pretty(i).unwrap_or_default()
});
Some(Entry {
kind: Kind::Tool {
name: b
.get("name")
.and_then(Value::as_str)
.unwrap_or("tool")
.to_string(),
},
content: input,
done: true,
result: None,
})
let name = b
.get("name")
.and_then(Value::as_str)
.unwrap_or("tool")
.to_string();
Some(Entry::done(Kind::Tool { name }, input))
}
_ => None,
};
if let Some(e) = entry {
entries.push(e);
entries.push(e.in_lane(lane));
}
}
}
Some("user") => match v.pointer("/message/content") {
Some(Value::String(s)) => push_user_text(entries, s),
Some(Value::String(s)) => push_user_text(entries, s, lane),
Some(Value::Array(blocks)) => {
for b in blocks {
match b.get("type").and_then(Value::as_str) {
Some("text") => push_user_text(entries, &text_of(b, "text")),
Some("text") => push_user_text(entries, &text_of(b, "text"), lane),
Some("tool_result") => {
let Some(idx) = b
.get("tool_use_id")
@@ -529,13 +806,13 @@ fn text_of(b: &Value, key: &str) -> String {
/// Translate a user text block into feed entries: each injected reminder as a
/// dimmed `Kind::Reminder`, then the real prompt as `Kind::User`.
fn push_user_text(entries: &mut Vec<Entry>, text: &str) {
fn push_user_text(entries: &mut Vec<Entry>, text: &str, lane: LaneId) {
let (reminders, prompt) = crate::app::extract_user_text(text);
for r in reminders {
entries.push(Entry { kind: Kind::Reminder, content: r, done: true, result: None });
entries.push(Entry::done(Kind::Reminder, r).in_lane(lane));
}
if !prompt.is_empty() {
entries.push(Entry { kind: Kind::User, content: prompt, done: true, result: None });
entries.push(Entry::done(Kind::User, prompt).in_lane(lane));
}
}
@@ -719,6 +996,102 @@ mod tests {
}
}
/// A subagent transcript is a separate file linked by `toolUseId`; the view
/// must splice it in right after the `Agent` call, in its own lane, with a
/// nested (depth-2) agent inside its parent's lane — and the main chain's
/// turn offsets must survive the insertion.
#[test]
fn subagent_files_splice_into_their_agent_call() {
let dir = std::env::temp_dir().join(format!("ct-agents-{}", std::process::id()));
let subs = dir.join("subagents");
std::fs::create_dir_all(&subs).unwrap();
let main = dir.join("s1.jsonl");
std::fs::write(
&main,
[
r#"{"type":"user","uuid":"u1","parentUuid":null,"message":{"role":"user","content":"go"}}"#,
r#"{"type":"assistant","uuid":"a1","parentUuid":"u1","message":{"model":"claude-x","content":[{"type":"tool_use","id":"toolu_A","name":"Agent","input":{"subagent_type":"Explore","description":"sweep","prompt":"p"}}]}}"#,
r#"{"type":"user","uuid":"u2","parentUuid":"a1","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_A","content":"done"}]}}"#,
r#"{"type":"assistant","uuid":"a2","parentUuid":"u2","message":{"model":"claude-x","content":[{"type":"text","text":"after"}]}}"#,
]
.join("\n"),
)
.unwrap();
// Depth-1 agent: makes its own nested Agent call.
std::fs::write(
subs.join("agent-aaa1.meta.json"),
r#"{"agentType":"Explore","description":"sweep","toolUseId":"toolu_A","spawnDepth":1}"#,
)
.unwrap();
std::fs::write(
subs.join("agent-aaa1.jsonl"),
[
r#"{"type":"user","isSidechain":true,"uuid":"s1","parentUuid":null,"message":{"role":"user","content":"p"}}"#,
r#"{"type":"assistant","isSidechain":true,"uuid":"s2","parentUuid":"s1","message":{"model":"claude-y","content":[{"type":"text","text":"child text"}]}}"#,
r#"{"type":"assistant","isSidechain":true,"uuid":"s3","parentUuid":"s2","message":{"model":"claude-y","content":[{"type":"tool_use","id":"toolu_B","name":"Agent","input":{"subagent_type":"oracle","description":"deep","prompt":"q"}}]}}"#,
]
.join("\n"),
)
.unwrap();
// Depth-2 agent, spawned by the first one.
std::fs::write(
subs.join("agent-bbb2.meta.json"),
r#"{"agentType":"oracle","description":"deep","toolUseId":"toolu_B","parentAgentId":"aaa1","spawnDepth":2}"#,
)
.unwrap();
std::fs::write(
subs.join("agent-bbb2.jsonl"),
r#"{"type":"assistant","isSidechain":true,"uuid":"n1","parentUuid":null,"message":{"model":"claude-z","content":[{"type":"text","text":"nested text"}]}}"#,
)
.unwrap();
let agents = scan_agents_in(&subs);
assert_eq!(agents.len(), 2);
assert_eq!(agents[0].agent_id, "aaa1");
assert_eq!(agents[1].parent_agent_id.as_deref(), Some("aaa1"));
let view = load_file_view(&main, "s1", &agents).expect("view");
let s = &view.session;
// Lanes: main + the two agents, labelled from their sidecars.
assert_eq!(s.lanes.len(), 3);
assert_eq!(s.lanes[1].title(), "Explore · sweep");
assert_eq!(
s.lanes[2].parent,
Some(1),
"nested agent hangs off its parent lane"
);
assert!(
s.lanes[1].finished(),
"a transcript on disk is a finished run"
);
assert_eq!(s.agent_lanes(), vec![1, 2]);
// Order: the child's entries sit between the Agent call and what
// followed it, and the nested lane sits inside its parent's stretch.
let order: Vec<(LaneId, &str)> = s
.entries
.iter()
.map(|e| (e.lane, e.content.as_str()))
.collect();
let pos = |needle: &str| {
order
.iter()
.position(|(_, c)| c.contains(needle))
.unwrap_or_else(|| panic!("missing {needle} in {order:?}"))
};
// A tool entry's content is its pretty-printed input.
assert!(pos("\"subagent_type\": \"Explore\"") < pos("child text"));
assert!(pos("child text") < pos("nested text"));
assert!(pos("nested text") < pos("after"));
assert_eq!(order[pos("child text")].0, 1);
assert_eq!(order[pos("nested text")].0, 2);
assert_eq!(order[pos("after")].0, MAIN_LANE);
// first_entry is recomputed after splicing, so a lane can anchor.
let first_of = |l: LaneId| s.entries.iter().position(|e| e.lane == l);
assert_eq!(s.lanes[1].first_entry, first_of(1));
assert_eq!(s.lanes[2].first_entry, first_of(2));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn materialize_chain_and_stitch() {
let tree = build_tree(branched());
@@ -874,12 +1247,12 @@ mod tests {
// Parse via the explicit-path seam: no HOME mutation (an unsafe
// set_var would race the env reads of concurrently running tests).
let uuid = "11111111-2222-3333-4444-555555555555";
let s = load_file_view(&p, uuid).map(|h| h.session);
let s = load_file_view(&p, uuid, &[]).map(|h| h.session);
std::fs::remove_file(&p).ok();
let s = s.expect("history loaded");
assert_eq!(s.key, uuid);
assert_eq!(s.model, "claude-x");
assert_eq!(s.main().model, "claude-x");
// user prompt, thinking, tool, text — sidechain line skipped.
assert_eq!(s.entries.len(), 4);
assert!(matches!(s.entries[0].kind, Kind::User));

View File

@@ -720,6 +720,43 @@ fn conv_color(c: ColorAttribute) -> Option<Color> {
}
}
/// Claude Code's own default model, as a `--model` argument (`opus`,
/// `opus[1m]`, …). Its settings files are the one place the **1M-context**
/// choice is written down — `/model` saves the pick there, suffix and all,
/// while a transcript records the same base model id either way.
///
/// Resolved the way Claude Code layers it: `ANTHROPIC_MODEL`, then
/// project-local, project, and user settings. `None` when nothing sets one
/// (Claude Code then picks for itself). Read fresh on every call — a `/model`
/// during the session must not be answered from a stale cache.
pub fn cc_default_model() -> Option<String> {
if let Ok(m) = std::env::var("ANTHROPIC_MODEL")
&& !m.is_empty()
{
return Some(m);
}
let user = std::env::var_os("HOME")
.map(|h| std::path::PathBuf::from(h).join(".claude/settings.json"));
[
Some(std::path::PathBuf::from(".claude/settings.local.json")),
Some(std::path::PathBuf::from(".claude/settings.json")),
user,
]
.into_iter()
.flatten()
.find_map(|p| settings_model(&p))
}
/// `model` field of one settings file (absent/unreadable/invalid → None).
fn settings_model(path: &std::path::Path) -> Option<String> {
let body = std::fs::read_to_string(path).ok()?;
let v: serde_json::Value = serde_json::from_str(&body).ok()?;
v.get("model")?
.as_str()
.filter(|m| !m.is_empty())
.map(str::to_string)
}
/// Background scan that replaces `App::model_choices` with the live alias set
/// read from the installed `claude` binary (see `discover_model_aliases`).
/// Runs off the UI thread; on failure the seeded fallback list stays in place.

893
src/ui.rs

File diff suppressed because it is too large Load Diff