From f4898a626f1bbdcc349dd77054ec39146e4c662c Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 11:42:26 +0200 Subject: [PATCH] fix(story-mode): a turn ended only by the next prompt says so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A turn with no end marker of its own (an abandoned or crashed turn) was closed when the next prompt started and then drawn as "Mission complete!". It now reads "To be continued…" / "The trail stops here: the next prompt starts a new turn.", and the page carries `superseded`. The Director's cut names what ended every turn: turn_duration, stop_hook_summary, a local command, an interrupt, or the next prompt. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/story-mode.ts | 27 +++++++++++++++++---------- src/types/story.ts | 2 ++ src/web/public/story-mode.js | 6 +++++- test/story-mode.test.ts | 10 +++++++++- 4 files changed, 33 insertions(+), 12 deletions(-) diff --git a/src/story-mode.ts b/src/story-mode.ts index 6922d4df..8b95b840 100644 --- a/src/story-mode.ts +++ b/src/story-mode.ts @@ -1144,6 +1144,8 @@ export interface StoryTurn { durationMs?: number; /** Claude Code's id for the prompt that started the turn. */ promptId?: string; + /** What ended the turn, for the Director's cut. */ + endedBy?: 'turn_duration' | 'stop_hook_summary' | 'local command' | 'interrupted' | 'next prompt'; } /** Plan mode keeps its plan in a file under `~/.claude/plans/`: writing it is planning, not changing code. */ @@ -1348,9 +1350,10 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] { } }; /** Ends a turn at `at` with no duration of its own: timed by its span, when the span is known. */ - const closeTurn = (t: StoryTurn, at: string) => { + const closeTurn = (t: StoryTurn, at: string, by: NonNullable) => { t.ended = true; t.endedAt = at; + t.endedBy = by; const span = Date.parse(at) - Date.parse(t.at); if (!t.partial && Number.isFinite(span) && span >= 0) t.durationMs ??= span; }; @@ -1420,7 +1423,7 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] { open.events.push(ev); } else { // Claude runs one turn at a time: a turn still open when the next one starts has ended. - if (open && !open.ended) closeTurn(open, e.at); + if (open && !open.ended) closeTurn(open, e.at, 'next prompt'); cur = { id: e.id, at: e.at, @@ -1479,6 +1482,7 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] { 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 + t.endedBy = e.durationMs !== undefined ? 'turn_duration' : (t.endedBy ?? 'stop_hook_summary'); } break; } @@ -1513,7 +1517,7 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] { 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); + closeTurn(t, e.at, 'local command'); } break; } @@ -1528,7 +1532,7 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] { intent: 'interrupt', }); t.interrupted = true; - closeTurn(t, e.at); + closeTurn(t, e.at, 'interrupted'); break; } } @@ -1547,6 +1551,7 @@ export function summarizeTurn(turn: StoryTurn): StoryTurnSummary { origin: turn.origin, complete: turn.ended && !turn.interrupted, interrupted: turn.interrupted, + ...(turn.endedBy === 'next prompt' ? { superseded: true } : {}), toolCalls: countToolCalls(turn.events), }; } @@ -2062,13 +2067,15 @@ function completePanel(turn: StoryTurn, page: Omit, trailin const done = turn.durationMs !== undefined ? `Done in ${roughDuration(turn.durationMs)}.` : 'Done.'; const story = turn.interrupted ? 'You stop Claude here.' - : running - ? `Claude's part is done${turn.durationMs !== undefined ? ` in ${roughDuration(turn.durationMs)}` : ''}. ${ - running === 1 ? 'A helper is' : `${running} helpers are` - } still working.` - : done; + : turn.endedBy === 'next prompt' + ? 'The trail stops here: the next prompt starts a new turn.' + : running + ? `Claude's part is done${turn.durationMs !== undefined ? ` in ${roughDuration(turn.durationMs)}` : ''}. ${ + running === 1 ? 'A helper is' : `${running} helpers are` + } still working.` + : done; const director = [ - turn.interrupted ? 'interrupted' : 'turn_duration', + turn.endedBy === 'next prompt' ? 'superseded by the next prompt' : (turn.endedBy ?? 'turn_duration'), turn.durationMs !== undefined ? exactDuration(turn.durationMs) : '', plural(page.toolCalls, 'tool call'), page.filesChanged ? plural(page.filesChanged, 'file') + ' changed' : '', diff --git a/src/types/story.ts b/src/types/story.ts index 0e515672..aec2e709 100644 --- a/src/types/story.ts +++ b/src/types/story.ts @@ -170,6 +170,8 @@ export interface StoryTurnSummary { origin: StoryTurnOrigin; complete: boolean; interrupted: boolean; + /** Ended only because the next prompt started (no end marker of its own: an abandoned or crashed turn). */ + superseded?: boolean; toolCalls: number; } diff --git a/src/web/public/story-mode.js b/src/web/public/story-mode.js index a4a11b43..f74927e5 100644 --- a/src/web/public/story-mode.js +++ b/src/web/public/story-mode.js @@ -2220,7 +2220,11 @@ Object.assign(CodemanApp.prototype, { : `You chose: ${p.question.answer}`; } if (rt.splash) { - rt.splash.querySelector('.story-splash-title').textContent = page.interrupted ? 'Stopped!' : 'Mission complete!'; + rt.splash.querySelector('.story-splash-title').textContent = page.interrupted + ? 'Stopped!' + : page.superseded + ? 'To be continued…' + : 'Mission complete!'; const chips = rt.splash.querySelector('.story-chips'); const items = []; if (page.durationMs !== undefined) items.push({ text: this._storyDuration(page.durationMs) }); diff --git a/test/story-mode.test.ts b/test/story-mode.test.ts index 6d611caa..6b28cf0f 100644 --- a/test/story-mode.test.ts +++ b/test/story-mode.test.ts @@ -879,7 +879,15 @@ describe('buildStoryPage', () => { { type: 'user', timestamp: '2026-10-18T09:50:00.000Z', message: { content: '[Request interrupted by user]' } }, ]) ); - expect(turns[0]).toMatchObject({ ended: true, interrupted: false }); + expect(turns[0]).toMatchObject({ ended: true, interrupted: false, endedBy: 'next prompt' }); + const superseded = buildStoryPage(turns[0]); + expect(superseded.superseded).toBe(true); + expect(superseded.panels[superseded.panels.length - 1].caption.story).toBe( + 'The trail stops here: the next prompt starts a new turn.' + ); + expect(superseded.panels[superseded.panels.length - 1].caption.director).toMatch( + /^superseded by the next prompt · / + ); expect(turns[0].durationMs).toBeGreaterThan(0); expect(turns[1]).toMatchObject({ ended: true, interrupted: true }); expect(turns[1].durationMs).toBeGreaterThan(0);