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:
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user