fix(story-mode): read how current Claude Code builds deliver prompts and end turns

Found by replaying round-2 review transcripts while the reviews ran, then
surveyed across 150 local transcripts:

- A prompt typed while Claude works writes no user line in current builds,
  only a queued_command attachment where Claude took it in (602 of them
  locally, 485 of those background-task reports). It now joins the running
  turn; a report that lands mid-turn is a notice on the wire, not a new
  mission panel, and a background helper's answer joins back from it.
- Some sessions never write turn_duration (13 of 135 locally): the Stop
  hook's stop_hook_summary (never preventedContinuation in 815 seen) now
  ends the turn, timed by its own span. Before, such a session's turns
  never ended.
- A helper started as a teammate (teammate_spawned) is a background helper;
  its <teammate-message> reports (text or a JSON idle notification) join it
  by name, and the turn they start reads "readme-writer: README done".
- A <cross-session-message> names its sender; pasted text loses Claude
  Code's <pasted_content> wrapper (it showed in balloons and the picker).
- The mission panel, its balloon and its drawer follow who sent each
  prompt (you, a background job, another session), not who started the turn.
- A caption names a script by its first line that says something, not
  `set -e`, a bare `cd` or `S=/tmp/...`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 10:41:14 +02:00
1 parent 03cb3a5961
commit 848566828f
5 files changed
+343 -26

No files matched your search

