show compaction as it happens and open tool calls while they run

Compaction was the one thing the harness does that emitted nothing at all while it
ran, and it can run for minutes — silence that reads as a hung turn, which costs a
server restart to discover it wasn't. The sidecar now reports both ends: the start
from the PreCompact hook, the finish from the compact_boundary message with the
token count, both durable so a reload or a reconnect still sees them.

Tool rows open themselves while they run and hold for five seconds after their
result, so the inputs are on screen at the moment the call is made rather than
after the fact. The clock lives outside React, keyed by tool call id: rows are
virtualised, so unmounting is not the call ending, and a fast call can render its
start and its result together — a row that only opens when it catches the pending
state never opens for exactly the quickest calls. A click outranks the clock for
as long as the row lives. A failure behaves identically and differs only in colour,
so it stays findable by scanning and nameable in conversation.

Shell logs move off the green-on-black pre onto the shared code surface, which is
the one block that had no copy button and the one you most often want to hand to
someone else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 05:10:22 +00:00
co-authored by Claude Opus 5
parent ce7968ac90
commit 969b2f3762
15 changed files with 694 additions and 147 deletions
+21 -1
View File
@@ -104,6 +104,10 @@ type Entry = {
summary?: string;
message?: { role?: string; content?: unknown };
isMeta?: boolean;
/** `system` entries carry their kind here — `compact_boundary` is the one this reader cares about. */
subtype?: string;
/** Present on a `compact_boundary`. camelCase on disk; the live SDK stream uses snake_case. */
compactMetadata?: { trigger?: string; preTokens?: number; durationMs?: number };
};
/**
@@ -346,7 +350,11 @@ export type ClaudeChatMessage =
// its memory.
| { role: 'divider'; sessionId: string }
/** A turn you stopped. See `INTERRUPTION_MARKERS`. */
| { role: 'interrupted' };
| { role: 'interrupted' }
// Where the agent rewrote its own context. Kept for the same reason as `divider`: the conversation
// above it is still yours to read, and the agent's memory of it is a summary. It is also the answer to
// "why did it go quiet for two minutes there", which is only useful if it survives a reload.
| { role: 'compact'; trigger: 'manual' | 'auto'; preTokens?: number; durationMs?: number; done: true };
/**
* Claude records an interrupted turn by writing one of these as the *user's* next message — it is how the
@@ -408,6 +416,18 @@ function parseClaudeTranscript(filePath: string, sessionId: string, fallbackCwd
const content = entry.message?.content;
if (entry.type === 'system' && entry.subtype === 'compact_boundary') {
const meta = entry.compactMetadata ?? {};
messages.push({
role: 'compact',
trigger: meta.trigger === 'manual' ? 'manual' : 'auto',
preTokens: meta.preTokens,
durationMs: meta.durationMs,
done: true,
});
continue;
}
if (entry.type === 'user' && !entry.isMeta) {
if (typeof content === 'string') {
if (content.trim()) messages.push(userOrInterruption(content));
+15 -3
View File
@@ -177,7 +177,13 @@ export type ServerMessage =
type: 'disconnected';
}
| { type: 'task:started'; taskId: string; description: string; taskType?: string }
| { type: 'task:notification'; taskId: string; status: 'completed' | 'failed' | 'stopped'; summary: string };
| { type: 'task:notification'; taskId: string; status: 'completed' | 'failed' | 'stopped'; summary: string }
// Context compaction, start and end. Worth its own pair of messages because compaction is the one thing
// the harness does that produces no output at all while it runs — and it can run for minutes. Without
// these the stream simply stops, which is indistinguishable from a hung turn, a dead socket or a
// crashed agent; the honest response to that is to restart the server, which is what actually happened.
| { type: 'compact:start'; trigger: 'manual' | 'auto' }
| { type: 'compact:done'; trigger: 'manual' | 'auto'; preTokens: number; durationMs?: number };
// The turn-output subset of ServerMessage — everything the agent sidecar produces on its own. The
// remaining members (session:init, sync:messages, disconnected, connection-level errors) are officer's:
@@ -196,7 +202,9 @@ export type TurnMessageType =
| 'error'
| 'stopped'
| 'task:started'
| 'task:notification';
| 'task:notification'
| 'compact:start'
| 'compact:done';
export type TurnMessage = Extract<ServerMessage, { type: TurnMessageType }> & { prevSeq?: number };
@@ -225,7 +233,11 @@ export type ChatEvent =
// Background-task lifecycle (run_in_background / Monitor), delivered in-stream by the persistent
// session — including AFTER the turn's `result`, which is the whole point of the persistent worker.
| { type: 'task:started'; taskId: string; description: string; taskType?: string }
| { type: 'task:notification'; taskId: string; status: 'completed' | 'failed' | 'stopped'; summary: string };
| { type: 'task:notification'; taskId: string; status: 'completed' | 'failed' | 'stopped'; summary: string }
// Compaction, start and end. The start half has no message in the harness's output stream at all — it
// comes from the `PreCompact` hook, which is why it is a sidecar concern and not the parser's.
| { type: 'compact:start'; trigger: 'manual' | 'auto' }
| { type: 'compact:done'; trigger: 'manual' | 'auto'; preTokens: number; durationMs?: number };
export type UserSession = {
sessionId: string;
+34 -1
View File
@@ -179,6 +179,12 @@ type PersistentSession = {
* side that called `interrupt()` knows better, so it says so here.
*/
interrupted: boolean;
/**
* When the `PreCompact` hook fired, so the `compact_boundary` that closes it can carry how long the
* silence lasted. The harness reports the boundary but not the duration, and the duration is the part
* that explains the wait.
*/
compactStartedAt?: number;
idleTimer?: ReturnType<typeof setTimeout>;
};
@@ -259,6 +265,25 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
includePartialMessages: true,
// The only warning that compaction is about to happen. Everything else the harness does narrates
// itself through the message stream; compaction goes silent for as long as it takes — 2.5 minutes
// in the worst case on disk here — and the stream resumes with no explanation of the gap. The hook
// returns immediately and never throws: it is a notification, and it must not be able to stall or
// fail the compaction it is announcing.
hooks: {
PreCompact: [
{
hooks: [
async (input) => {
session.compactStartedAt = Date.now();
const trigger = 'trigger' in input && input.trigger === 'manual' ? 'manual' : 'auto';
session.emit({ type: 'compact:start', trigger });
return { continue: true };
},
],
},
],
},
abortController: abort,
pathToClaudeCodeExecutable: CLAUDE_BIN,
settingSources: ['user', 'project', 'local'],
@@ -304,7 +329,15 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
const state = createParseState();
const emit = (raw: ChatEvent) => {
// A turn we interrupted ends in a failed `result`. That is the stop landing, not a fault.
const event: ChatEvent = raw.type === 'error' && session.interrupted ? { type: 'stopped' } : raw;
const event: ChatEvent =
raw.type === 'error' && session.interrupted
? { type: 'stopped' }
: // The boundary knows what it dropped; only this side knows how long it took, because the start
// came from a hook rather than from the stream.
raw.type === 'compact:done' && session.compactStartedAt
? { ...raw, durationMs: Date.now() - session.compactStartedAt }
: raw;
if (event.type === 'compact:done') session.compactStartedAt = undefined;
if (event.type === 'task:started') {
// Work is running — hold off idle-GC until it finishes.
session.pendingTasks.add(event.taskId);
@@ -150,6 +150,25 @@ describe('processLine', () => {
expect(sessionIds).toEqual(['sess_abc123']);
});
test('handles compact_boundary — emits compact:done with what it dropped', () => {
const state = createParseState();
const { events, callbacks } = makeCallbacks();
const line = JSON.stringify({
type: 'system',
subtype: 'compact_boundary',
compact_metadata: { trigger: 'auto', pre_tokens: 168215 },
});
processLine(line, state, callbacks);
expect(events).toEqual([{ type: 'compact:done', trigger: 'auto', preTokens: 168215 }]);
});
test('handles compact_boundary with no metadata — still announces the boundary', () => {
const state = createParseState();
const { events, callbacks } = makeCallbacks();
processLine(JSON.stringify({ type: 'system', subtype: 'compact_boundary' }), state, callbacks);
expect(events).toEqual([{ type: 'compact:done', trigger: 'auto', preTokens: 0 }]);
});
test('handles result — sets gotResult, emits cost', () => {
const state = createParseState();
state.textBuffers.set('', 'trailing');
@@ -163,6 +163,15 @@ function handleSystem(msg: Record<string, unknown>, callbacks: StreamParserCallb
status: (msg.status as 'completed' | 'failed' | 'stopped') ?? 'completed',
summary: (msg.summary as string) ?? '',
});
} else if (subtype === 'compact_boundary') {
// The harness rewrote its own context. This lands *after* the work — the matching start comes from
// the `PreCompact` hook — and is the only place the size of what was dropped is reported.
const meta = (msg.compact_metadata ?? {}) as Record<string, unknown>;
callbacks.onEvent({
type: 'compact:done',
trigger: meta.trigger === 'manual' ? 'manual' : 'auto',
preTokens: typeof meta.pre_tokens === 'number' ? meta.pre_tokens : 0,
});
}
}
@@ -136,6 +136,19 @@ describe('createTurnStream', () => {
]);
});
test('compaction flushes the interrupted sentence before announcing itself', () => {
const { durable } = run([
{ type: 'delta', text: 'mid-thought' },
{ type: 'compact:start', trigger: 'auto' },
{ type: 'compact:done', trigger: 'auto', preTokens: 168215, durationMs: 154054 },
]);
expect(durable).toEqual([
{ type: 'assistant:text', text: 'mid-thought' },
{ type: 'compact:start', trigger: 'auto' },
{ type: 'compact:done', trigger: 'auto', preTokens: 168215, durationMs: 154054 },
]);
});
test('streams are independent', () => {
const a = createTurnStream('a');
const b = createTurnStream('b');
+18
View File
@@ -121,6 +121,24 @@ export function createTurnStream(sessionId: string): TurnStream {
durable: true,
},
];
case 'compact:start':
// Flushed first: whatever the agent had said before compaction began is finished text, and the
// notice belongs after it rather than in the middle of a paragraph it interrupted.
return [...flushAll(), { msg: { type: 'compact:start', trigger: event.trigger }, durable: true }];
case 'compact:done':
return [
{
msg: {
type: 'compact:done',
trigger: event.trigger,
preTokens: event.preTokens,
durationMs: event.durationMs,
},
durable: true,
},
];
}
}