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, <conversation>/subagents/agent-<id>.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) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 09:41:39 +02:00
1 parent fc279f998c
commit fb71180632
5 files changed
+261 -63

No files matched your search

+29 -3
View File
@@ -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<string, { name: string; input: ReducedInput }>();
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<StoryEntry, { k: 'result' }>): 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);
+19 -11
View File
@@ -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<StoryEntriesResult> {
async function load(path: string, sidechain: boolean): Promise<StoryEntriesResult> {
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<StoryEntriesResult> {
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<StoryEntriesResult> {
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<StoryEntriesResult> {
const running = inFlight.get(path);
export function readStoryEntries(
path: string,
{ sidechain = false }: { sidechain?: boolean } = {}
): Promise<StoryEntriesResult> {
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;
}
+11
View File
@@ -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>`. */
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 };
+111 -47
View File
@@ -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=<id>` 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=<id>]`: 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, `<conversation>/subagents/agent-<agentId>.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 `<conversation id>.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<string | null> {
@@ -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<Source> {
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<string, unknown>;
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<ApiResponse<StoryResponse>> => {
app.get('/api/sessions/:id/story', async (req, reply): Promise<ApiResponse<StoryResponse> | 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<ApiResponse<StoryHelperResponse> | 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),
},
};
}
);
}
+91 -2
View File
@@ -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'));