+174 -20
View File
@@ -107,6 +107,8 @@ export type StoryEntry =
queued: 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. */
teammates?: StoryTeammateReport[];
}
| { k: 'text'; at: string; text: string }
| { k: 'tool'; at: string; id: string; name: string; input: ReducedInput }
@@ -135,6 +137,13 @@ interface StoryTaskReport {
durationMs?: number;
}
/** One `<teammate-message>`: a helper started as a teammate, reporting back by name. */
interface StoryTeammateReport {
from: string;
summary?: string;
result?: string;
}
// ========== Small helpers ==========
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
@@ -465,11 +474,61 @@ const SYSTEM_REMINDER_RE = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
const LOCAL_WRAPPER_RE =
/^\s*<(local-command-caveat|local-command-stdout|local-command-stderr|bash-input|bash-stdout|bash-stderr)>/;
function readPrompt(
raw: string
): { text: string; kind: 'prompt' | 'task' | 'interrupt'; task?: StoryTaskReport } | null {
const text = raw.replace(SYSTEM_REMINDER_RE, '').trim();
/**
* Messages from teammates: `<teammate-message teammate_id="x" summary="...">body</teammate-message>`,
* where the body is text or a JSON notification (`{"type":"idle_notification","result":"..."}`).
*/
function readTeammateMessages(text: string): StoryTeammateReport[] {
const reports: StoryTeammateReport[] = [];
for (const m of text.matchAll(/<teammate-message\b([^>]*)>([\s\S]*?)<\/teammate-message>/g)) {
const from = /teammate_id="([^"]*)"/.exec(m[1])?.[1]?.split('@')[0];
if (!from) continue;
const summary = /summary="([^"]*)"/.exec(m[1])?.[1];
let body: string | undefined = m[2].trim();
if (body.startsWith('{')) {
try {
const note: unknown = JSON.parse(body);
body = isRecord(note) ? (str(note.result) ?? str(note.summary) ?? str(note.message)) : undefined;
} catch {
// not JSON after all: keep it as text
}
}
reports.push({
from: clip(from, 80),
summary: summary ? clip(summary, 200) : undefined,
result: body || undefined,
});
}
return reports;
}
function readPrompt(raw: string): {
text: string;
kind: 'prompt' | 'task' | 'peer' | 'interrupt';
task?: StoryTaskReport;
teammates?: StoryTeammateReport[];
} | null {
const text = raw
.replace(SYSTEM_REMINDER_RE, '')
.replace(/<\/?pasted_content\b[^>]*>/g, '') // Claude Code's wrapper around pasted text
.replace(/^\s*Another Claude session sent a message:\s*/, '')
.trim();
if (!text) return null;
if (/^<teammate-message\b/.test(text)) {
const teammates = readTeammateMessages(text);
const first = teammates.find((t) => t.summary || t.result) ?? teammates[0];
if (first)
return {
text: `${first.from}: ${first.summary || (first.result ? firstSentence(first.result, 200) : 'reports back')}`,
kind: 'task',
teammates,
};
}
const peer = /^<cross-session-message\b([^>]*)>([\s\S]*?)(?:<\/cross-session-message>|$)/.exec(text);
if (peer) {
const name = /from-name="([^"]*)"/.exec(peer[1])?.[1];
return { text: `${name ? `${name}: ` : ''}${peer[2].trim()}`, kind: 'peer' };
}
if (/^\[Request interrupted/.test(text)) return { text, kind: 'interrupt' };
if (LOCAL_WRAPPER_RE.test(text)) return null;
const command = /<command-name>([^<]*)<\/command-name>/.exec(text);
@@ -495,10 +554,10 @@ function readPrompt(
return { text, kind: 'prompt' };
}
function originOf(turnOrigin: unknown, kind: 'prompt' | 'task'): StoryTurnOrigin {
function originOf(turnOrigin: unknown, kind: 'prompt' | 'task' | 'peer'): StoryTurnOrigin {
if (turnOrigin === 'task_notification' || turnOrigin === 'task-notification' || kind === 'task') return 'task';
if (turnOrigin === 'peer' || turnOrigin === 'coordinator' || kind === 'peer') return 'peer';
if (turnOrigin === 'human') return 'human';
if (turnOrigin === 'task_notification' || kind === 'task') return 'task';
if (turnOrigin === 'peer') return 'peer';
if (typeof turnOrigin === 'string') return 'system';
return 'human';
}
@@ -688,7 +747,8 @@ export function createStoryReducer({ sidechain = false }: { sidechain?: boolean
}
if (name === 'Agent' || name === 'Task') {
// A background start answers with launch notes meant for Claude only, never the helper's answer.
const background = extra.isAsync === true || extra.status === 'async_launched';
const background =
extra.isAsync === true || extra.status === 'async_launched' || extra.status === 'teammate_spawned';
const answer = background ? '' : blockText(extra.content) || text;
result.helper = {
background: background || undefined,
@@ -721,13 +781,47 @@ export function createStoryReducer({ sidechain = false }: { sidechain?: boolean
const prompt = readPrompt(blockText(content));
if (!prompt) return out;
if (prompt.kind === 'interrupt') return [{ k: 'interrupt', at }];
return [
promptEntry(
prompt,
str(entry.uuid) ?? `prompt-${at}`,
at,
originOf(entry.turnOrigin, prompt.kind),
entry.promptSource === 'queued'
),
];
}
/**
* A prompt that arrived while Claude worked: current builds write no user line for it, only an
* `attachment` of type `queued_command` where Claude took it in (a typed prompt, a background
* task's notification, a message from another session).
*/
function reduceQueuedCommand(entry: Record<string, unknown>, at: string): StoryEntry[] {
const a = isRecord(entry.attachment) ? entry.attachment : {};
if (a.type !== 'queued_command') return [];
const prompt = readPrompt(typeof a.prompt === 'string' ? a.prompt : blockText(a.prompt));
if (!prompt || prompt.kind === 'interrupt') return [];
const from = isRecord(a.origin) ? a.origin.kind : undefined;
const kind = a.commandMode === 'task-notification' && prompt.kind === 'prompt' ? 'task' : prompt.kind;
const id = str(a.source_uuid) ?? str(entry.uuid) ?? `queued-${at}`;
return [promptEntry(prompt, id, at, originOf(from ?? 'human', kind), true)];
}
function promptEntry(
prompt: NonNullable<ReturnType<typeof readPrompt>>,
id: string,
at: string,
origin: StoryTurnOrigin,
queued: boolean
): Extract<StoryEntry, { k: 'prompt' }> {
const reduced: Extract<StoryEntry, { k: 'prompt' }> = {
k: 'prompt',
id: str(entry.uuid) ?? `prompt-${at}`,
id,
at,
text: clip(prompt.text, STORY_LIMITS.promptChars),
origin: originOf(entry.turnOrigin, prompt.kind),
queued: entry.promptSource === 'queued',
origin,
queued,
};
if (prompt.task) {
const t = prompt.task;
@@ -739,7 +833,13 @@ export function createStoryReducer({ sidechain = false }: { sidechain?: boolean
durationMs: t.durationMs,
};
}
return [reduced];
if (prompt.teammates?.length)
reduced.teammates = prompt.teammates.slice(0, 8).map((t) => ({
from: t.from,
summary: t.summary,
result: t.result ? clip(t.result, STORY_LIMITS.helperChars) : undefined,
}));
return reduced;
}
function reduceAssistant(entry: Record<string, unknown>, at: string): StoryEntry[] {
@@ -771,8 +871,13 @@ export function createStoryReducer({ sidechain = false }: { sidechain?: boolean
return reduceUser(line, at);
case 'assistant':
return reduceAssistant(line, at);
case 'attachment':
return reduceQueuedCommand(line, at);
case 'system':
if (line.subtype === 'turn_duration') return [{ k: 'end', at, durationMs: num(line.durationMs) }];
// Some sessions never write turn_duration; the Stop hook's summary marks the same end (unless
// the hook kept Claude going).
if (line.subtype === 'stop_hook_summary' && line.preventedContinuation !== true) return [{ k: 'end', at }];
if (line.subtype === 'compact_boundary') return [{ k: 'compact', at }];
return [];
default:
@@ -930,7 +1035,25 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] {
const byId = new Map<string, StoryEvent>();
/** Helper calls by tool id, across turns: a background helper reports back in a later turn. */
const helpers = new Map<string, StoryEvent>();
/** The same helpers by name: a teammate reports back by name, not by call. */
const helpersByName = new Map<string, StoryEvent>();
let say = 0;
const teammatesBack = (reports: StoryTeammateReport[], prompt: StoryEvent) => {
const told = reports.filter((r) => r.result || r.summary);
if (told.length) {
prompt.output = clip(
told.map((r) => `${r.from}: ${r.result ?? r.summary}`).join('\n\n'),
STORY_LIMITS.helperChars
);
}
for (const r of reports) {
const ev = helpersByName.get(r.from);
if (!ev?.helper) continue;
const helper: StoryHelper = { ...ev.helper, running: undefined };
if (r.result) helper.result = r.result;
ev.helper = helper;
}
};
const reportBack = (report: StoryTaskReport, prompt: StoryEvent) => {
if (report.result) {
prompt.output = report.result;
@@ -964,9 +1087,23 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] {
for (const e of entries) {
switch (e.k) {
case 'prompt': {
const ev: StoryEvent = { id: e.id, at: e.at, actor: 'you', tool: 'Prompt', label: e.text, intent: 'prompt' };
const ev: StoryEvent = {
id: e.id,
at: e.at,
actor: e.origin === 'human' ? 'you' : 'system',
tool: 'Prompt',
label: e.text,
intent: 'prompt',
origin: e.origin,
};
if (e.task) reportBack(e.task, ev);
if (e.teammates) teammatesBack(e.teammates, ev);
if (cur && !cur.ended && e.queued) {
// A report that lands while Claude works is a line on the wire, not a new mission.
if (e.origin === 'task') {
ev.intent = 'notice';
ev.tool = 'Report';
}
cur.events.push(ev);
} else {
cur = {
@@ -1001,6 +1138,8 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] {
if (ev.helper) {
helpers.set(e.id, ev);
if (helpers.size > 500) helpers.delete(helpers.keys().next().value as string);
helpersByName.set(ev.helper.name, ev);
if (helpersByName.size > 500) helpersByName.delete(helpersByName.keys().next().value as string);
}
break;
}
@@ -1017,9 +1156,9 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] {
// Claude Code times a turn a background task started from the prompt that started the chain;
// a turn never takes longer than its own span, so that is the cap.
const span = Date.parse(e.at) - Date.parse(t.at);
if (e.durationMs !== undefined)
t.durationMs =
!t.partial && Number.isFinite(span) && span >= 0 ? Math.min(e.durationMs, span) : e.durationMs;
const known = !t.partial && Number.isFinite(span) && span >= 0;
if (e.durationMs !== undefined) t.durationMs = known ? Math.min(e.durationMs, span) : e.durationMs;
else if (known && t.durationMs === undefined) t.durationMs = span; // ended by the Stop hook's summary
}
break;
}
@@ -1053,7 +1192,7 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] {
return turns;
}
const NOT_TOOL_CALLS = new Set<StoryIntent>(['prompt', 'say', 'interrupt', 'compact']);
const NOT_TOOL_CALLS = new Set<StoryIntent>(['prompt', 'say', 'interrupt', 'compact', 'notice']);
const countToolCalls = (events: StoryEvent[]) => events.filter((e) => !NOT_TOOL_CALLS.has(e.intent)).length;
export function summarizeTurn(turn: StoryTurn): StoryTurnSummary {
@@ -1089,6 +1228,7 @@ const PANEL_FOR: Record<StoryIntent, StoryPanelKind | null> = {
ask: 'ask',
compact: 'compact',
interrupt: null,
notice: null,
};
/** Kinds whose consecutive events share one panel. A prompt or a question always gets its own. */
const MERGEABLE = new Set<StoryPanelKind>([
@@ -1244,7 +1384,13 @@ function countBy(events: StoryEvent[], key: (e: StoryEvent) => string): string {
return [...counts].map(([k, n]) => (n > 1 ? `${k} ×${n}` : k)).join(' · ');
}
const shortCommand = (e: StoryEvent) => clip(e.label.split('\n')[0] ?? '', 60);
/** Lines of a script that say nothing about what it does: `set -e`, a bare `cd dir`, `S=/tmp/x`, comments. */
const NOISE_LINE = /^\s*(#.*|set\s+[-+][a-zA-Z]+(\s+\w+)?|cd\s+\S+|[A-Za-z_][A-Za-z0-9_]*=\S*)?\s*$/;
/** A command's first line that says what it does, for a caption. */
const shortCommand = (e: StoryEvent) => {
const lines = e.label.split('\n');
return clip(lines.find((l) => !NOISE_LINE.test(l)) ?? lines[0] ?? '', 60);
};
function montageSummary(evs: StoryEvent[]): string {
const parts: string[] = [];
@@ -1292,8 +1438,16 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
peer: 'Another session sends a message.',
system: 'Something automatic starts this turn.',
};
story = index > 0 ? 'You add something while Claude works.' : lines[turn.origin];
director = `Prompt · ${plural(words, 'word')}${turn.origin === 'human' ? '' : ` · ${turn.origin}`}`;
const origin = evs[0]?.origin ?? turn.origin;
story =
index === 0
? lines[turn.origin]
: origin === 'human'
? 'You add something while Claude works.'
: origin === 'peer'
? 'Another session chimes in while Claude works.'
: lines[origin];
director = `Prompt · ${plural(words, 'word')}${origin === 'human' ? '' : ` · ${origin}`}`;
break;
}
case 'investigate': {
+3
View File
@@ -29,6 +29,7 @@ export type StoryIntent =
| 'ask'
| 'compact'
| 'interrupt'
| 'notice'
| 'tool';
/** The comic panel a run of events is drawn as. */
@@ -120,6 +121,8 @@ export interface StoryEvent {
description?: string;
/** A save that ships the work: `gh pr create` (`pr`) or `git push` (`push`). */
ships?: 'pr' | 'push';
/** Who a prompt came from (a prompt added mid-turn can come from someone other than the turn's starter). */
origin?: StoryTurnOrigin;
output?: string;
outputTruncated?: boolean;
edit?: StoryEdit;
+22 -5
View File
@@ -735,7 +735,15 @@ const StoryArt = (() => {
},
draw(ctx, W, H, f, p, hit) {
const ax = Math.max(W * 0.78, W - 104);
hit('prompt', ax - 56, 110, 112, 140, p.page?.origin === 'task' ? 'Read the report' : 'Read the whole prompt');
const from = p.page?.events[p.panel.events[0]]?.origin;
hit(
'prompt',
ax - 56,
110,
112,
140,
from === 'task' ? 'Read the report' : from === 'peer' ? 'Read the message' : 'Read the whole prompt'
);
const a = agent(ctx, ax, 244, {
frame: f,
mood: 'listen',
@@ -2069,7 +2077,9 @@ Object.assign(CodemanApp.prototype, {
rt.balloon.append(balloonSpan);
}
balloonSpan.textContent = balloonText || '';
const notice = p.kind === 'mission' && index === 0 && page.origin && page.origin !== 'human';
// A prompt somebody else sent (a background job, another session) is a notice, not your speech.
const origin = page.events[p.events[0]]?.origin ?? (index === 0 ? page.origin : 'human');
const notice = p.kind === 'mission' && origin && origin !== 'human';
rt.balloon.className = `story-balloon ${
notice
? 'story-balloon--note'
@@ -2646,9 +2656,16 @@ Object.assign(CodemanApp.prototype, {
source.view === 'diff' && items.length && !items.some((x) => x.ev.edit || x.ev.pending) ? 'output' : source.view;
if (view === 'prompt') {
const prompts = items.filter((x) => x.ev.intent === 'prompt');
const task = page.origin === 'task' && prompts[0]?.ev.id === page.events[0]?.id;
kind = task ? 'Report' : 'Mission';
title = task ? 'A background job reports back' : prompts.length > 1 ? 'What you asked' : 'Your prompt';
const from = prompts[0]?.ev.origin ?? 'human';
kind = from === 'task' ? 'Report' : from === 'peer' ? 'Message' : 'Mission';
title =
from === 'task'
? 'A background job reports back'
: from === 'peer'
? 'A message from another session'
: prompts.length > 1
? 'What you asked'
: 'Your prompt';
for (const { ev } of prompts) {
out.push(el('pre', 'story-pre story-prompt', ev.label));
if (ev.output !== undefined) {