chat: persistent Agent SDK session per chat — decouple worker from turn (Phase 1)

Root fix for orphaned background tasks: the platform drove Claude Code as a
one-shot `claude -p` per turn (stdin ignored, process exits at turn end), so
run_in_background/Monitor work — and its task_notification — had no live harness
to return to. Now each chat session runs ONE long-lived Agent SDK query() with
streaming input; turns are user messages pushed onto it, and the session stays
warm between turns.

- claude-manager: persistent `query({ prompt: AsyncIterable, options })` per
  sessionKey (bypassPermissions, --resume, mcp via extraArgs, CLAUDECODE stripped).
  Single consumer loop maps every SDK message → ChatEvent, incl. post-turn
  task_started / task_notification. interrupt() = stop-turn; abort() = kill-session;
  30-min idle GC.
- stream-parser: processMessage() (object-level, reused by the SDK loop) + task
  message handling. ChatEvent/ServerMessage gain task:started / task:notification.
- API: the sidecar event subscription is now SESSION-scoped (no longer unsubscribes
  on 'result'), so background events after turn-end still reach the client. First
  turn opens the session; later turns push onto it. handleStop → interrupt (keeps
  session warm); disconnect/deleteSession → kill.
- protocol/sidecar-registry/user-instance: claude:interrupt command + interruptClaude.
- client: render task:started / task:notification in the transcript.

Verified end-to-end through the real chat WS: a run_in_background task's completion
arrives ~6s AFTER the turn's result; multi-turn on one warm session works.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 00:00:11 +00:00
co-authored by Claude Opus 4.8
parent b9d539c1bd
commit 449f28b1e5
10 changed files with 329 additions and 155 deletions
+57 -26
View File
@@ -118,6 +118,62 @@ function handleResult(
callbacks.onEvent({ type: 'result', cost });
}
function handleSystem(msg: Record<string, unknown>, callbacks: StreamParserCallbacks): void {
const subtype = msg.subtype as string | undefined;
if (subtype === 'init') {
const sessionId = msg.session_id as string | undefined;
if (sessionId) callbacks.onSessionId(sessionId);
} else if (subtype === 'task_started') {
callbacks.onEvent({
type: 'task:started',
taskId: (msg.task_id as string) ?? '',
description: (msg.description as string) ?? '',
taskType: msg.task_type as string | undefined,
});
} else if (subtype === 'task_notification') {
callbacks.onEvent({
type: 'task:notification',
taskId: (msg.task_id as string) ?? '',
status: (msg.status as 'completed' | 'failed' | 'stopped') ?? 'completed',
summary: (msg.summary as string) ?? '',
});
}
}
/**
* Map a single already-parsed message object (a Claude Code stream-json message, or the equivalent
* Agent SDK message — same shapes) to ChatEvents. Returns false if the message type was ignored.
*/
export function processMessage(
msg: Record<string, unknown>,
state: ParseState,
callbacks: StreamParserCallbacks,
): boolean {
const type = msg.type as string;
switch (type) {
case 'stream_event':
handleStreamEvent(msg, state, callbacks.onEvent);
break;
case 'assistant':
handleAssistant(msg, state, callbacks.onEvent);
break;
case 'user':
handleUser(msg, callbacks.onEvent);
break;
case 'system':
handleSystem(msg, callbacks);
break;
case 'result':
handleResult(msg, state, callbacks);
break;
default:
return false;
}
return true;
}
/**
* Process a single NDJSON line from Claude Code's stream output.
* Returns false if the line was skipped (empty or malformed), true otherwise.
@@ -136,32 +192,7 @@ export function processLine(
return false;
}
const type = msg.type as string;
switch (type) {
case 'stream_event':
handleStreamEvent(msg, state, callbacks.onEvent);
break;
case 'assistant':
handleAssistant(msg, state, callbacks.onEvent);
break;
case 'user':
handleUser(msg, callbacks.onEvent);
break;
case 'system':
if (msg.subtype === 'init') {
const sessionId = msg.session_id as string | undefined;
if (sessionId) callbacks.onSessionId(sessionId);
}
break;
case 'result':
handleResult(msg, state, callbacks);
break;
default:
return false;
}
return true;
return processMessage(msg, state, callbacks);
}
/**