chapterhouse 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +1 -1
  2. package/agents/korg.agent.md +20 -0
  3. package/dist/api/auth.js +11 -1
  4. package/dist/api/auth.test.js +29 -0
  5. package/dist/api/errors.js +23 -0
  6. package/dist/api/route-coverage.test.js +61 -21
  7. package/dist/api/routes/agents.js +472 -0
  8. package/dist/api/routes/memory.js +299 -0
  9. package/dist/api/routes/projects.js +170 -0
  10. package/dist/api/routes/sessions.js +347 -0
  11. package/dist/api/routes/system.js +82 -0
  12. package/dist/api/routes/wiki.js +455 -0
  13. package/dist/api/routes/wiki.test.js +49 -0
  14. package/dist/api/send-json.js +16 -0
  15. package/dist/api/send-json.test.js +18 -0
  16. package/dist/api/server-runtime.js +45 -3
  17. package/dist/api/server.js +34 -1764
  18. package/dist/api/server.test.js +239 -8
  19. package/dist/api/sse-hub.js +37 -0
  20. package/dist/cli.js +1 -1
  21. package/dist/config.js +151 -58
  22. package/dist/config.test.js +29 -0
  23. package/dist/copilot/okr-mapper.js +2 -11
  24. package/dist/copilot/orchestrator.js +358 -352
  25. package/dist/copilot/orchestrator.test.js +139 -4
  26. package/dist/copilot/prompt-date.js +2 -1
  27. package/dist/copilot/session-manager.js +25 -23
  28. package/dist/copilot/session-manager.test.js +35 -1
  29. package/dist/copilot/standup.js +2 -2
  30. package/dist/copilot/task-event-log.js +7 -1
  31. package/dist/copilot/task-event-log.test.js +13 -0
  32. package/dist/copilot/tools/agent.js +608 -0
  33. package/dist/copilot/tools/index.js +19 -0
  34. package/dist/copilot/tools/memory.js +678 -0
  35. package/dist/copilot/tools/models.js +2 -0
  36. package/dist/copilot/tools/okr.js +171 -0
  37. package/dist/copilot/tools/wiki.js +333 -0
  38. package/dist/copilot/tools-deps.js +4 -0
  39. package/dist/copilot/tools.agent.test.js +10 -8
  40. package/dist/copilot/tools.inventory.test.js +76 -0
  41. package/dist/copilot/tools.js +1 -1725
  42. package/dist/copilot/tools.okr.test.js +31 -0
  43. package/dist/copilot/tools.wiki.test.js +358 -6
  44. package/dist/copilot/turn-event-log.js +31 -4
  45. package/dist/copilot/turn-event-log.test.js +24 -2
  46. package/dist/copilot/workiq-installer.test.js +2 -2
  47. package/dist/daemon-install.js +3 -2
  48. package/dist/daemon.js +9 -17
  49. package/dist/integrations/ado-client.js +90 -9
  50. package/dist/integrations/ado-client.test.js +56 -0
  51. package/dist/integrations/team-push.js +1 -0
  52. package/dist/integrations/team-push.test.js +6 -0
  53. package/dist/integrations/teams-notify.js +1 -0
  54. package/dist/integrations/teams-notify.test.js +5 -0
  55. package/dist/memory/active-scope.test.js +0 -1
  56. package/dist/memory/checkpoint.js +89 -72
  57. package/dist/memory/checkpoint.test.js +23 -3
  58. package/dist/memory/eot.js +194 -89
  59. package/dist/memory/eot.test.js +186 -3
  60. package/dist/memory/hooks.js +2 -4
  61. package/dist/memory/housekeeping-scheduler.js +1 -1
  62. package/dist/memory/housekeeping-scheduler.test.js +1 -2
  63. package/dist/memory/housekeeping.js +100 -3
  64. package/dist/memory/housekeeping.test.js +33 -2
  65. package/dist/memory/reflect.test.js +2 -0
  66. package/dist/memory/scope-lock.js +26 -0
  67. package/dist/memory/scope-lock.test.js +118 -0
  68. package/dist/memory/scopes.test.js +0 -1
  69. package/dist/mode-context.js +58 -5
  70. package/dist/mode-context.test.js +68 -0
  71. package/dist/paths.js +1 -0
  72. package/dist/setup.js +3 -2
  73. package/dist/shared/api-schemas.js +48 -5
  74. package/dist/store/connection.js +96 -0
  75. package/dist/store/db.js +5 -1498
  76. package/dist/store/db.test.js +182 -1
  77. package/dist/store/migrations.js +460 -0
  78. package/dist/store/repositories/memory.js +281 -0
  79. package/dist/store/repositories/okr.js +3 -0
  80. package/dist/store/repositories/projects.js +5 -0
  81. package/dist/store/repositories/sessions.js +284 -0
  82. package/dist/store/repositories/wiki.js +60 -0
  83. package/dist/store/schema.js +501 -0
  84. package/dist/util/logger.js +3 -2
  85. package/dist/wiki/consolidation.js +50 -9
  86. package/dist/wiki/consolidation.test.js +45 -0
  87. package/dist/wiki/frontmatter.js +45 -14
  88. package/dist/wiki/frontmatter.test.js +26 -1
  89. package/dist/wiki/fs.js +16 -4
  90. package/dist/wiki/fs.test.js +84 -0
  91. package/dist/wiki/index-manager.js +30 -2
  92. package/dist/wiki/index-manager.test.js +43 -12
  93. package/dist/wiki/ingest.js +17 -1
  94. package/dist/wiki/lock.js +11 -1
  95. package/dist/wiki/log-manager.js +2 -7
  96. package/dist/wiki/migrate.js +44 -17
  97. package/dist/wiki/project-registry.js +10 -5
  98. package/dist/wiki/project-registry.test.js +14 -0
  99. package/dist/wiki/scheduler.js +1 -1
  100. package/dist/wiki/seed-team-wiki.js +2 -1
  101. package/dist/wiki/team-sync.js +31 -6
  102. package/dist/wiki/team-sync.test.js +81 -0
  103. package/package.json +1 -1
  104. package/web/dist/assets/WikiEdit-BZXAdarz.js +30 -0
  105. package/web/dist/assets/WikiEdit-BZXAdarz.js.map +1 -0
  106. package/web/dist/assets/WikiGraph-KrCYco4v.js +2 -0
  107. package/web/dist/assets/WikiGraph-KrCYco4v.js.map +1 -0
  108. package/web/dist/assets/index-CUm2Wbuh.js +250 -0
  109. package/web/dist/assets/index-CUm2Wbuh.js.map +1 -0
  110. package/web/dist/index.html +1 -1
  111. package/web/dist/assets/index-iQrv3lQN.js +0 -286
  112. package/web/dist/assets/index-iQrv3lQN.js.map +0 -1
