From fb711806327505e6ac0f25c5b398409ceae2d5f6 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 09:41:39 +0200 Subject: [PATCH] feat(story-mode): a helper's own steps, from its transcript GET /api/sessions/:id/story/helpers/:agentId draws one helper (an Agent call) as a page from its own transcript, /subagents/agent-.jsonl beside the session's, with the type, description and finished flag from its .meta.json. The file is derived from the session's own transcript path and an id checked against [A-Za-z0-9_-]{1,64}; a helper with no file yet is page null, not an error. The reducer gains a sidechain mode (every helper line is a sidechain line) and keeps the agentId of Agent results, so the main page links each helper to its steps. Same gates and ETag/304 as /story, now shared. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/story-mode.ts | 32 ++++++- src/story-reader.ts | 30 +++--- src/types/story.ts | 11 +++ src/web/routes/story-routes.ts | 158 ++++++++++++++++++++++--------- test/routes/story-routes.test.ts | 93 +++++++++++++++++- 5 files changed, 261 insertions(+), 63 deletions(-) diff --git a/src/story-mode.ts b/src/story-mode.ts index fa15761a..2cb9da63 100644 --- a/src/story-mode.ts +++ b/src/story-mode.ts @@ -103,7 +103,7 @@ export type StoryEntry = output?: string; outputTruncated?: boolean; edit?: StoryEdit; - helper?: { result?: string; toolUses?: number; durationMs?: number }; + helper?: { result?: string; toolUses?: number; durationMs?: number; agentId?: string }; answer?: string; tests?: StoryTests; } @@ -466,8 +466,10 @@ export interface StoryReducer { /** * A reducer remembers each tool call's name and input until its result arrives, since the result line * does not repeat them. Bounded: a call whose result never comes is forgotten after 4,000 newer calls. + * `sidechain` reads a helper's own transcript, where every line is a sidechain line; the main + * transcript skips them. */ -export function createStoryReducer(): StoryReducer { +export function createStoryReducer({ sidechain = false }: { sidechain?: boolean } = {}): StoryReducer { const calls = new Map(); const remember = (id: string, name: string, input: ReducedInput) => { calls.set(id, { name, input }); @@ -504,6 +506,10 @@ export function createStoryReducer(): StoryReducer { result: answer ? clip(answer, STORY_LIMITS.helperChars) : undefined, toolUses: num(extra.totalToolUseCount), durationMs: num(extra.totalDurationMs), + agentId: + typeof extra.agentId === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(extra.agentId) + ? extra.agentId + : undefined, }; } else if (name === 'AskUserQuestion' && isRecord(extra.answers)) { const first = Object.values(extra.answers).find((v): v is string => typeof v === 'string'); @@ -560,7 +566,7 @@ export function createStoryReducer(): StoryReducer { return { reduce(line: unknown): StoryEntry[] { - if (!isRecord(line) || line.isSidechain === true) return []; + if (!isRecord(line) || (line.isSidechain === true && !sidechain)) return []; const at = str(line.timestamp) ?? ''; switch (line.type) { case 'user': @@ -703,6 +709,7 @@ function applyResult(ev: StoryEvent, r: Extract): v if (r.helper.result) helper.result = r.helper.result; if (r.helper.toolUses !== undefined) helper.toolUses = r.helper.toolUses; if (r.helper.durationMs !== undefined) helper.durationMs = r.helper.durationMs; + if (r.helper.agentId) helper.agentId = r.helper.agentId; ev.helper = helper; } if (r.answer && ev.question) ev.question = { ...ev.question, answer: r.answer }; @@ -1247,6 +1254,25 @@ export function buildStoryPage(turn: StoryTurn): StoryPage { return { ...base, panels }; } +/** + * A helper's own transcript as one page. Its first prompt is the job Claude gave it; a later prompt + * (Claude messaging it again) continues the same page. `finished` comes from the helper's metadata, + * since a helper's transcript has no `turn_duration` of its own. + */ +export function buildHelperPage(turns: StoryTurn[], finished?: boolean): StoryPage | null { + if (!turns.length) return null; + const first = turns[0]; + const last = turns[turns.length - 1]; + return buildStoryPage({ + ...first, + events: turns.flatMap((t) => t.events), + ended: finished ?? last.ended, + interrupted: false, + endedAt: last.endedAt, + durationMs: undefined, + }); +} + /** The turn list for the page picker, newest last, capped. */ export function summarizeTurns(turns: StoryTurn[]): StoryTurnSummary[] { return turns.slice(-STORY_LIMITS.turnsListed).map(summarizeTurn); diff --git a/src/story-reader.ts b/src/story-reader.ts index 96859127..874700fd 100644 --- a/src/story-reader.ts +++ b/src/story-reader.ts @@ -36,6 +36,7 @@ const MAX_CHARS = 6_000_000; const MAX_TRANSCRIPTS = 6; interface CacheEntry { + sidechain: boolean; size: number; mtimeMs: number; /** Byte offset up to which complete lines were consumed. */ @@ -121,10 +122,11 @@ function ingest(entry: CacheEntry, buf: Buffer, startsMidLine: boolean): void { } } -async function load(path: string): Promise { +async function load(path: string, sidechain: boolean): Promise { const info = await stat(path); if (!info.isFile()) throw new Error('Transcript is not a regular file'); - const cached = cache.get(path); + const key = sidechain ? `${path}#sidechain` : path; + const cached = cache.get(key); const version = versionOf(info.size, info.mtimeMs); if (cached && cached.size === info.size && cached.mtimeMs === info.mtimeMs) { return { entries: cached.entries, partial: cached.partial, version }; @@ -140,7 +142,7 @@ async function load(path: string): Promise { ingest(cached, buf, false); cached.size = info.size; cached.mtimeMs = info.mtimeMs; - cache.set(path, cached); + cache.set(key, cached); return { entries: cached.entries, partial: cached.partial, version }; } // First read, a rewrite, or more appended than a tail holds: start over from the tail, and take a @@ -150,30 +152,36 @@ async function load(path: string): Promise { for (const tail of TAIL_STEPS) { const start = Math.max(0, info.size - tail); fresh = { + sidechain, size: info.size, mtimeMs: info.mtimeMs, offset: start, entries: [], chars: 0, - reducer: createStoryReducer(), + reducer: createStoryReducer({ sidechain }), partial: start > 0, }; ingest(fresh, await readRange(path, start, info.size), start > 0); if (start === 0 || hasTurnStart(fresh.entries)) break; } const done = fresh as CacheEntry; - cache.set(path, done); + cache.set(key, done); return { entries: done.entries, partial: done.partial, version }; } /** - * Reduced entries for the transcript at `path`, newest last. Throws when the file cannot be read; - * the caller turns that into an error response. + * Reduced entries for the transcript at `path`, newest last. `sidechain` reads a helper's own + * transcript (every line there is a sidechain line). Throws when the file cannot be read; the caller + * turns that into an error response. */ -export function readStoryEntries(path: string): Promise { - const running = inFlight.get(path); +export function readStoryEntries( + path: string, + { sidechain = false }: { sidechain?: boolean } = {} +): Promise { + const key = sidechain ? `${path}#sidechain` : path; + const running = inFlight.get(key); if (running) return running; - const promise = load(path).finally(() => inFlight.delete(path)); - inFlight.set(path, promise); + const promise = load(path, sidechain).finally(() => inFlight.delete(key)); + inFlight.set(key, promise); return promise; } diff --git a/src/types/story.ts b/src/types/story.ts index 0fe2e9eb..f2b9e8d4 100644 --- a/src/types/story.ts +++ b/src/types/story.ts @@ -82,6 +82,8 @@ export interface StoryHelper { result?: string; toolUses?: number; durationMs?: number; + /** Claude Code's id for the helper; its own steps are at `GET /api/sessions/:id/story/helpers/`. */ + agentId?: string; } export interface StoryTests { @@ -164,6 +166,15 @@ export interface StoryPage extends StoryTurnSummary { helpers: number; } +/** `GET /api/sessions/:id/story/helpers/:agentId`: one helper's own steps, as one page. */ +export type StoryHelperResponse = + | { supported: false; reason: string } + | { + supported: true; + helper: { agentId: string; type?: string; description?: string; finished?: boolean }; + page: StoryPage | null; + }; + export type StoryResponse = | { supported: false; reason: string } | { supported: true; turns: StoryTurnSummary[]; page: StoryPage | null }; diff --git a/src/web/routes/story-routes.ts b/src/web/routes/story-routes.ts index 9ccacd43..09bfed4f 100644 --- a/src/web/routes/story-routes.ts +++ b/src/web/routes/story-routes.ts @@ -1,23 +1,36 @@ /** - * @fileoverview `GET /api/sessions/:id/story`: one turn of a Claude Code session as a comic page - * (Story Mode, `story-mode.js` in the browser). EXPERIMENTAL: the payload shape is not frozen. + * @fileoverview Story Mode routes (`story-mode.js` in the browser). EXPERIMENTAL: payload shapes are + * not frozen. * - * Query: `turn=` picks a turn from the `turns` list; without it the newest turn is drawn. - * Answers `{ supported: false, reason }` for a session it cannot draw, decided from the CLI's + * - `GET /api/sessions/:id/story[?turn=]`: one turn of a Claude Code session as a comic page; + * without `turn` the newest turn is drawn. + * - `GET /api/sessions/:id/story/helpers/:agentId`: one helper's own steps (an Agent call's + * transcript, `/subagents/agent-.jsonl` beside the session's), as one page. + * + * Both answer `{ supported: false, reason }` for a session they cannot draw, decided from the CLI's * registry capability (`capabilities.transcript === 'claude-jsonl'`, never the mode id) and from * where the transcript lives: a remote or Docker session's transcript is not on this host. * - * Read-only: unlike `last-response` it never adopts a conversation id or persists anything. The file + * Read-only: unlike `last-response` they never adopt a conversation id or persist anything. The file * read is the session's own transcript, chosen here and never named by the request: the path the * Claude hook reported for this session, else `.jsonl` under the projects directory. + * A helper's file is derived from that path and an `agentId` checked against a strict charset. * Reading is bounded and async (story-reader.ts); what comes back is untrusted text the browser * renders with `textContent`, and file paths in it open through the existing file preview. */ -import type { FastifyInstance } from 'fastify'; -import { ApiErrorCode, createErrorResponse, type ApiResponse, type StoryResponse } from '../../types.js'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { readFile, stat } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; +import { + ApiErrorCode, + createErrorResponse, + type ApiResponse, + type StoryHelperResponse, + type StoryResponse, +} from '../../types.js'; import { getCli } from '../../config/cli-registry/registry.js'; -import { buildStoryPage, buildStoryTurns, summarizeTurns } from '../../story-mode.js'; +import { buildHelperPage, buildStoryPage, buildStoryTurns, summarizeTurns } from '../../story-mode.js'; import { readStoryEntries } from '../../story-reader.js'; import { findClaudeTranscriptPath } from '../../utils/claude-transcript.js'; import { findSessionOrFail } from '../route-helpers.js'; @@ -28,6 +41,8 @@ import type { ConfigPort, SessionPort } from '../ports/index.js'; * page an older server built. */ const SERVER_EPOCH = Date.now().toString(36); +/** A helper's `.meta.json` is a few hundred bytes; anything bigger is not one. */ +const MAX_META_BYTES = 64 * 1024; /** The hook-reported path when it looks like a transcript, else a lookup by conversation id. */ async function resolveTranscriptPath(hookPath: string | null, conversationId: string): Promise { @@ -35,58 +50,107 @@ async function resolveTranscriptPath(hookPath: string | null, conversationId: st return findClaudeTranscriptPath(conversationId); } +type Source = { unsupported: string } | { path: string | null }; + +/** Whether this session can be drawn at all, and where its transcript is. */ +async function storySource(ctx: SessionPort & ConfigPort, id: string, req: FastifyRequest): Promise { + const session = findSessionOrFail(ctx, id, req); + if (getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl') { + return { unsupported: 'Story Mode draws Claude Code sessions. This session runs a different CLI.' }; + } + if (session.remote || session.docker) { + return { + unsupported: + 'Story Mode reads transcripts on this machine. This session keeps its transcript on another host or inside a container.', + }; + } + return { path: await resolveTranscriptPath(ctx.getTranscriptPath(id), session.claudeSessionId || session.id) }; +} + +/** + * Sets the ETag and answers true (after sending a 304) when the browser already holds this version. + * A live view refetches every few seconds; while the file has not changed, nothing gets built. + */ +function notModified(req: FastifyRequest, reply: FastifyReply, tag: string): boolean { + const etag = `W/"story-${SERVER_EPOCH}-${tag}"`; + reply.header('ETag', etag).header('Cache-Control', 'no-cache'); + if (req.headers['if-none-match'] !== etag) return false; + reply.code(304).send(); + return true; +} + +async function readHelperMeta(path: string): Promise<{ type?: string; description?: string; finished?: boolean }> { + try { + if ((await stat(path)).size > MAX_META_BYTES) return {}; + const meta = JSON.parse(await readFile(path, 'utf8')) as Record; + return { + type: typeof meta.agentType === 'string' ? meta.agentType.slice(0, 80) : undefined, + description: typeof meta.description === 'string' ? meta.description.slice(0, 300) : undefined, + finished: typeof meta.finished === 'boolean' ? meta.finished : undefined, + }; + } catch { + return {}; + } +} + export function registerStoryRoutes(app: FastifyInstance, ctx: SessionPort & ConfigPort): void { - app.get('/api/sessions/:id/story', async (req, reply): Promise> => { + app.get('/api/sessions/:id/story', async (req, reply): Promise | void> => { const { id } = req.params as { id: string }; const { turn } = req.query as { turn?: unknown }; - const session = findSessionOrFail(ctx, id, req); + const source = await storySource(ctx, id, req); + if ('unsupported' in source) return { success: true, data: { supported: false, reason: source.unsupported } }; + if (!source.path) return { success: true, data: { supported: true, turns: [], page: null } }; - if (getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl') { - return { - success: true, - data: { supported: false, reason: 'Story Mode draws Claude Code sessions. This session runs a different CLI.' }, - }; - } - if (session.remote || session.docker) { - return { - success: true, - data: { - supported: false, - reason: - 'Story Mode reads transcripts on this machine. This session keeps its transcript on another host or inside a container.', - }, - }; - } - - const transcriptPath = await resolveTranscriptPath( - ctx.getTranscriptPath(id), - session.claudeSessionId || session.id - ); - if (!transcriptPath) return { success: true, data: { supported: true, turns: [], page: null } }; - - let entries; - let version; + let read; try { - ({ entries, version } = await readStoryEntries(transcriptPath)); + read = await readStoryEntries(source.path); } catch { reply.code(500); return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Could not read the session transcript'); } + const turnKey = typeof turn === 'string' && turn ? turn : ''; + if (notModified(req, reply, `${read.version}-${encodeURIComponent(turnKey || 'latest')}`)) return; - // A live view refetches every few seconds; while the transcript has not changed, answer 304 - // before building anything. `no-cache` makes the browser revalidate every time. - const etag = `W/"story-${SERVER_EPOCH}-${version}-${encodeURIComponent(typeof turn === 'string' && turn ? turn : 'latest')}"`; - reply.header('ETag', etag).header('Cache-Control', 'no-cache'); - if (req.headers['if-none-match'] === etag) { - reply.code(304); - return reply.send(); - } - - const turns = buildStoryTurns(entries); - const wanted = typeof turn === 'string' && turn ? turns.find((t) => t.id === turn) : turns[turns.length - 1]; + const turns = buildStoryTurns(read.entries); + const wanted = turnKey ? turns.find((t) => t.id === turnKey) : turns[turns.length - 1]; return { success: true, data: { supported: true, turns: summarizeTurns(turns), page: wanted ? buildStoryPage(wanted) : null }, }; }); + + app.get( + '/api/sessions/:id/story/helpers/:agentId', + async (req, reply): Promise | void> => { + const { id, agentId } = req.params as { id: string; agentId: string }; + if (!/^[A-Za-z0-9_-]{1,64}$/.test(agentId)) { + reply.code(400); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid helper id'); + } + const source = await storySource(ctx, id, req); + if ('unsupported' in source) return { success: true, data: { supported: false, reason: source.unsupported } }; + const helper = { agentId }; + if (!source.path) return { success: true, data: { supported: true, helper, page: null } }; + + const dir = join(dirname(source.path), basename(source.path, '.jsonl'), 'subagents'); + const path = join(dir, `agent-${agentId}.jsonl`); + let read; + try { + read = await readStoryEntries(path, { sidechain: true }); + } catch { + // Not written yet (a helper that just started), or gone: nothing to draw, not an error. + return { success: true, data: { supported: true, helper, page: null } }; + } + const meta = await readHelperMeta(join(dir, `agent-${agentId}.meta.json`)); + if (notModified(req, reply, `helper-${read.version}-${meta.finished ? 1 : 0}`)) return; + return { + success: true, + data: { + supported: true, + helper: { ...helper, ...meta }, + page: buildHelperPage(buildStoryTurns(read.entries), meta.finished), + }, + }; + } + ); } diff --git a/test/routes/story-routes.test.ts b/test/routes/story-routes.test.ts index 681d6a24..38b75ab7 100644 --- a/test/routes/story-routes.test.ts +++ b/test/routes/story-routes.test.ts @@ -2,14 +2,14 @@ * @fileoverview GET /api/sessions/:id/story (Story Mode) against invented transcripts in a temp dir, * plus the reader's incremental tail read. Port: N/A (app.inject()). */ -import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createRouteTestHarness } from './_route-test-utils.js'; import { registerStoryRoutes } from '../../src/web/routes/story-routes.js'; import { clearStoryReaderCache, readStoryEntries } from '../../src/story-reader.js'; -import { bash, demoTurn, end, prompt, read, resetClock, said } from '../story-fixtures.js'; +import { bash, demoTurn, edit, end, prompt, read, resetClock, result, said, tool } from '../story-fixtures.js'; const URL = '/api/sessions/test-session-1/story'; const jsonl = (lines: unknown[]) => lines.map((l) => JSON.stringify(l)).join('\n') + '\n'; @@ -167,6 +167,95 @@ describe('GET /api/sessions/:id/story', () => { }); }); +describe('GET /api/sessions/:id/story/helpers/:agentId', () => { + const side = (lines: unknown[]) => lines.map((l) => ({ ...(l as object), isSidechain: true })); + + it("draws a helper's own steps, and the main page carries its agentId", async () => { + writeFileSync( + transcript, + jsonl([ + prompt('Write the docs with a helper'), + tool('a1', 'Agent', { + description: 'Write README', + subagent_type: 'general-purpose', + prompt: 'Write README.md', + }), + result('a1', 'Done', { + toolUseResult: { + status: 'completed', + agentId: 'abc123', + content: [{ type: 'text', text: 'README written.' }], + totalToolUseCount: 2, + }, + }), + end(5000), + ]) + ); + const sub = join(dir, 'conversation', 'subagents'); + mkdirSync(sub, { recursive: true }); + writeFileSync( + join(sub, 'agent-abc123.jsonl'), + jsonl( + side([ + prompt('Write README.md for the script'), + ...read('r1', '/w/script.sh'), + ...edit('e1', '/w/README.md', ['+# Script', '+Usage'], 1), + ]) + ) + ); + writeFileSync( + join(sub, 'agent-abc123.meta.json'), + JSON.stringify({ agentType: 'general-purpose', description: 'Write README', finished: true }) + ); + const { app } = await setup(); + + const main = (await app.inject({ method: 'GET', url: URL })).json().data.page; + expect(main.events.find((e: { helper?: unknown }) => e.helper).helper).toMatchObject({ + agentId: 'abc123', + toolUses: 2, + }); + + const res = await app.inject({ method: 'GET', url: `${URL}/helpers/abc123` }); + const data = res.json().data; + expect(data.helper).toEqual({ + agentId: 'abc123', + type: 'general-purpose', + description: 'Write README', + finished: true, + }); + expect(data.page.prompt).toBe('Write README.md for the script'); + expect(data.page.panels.map((p: { kind: string }) => p.kind)).toEqual([ + 'mission', + 'investigate', + 'fix', + 'complete', + ]); + expect(res.headers.etag).toMatch(/^W\/"story-/); + const again = await app.inject({ + method: 'GET', + url: `${URL}/helpers/abc123`, + headers: { 'if-none-match': res.headers.etag as string }, + }); + expect(again.statusCode).toBe(304); + }); + + it('answers page null for a helper that has not written anything yet', async () => { + writeFileSync(transcript, jsonl(demoTurn())); + const { app } = await setup(); + const data = (await app.inject({ method: 'GET', url: `${URL}/helpers/notyet1` })).json().data; + expect(data).toEqual({ supported: true, helper: { agentId: 'notyet1' }, page: null }); + }); + + it('refuses an id that could leave the subagents folder', async () => { + const { app } = await setup(); + for (const bad of ['..', '..%2F..%2Fsecret', 'a.b', 'x'.repeat(65)]) { + const res = await app.inject({ method: 'GET', url: `${URL}/helpers/${bad}` }); + // `..` never reaches the route (the URL is normalized first: 404); the rest hit the id check (400). + expect([400, 404], bad).toContain(res.statusCode); + } + }); +}); + describe('readStoryEntries', () => { it('leaves a line that is still being written for the next read', async () => { const full = JSON.stringify(prompt('half written'));