diff --git a/docs/api-reference.md b/docs/api-reference.md index d9080f83..f873001a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1067,7 +1067,7 @@ Responses carry a weak `ETag` with `Cache-Control: no-cache`; a request with a m A `page` carries the turn's summary fields plus `events` (one per prompt, sentence Claude wrote and tool call, with `intent`, `ok` when the call returned, `edit` hunks in Claude Code's own `structuredPatch` shape, the tail of `output`, `helper`, `tests` counts, and `files` for the files a Bash call printed with `cat`/`head`/`tail`, as the command named them), `panels` (each `{ kind, events, caption: { story, director }, balloon?, files?, ... }`, at most 12 per page, indices into `events`), `filesChanged`, `added`, `removed`, `helpers`, `helpersRunning`, `partial` (the turn began before the part that was read) and `truncated` (older outputs were dropped to keep the page small). A transcript that cannot be read is a `500` with `OPERATION_FAILED`. -A helper started in the background (`helper.background`) carries `helper.running: true` until its `` arrives; that notification starts a turn of its own (origin `task`), whose prompt event carries the helper's answer as `output`, and the answer, tool count and duration are joined back onto the helper in the turn that started it. Claude Code times such a turn from the prompt that started the chain; Story Mode caps a turn's `durationMs` at the turn's own span. A helper started as a teammate (`status: teammate_spawned`) is a background helper too, and its `` reports join it by name. A prompt that arrives while Claude works (typed, a background job's report, a message from another session) is written by current Claude Code builds as a `queued_command` attachment, not a user line; it joins the running turn, a prompt event carrying `origin` (`human`, `task`, `peer`, `system`), and a report that lands mid-turn is an event with intent `notice` rather than a new mission panel. A Bash call that writes files through here-documents (`cat > f <<'EOF'`) carries them as `edits` (whole-file edits, counted in `filesChanged`), and its label leaves the bodies out; `headline` is the part of a one-liner that decided its intent. The advisor (a server tool) is an event with tool `Advisor` and intent `helper`; its answer is encrypted in the transcript. `tests` may carry `errors` (pytest's collection errors). A turn ends at `turn_duration`, or at the Stop hook's `stop_hook_summary` (unless it prevented the stop) in sessions that never write `turn_duration`, timed then by its own span. +A helper started in the background (`helper.background`) carries `helper.running: true` until its `` arrives; that notification starts a turn of its own (origin `task`), whose prompt event carries the helper's answer as `output`, and the answer, tool count and duration are joined back onto the helper in the turn that started it. Claude Code times such a turn from the prompt that started the chain; Story Mode caps a turn's `durationMs` at the turn's own span. A helper started as a teammate (`status: teammate_spawned`) is a background helper too, and its `` reports join it by name. A prompt that arrives while Claude works (typed, a background job's report, a message from another session) is written by current Claude Code builds as a `queued_command` attachment, not a user line; it joins the running turn, a prompt event carrying `origin` (`human`, `task`, `peer`, `system`), and a report that lands mid-turn is an event with intent `notice` rather than a new mission panel. A Bash call that writes files through here-documents (`cat > f <<'EOF'`) carries them as `edits` (whole-file edits, counted in `filesChanged`), and its label leaves the bodies out; `headline` is the part of a one-liner that decided its intent. The advisor (a server tool) is an event with tool `Advisor` and intent `helper`; its answer is encrypted in the transcript. `tests` may carry `errors` (pytest's collection errors). A slash command is a prompt event with tool `Command`; a local one (`/compact`, `/model`) ends its turn at its own output (``, kept as the event's `output`), and a compaction that lands between turns belongs to the turn after it. A turn ends at `turn_duration`, or at the Stop hook's `stop_hook_summary` (unless it prevented the stop) in sessions that never write `turn_duration`, timed then by its own span. ## Git worktrees of a case diff --git a/src/story-mode.ts b/src/story-mode.ts index 4fefb620..a3998296 100644 --- a/src/story-mode.ts +++ b/src/story-mode.ts @@ -111,6 +111,8 @@ export type StoryEntry = text: string; origin: StoryTurnOrigin; queued: boolean; + /** A slash command (`/compact`), not a prompt to Claude. */ + slash?: boolean; /** A background task reporting back: which call it answers, and how it went. */ task?: StoryTaskReport; /** Teammates (helpers started as a team) reporting back, matched to their helper by name. */ @@ -134,6 +136,8 @@ export type StoryEntry = } | { k: 'end'; at: string; durationMs?: number } | { k: 'compact'; at: string } + /** What a local slash command printed (``): its whole answer. */ + | { k: 'local'; at: string; text: string } | { k: 'interrupt'; at: string }; /** What a `` says about the background call it answers. */ @@ -600,6 +604,8 @@ function readTeammateMessages(text: string): StoryTeammateReport[] { function readPrompt(raw: string): { text: string; kind: 'prompt' | 'task' | 'peer' | 'interrupt'; + /** A slash command (`/compact`, `/model`), from its ``. */ + slash?: boolean; task?: StoryTaskReport; teammates?: StoryTeammateReport[]; } | null { @@ -629,7 +635,7 @@ function readPrompt(raw: string): { const command = /([^<]*)<\/command-name>/.exec(text); if (command) { const args = /([^<]*)<\/command-args>/.exec(text)?.[1] ?? ''; - return { text: `${command[1].trim()} ${args.trim()}`.trim(), kind: 'prompt' }; + return { text: `${command[1].trim()} ${args.trim()}`.trim(), kind: 'prompt', slash: true }; } if (/^/.test(text)) { const tag = (name: string) => new RegExp(`<${name}>([\\s\\S]*?)`).exec(text)?.[1]?.trim(); @@ -887,6 +893,12 @@ export function createStoryReducer({ sidechain = false }: { sidechain?: boolean if (results.length) return out; } if (entry.isMeta === true || entry.turnCompanion === true) return out; + const local = /^\s*([\s\S]*?)<\/local-command-\1>/.exec(blockText(content)); + if (local) { + // eslint-disable-next-line no-control-regex + const text = local[2].replace(/\x1b\[[0-9;]*[A-Za-z]/g, '').trim(); + return [{ k: 'local', at, text: clip(text, 2000) }]; + } const prompt = readPrompt(blockText(content)); if (!prompt) return out; if (prompt.kind === 'interrupt') return [{ k: 'interrupt', at }]; @@ -932,6 +944,7 @@ export function createStoryReducer({ sidechain = false }: { sidechain?: boolean origin, queued, }; + if (prompt.slash) reduced.slash = true; if (prompt.task) { const t = prompt.task; reduced.task = { @@ -1181,6 +1194,8 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] { const byId = new Map(); /** Helper calls by tool id, across turns: a background helper reports back in a later turn. */ const helpers = new Map(); + /** A compaction between turns, held for the turn that comes next. */ + let pendingCompact: StoryEvent | null = null; /** The same helpers by name: a teammate reports back by name, not by call. */ const helpersByName = new Map(); let say = 0; @@ -1244,7 +1259,7 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] { id: e.id, at: e.at, actor: e.origin === 'human' ? 'you' : 'system', - tool: 'Prompt', + tool: e.slash ? 'Command' : 'Prompt', label: e.text, intent: 'prompt', origin: e.origin, @@ -1274,6 +1289,10 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] { }; turns.push(cur); byId.clear(); + if (pendingCompact) { + cur.events.push(pendingCompact); + pendingCompact = null; + } } break; } @@ -1318,16 +1337,31 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] { } break; } - case 'compact': - ensure(e.at).events.push({ + case 'compact': { + const ev: StoryEvent = { id: `compact-${say++}`, at: e.at, actor: 'system', tool: 'Compact', label: 'Context compacted', intent: 'compact', - }); + }; + // Between turns (`/compact` writes its boundary before the command line itself), it belongs to + // the turn that comes next; mid-turn (an automatic compaction), to the running one. + const t = cur as StoryTurn | null; + if (t && !t.ended) t.events.push(ev); + else pendingCompact = ev; break; + } + case 'local': { + // A local slash command answers itself: its turn ends with what it printed. + const t = cur as StoryTurn | null; + if (t && !t.ended && t.events[0]?.tool === 'Command' && countToolCalls(t.events) === 0) { + if (e.text) t.events[0].output = e.text; + closeTurn(t, e.at); + } + break; + } case 'interrupt': { const t = ensure(e.at); t.events.push({ @@ -1623,8 +1657,10 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde system: 'Something automatic starts this turn.', }; const origin = evs[0]?.origin ?? turn.origin; - story = - index === 0 + const command = evs[0]?.tool === 'Command' ? evs[0].label.split(/\s/)[0] : ''; + story = command + ? `You run ${command}.` + : index === 0 ? lines[turn.origin] : origin === 'human' ? 'You add something while Claude works.' diff --git a/test/story-mode.test.ts b/test/story-mode.test.ts index 4363a061..a5c0e3ce 100644 --- a/test/story-mode.test.ts +++ b/test/story-mode.test.ts @@ -245,7 +245,8 @@ describe('createStoryReducer', () => { prompt('ok'), prompt('ls'), ]); - expect(entries).toEqual([]); + // None is a prompt; a local command's output is kept apart, as what that command printed. + expect(entries).toEqual([{ k: 'local', at: expect.any(String), text: 'ok' }]); }); it('reads slash commands, task notifications, interrupts and strips system reminders', () => { @@ -506,6 +507,37 @@ describe('buildStoryTurns', () => { expect(pasted.prompt).toBe('Do these steps.'); }); + it('/compact is its own turn: you run it, Claude packs up, and the command answers itself', () => { + const boundary = { type: 'system', subtype: 'compact_boundary', timestamp: '2026-10-18T09:50:00.000Z' }; + const summary = prompt('This session is being continued from a previous conversation.', { isCompactSummary: true }); + const command = prompt( + '/compact\ncompact\n' + ); + const stdout = prompt('\u001b[2mCompacted \u001b[22m', { + turnOrigin: undefined, + }); + const turns = buildStoryTurns( + entriesOf([prompt('one'), ...bash('b', 'ls', 'List', 'a'), end(1000), boundary, summary, command, stdout]) + ); + expect(turns).toHaveLength(2); + expect(turns[0].events.some((e) => e.intent === 'compact')).toBe(false); + expect(turns[1]).toMatchObject({ prompt: '/compact', ended: true }); + const page = buildStoryPage(turns[1]); + expect(page.panels.map((p) => p.kind)).toEqual(['mission', 'compact', 'complete']); + expect(page.panels[0].caption.story).toBe('You run /compact.'); + expect(page.events[0]).toMatchObject({ tool: 'Command', output: 'Compacted' }); + // A local command like /model ends at its output too. + const [, model] = buildStoryTurns( + entriesOf([ + prompt('one'), + end(1000), + prompt('/modelopus'), + prompt('Set model to opus'), + ]) + ); + expect(model).toMatchObject({ prompt: '/model opus', ended: true }); + }); + it('an interrupt ends the turn', () => { const turns = buildStoryTurns( entriesOf([prompt('go'), ...bash('b', 'ls', 'List', 'a'), prompt('[Request interrupted by user]')])