@@ -11,7 +11,7 @@ import { config, DEFAULT_MODEL } from "../config.js";
11
11
  import { loadMcpConfig } from "./mcp-config.js";
12
12
  import { getSkillDirectories } from "./skills.js";
13
13
  import { resetClient } from "./client.js";
14
- import { logConversation, getState, setState, deleteState, getCopilotSession, upsertCopilotSession, getTaskSessionKey, getDb, appendTaskEvent } from "../store/db.js";
14
+ import { logConversation, getState, setState, deleteState, getCopilotSession, upsertCopilotSession, deleteCopilotSession, getTaskSessionKey, getDb, appendTaskEvent } from "../store/db.js";
15
15
  import { maybeWriteEpisode } from "./episode-writer.js";
16
16
  import { SESSIONS_DIR } from "../paths.js";
17
17
  import { resolveModel } from "./router.js";
@@ -27,14 +27,17 @@ import { detectProjectRuleWarnings } from "./project-rule-warnings.js";
27
27
  import { loadRegistry } from "../wiki/project-registry.js";
28
28
  import { loadProjectRules } from "../wiki/project-rules.js";
29
29
  import { resolveProject } from "./project-resolution.js";
30
+ import { stopClassifier } from "./classifier.js";
30
31
  const log = childLogger("orchestrator");
31
32
  const MAX_RETRIES = 3;
32
- const RECONNECT_DELAYS_MS = [1_000, 3_000, 10_000];
33
+ const RECONNECT_BASE_DELAY_MS = 1_000;
34
+ const RECONNECT_MAX_DELAY_MS = 10_000;
33
35
  const HEALTH_CHECK_INTERVAL_MS = 30_000;
34
36
  const AGENT_REPLY_CHUNK_SIZE = 500;
35
37
  const AGENT_REPLY_CHUNK_THRESHOLD = 8 * 1024;
36
38
  const ORCHESTRATOR_SESSION_KEY = "orchestrator_session_id";
37
39
  const LAST_AUTHENTICATED_USER_KEY = "last_authenticated_user";
