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
+4 -9
View File
@@ -50,16 +50,11 @@ export async function sendClaudeCodeStreaming(params: ClaudeCodeStreamingParams)
logger.info('Claude Code streaming exec (via sidecar)', { sessionKey: params.sessionKey });
// Subscribe to events for this session
// Session-scoped subscription. The persistent session outlives the turn, so background task events
// (task:notification) arrive AFTER 'result' — do NOT unsubscribe on a terminal turn event; only on
// an explicit kill/teardown (the returned handle, called from deleteSession/disconnect).
const unsub = sidecar.onClaudeEvent((sessionKey, event) => {
if (sessionKey === params.sessionKey) {
onEvent(event);
// Unsubscribe when we get a terminal event
if (event.type === 'result' || event.type === 'error') {
unsub();
}
}
if (sessionKey === params.sessionKey) onEvent(event);
});
await sidecar.spawnClaudeStreaming(spawnParams);