40
+ let taskEventLogCleanup;
38
41
  function getWikiSummary() {
39
42
  try {
40
43
  const db = getDb();
@@ -75,14 +78,6 @@ const turnContextStorage = new AsyncLocalStorage();
75
78
  let copilotClient;
76
79
  let healthCheckTimer;
77
80
  let currentUserContext;
78
- /**
79
- * Last-seen auth context — persists after a turn completes so that callers
80
- * which inspect it outside of an active turn (e.g. /api/cancel) still see the
81
- * most recent values. Tools that run DURING a turn should use the per-turn
82
- * AsyncLocalStorage context for safety in concurrent-session scenarios.
83
- */
84
- let currentAuthenticatedUser;
85
- let currentAuthorizationHeader;
86
81
  let lastRouteResult;
87
82
  let memoryCoordinator;
88
83
  export function getLastRouteResult() {
@@ -155,7 +150,7 @@ export function getCurrentActiveProjectRules() {
155
150
  return turnContextStorage.getStore()?.activeProjectRules ?? null;
156
151
  }
157
152
  export function getCurrentAuthenticatedUser() {
158
- return turnContextStorage.getStore()?.authUser ?? currentAuthenticatedUser;
153
+ return turnContextStorage.getStore()?.authUser;
159
154
  }
160
155
  export function getLastAuthenticatedUser() {
161
156
  const raw = getState(LAST_AUTHENTICATED_USER_KEY);
@@ -172,7 +167,7 @@ export function getLastAuthenticatedUser() {
172
167
  }
173
168
  }
174
169
  export function getCurrentAuthorizationHeader() {
175
- return turnContextStorage.getStore()?.authHeader ?? currentAuthorizationHeader;
170
+ return turnContextStorage.getStore()?.authHeader;
176
171
  }
177
172
  // ---------------------------------------------------------------------------
178
173
  // Internal helpers
@@ -238,14 +233,9 @@ function updateUserContext(source) {
238
233
  export function invalidateOrchestratorSession(sessionKey) {
239
234
  registry?.get(sessionKey)?.invalidateSession();
240
235
  }
241
- function updateRequestContext(source) {
242
- if (source.type !== "web" && source.type !== "sse-web") {
243
- currentAuthenticatedUser = undefined;
244
- currentAuthorizationHeader = undefined;
236
+ function recordLastAuthenticatedUser(source) {
237
+ if (source.type !== "web" && source.type !== "sse-web")
245
238
  return;
246
- }
247
- currentAuthenticatedUser = source.user;
248
- currentAuthorizationHeader = source.authorizationHeader?.trim() || undefined;
249
239
  if (source.user) {
250
240
  setState(LAST_AUTHENTICATED_USER_KEY, JSON.stringify(source.user));
251
241
  }
@@ -290,7 +280,7 @@ export function feedAgentResult(taskId, agentSlug, result) {
290
280
  catch (err) {
291
281
  log.warn({ err: err instanceof Error ? err.message : err, taskId, agentSlug }, "Failed to persist agent completion");
292
282
  }
293
- void (async () => {
283
+ const ackPromise = (async () => {
294
284
  await new Promise((resolve) => setImmediate(resolve));
295
285
  const prompt = `[Agent task completed] @${agentSlug} finished task ${taskId}. The user has already seen this reply in the agent's own bubble. Acknowledge briefly without restating content.`;
296
286
  sendToOrchestrator(prompt, { type: "background", sessionKey }, (text, done) => {
@@ -299,6 +289,9 @@ export function feedAgentResult(taskId, agentSlug, result) {
299
289
  }
300
290
  }, undefined, undefined, undefined, undefined, undefined, { suppressPromptLog: true });
301
291
  })();
292
+ void ackPromise.catch((err) => {
293
+ log.error({ err, taskId, agentSlug, sessionKey }, "unhandled rejection in feedAgentResult");
294
+ });
302
295
  }
303
296
  function sleep(ms) {
304
297
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -407,6 +400,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
407
400
  }
408
401
  catch (err) {
409
402
  log.warn({ sessionKey, err: err instanceof Error ? err.message : err }, "Could not resume session, creating new");
403
+ deleteCopilotSession(sessionKey);
410
404
  if (sessionKey === "default")
411
405
  deleteState(ORCHESTRATOR_SESSION_KEY);
412
406
  }
@@ -443,7 +437,8 @@ export async function initOrchestrator(client) {
443
437
  resolveScopeForSession: getMemoryScopeForSession,
444
438
  });
445
439
  // Initialize per-task ring buffer — subscribes to agentEventBus for session:tool_call events.
446
- initTaskEventLog();
440
+ taskEventLogCleanup?.();
441
+ taskEventLogCleanup = initTaskEventLog();
447
442
  // (Re-)create the registry — supports multiple initOrchestrator calls in tests
448
443
  if (registry) {
449
444
  await registry.shutdown();
@@ -485,16 +480,7 @@ export async function initOrchestrator(client) {
485
480
  * Override with CHAPTERHOUSE_ORCHESTRATOR_TIMEOUT_MS env var (parsed as integer ms).
486
481
  * Applies per-session-turn; concurrent sessions each have their own independent timeout.
487
482
  * Part of the 3-layer timing contract — see systemd unit TimeoutStopSec comment. */
488
- const DEFAULT_ORCHESTRATOR_TIMEOUT_MS = 1_800_000;
489
- export const ORCHESTRATOR_TIMEOUT_MS = (() => {
490
- const env = process.env.CHAPTERHOUSE_ORCHESTRATOR_TIMEOUT_MS;
491
- if (env) {
492
- const parsed = parseInt(env, 10);
493
- if (!isNaN(parsed) && parsed > 0)
494
- return parsed;
495
- }
496
- return DEFAULT_ORCHESTRATOR_TIMEOUT_MS;
497
- })();
483
+ export const ORCHESTRATOR_TIMEOUT_MS = config.orchestratorTimeoutMs;
498
484
  /**
499
485
  * Execute a single queued item on its session.
500
486
  * Wraps the entire turn in AsyncLocalStorage so all tool handlers (e.g. delegate_to_agent,
@@ -512,9 +498,6 @@ async function executeOnSession(manager, item) {
512
498
  const sessionPrompt = activeProjectRules
513
499
  ? `${warningBlock}${renderActiveProjectRulesBlock(activeProjectRules.project.slug, activeProjectRules.project.path, activeProjectRules.rules.path, activeProjectRules.rules.hard, activeProjectRules.rules.soft)}\n\n${item.prompt}`
514
500
  : item.prompt;
515
- // Update last-seen globals (backwards compat — for callers that inspect after a turn ends)
516
- currentAuthenticatedUser = item.authUser;
517
- currentAuthorizationHeader = item.authHeader;
518
501
  const runTurn = () => turnContextStorage.run({
519
502
  sessionKey,
520
503
  sourceChannel: item.sourceChannel,
@@ -532,327 +515,332 @@ async function executeOnSession(manager, item) {
532
515
  // actual spawn parameters (name, description) passed to the task() tool call.
533
516
  const spawnArgsMap = new Map();
534
517
  const toolStartDetails = new Map();
535
- // Unconditional capture — must fire even when onActivity is absent so the DB handler can resolve names.
536
- const unsubSpawnCapture = session.on("tool.execution_start", (event) => {
537
- const data = event.data;
538
- if (data.toolCallId) {
539
- toolStartDetails.set(data.toolCallId, {
540
- toolName: String(data.toolName ?? "unknown"),
541
- mcpServerName: typeof data.mcpServerName === "string" ? data.mcpServerName : undefined,
542
- arguments: data.arguments,
543
- });
544
- }
545
- if (data.toolName === "task" && data.toolCallId) {
546
- const args = (data.arguments ?? {});
547
- spawnArgsMap.set(data.toolCallId, {
548
- name: typeof args.name === "string" ? args.name : undefined,
549
- description: typeof args.description === "string" ? args.description : undefined,
550
- prompt: typeof args.prompt === "string"
551
- ? args.prompt
552
- : typeof args.task === "string"
553
- ? args.task
554
- : undefined,
555
- });
556
- }
557
- });
558
- const unsubToolDone = session.on("tool.execution_complete", (event) => {
559
- toolCallExecuted = true;
560
- toolCallCount++;
561
- const data = event.data;
562
- const result = data.result;
563
- const resultPreview = typeof result?.content === "string" ? result.content.slice(0, 400) : undefined;
564
- const detailedContent = typeof result?.detailedContent === "string"
565
- ? result.detailedContent
566
- : typeof result?.content === "string"
567
- ? result.content
568
- : undefined;
569
- const toolCallId = String(data.toolCallId ?? "");
570
- const startDetails = toolStartDetails.get(toolCallId);
571
- const completionToolName = data.toolName;
572
- if (item.onActivity) {
573
- item.onActivity({
574
- kind: "tool_complete",
575
- toolCallId: data.toolCallId,
576
- success: data.success,
577
- resultPreview,
578
- detailedContent,
579
- }, item.turnId);
580
- }
581
- // Emit turn:delta with tool-call part (coexistence — #130)
582
- const toolPart = {
583
- type: "tool-call",
584
- toolCallId,
585
- toolName: typeof completionToolName === "string" && completionToolName.length > 0
586
- ? completionToolName
587
- : (startDetails?.toolName ?? "unknown"),
588
- mcpServerName: startDetails?.mcpServerName,
589
- arguments: startDetails?.arguments,
590
- status: data.success !== false ? "done" : "failed",
591
- resultPreview,
592
- detailedContent,
593
- };
594
- emitTurnEvent(sessionKey, { type: "turn:delta", turnId: item.turnId, sessionKey, part: toolPart });
595
- if (toolCallId) {
596
- toolStartDetails.delete(toolCallId);
597
- }
598
- });
599
- const unsubToolStart = item.onActivity
600
- ? session.on("tool.execution_start", (event) => {
601
- const data = event.data;
602
- item.onActivity({
603
- kind: "tool_start",
604
- toolCallId: data.toolCallId,
605
- toolName: data.toolName,
606
- mcpServerName: data.mcpServerName,
607
- arguments: data.arguments,
608
- }, item.turnId);
609
- })
610
- : () => { };
611
- const unsubReasoning = item.onActivity
612
- ? session.on("assistant.reasoning_delta", (event) => {
613
- item.onActivity({
614
- kind: "thinking_delta",
615
- reasoningId: event.data.reasoningId,
616
- deltaContent: event.data.deltaContent,
617
- }, item.turnId);
618
- })
619
- : () => { };
620
- const unsubSubStart = item.onActivity
621
- ? session.on("subagent.started", (event) => {
622
- const data = event.data;
623
- const spawnArgs = spawnArgsMap.get(data.toolCallId);
624
- const agentSlug = (typeof spawnArgs?.name === "string" ? spawnArgs.name : (data.agentName || "unknown"))
625
- .toLowerCase()
626
- .replace(/\s+/g, "-");
627
- const resolvedDescription = (typeof spawnArgs?.description === "string"
628
- ? spawnArgs.description
629
- : data.agentDescription || data.agentDisplayName || `Agent dispatch: ${agentSlug}`).slice(0, 500);
630
- item.onActivity({
631
- kind: "subagent_started",
632
- toolCallId: data.toolCallId,
633
- agentName: data.agentName,
634
- agentDisplayName: data.agentDisplayName,
635
- agentDescription: resolvedDescription,
636
- agentSlug,
637
- }, item.turnId);
638
- })
639
- : () => { };
640
- const unsubSubDone = item.onActivity
641
- ? session.on("subagent.completed", (event) => {
642
- const data = event.data;
643
- item.onActivity({
644
- kind: "subagent_completed",
645
- toolCallId: data.toolCallId,
646
- agentName: data.agentName,
647
- agentDisplayName: data.agentDisplayName,
648
- durationMs: data.durationMs,
649
- }, item.turnId);
650
- })
651
- : () => { };
652
- const unsubSubFail = item.onActivity
653
- ? session.on("subagent.failed", (event) => {
654
- const data = event.data;
655
- item.onActivity({
656
- kind: "subagent_failed",
657
- toolCallId: data.toolCallId,
658
- agentName: data.agentName,
659
- agentDisplayName: data.agentDisplayName,
660
- error: data.error,
661
- }, item.turnId);
662
- })
663
- : () => { };
664
- // Always persist SDK subagent dispatches to agent_tasks so Workers tab shows them.
665
- // Also emit turn events unconditionally alongside existing callback path (#130).
666
- const unsubTurnToolStart = session.on("tool.execution_start", (event) => {
667
- const data = event.data;
668
- // Skip nested subagent tool calls (handled via subagent.started/completed)
669
- const part = {
670
- type: "tool-call",
671
- toolCallId: String(data.toolCallId ?? ""),
672
- toolName: String(data.toolName ?? "unknown"),
673
- mcpServerName: typeof data.mcpServerName === "string" ? data.mcpServerName : undefined,
674
- arguments: data.arguments,
675
- status: "running",
676
- };
677
- emitTurnEvent(sessionKey, { type: "turn:delta", turnId: item.turnId, sessionKey, part });
678
- });
679
- const unsubTurnReasoning = session.on("assistant.reasoning_delta", (event) => {
680
- const part = {
681
- type: "thinking",
682
- reasoningId: event.data.reasoningId,
683
- text: event.data.deltaContent,
684
- active: true,
685
- };
686
- emitTurnEvent(sessionKey, { type: "turn:delta", turnId: item.turnId, sessionKey, part });
687
- });
688
518
  const db = getDb();
689
519
  // Set of task IDs for subagents spawned in THIS turn — used to filter nested tool events.
690
520
  const activeSubagentTaskIds = new Set();
691
- const unsubSubStartDb = session.on("subagent.started", (event) => {
692
- try {
521
+ let unsubSpawnCapture = () => { };
522
+ let unsubToolDone = () => { };
523
+ let unsubToolStart = () => { };
524
+ let unsubReasoning = () => { };
525
+ let unsubSubStart = () => { };
526
+ let unsubSubDone = () => { };
527
+ let unsubSubFail = () => { };
528
+ let unsubTurnToolStart = () => { };
529
+ let unsubTurnReasoning = () => { };
530
+ let unsubSubStartDb = () => { };
531
+ let unsubSubDoneDb = () => { };
532
+ let unsubSubFailDb = () => { };
533
+ let unsubNestedToolStart = () => { };
534
+ let unsubNestedToolDone = () => { };
535
+ let unsubDelta = () => { };
536
+ try {
537
+ // Unconditional capture — must fire even when onActivity is absent so the DB handler can resolve names.
538
+ unsubSpawnCapture = session.on("tool.execution_start", (event) => {
693
539
  const data = event.data;
694
- const spawnArgs = spawnArgsMap.get(data.toolCallId);
695
- const agentSlug = (typeof spawnArgs?.name === "string" ? spawnArgs.name : (data.agentName || "unknown"))
696
- .toLowerCase()
697
- .replace(/\s+/g, "-");
698
- const description = (typeof spawnArgs?.description === "string"
699
- ? spawnArgs.description
700
- : data.agentDescription || data.agentDisplayName || `Agent dispatch: ${agentSlug}`).slice(0, 500);
701
- const prompt = typeof spawnArgs?.prompt === "string" ? spawnArgs.prompt : null;
702
- db.prepare(`INSERT OR IGNORE INTO agent_tasks (task_id, agent_slug, description, prompt, status, origin_channel, session_key, source)
703
- VALUES (?, ?, ?, ?, 'running', ?, ?, 'adhoc')`).run(data.toolCallId, agentSlug, description, prompt, item.sourceChannel || null, sessionKey);
704
- if (prompt) {
705
- db.prepare(`UPDATE agent_tasks SET prompt = COALESCE(prompt, ?) WHERE task_id = ?`).run(prompt, data.toolCallId);
540
+ if (data.toolCallId) {
541
+ toolStartDetails.set(data.toolCallId, {
542
+ toolName: String(data.toolName ?? "unknown"),
543
+ mcpServerName: typeof data.mcpServerName === "string" ? data.mcpServerName : undefined,
544
+ arguments: data.arguments,
545
+ });
706
546
  }
707
- activeSubagentTaskIds.add(data.toolCallId);
708
- void agentEventBus.emit({
709
- type: "session:created",
710
- sessionId: data.toolCallId,
711
- agentName: agentSlug,
712
- payload: { agentName: agentSlug, priority: "normal" },
713
- timestamp: new Date(),
714
- });
715
- // Emit turn:delta with subagent part (coexistence — #130)
716
- const subPart = {
717
- type: "subagent",
718
- toolCallId: String(data.toolCallId ?? ""),
719
- agentName: data.agentName ?? agentSlug,
720
- agentDisplayName: data.agentDisplayName ?? agentSlug,
721
- agentDescription: description,
722
- agentSlug,
723
- status: "running",
724
- };
725
- emitTurnEvent(sessionKey, { type: "turn:delta", turnId: item.turnId, sessionKey, part: subPart });
726
- }
727
- catch { /* non-fatal */ }
728
- });
729
- const unsubSubDoneDb = session.on("subagent.completed", (event) => {
730
- try {
731
- const doneData = event.data;
732
- const taskId = String(doneData.toolCallId ?? "");
733
- const finalResult = typeof doneData.result?.detailedContent === "string"
734
- ? doneData.result.detailedContent
735
- : typeof doneData.result?.content === "string"
736
- ? doneData.result.content
737
- : null;
738
- spawnArgsMap.delete(taskId);
739
- activeSubagentTaskIds.delete(taskId);
740
- db.prepare(`UPDATE agent_tasks SET status = 'completed', result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(finalResult?.slice(0, 10000) ?? null, taskId);
741
- if (finalResult) {
742
- void memoryCoordinator?.onAgentTaskComplete(taskId, finalResult).catch((error) => {
743
- log.error({ err: error, taskId }, "memory.eot.error");
547
+ if (data.toolName === "task" && data.toolCallId) {
548
+ const args = (data.arguments ?? {});
549
+ spawnArgsMap.set(data.toolCallId, {
550
+ name: typeof args.name === "string" ? args.name : undefined,
551
+ description: typeof args.description === "string" ? args.description : undefined,
552
+ prompt: typeof args.prompt === "string"
553
+ ? args.prompt
554
+ : typeof args.task === "string"
555
+ ? args.task
556
+ : undefined,
744
557
  });
745
558
  }
746
- const taskRow = db.prepare(`SELECT agent_slug FROM agent_tasks WHERE task_id = ?`).get(taskId);
747
- void agentEventBus.emit({
748
- type: "session:destroyed",
749
- sessionId: taskId,
750
- agentName: taskRow?.agent_slug,
751
- payload: { agentName: taskRow?.agent_slug ?? "", reason: "complete" },
752
- timestamp: new Date(),
753
- });
754
- // Emit turn:delta with subagent completed part (coexistence — #130)
755
- const donePart = {
756
- type: "subagent",
757
- toolCallId: String(doneData.toolCallId ?? ""),
758
- agentName: doneData.agentName ?? taskRow?.agent_slug ?? "agent",
759
- agentDisplayName: doneData.agentDisplayName ?? taskRow?.agent_slug ?? "agent",
760
- status: "done",
761
- durationMs: typeof doneData.durationMs === "number" ? doneData.durationMs : undefined,
559
+ });
560
+ unsubToolDone = session.on("tool.execution_complete", (event) => {
561
+ toolCallExecuted = true;
562
+ toolCallCount++;
563
+ const data = event.data;
564
+ const result = data.result;
565
+ const resultPreview = typeof result?.content === "string" ? result.content.slice(0, 400) : undefined;
566
+ const detailedContent = typeof result?.detailedContent === "string"
567
+ ? result.detailedContent
568
+ : typeof result?.content === "string"
569
+ ? result.content
570
+ : undefined;
571
+ const toolCallId = String(data.toolCallId ?? "");
572
+ const startDetails = toolStartDetails.get(toolCallId);
573
+ const completionToolName = data.toolName;
574
+ if (item.onActivity) {
575
+ item.onActivity({
576
+ kind: "tool_complete",
577
+ toolCallId: data.toolCallId,
578
+ success: data.success,
579
+ resultPreview,
580
+ detailedContent,
581
+ }, item.turnId);
582
+ }
583
+ const toolPart = {
584
+ type: "tool-call",
585
+ toolCallId,
586
+ toolName: typeof completionToolName === "string" && completionToolName.length > 0
587
+ ? completionToolName
588
+ : (startDetails?.toolName ?? "unknown"),
589
+ mcpServerName: startDetails?.mcpServerName,
590
+ arguments: startDetails?.arguments,
591
+ status: data.success !== false ? "done" : "failed",
592
+ resultPreview,
593
+ detailedContent,
762
594
  };
763
- emitTurnEvent(sessionKey, { type: "turn:delta", turnId: item.turnId, sessionKey, part: donePart });
764
- }
765
- catch { /* non-fatal */ }
766
- });
767
- const unsubSubFailDb = session.on("subagent.failed", (event) => {
768
- try {
595
+ emitTurnEvent(sessionKey, { type: "turn:delta", turnId: item.turnId, sessionKey, part: toolPart });
596
+ if (toolCallId) {
597
+ toolStartDetails.delete(toolCallId);
598
+ }
599
+ });
600
+ unsubToolStart = item.onActivity
601
+ ? session.on("tool.execution_start", (event) => {
602
+ const data = event.data;
603
+ item.onActivity({
604
+ kind: "tool_start",
605
+ toolCallId: data.toolCallId,
606
+ toolName: data.toolName,
607
+ mcpServerName: data.mcpServerName,
608
+ arguments: data.arguments,
609
+ }, item.turnId);
610
+ })
611
+ : () => { };
612
+ unsubReasoning = item.onActivity
613
+ ? session.on("assistant.reasoning_delta", (event) => {
614
+ item.onActivity({
615
+ kind: "thinking_delta",
616
+ reasoningId: event.data.reasoningId,
617
+ deltaContent: event.data.deltaContent,
618
+ }, item.turnId);
619
+ })
620
+ : () => { };
621
+ unsubSubStart = item.onActivity
622
+ ? session.on("subagent.started", (event) => {
623
+ const data = event.data;
624
+ const spawnArgs = spawnArgsMap.get(data.toolCallId);
625
+ const agentSlug = (typeof spawnArgs?.name === "string" ? spawnArgs.name : (data.agentName || "unknown"))
626
+ .toLowerCase()
627
+ .replace(/\s+/g, "-");
628
+ const resolvedDescription = (typeof spawnArgs?.description === "string"
629
+ ? spawnArgs.description
630
+ : data.agentDescription || data.agentDisplayName || `Agent dispatch: ${agentSlug}`).slice(0, 500);
631
+ item.onActivity({
632
+ kind: "subagent_started",
633
+ toolCallId: data.toolCallId,
634
+ agentName: data.agentName,
635
+ agentDisplayName: data.agentDisplayName,
636
+ agentDescription: resolvedDescription,
637
+ agentSlug,
638
+ }, item.turnId);
639
+ })
640
+ : () => { };
641
+ unsubSubDone = item.onActivity
642
+ ? session.on("subagent.completed", (event) => {
643
+ const data = event.data;
644
+ item.onActivity({
645
+ kind: "subagent_completed",
646
+ toolCallId: data.toolCallId,
647
+ agentName: data.agentName,
648
+ agentDisplayName: data.agentDisplayName,
649
+ durationMs: data.durationMs,
650
+ }, item.turnId);
651
+ })
652
+ : () => { };
653
+ unsubSubFail = item.onActivity
654
+ ? session.on("subagent.failed", (event) => {
655
+ const data = event.data;
656
+ item.onActivity({
657
+ kind: "subagent_failed",
658
+ toolCallId: data.toolCallId,
659
+ agentName: data.agentName,
660
+ agentDisplayName: data.agentDisplayName,
661
+ error: data.error,
662
+ }, item.turnId);
663
+ })
664
+ : () => { };
665
+ unsubTurnToolStart = session.on("tool.execution_start", (event) => {
769
666
  const data = event.data;
770
- spawnArgsMap.delete(data.toolCallId);
771
- activeSubagentTaskIds.delete(data.toolCallId);
772
- db.prepare(`UPDATE agent_tasks SET status = 'error', result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(data.error || "Subagent failed", data.toolCallId);
773
- const taskRow = db.prepare(`SELECT agent_slug FROM agent_tasks WHERE task_id = ?`).get(data.toolCallId);
774
- void agentEventBus.emit({
775
- type: "session:error",
776
- sessionId: data.toolCallId,
777
- agentName: taskRow?.agent_slug,
778
- payload: { agentName: taskRow?.agent_slug ?? "", error: data.error ?? "Subagent failed" },
779
- timestamp: new Date(),
780
- });
781
- // Emit turn:delta with subagent failed part (coexistence — #130)
782
- const failPart = {
783
- type: "subagent",
667
+ const part = {
668
+ type: "tool-call",
784
669
  toolCallId: String(data.toolCallId ?? ""),
785
- agentName: data.agentName ?? taskRow?.agent_slug ?? "agent",
786
- agentDisplayName: data.agentDisplayName ?? taskRow?.agent_slug ?? "agent",
787
- status: "failed",
788
- error: data.error,
670
+ toolName: String(data.toolName ?? "unknown"),
671
+ mcpServerName: typeof data.mcpServerName === "string" ? data.mcpServerName : undefined,
672
+ arguments: data.arguments,
673
+ status: "running",
789
674
  };
790
- emitTurnEvent(sessionKey, { type: "turn:delta", turnId: item.turnId, sessionKey, part: failPart });
791
- }
792
- catch { /* non-fatal */ }
793
- });
794
- // ---------------------------------------------------------------------------
795
- // Nested tool-call streaming — capture tool.execution_start / _complete events
796
- // whose parentToolCallId matches a known subagent task id, persist them to
797
- // agent_task_events, and broadcast to per-task SSE subscribers.
798
- // ---------------------------------------------------------------------------
799
- const unsubNestedToolStart = session.on("tool.execution_start", (event) => {
800
- try {
801
- const data = event.data;
802
- const parentId = data.parentToolCallId;
803
- if (!parentId || !activeSubagentTaskIds.has(parentId))
804
- return;
805
- const toolName = data.toolName ?? null;
806
- const args = data.arguments ?? {};
807
- let summary = null;
808
- if (typeof args.command === "string")
809
- summary = args.command.slice(0, 120);
810
- else if (typeof args.path === "string")
811
- summary = args.path.slice(0, 120);
812
- else if (typeof args.query === "string")
813
- summary = args.query.slice(0, 120);
814
- else if (typeof args.prompt === "string")
815
- summary = args.prompt.slice(0, 120);
816
- const ev = appendTaskEvent(parentId, "tool_start", toolName, summary);
817
- if (ev)
818
- emitTaskEvent(parentId, ev);
819
- }
820
- catch { /* non-fatal */ }
821
- });
822
- const unsubNestedToolDone = session.on("tool.execution_complete", (event) => {
823
- try {
824
- const data = event.data;
825
- const parentId = data.parentToolCallId;
826
- if (!parentId || !activeSubagentTaskIds.has(parentId))
827
- return;
828
- const success = data.success !== false;
829
- const resultContent = data.result?.content ?? data.result?.detailedContent;
830
- const summary = typeof resultContent === "string"
831
- ? (success ? resultContent.slice(0, 120) : `error: ${resultContent.slice(0, 100)}`)
832
- : (success ? "ok" : "error");
833
- const ev = appendTaskEvent(parentId, "tool_complete", null, summary);
834
- if (ev)
835
- emitTaskEvent(parentId, ev);
836
- }
837
- catch { /* non-fatal */ }
838
- });
839
- const unsubDelta = session.on("assistant.message_delta", (event) => {
840
- if (toolCallExecuted && accumulated.length > 0 && !accumulated.endsWith("\n")) {
841
- accumulated += "\n";
842
- }
843
- toolCallExecuted = false;
844
- const delta = event.data.deltaContent;
845
- accumulated += delta;
846
- item.callback(accumulated, false, item.turnId);
847
- // Emit alongside existing callback path (coexistence — #130)
848
- emitTurnEvent(sessionKey, {
849
- type: "turn:delta",
850
- turnId: item.turnId,
851
- sessionKey,
852
- part: { type: "text", text: delta },
675
+ emitTurnEvent(sessionKey, { type: "turn:delta", turnId: item.turnId, sessionKey, part });
676
+ });
677
+ unsubTurnReasoning = session.on("assistant.reasoning_delta", (event) => {
678
+ const part = {
679
+ type: "thinking",
680
+ reasoningId: event.data.reasoningId,
681
+ text: event.data.deltaContent,
682
+ active: true,
683
+ };
684
+ emitTurnEvent(sessionKey, { type: "turn:delta", turnId: item.turnId, sessionKey, part });
685
+ });
686
+ unsubSubStartDb = session.on("subagent.started", (event) => {
687
+ try {
688
+ const data = event.data;
689
+ const spawnArgs = spawnArgsMap.get(data.toolCallId);
690
+ const agentSlug = (typeof spawnArgs?.name === "string" ? spawnArgs.name : (data.agentName || "unknown"))
691
+ .toLowerCase()
692
+ .replace(/\s+/g, "-");
693
+ const description = (typeof spawnArgs?.description === "string"
694
+ ? spawnArgs.description
695
+ : data.agentDescription || data.agentDisplayName || `Agent dispatch: ${agentSlug}`).slice(0, 500);
696
+ const prompt = typeof spawnArgs?.prompt === "string" ? spawnArgs.prompt : null;
697
+ db.prepare(`INSERT OR IGNORE INTO agent_tasks (task_id, agent_slug, description, prompt, status, origin_channel, session_key, source)
698
+ VALUES (?, ?, ?, ?, 'running', ?, ?, 'adhoc')`).run(data.toolCallId, agentSlug, description, prompt, item.sourceChannel || null, sessionKey);
699
+ if (prompt) {
700
+ db.prepare(`UPDATE agent_tasks SET prompt = COALESCE(prompt, ?) WHERE task_id = ?`).run(prompt, data.toolCallId);
701
+ }
702
+ activeSubagentTaskIds.add(data.toolCallId);
703
+ void agentEventBus.emit({
704
+ type: "session:created",
705
+ sessionId: data.toolCallId,
706
+ agentName: agentSlug,
707
+ payload: { agentName: agentSlug, priority: "normal" },
708
+ timestamp: new Date(),
709
+ });
710
+ const subPart = {
711
+ type: "subagent",
712
+ toolCallId: String(data.toolCallId ?? ""),
713
+ agentName: data.agentName ?? agentSlug,
714
+ agentDisplayName: data.agentDisplayName ?? agentSlug,
715
+ agentDescription: description,
716
+ agentSlug,
717
+ status: "running",
718
+ };
719
+ emitTurnEvent(sessionKey, { type: "turn:delta", turnId: item.turnId, sessionKey, part: subPart });
720
+ }
721
+ catch { /* non-fatal */ }
722
+ });
723
+ unsubSubDoneDb = session.on("subagent.completed", (event) => {
724
+ try {
725
+ const doneData = event.data;
726
+ const taskId = String(doneData.toolCallId ?? "");
727
+ const finalResult = typeof doneData.result?.detailedContent === "string"
728
+ ? doneData.result.detailedContent
729
+ : typeof doneData.result?.content === "string"
730
+ ? doneData.result.content
731
+ : null;
732
+ spawnArgsMap.delete(taskId);
733
+ activeSubagentTaskIds.delete(taskId);
734
+ if (finalResult && finalResult.length > 10_000) {
735
+ log.warn({ taskId, length: finalResult.length, limit: 10_000 }, "subagent result truncated before persistence");
736
+ }
737
+ db.prepare(`UPDATE agent_tasks SET status = 'completed', result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(finalResult?.slice(0, 10000) ?? null, taskId);
738
+ if (finalResult) {
739
+ void memoryCoordinator?.onAgentTaskComplete(taskId, finalResult).catch((error) => {
740
+ log.error({ err: error, taskId }, "memory.eot.error");
741
+ });
742
+ }
743
+ const taskRow = db.prepare(`SELECT agent_slug FROM agent_tasks WHERE task_id = ?`).get(taskId);
744
+ void agentEventBus.emit({
745
+ type: "session:destroyed",
746
+ sessionId: taskId,
747
+ agentName: taskRow?.agent_slug,
748
+ payload: { agentName: taskRow?.agent_slug ?? "", reason: "complete" },
749
+ timestamp: new Date(),
750
+ });
751
+ const donePart = {
752
+ type: "subagent",
753
+ toolCallId: String(doneData.toolCallId ?? ""),
754
+ agentName: doneData.agentName ?? taskRow?.agent_slug ?? "agent",
755
+ agentDisplayName: doneData.agentDisplayName ?? taskRow?.agent_slug ?? "agent",
756
+ status: "done",
757
+ durationMs: typeof doneData.durationMs === "number" ? doneData.durationMs : undefined,
758
+ };
759
+ emitTurnEvent(sessionKey, { type: "turn:delta", turnId: item.turnId, sessionKey, part: donePart });
760
+ }
761
+ catch { /* non-fatal */ }
762
+ });
763
+ unsubSubFailDb = session.on("subagent.failed", (event) => {
764
+ try {
765
+ const data = event.data;
766
+ spawnArgsMap.delete(data.toolCallId);
767
+ activeSubagentTaskIds.delete(data.toolCallId);
768
+ db.prepare(`UPDATE agent_tasks SET status = 'error', result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(data.error || "Subagent failed", data.toolCallId);
769
+ const taskRow = db.prepare(`SELECT agent_slug FROM agent_tasks WHERE task_id = ?`).get(data.toolCallId);
770
+ void agentEventBus.emit({
771
+ type: "session:error",
772
+ sessionId: data.toolCallId,
773
+ agentName: taskRow?.agent_slug,
774
+ payload: { agentName: taskRow?.agent_slug ?? "", error: data.error ?? "Subagent failed" },
775
+ timestamp: new Date(),
776
+ });
777
+ const failPart = {
778
+ type: "subagent",
779
+ toolCallId: String(data.toolCallId ?? ""),
780
+ agentName: data.agentName ?? taskRow?.agent_slug ?? "agent",
781
+ agentDisplayName: data.agentDisplayName ?? taskRow?.agent_slug ?? "agent",
782
+ status: "failed",
783
+ error: data.error,
784
+ };
785
+ emitTurnEvent(sessionKey, { type: "turn:delta", turnId: item.turnId, sessionKey, part: failPart });
786
+ }
787
+ catch { /* non-fatal */ }
788
+ });
789
+ unsubNestedToolStart = session.on("tool.execution_start", (event) => {
790
+ try {
791
+ const data = event.data;
792
+ const parentId = data.parentToolCallId;
793
+ if (!parentId || !activeSubagentTaskIds.has(parentId))
794
+ return;
795
+ const toolName = data.toolName ?? null;
796
+ const args = data.arguments ?? {};
797
+ let summary = null;
798
+ if (typeof args.command === "string")
799
+ summary = args.command.slice(0, 120);
800
+ else if (typeof args.path === "string")
801
+ summary = args.path.slice(0, 120);
802
+ else if (typeof args.query === "string")
803
+ summary = args.query.slice(0, 120);
804
+ else if (typeof args.prompt === "string")
805
+ summary = args.prompt.slice(0, 120);
806
+ const ev = appendTaskEvent(parentId, "tool_start", toolName, summary);
807
+ if (ev)
808
+ emitTaskEvent(parentId, ev);
809
+ }
810
+ catch { /* non-fatal */ }
811
+ });
812
+ unsubNestedToolDone = session.on("tool.execution_complete", (event) => {
813
+ try {
814
+ const data = event.data;
815
+ const parentId = data.parentToolCallId;
816
+ if (!parentId || !activeSubagentTaskIds.has(parentId))
817
+ return;
818
+ const success = data.success !== false;
819
+ const resultContent = data.result?.content ?? data.result?.detailedContent;
820
+ const summary = typeof resultContent === "string"
821
+ ? (success ? resultContent.slice(0, 120) : `error: ${resultContent.slice(0, 100)}`)
822
+ : (success ? "ok" : "error");
823
+ const ev = appendTaskEvent(parentId, "tool_complete", null, summary);
824
+ if (ev)
825
+ emitTaskEvent(parentId, ev);
826
+ }
827
+ catch { /* non-fatal */ }
828
+ });
829
+ unsubDelta = session.on("assistant.message_delta", (event) => {
830
+ if (toolCallExecuted && accumulated.length > 0 && !accumulated.endsWith("\n")) {
831
+ accumulated += "\n";
832
+ }
833
+ toolCallExecuted = false;
834
+ const delta = event.data.deltaContent;
835
+ accumulated += delta;
836
+ item.callback(accumulated, false, item.turnId);
837
+ emitTurnEvent(sessionKey, {
838
+ type: "turn:delta",
839
+ turnId: item.turnId,
840
+ sessionKey,
841
+ part: { type: "text", text: delta },
842
+ });
853
843
  });
854
- });
855
- try {
856
844
  if (warningBlock) {
857
845
  accumulated = warningBlock;
858
846
  item.callback(accumulated, false, item.turnId);
@@ -1008,9 +996,12 @@ function isRecoverableError(err) {
1008
996
  return false;
1009
997
  return /disconnect|connection|EPIPE|ECONNRESET|ECONNREFUSED|socket|closed|ENOENT|spawn|not found|expired|stale/i.test(msg);
1010
998
  }
1011
- export async function sendToOrchestrator(prompt, source, callback, attachments, onActivity, onQueued, onAdvance, externalTurnId, options) {
999
+ function reconnectDelayMs(attempt) {
1000
+ return Math.min(RECONNECT_BASE_DELAY_MS * (2 ** attempt), RECONNECT_MAX_DELAY_MS);
1001
+ }
1002
+ export function sendToOrchestrator(prompt, source, callback, attachments, onActivity, onQueued, onAdvance, externalTurnId, options) {
1012
1003
  updateUserContext(source);
1013
- updateRequestContext(source);
1004
+ recordLastAuthenticatedUser(source);
1014
1005
  // Use the externally-supplied turnId if provided (POST→SSE path needs the ID
1015
1006
  // returned to the client to match every emitted event — Fix 1 root cause).
1016
1007
  const turnId = externalTurnId ?? randomUUID();
@@ -1040,13 +1031,16 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
1040
1031
  const authUser = (source.type === "web" || source.type === "sse-web") ? source.user : undefined;
1041
1032
  const authHeader = (source.type === "web" || source.type === "sse-web") ? source.authorizationHeader?.trim() || undefined : undefined;
1042
1033
  const emitSessionLifecycle = usesSessionTurnLifecycle(source);
1034
+ const manager = registry?.getOrCreate(sessionKey);
1043
1035
  if (emitSessionLifecycle) {
1044
1036
  emitTurnEvent(sessionKey, { type: "turn:started", turnId, sessionKey, prompt, attachments });
1045
1037
  }
1046
- const manager = registry.getOrCreate(sessionKey);
1047
- void (async () => {
1038
+ const sendPromise = (async () => {
1048
1039
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
1049
1040
  try {
1041
+ if (!manager) {
1042
+ throw new Error("Orchestrator is not initialized");
1043
+ }
1050
1044
  const finalContent = await new Promise((resolve, reject) => {
1051
1045
  manager.enqueue({
1052
1046
  prompt: taggedPrompt,
@@ -1106,7 +1100,7 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
1106
1100
  return;
1107
1101
  }
1108
1102
  if (isRecoverableError(err) && attempt < MAX_RETRIES) {
1109
- const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
1103
+ const delay = reconnectDelayMs(attempt);
1110
1104
  log.warn({ msg, attempt: attempt + 1, maxRetries: MAX_RETRIES, delayMs: delay }, "Recoverable error, retrying");
1111
1105
  await sleep(delay);
1112
1106
  try {
@@ -1124,6 +1118,10 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
1124
1118
  }
1125
1119
  }
1126
1120
  })();
1121
+ void sendPromise.catch((err) => {
1122
+ log.error({ err, sessionKey, turnId }, "unhandled rejection in sendToOrchestrator");
1123
+ });
1124
+ return Promise.resolve();
1127
1125
  }
1128
1126
  /**
1129
1127
  * Abort the active turn on `sessionKey` and immediately start a new turn with `newPrompt`.
@@ -1144,8 +1142,9 @@ export async function interruptCurrentTurn(sessionKey, newPrompt, source, callba
1144
1142
  return sendToOrchestrator(newPrompt, source, callback, attachments, onActivity, undefined, undefined, externalTurnId);
1145
1143
  }
1146
1144
  updateUserContext(source);
1147
- updateRequestContext(source);
1145
+ recordLastAuthenticatedUser(source);
1148
1146
  const turnId = externalTurnId ?? randomUUID();
1147
+ const abortedTurnId = manager.currentTurnId;
1149
1148
  const sourceLabel = source.type === "background" ? "background" : "web";
1150
1149
  const sourceChannel = source.type === "web" ? "web" : undefined;
1151
1150
  const authUser = (source.type === "web" || source.type === "sse-web") ? source.user : undefined;
@@ -1158,7 +1157,7 @@ export async function interruptCurrentTurn(sessionKey, newPrompt, source, callba
1158
1157
  manager.cancelQueued();
1159
1158
  // Enqueue the replacement BEFORE awaiting abort. When sendAndWait resolves on
1160
1159
  // abort, the drain loop immediately finds the replacement in the queue — zero gap.
1161
- void (async () => {
1160
+ const interruptPromise = (async () => {
1162
1161
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
1163
1162
  try {
1164
1163
  const finalContent = await new Promise((resolve, reject) => {
@@ -1170,7 +1169,7 @@ export async function interruptCurrentTurn(sessionKey, newPrompt, source, callba
1170
1169
  onActivity: onActivity,
1171
1170
  turnId,
1172
1171
  isInterrupt: true,
1173
- onInterrupted,
1172
+ onInterrupted: undefined,
1174
1173
  sourceChannel,
1175
1174
  sessionKey,
1176
1175
  authUser,
@@ -1207,7 +1206,7 @@ export async function interruptCurrentTurn(sessionKey, newPrompt, source, callba
1207
1206
  if (/cancelled|abort/i.test(msg))
1208
1207
  return;
1209
1208
  if (isRecoverableError(err) && attempt < MAX_RETRIES) {
1210
- const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
1209
+ const delay = reconnectDelayMs(attempt);
1211
1210
  log.warn({ msg, attempt: attempt + 1, maxRetries: MAX_RETRIES, delayMs: delay }, "Recoverable error on interrupt turn, retrying");
1212
1211
  await sleep(delay);
1213
1212
  try {
@@ -1222,6 +1221,12 @@ export async function interruptCurrentTurn(sessionKey, newPrompt, source, callba
1222
1221
  }
1223
1222
  }
1224
1223
  })();
1224
+ void interruptPromise.catch((err) => {
1225
+ log.error({ err, sessionKey, turnId }, "unhandled rejection in interruptCurrentTurn");
1226
+ });
1227
+ if (abortedTurnId) {
1228
+ onInterrupted?.(abortedTurnId);
1229
+ }
1225
1230
  // Abort the in-flight turn AFTER enqueueing the replacement — SDK sends the
1226
1231
  // abort RPC; server emits session.idle; sendAndWait resolves; drain loop picks
1227
1232
  // up the replacement immediately.
@@ -1364,6 +1369,7 @@ export function getAgentInfo() {
1364
1369
  }
1365
1370
  /** Clean up on shutdown/restart. */
1366
1371
  export async function shutdownAgents() {
1372
+ stopClassifier();
1367
1373
  memoryCoordinator?.shutdown();
1368
1374
  memoryCoordinator = undefined;
1369
1375
  if (!registry) {