chapterhouse 0.3.1 → 0.3.3

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.
@@ -1,11 +1,13 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { approveAll } from "@github/copilot-sdk";
2
3
  import { createTools } from "./tools.js";
3
4
  import { getOrchestratorSystemMessage } from "./system-message.js";
5
+ import { CHAPTERHOUSE_VERSION } from "../version.js";
4
6
  import { config, DEFAULT_MODEL } from "../config.js";
5
7
  import { loadMcpConfig } from "./mcp-config.js";
6
8
  import { getSkillDirectories } from "./skills.js";
7
9
  import { resetClient } from "./client.js";
8
- import { logConversation, getState, setState, deleteState, getCopilotSession, upsertCopilotSession, getTaskSessionKey, getDb, bumpProjectLastUsed } from "../store/db.js";
10
+ import { logConversation, getState, setState, deleteState, getCopilotSession, upsertCopilotSession, getTaskSessionKey, getDb, bumpProjectLastUsed, appendTaskEvent } from "../store/db.js";
9
11
  import { maybeWriteEpisode } from "./episode-writer.js";
10
12
  import { getWikiSummary } from "../wiki/context.js";
11
13
  import { SESSIONS_DIR } from "../paths.js";
@@ -14,11 +16,8 @@ import { loadAgents, ensureDefaultAgents, clearActiveTasks, getAgentRegistry, se
14
16
  import { normalizeProjectPath, setChannelProject } from "../squad/context.js";
15
17
  import { getSquadCoordinatorSystemMessage } from "../squad/charter.js";
16
18
  import { childLogger } from "../util/logger.js";
19
+ import { SessionManager, SessionRegistry, SESSION_IDLE_TTL_MS, SESSION_MAX_ACTIVE, } from "./session-manager.js";
17
20
  const log = childLogger("orchestrator");
18
- /**
19
- * Permission handler for the orchestrator session.
20
- * Approves all tool requests so @chapterhouse has full access to all tools.
21
- */
22
21
  const orchestratorPermissionHandler = approveAll;
23
22
  const MAX_RETRIES = 3;
24
23
  const RECONNECT_DELAYS_MS = [1_000, 3_000, 10_000];
@@ -33,61 +32,84 @@ let proactiveNotifyFn;
33
32
  export function setProactiveNotify(fn) {
34
33
  proactiveNotifyFn = fn;
35
34
  }
35
+ const turnContextStorage = new AsyncLocalStorage();
36
+ // ---------------------------------------------------------------------------
37
+ // Module-level state (not per-session)
38
+ // ---------------------------------------------------------------------------
36
39
  let copilotClient;
37
40
  let healthCheckTimer;
38
41
  let currentUserContext;
42
+ /**
43
+ * Last-seen auth context — persists after a turn completes so that callers
44
+ * which inspect it outside of an active turn (e.g. /api/cancel) still see the
45
+ * most recent values. Tools that run DURING a turn should use the per-turn
46
+ * AsyncLocalStorage context for safety in concurrent-session scenarios.
47
+ */
39
48
  let currentAuthenticatedUser;
40
49
  let currentAuthorizationHeader;
41
- // Router state
42
- let recentTiers = [];
43
50
  let lastRouteResult;
44
51
  export function getLastRouteResult() {
45
52
  return lastRouteResult;
46
53
  }
47
- // Session map one entry per session key ("default" or "project:{normalizedRoot}")
48
- const sessionMap = new Map();
49
- const sessionModelMap = new Map();
50
- const sessionCreatePromises = new Map();
51
- // Track which session key the currently-executing turn belongs to (for abort + task routing)
52
- let currentProcessingSessionKey;
54
+ const taskEventListeners = new Map();
55
+ export function subscribeTaskEvents(taskId, listener) {
56
+ if (!taskEventListeners.has(taskId)) {
57
+ taskEventListeners.set(taskId, new Set());
58
+ }
59
+ taskEventListeners.get(taskId).add(listener);
60
+ return () => {
61
+ const set = taskEventListeners.get(taskId);
62
+ if (set) {
63
+ set.delete(listener);
64
+ if (set.size === 0)
65
+ taskEventListeners.delete(taskId);
66
+ }
67
+ };
68
+ }
69
+ function emitTaskEvent(taskId, event) {
70
+ const set = taskEventListeners.get(taskId);
71
+ if (set) {
72
+ for (const listener of set) {
73
+ try {
74
+ listener(event);
75
+ }
76
+ catch { /* non-fatal */ }
77
+ }
78
+ }
79
+ }
80
+ // ---------------------------------------------------------------------------
81
+ // SessionRegistry — the single owner of all per-session orchestrators
82
+ // ---------------------------------------------------------------------------
83
+ let registry;
84
+ function buildRegistry() {
85
+ return new SessionRegistry({ idleTtlMs: SESSION_IDLE_TTL_MS, maxActive: SESSION_MAX_ACTIVE }, (sessionKey) => new SessionManager(sessionKey, processItem, createOrResumeSession));
86
+ }
87
+ // ---------------------------------------------------------------------------
88
+ // Context getters — exported for tools.ts and server.ts
89
+ // ---------------------------------------------------------------------------
53
90
  export function getCurrentSessionKey() {
54
- return currentProcessingSessionKey ?? "default";
55
- }
56
- const messageQueue = [];
57
- let processing = false;
58
- let currentCallback;
59
- /** The channel currently being processed — tools use this to tag new workers. */
60
- let currentSourceChannel;
61
- /** Get the channel that originated the message currently being processed. */
91
+ return turnContextStorage.getStore()?.sessionKey ?? "default";
92
+ }
62
93
  export function getCurrentSourceChannel() {
63
- return currentSourceChannel;
94
+ return turnContextStorage.getStore()?.sourceChannel;
64
95
  }
65
- let currentChannelKey;
66
96
  export function getCurrentChannelKey() {
67
- return currentChannelKey;
97
+ return turnContextStorage.getStore()?.channelKey;
68
98
  }
69
- /**
70
- * The activity callback for the message currently being processed. Tool handlers
71
- * (notably `delegate_to_agent`) read this to forward child-session events back
72
- * to the parent's SSE connection.
73
- */
74
- let currentActivityCallback;
75
99
  export function getCurrentActivityCallback() {
76
- return currentActivityCallback;
100
+ return turnContextStorage.getStore()?.activityCallback;
77
101
  }
78
102
  export function getCurrentAuthenticatedUser() {
79
- return currentAuthenticatedUser;
103
+ return turnContextStorage.getStore()?.authUser ?? currentAuthenticatedUser;
80
104
  }
81
105
  export function getLastAuthenticatedUser() {
82
106
  const raw = getState(LAST_AUTHENTICATED_USER_KEY);
83
- if (!raw) {
107
+ if (!raw)
84
108
  return undefined;
85
- }
86
109
  try {
87
110
  const parsed = JSON.parse(raw);
88
- if (!parsed?.id || !parsed?.name || !parsed?.email || !parsed?.role) {
111
+ if (!parsed?.id || !parsed?.name || !parsed?.email || !parsed?.role)
89
112
  return undefined;
90
- }
91
113
  return parsed;
92
114
  }
93
115
  catch {
@@ -95,8 +117,11 @@ export function getLastAuthenticatedUser() {
95
117
  }
96
118
  }
97
119
  export function getCurrentAuthorizationHeader() {
98
- return currentAuthorizationHeader;
120
+ return turnContextStorage.getStore()?.authHeader ?? currentAuthorizationHeader;
99
121
  }
122
+ // ---------------------------------------------------------------------------
123
+ // Internal helpers
124
+ // ---------------------------------------------------------------------------
100
125
  function getSessionConfig() {
101
126
  const tools = createTools({
102
127
  client: copilotClient,
@@ -118,17 +143,16 @@ function sameUserContext(a, b) {
118
143
  return a?.name === b?.name && a?.role === b?.role;
119
144
  }
120
145
  function updateUserContext(source) {
121
- if (source.type !== "web") {
146
+ if (source.type !== "web")
122
147
  return;
123
- }
124
148
  const nextContext = source.user
125
149
  ? { name: source.user.name, role: source.user.role }
126
150
  : undefined;
127
- if (sameUserContext(currentUserContext, nextContext)) {
151
+ if (sameUserContext(currentUserContext, nextContext))
128
152
  return;
129
- }
130
153
  currentUserContext = nextContext;
131
- sessionMap.delete("default");
154
+ // Invalidate the default session so it's recreated with the updated system message
155
+ registry?.get("default")?.invalidateSession();
132
156
  }
133
157
  function updateRequestContext(source) {
134
158
  if (source.type !== "web") {
@@ -142,7 +166,6 @@ function updateRequestContext(source) {
142
166
  setState(LAST_AUTHENTICATED_USER_KEY, JSON.stringify(source.user));
143
167
  }
144
168
  }
145
- /** Feed an agent task result into the orchestrator as a new turn. */
146
169
  export function feedAgentResult(taskId, agentSlug, result) {
147
170
  const prompt = `[Agent task completed] @${agentSlug} finished task ${taskId}:\n\n${result}`;
148
171
  const sessionKey = getTaskSessionKey(taskId);
@@ -155,7 +178,6 @@ export function feedAgentResult(taskId, agentSlug, result) {
155
178
  function sleep(ms) {
156
179
  return new Promise((resolve) => setTimeout(resolve, ms));
157
180
  }
158
- /** Ensure the SDK client is connected, resetting if necessary. Coalesces concurrent resets. */
159
181
  let resetPromise;
160
182
  async function ensureClient() {
161
183
  if (copilotClient && copilotClient.getState() === "connected") {
@@ -171,7 +193,6 @@ async function ensureClient() {
171
193
  }
172
194
  return resetPromise;
173
195
  }
174
- /** Start periodic health check that proactively reconnects the client. */
175
196
  function startHealthCheck() {
176
197
  if (healthCheckTimer)
177
198
  return;
@@ -183,9 +204,10 @@ function startHealthCheck() {
183
204
  if (state !== "connected") {
184
205
  log.info({ state }, "Health check: client not connected, resetting");
185
206
  await ensureClient();
186
- // Session may need recovery after client reset
187
- sessionMap.clear();
188
- sessionModelMap.clear();
207
+ // Invalidate all cached sessions they're tied to the old connection
208
+ for (const [, mgr] of registry.getAll()) {
209
+ mgr.invalidateSession();
210
+ }
189
211
  }
190
212
  }
191
213
  catch (err) {
@@ -193,27 +215,7 @@ function startHealthCheck() {
193
215
  }
194
216
  }, HEALTH_CHECK_INTERVAL_MS);
195
217
  }
196
- /** Ensure a session exists for the given key, creating/resuming as needed. Concurrency-safe. */
197
- async function ensureOrchestratorSession(sessionKey, projectRoot) {
198
- const existing = sessionMap.get(sessionKey);
199
- if (existing)
200
- return existing;
201
- // Coalesce concurrent callers for the same key
202
- const inFlight = sessionCreatePromises.get(sessionKey);
203
- if (inFlight)
204
- return inFlight;
205
- const promise = createOrResumeSession(sessionKey, projectRoot);
206
- sessionCreatePromises.set(sessionKey, promise);
207
- try {
208
- const session = await promise;
209
- sessionMap.set(sessionKey, session);
210
- return session;
211
- }
212
- finally {
213
- sessionCreatePromises.delete(sessionKey);
214
- }
215
- }
216
- /** Internal: actually create or resume a session (not concurrency-safe — use ensureOrchestratorSession). */
218
+ /** Internal: create or resume a CopilotSession. Called by SessionManager.ensureSession(). */
217
219
  async function createOrResumeSession(sessionKey, projectRoot) {
218
220
  const client = await ensureClient();
219
221
  const { tools, mcpServers, skillDirectories } = getSessionConfig();
@@ -223,7 +225,6 @@ async function createOrResumeSession(sessionKey, projectRoot) {
223
225
  backgroundCompactionThreshold: 0.80,
224
226
  bufferExhaustionThreshold: 0.95,
225
227
  };
226
- // Build the correct system message for this session mode
227
228
  let systemMessageContent;
228
229
  if (isProjectSession && projectRoot) {
229
230
  systemMessageContent = await getSquadCoordinatorSystemMessage(projectRoot);
@@ -232,9 +233,9 @@ async function createOrResumeSession(sessionKey, projectRoot) {
232
233
  const memorySummary = getWikiSummary();
233
234
  systemMessageContent = getOrchestratorSystemMessage({
234
235
  ...getSystemMessageOptions(memorySummary, isProjectSession ? projectRoot : undefined),
236
+ version: CHAPTERHOUSE_VERSION,
235
237
  });
236
238
  }
237
- // Try to resume from copilot_sessions; fall back to legacy max_state key for the default session
238
239
  const stored = getCopilotSession(sessionKey);
239
240
  const savedSessionId = stored?.copilotSessionId ?? (sessionKey === "default" ? getState(ORCHESTRATOR_SESSION_KEY) : undefined);
240
241
  if (savedSessionId) {
@@ -253,7 +254,9 @@ async function createOrResumeSession(sessionKey, projectRoot) {
253
254
  });
254
255
  log.info({ sessionKey }, "Session resumed successfully");
255
256
  upsertCopilotSession(sessionKey, isProjectSession ? "project" : "default", session.sessionId, projectRoot, config.copilotModel);
256
- sessionModelMap.set(sessionKey, config.copilotModel);
257
+ const mgr = registry?.get(sessionKey);
258
+ if (mgr)
259
+ mgr.currentModel = config.copilotModel;
257
260
  return session;
258
261
  }
259
262
  catch (err) {
@@ -262,7 +265,6 @@ async function createOrResumeSession(sessionKey, projectRoot) {
262
265
  deleteState(ORCHESTRATOR_SESSION_KEY);
263
266
  }
264
267
  }
265
- // Create a fresh session
266
268
  log.info({ sessionKey }, "Creating new session");
267
269
  const session = await client.createSession({
268
270
  model: config.copilotModel,
@@ -277,20 +279,25 @@ async function createOrResumeSession(sessionKey, projectRoot) {
277
279
  });
278
280
  log.info({ sessionKey, sessionId: session.sessionId.slice(0, 8) }, "Session created");
279
281
  upsertCopilotSession(sessionKey, isProjectSession ? "project" : "default", session.sessionId, projectRoot, config.copilotModel);
280
- // Backward compat: also persist the default session to the legacy state key
281
282
  if (sessionKey === "default")
282
283
  setState(ORCHESTRATOR_SESSION_KEY, session.sessionId);
283
- sessionModelMap.set(sessionKey, config.copilotModel);
284
+ const mgr = registry?.get(sessionKey);
285
+ if (mgr)
286
+ mgr.currentModel = config.copilotModel;
284
287
  return session;
285
288
  }
286
289
  export async function initOrchestrator(client) {
287
290
  copilotClient = client;
291
+ // (Re-)create the registry — supports multiple initOrchestrator calls in tests
292
+ if (registry) {
293
+ await registry.shutdown();
294
+ }
295
+ registry = buildRegistry();
296
+ registry.startEvictionTimer();
288
297
  const { mcpServers, skillDirectories } = getSessionConfig();
289
- // Initialize agent system
290
298
  ensureDefaultAgents();
291
299
  const agents = loadAgents();
292
300
  log.info({ count: agents.length, agents: agents.map((a) => `@${a.slug}`) }, "Agents loaded");
293
- // Validate configured model against available models
294
301
  try {
295
302
  const models = await client.listModels();
296
303
  const configured = config.copilotModel;
@@ -307,16 +314,17 @@ export async function initOrchestrator(client) {
307
314
  log.info({ skillDirectories }, "Skill directories");
308
315
  log.info("Persistent session mode — conversation history maintained by SDK");
309
316
  startHealthCheck();
310
- // Eagerly create/resume the default orchestrator session
311
317
  try {
312
- await ensureOrchestratorSession("default");
318
+ const defaultManager = registry.getOrCreate("default");
319
+ await defaultManager.ensureSession();
313
320
  }
314
321
  catch (err) {
315
322
  log.error({ err: err instanceof Error ? err.message : err }, "Failed to create initial session (will retry on first message)");
316
323
  }
317
324
  }
318
- /** How long to wait for the orchestrator to finish a turn (30 min default).
325
+ /** How long to wait for the orchestrator to finish a single session turn (30 min default).
319
326
  * Override with CHAPTERHOUSE_ORCHESTRATOR_TIMEOUT_MS env var (parsed as integer ms).
327
+ * Applies per-session-turn; concurrent sessions each have their own independent timeout.
320
328
  * Part of the 3-layer timing contract — see systemd unit TimeoutStopSec comment. */
321
329
  const DEFAULT_ORCHESTRATOR_TIMEOUT_MS = 1_800_000;
322
330
  export const ORCHESTRATOR_TIMEOUT_MS = (() => {
@@ -328,245 +336,301 @@ export const ORCHESTRATOR_TIMEOUT_MS = (() => {
328
336
  }
329
337
  return DEFAULT_ORCHESTRATOR_TIMEOUT_MS;
330
338
  })();
331
- /** Send a prompt on a session identified by sessionKey, return the response. */
332
- async function executeOnSession(sessionKey, prompt, callback, attachments, onActivity) {
333
- const projectRoot = sessionKey.startsWith("project:") ? sessionKey.slice("project:".length) : undefined;
334
- const session = await ensureOrchestratorSession(sessionKey, projectRoot);
335
- currentProcessingSessionKey = sessionKey;
336
- currentCallback = callback;
337
- currentActivityCallback = onActivity;
338
- let accumulated = "";
339
- let toolCallExecuted = false;
340
- let toolCallCount = 0;
341
- const unsubToolDone = session.on("tool.execution_complete", (event) => {
342
- toolCallExecuted = true;
343
- toolCallCount++;
344
- if (onActivity) {
345
- const data = event.data;
346
- const result = data.result;
347
- const resultPreview = typeof result?.content === "string" ? result.content.slice(0, 400) : undefined;
348
- const detailedContent = typeof result?.detailedContent === "string"
349
- ? result.detailedContent
350
- : typeof result?.content === "string"
351
- ? result.content
352
- : undefined;
353
- onActivity({
354
- kind: "tool_complete",
355
- toolCallId: data.toolCallId,
356
- success: data.success,
357
- resultPreview,
358
- detailedContent,
359
- });
360
- }
361
- });
362
- const unsubToolStart = onActivity
363
- ? session.on("tool.execution_start", (event) => {
364
- const data = event.data;
365
- onActivity({
366
- kind: "tool_start",
367
- toolCallId: data.toolCallId,
368
- toolName: data.toolName,
369
- mcpServerName: data.mcpServerName,
370
- arguments: data.arguments,
371
- });
372
- })
373
- : () => { };
374
- const unsubReasoning = onActivity
375
- ? session.on("assistant.reasoning_delta", (event) => {
376
- onActivity({
377
- kind: "thinking_delta",
378
- reasoningId: event.data.reasoningId,
379
- deltaContent: event.data.deltaContent,
380
- });
381
- })
382
- : () => { };
383
- const unsubSubStart = onActivity
384
- ? session.on("subagent.started", (event) => {
385
- const data = event.data;
386
- onActivity({
387
- kind: "subagent_started",
388
- toolCallId: data.toolCallId,
389
- agentName: data.agentName,
390
- agentDisplayName: data.agentDisplayName,
391
- agentDescription: data.agentDescription,
392
- });
393
- })
394
- : () => { };
395
- const unsubSubDone = onActivity
396
- ? session.on("subagent.completed", (event) => {
397
- const data = event.data;
398
- onActivity({
399
- kind: "subagent_completed",
400
- toolCallId: data.toolCallId,
401
- agentName: data.agentName,
402
- agentDisplayName: data.agentDisplayName,
403
- durationMs: data.durationMs,
404
- });
405
- })
406
- : () => { };
407
- const unsubSubFail = onActivity
408
- ? session.on("subagent.failed", (event) => {
409
- const data = event.data;
410
- onActivity({
411
- kind: "subagent_failed",
412
- toolCallId: data.toolCallId,
413
- agentName: data.agentName,
414
- agentDisplayName: data.agentDisplayName,
415
- error: data.error,
416
- });
417
- })
418
- : () => { };
419
- // Always persist SDK subagent dispatches to agent_tasks so Workers tab shows them.
420
- // These fire when the built-in `task` tool (Squad coordinator) routes work to a
421
- // specialist — separate from `delegate_to_agent` which handles CH-registry agents.
422
- const db = getDb();
423
- const unsubSubStartDb = session.on("subagent.started", (event) => {
424
- try {
339
+ /**
340
+ * Execute a single queued item on its session.
341
+ * Wraps the entire turn in AsyncLocalStorage so all tool handlers (e.g. delegate_to_agent,
342
+ * register_task) see the correct per-session context even when multiple sessions run
343
+ * concurrently. This is the core of the per-session isolation guarantee.
344
+ */
345
+ async function executeOnSession(manager, item) {
346
+ const { sessionKey } = manager;
347
+ const session = await manager.ensureSession();
348
+ // Update last-seen globals (backwards compat — for callers that inspect after a turn ends)
349
+ currentAuthenticatedUser = item.authUser;
350
+ currentAuthorizationHeader = item.authHeader;
351
+ return turnContextStorage.run({
352
+ sessionKey,
353
+ sourceChannel: item.sourceChannel,
354
+ channelKey: item.channelKey,
355
+ authUser: item.authUser,
356
+ authHeader: item.authHeader,
357
+ activityCallback: item.onActivity,
358
+ }, async () => {
359
+ let accumulated = "";
360
+ let toolCallExecuted = false;
361
+ let toolCallCount = 0;
362
+ // Per-turn map: toolCallId → spawn args stashed from tool.execution_start when toolName === "task".
363
+ // Correlates the SDK's subagent.started event (which only carries agent_type fields) with the
364
+ // actual spawn parameters (name, description) passed to the task() tool call.
365
+ const spawnArgsMap = new Map();
366
+ // Unconditional capture — must fire even when onActivity is absent so the DB handler can resolve names.
367
+ const unsubSpawnCapture = session.on("tool.execution_start", (event) => {
425
368
  const data = event.data;
426
- const agentSlug = (data.agentName || "unknown").toLowerCase().replace(/\s+/g, "-");
427
- const description = (data.agentDescription || data.agentDisplayName || `Squad dispatch: ${agentSlug}`).slice(0, 500);
428
- db.prepare(`INSERT OR IGNORE INTO agent_tasks (task_id, agent_slug, description, status, origin_channel, session_key, source) VALUES (?, ?, ?, 'running', ?, ?, 'squad')`).run(data.toolCallId, agentSlug, description, currentSourceChannel || null, sessionKey);
429
- }
430
- catch { /* non-fatal */ }
431
- });
432
- const unsubSubDoneDb = session.on("subagent.completed", (event) => {
433
- try {
434
- db.prepare(`UPDATE agent_tasks SET status = 'completed', completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(event.data.toolCallId);
435
- }
436
- catch { /* non-fatal */ }
437
- });
438
- const unsubSubFailDb = session.on("subagent.failed", (event) => {
369
+ if (data.toolName === "task" && data.toolCallId) {
370
+ const args = (data.arguments ?? {});
371
+ spawnArgsMap.set(data.toolCallId, {
372
+ name: typeof args.name === "string" ? args.name : undefined,
373
+ description: typeof args.description === "string" ? args.description : undefined,
374
+ });
375
+ }
376
+ });
377
+ const unsubToolDone = session.on("tool.execution_complete", (event) => {
378
+ toolCallExecuted = true;
379
+ toolCallCount++;
380
+ if (item.onActivity) {
381
+ const data = event.data;
382
+ const result = data.result;
383
+ const resultPreview = typeof result?.content === "string" ? result.content.slice(0, 400) : undefined;
384
+ const detailedContent = typeof result?.detailedContent === "string"
385
+ ? result.detailedContent
386
+ : typeof result?.content === "string"
387
+ ? result.content
388
+ : undefined;
389
+ item.onActivity({
390
+ kind: "tool_complete",
391
+ toolCallId: data.toolCallId,
392
+ success: data.success,
393
+ resultPreview,
394
+ detailedContent,
395
+ });
396
+ }
397
+ });
398
+ const unsubToolStart = item.onActivity
399
+ ? session.on("tool.execution_start", (event) => {
400
+ const data = event.data;
401
+ item.onActivity({
402
+ kind: "tool_start",
403
+ toolCallId: data.toolCallId,
404
+ toolName: data.toolName,
405
+ mcpServerName: data.mcpServerName,
406
+ arguments: data.arguments,
407
+ });
408
+ })
409
+ : () => { };
410
+ const unsubReasoning = item.onActivity
411
+ ? session.on("assistant.reasoning_delta", (event) => {
412
+ item.onActivity({
413
+ kind: "thinking_delta",
414
+ reasoningId: event.data.reasoningId,
415
+ deltaContent: event.data.deltaContent,
416
+ });
417
+ })
418
+ : () => { };
419
+ const unsubSubStart = item.onActivity
420
+ ? session.on("subagent.started", (event) => {
421
+ const data = event.data;
422
+ const spawnArgs = spawnArgsMap.get(data.toolCallId);
423
+ const agentSlug = (typeof spawnArgs?.name === "string" ? spawnArgs.name : (data.agentName || "unknown"))
424
+ .toLowerCase()
425
+ .replace(/\s+/g, "-");
426
+ const resolvedDescription = (typeof spawnArgs?.description === "string"
427
+ ? spawnArgs.description
428
+ : data.agentDescription || data.agentDisplayName || `Squad dispatch: ${agentSlug}`).slice(0, 500);
429
+ item.onActivity({
430
+ kind: "subagent_started",
431
+ toolCallId: data.toolCallId,
432
+ agentName: data.agentName,
433
+ agentDisplayName: data.agentDisplayName,
434
+ agentDescription: resolvedDescription,
435
+ agentSlug,
436
+ });
437
+ })
438
+ : () => { };
439
+ const unsubSubDone = item.onActivity
440
+ ? session.on("subagent.completed", (event) => {
441
+ const data = event.data;
442
+ item.onActivity({
443
+ kind: "subagent_completed",
444
+ toolCallId: data.toolCallId,
445
+ agentName: data.agentName,
446
+ agentDisplayName: data.agentDisplayName,
447
+ durationMs: data.durationMs,
448
+ });
449
+ })
450
+ : () => { };
451
+ const unsubSubFail = item.onActivity
452
+ ? session.on("subagent.failed", (event) => {
453
+ const data = event.data;
454
+ item.onActivity({
455
+ kind: "subagent_failed",
456
+ toolCallId: data.toolCallId,
457
+ agentName: data.agentName,
458
+ agentDisplayName: data.agentDisplayName,
459
+ error: data.error,
460
+ });
461
+ })
462
+ : () => { };
463
+ // Always persist SDK subagent dispatches to agent_tasks so Workers tab shows them.
464
+ const db = getDb();
465
+ // Set of task IDs for subagents spawned in THIS turn — used to filter nested tool events.
466
+ const activeSubagentTaskIds = new Set();
467
+ const unsubSubStartDb = session.on("subagent.started", (event) => {
468
+ try {
469
+ const data = event.data;
470
+ const spawnArgs = spawnArgsMap.get(data.toolCallId);
471
+ const agentSlug = (typeof spawnArgs?.name === "string" ? spawnArgs.name : (data.agentName || "unknown"))
472
+ .toLowerCase()
473
+ .replace(/\s+/g, "-");
474
+ const description = (typeof spawnArgs?.description === "string"
475
+ ? spawnArgs.description
476
+ : data.agentDescription || data.agentDisplayName || `Squad dispatch: ${agentSlug}`).slice(0, 500);
477
+ db.prepare(`INSERT OR IGNORE INTO agent_tasks (task_id, agent_slug, description, status, origin_channel, session_key, source) VALUES (?, ?, ?, 'running', ?, ?, 'squad')`).run(data.toolCallId, agentSlug, description, item.sourceChannel || null, sessionKey);
478
+ activeSubagentTaskIds.add(data.toolCallId);
479
+ }
480
+ catch { /* non-fatal */ }
481
+ });
482
+ const unsubSubDoneDb = session.on("subagent.completed", (event) => {
483
+ try {
484
+ spawnArgsMap.delete(event.data.toolCallId);
485
+ activeSubagentTaskIds.delete(event.data.toolCallId);
486
+ db.prepare(`UPDATE agent_tasks SET status = 'completed', completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(event.data.toolCallId);
487
+ }
488
+ catch { /* non-fatal */ }
489
+ });
490
+ const unsubSubFailDb = session.on("subagent.failed", (event) => {
491
+ try {
492
+ const data = event.data;
493
+ spawnArgsMap.delete(data.toolCallId);
494
+ activeSubagentTaskIds.delete(data.toolCallId);
495
+ db.prepare(`UPDATE agent_tasks SET status = 'error', result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(data.error || "Subagent failed", data.toolCallId);
496
+ }
497
+ catch { /* non-fatal */ }
498
+ });
499
+ // ---------------------------------------------------------------------------
500
+ // Nested tool-call streaming — capture tool.execution_start / _complete events
501
+ // whose parentToolCallId matches a known subagent task id, persist them to
502
+ // agent_task_events, and broadcast to per-task SSE subscribers.
503
+ // ---------------------------------------------------------------------------
504
+ const unsubNestedToolStart = session.on("tool.execution_start", (event) => {
505
+ try {
506
+ const data = event.data;
507
+ const parentId = data.parentToolCallId;
508
+ if (!parentId || !activeSubagentTaskIds.has(parentId))
509
+ return;
510
+ const toolName = data.toolName ?? null;
511
+ const args = data.arguments ?? {};
512
+ let summary = null;
513
+ if (typeof args.command === "string")
514
+ summary = args.command.slice(0, 120);
515
+ else if (typeof args.path === "string")
516
+ summary = args.path.slice(0, 120);
517
+ else if (typeof args.query === "string")
518
+ summary = args.query.slice(0, 120);
519
+ else if (typeof args.prompt === "string")
520
+ summary = args.prompt.slice(0, 120);
521
+ const ev = appendTaskEvent(parentId, "tool_start", toolName, summary);
522
+ if (ev)
523
+ emitTaskEvent(parentId, ev);
524
+ }
525
+ catch { /* non-fatal */ }
526
+ });
527
+ const unsubNestedToolDone = session.on("tool.execution_complete", (event) => {
528
+ try {
529
+ const data = event.data;
530
+ const parentId = data.parentToolCallId;
531
+ if (!parentId || !activeSubagentTaskIds.has(parentId))
532
+ return;
533
+ const success = data.success !== false;
534
+ const resultContent = data.result?.content ?? data.result?.detailedContent;
535
+ const summary = typeof resultContent === "string"
536
+ ? (success ? resultContent.slice(0, 120) : `error: ${resultContent.slice(0, 100)}`)
537
+ : (success ? "ok" : "error");
538
+ const ev = appendTaskEvent(parentId, "tool_complete", null, summary);
539
+ if (ev)
540
+ emitTaskEvent(parentId, ev);
541
+ }
542
+ catch { /* non-fatal */ }
543
+ });
544
+ const unsubDelta = session.on("assistant.message_delta", (event) => {
545
+ if (toolCallExecuted && accumulated.length > 0 && !accumulated.endsWith("\n")) {
546
+ accumulated += "\n";
547
+ }
548
+ toolCallExecuted = false;
549
+ accumulated += event.data.deltaContent;
550
+ item.callback(accumulated, false);
551
+ });
439
552
  try {
440
- const data = event.data;
441
- db.prepare(`UPDATE agent_tasks SET status = 'error', result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(data.error || "Subagent failed", data.toolCallId);
442
- }
443
- catch { /* non-fatal */ }
444
- });
445
- const unsubDelta = session.on("assistant.message_delta", (event) => {
446
- // After a tool call completes, ensure a line break separates the text blocks
447
- // so they don't visually run together in the rendered chat.
448
- if (toolCallExecuted && accumulated.length > 0 && !accumulated.endsWith("\n")) {
449
- accumulated += "\n";
553
+ const result = await session.sendAndWait({ prompt: item.prompt, ...(item.attachments && item.attachments.length > 0 ? { attachments: item.attachments } : {}) }, ORCHESTRATOR_TIMEOUT_MS);
554
+ const finalContent = result?.data?.content || accumulated || "(No response)";
555
+ return finalContent;
450
556
  }
451
- toolCallExecuted = false;
452
- accumulated += event.data.deltaContent;
453
- callback(accumulated, false);
454
- });
455
- try {
456
- const result = await session.sendAndWait({ prompt, ...(attachments && attachments.length > 0 ? { attachments } : {}) }, ORCHESTRATOR_TIMEOUT_MS);
457
- const finalContent = result?.data?.content || accumulated || "(No response)";
458
- return finalContent;
459
- }
460
- catch (err) {
461
- const msg = err instanceof Error ? err.message : String(err);
462
- // On timeout, never throw the message was already sent to the persistent
463
- // session and may have been (partially) processed. Return what we have.
464
- if (/timeout/i.test(msg)) {
465
- if (accumulated.length > 0) {
466
- log.warn({ timeoutSec: ORCHESTRATOR_TIMEOUT_MS / 1000, charCount: accumulated.length }, "Timeout with partial response — returning partial");
467
- return accumulated;
557
+ catch (err) {
558
+ const msg = err instanceof Error ? err.message : String(err);
559
+ if (/timeout/i.test(msg)) {
560
+ if (accumulated.length > 0) {
561
+ log.warn({ sessionKey, timeoutSec: ORCHESTRATOR_TIMEOUT_MS / 1000, charCount: accumulated.length }, "Timeout with partial response — returning partial");
562
+ return accumulated;
563
+ }
564
+ if (toolCallCount > 0) {
565
+ log.warn({ sessionKey, timeoutSec: ORCHESTRATOR_TIMEOUT_MS / 1000, toolCallCount }, "Timeout — tool calls ran but no text yet, session still working");
566
+ return "I'm still working on this — I've started processing but it's taking longer than expected. I'll send you the results when I'm done.";
567
+ }
568
+ log.warn({ sessionKey, timeoutSec: ORCHESTRATOR_TIMEOUT_MS / 1000 }, "Timeout with no activity session may be stuck");
569
+ return "Sorry, that request timed out before I could start working on it. Try again or break it into smaller pieces?";
468
570
  }
469
- // No text yet but tool calls ran — the session is working in the background
470
- // (e.g. delegate_to_agent dispatched). Don't error out.
471
- if (toolCallCount > 0) {
472
- log.warn({ timeoutSec: ORCHESTRATOR_TIMEOUT_MS / 1000, toolCallCount }, "Timeout — tool calls ran but no text yet, session still working");
473
- return "I'm still working on this — I've started processing but it's taking longer than expected. I'll send you the results when I'm done.";
571
+ if (/closed|destroy|disposed|invalid|expired|not found/i.test(msg)) {
572
+ log.warn({ sessionKey, msg }, "Session appears dead, will recreate");
573
+ manager.invalidateSession();
574
+ if (sessionKey === "default")
575
+ deleteState(ORCHESTRATOR_SESSION_KEY);
474
576
  }
475
- // No text, no tool calls — the session is truly stuck
476
- log.warn({ timeoutSec: ORCHESTRATOR_TIMEOUT_MS / 1000 }, "Timeout with no activity — session may be stuck");
477
- return "Sorry, that request timed out before I could start working on it. Try again or break it into smaller pieces?";
577
+ throw err;
478
578
  }
479
- // If the session is broken, invalidate it so it's recreated on next attempt
480
- if (/closed|destroy|disposed|invalid|expired|not found/i.test(msg)) {
481
- log.warn({ sessionKey, msg }, "Session appears dead, will recreate");
482
- sessionMap.delete(sessionKey);
483
- sessionModelMap.delete(sessionKey);
484
- if (sessionKey === "default")
485
- deleteState(ORCHESTRATOR_SESSION_KEY);
579
+ finally {
580
+ unsubDelta();
581
+ unsubToolDone();
582
+ unsubToolStart();
583
+ unsubSpawnCapture();
584
+ unsubReasoning();
585
+ unsubSubStart();
586
+ unsubSubDone();
587
+ unsubSubFail();
588
+ unsubSubStartDb();
589
+ unsubSubDoneDb();
590
+ unsubSubFailDb();
591
+ unsubNestedToolStart();
592
+ unsubNestedToolDone();
486
593
  }
487
- throw err;
488
- }
489
- finally {
490
- unsubDelta();
491
- unsubToolDone();
492
- unsubToolStart();
493
- unsubReasoning();
494
- unsubSubStart();
495
- unsubSubDone();
496
- unsubSubFail();
497
- unsubSubStartDb();
498
- unsubSubDoneDb();
499
- unsubSubFailDb();
500
- currentCallback = undefined;
501
- currentActivityCallback = undefined;
502
- currentProcessingSessionKey = undefined;
503
- }
594
+ });
504
595
  }
505
- /** Process the message queue one at a time. */
506
- async function processQueue() {
507
- if (processing) {
508
- if (messageQueue.length > 0) {
509
- log.debug({ queueLength: messageQueue.length }, "Message queued, orchestrator is busy");
510
- }
511
- return;
596
+ /**
597
+ * Process a single queued item: route model, handle @mentions, execute.
598
+ * This is the SessionManager worker — one call per turn, inside the drain loop.
599
+ */
600
+ async function processItem(item, manager) {
601
+ const { sessionKey } = manager;
602
+ if (item.targetAgent && item.targetAgent !== "chapterhouse") {
603
+ setActiveAgent(item.channelKey || "default", item.targetAgent);
604
+ return executeOnSession(manager, item);
512
605
  }
513
- processing = true;
514
- while (messageQueue.length > 0) {
515
- const item = messageQueue.shift();
516
- currentSourceChannel = item.sourceChannel;
517
- currentChannelKey = item.channelKey;
518
- const { sessionKey } = item;
519
- try {
520
- let result;
521
- if (item.targetAgent && item.targetAgent !== "chapterhouse") {
522
- // @mention switches the active agent — route through the session
523
- setActiveAgent(item.channelKey || "default", item.targetAgent);
524
- result = await executeOnSession(sessionKey, item.prompt, item.callback, item.attachments, item.onActivity);
606
+ const currentModel = manager.currentModel ?? config.copilotModel;
607
+ const routeResult = await resolveModel(item.prompt, currentModel, manager.recentTiers);
608
+ if (routeResult.switched) {
609
+ log.info({ model: routeResult.model, tier: routeResult.overrideName || routeResult.tier }, "Auto-routing: switching model");
610
+ config.copilotModel = routeResult.model;
611
+ const existingSession = manager.session;
612
+ if (existingSession) {
613
+ try {
614
+ await existingSession.setModel(routeResult.model);
615
+ manager.currentModel = routeResult.model;
616
+ log.info({ sessionKey }, "Model switched in-place");
525
617
  }
526
- else {
527
- // Route the model before executing
528
- const currentModel = sessionModelMap.get(sessionKey) ?? config.copilotModel;
529
- const routeResult = await resolveModel(item.prompt, currentModel, recentTiers);
530
- if (routeResult.switched) {
531
- log.info({ model: routeResult.model, tier: routeResult.overrideName || routeResult.tier }, "Auto-routing: switching model");
532
- config.copilotModel = routeResult.model;
533
- const existingSession = sessionMap.get(sessionKey);
534
- if (existingSession) {
535
- try {
536
- await existingSession.setModel(routeResult.model);
537
- sessionModelMap.set(sessionKey, routeResult.model);
538
- log.info({ sessionKey }, "Model switched in-place");
539
- }
540
- catch (err) {
541
- log.warn({ sessionKey, err: err instanceof Error ? err.message : err }, "setModel() failed, will recreate session");
542
- sessionMap.delete(sessionKey);
543
- if (sessionKey === "default")
544
- deleteState(ORCHESTRATOR_SESSION_KEY);
545
- }
546
- }
547
- }
548
- if (routeResult.tier) {
549
- recentTiers.push(routeResult.tier);
550
- if (recentTiers.length > 5)
551
- recentTiers = recentTiers.slice(-5);
552
- }
553
- lastRouteResult = routeResult;
554
- result = await executeOnSession(sessionKey, item.prompt, item.callback, item.attachments, item.onActivity);
618
+ catch (err) {
619
+ log.warn({ sessionKey, err: err instanceof Error ? err.message : err }, "setModel() failed, will recreate session");
620
+ manager.invalidateSession();
621
+ if (sessionKey === "default")
622
+ deleteState(ORCHESTRATOR_SESSION_KEY);
555
623
  }
556
- item.resolve(result);
557
624
  }
558
- catch (err) {
559
- item.reject(err);
560
- }
561
- currentSourceChannel = undefined;
562
- currentChannelKey = undefined;
563
625
  }
564
- processing = false;
626
+ if (routeResult.tier) {
627
+ manager.addRecentTier(routeResult.tier);
628
+ }
629
+ lastRouteResult = routeResult;
630
+ return executeOnSession(manager, item);
565
631
  }
566
632
  function isRecoverableError(err) {
567
633
  const msg = err instanceof Error ? err.message : String(err);
568
- // Timeouts are NOT retryable on a persistent session — the message was already
569
- // sent and likely processed; re-sending creates "duplicate" responses.
570
634
  if (/timeout/i.test(msg))
571
635
  return false;
572
636
  return /disconnect|connection|EPIPE|ECONNRESET|ECONNREFUSED|socket|closed|ENOENT|spawn|not found|expired|stale/i.test(msg);
@@ -576,14 +640,10 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
576
640
  updateRequestContext(source);
577
641
  const sourceLabel = source.type === "web" ? "web" : "background";
578
642
  logMessage("in", sourceLabel, prompt);
579
- // Derive the session key: project sessions come from web messages with a projectPath;
580
- // background completions carry their own sessionKey; everything else is "default".
581
643
  let sessionKey;
582
644
  if (source.type === "web" && source.projectPath && config.squadEnabled) {
583
645
  sessionKey = "project:" + normalizeProjectPath(source.projectPath);
584
- // Keep the legacy channel-project map in sync for tools that read it
585
646
  setChannelProject(source.connectionId, normalizeProjectPath(source.projectPath));
586
- // Bump last-used timestamp so sidebar can sort by real activity
587
647
  bumpProjectLastUsed(normalizeProjectPath(source.projectPath));
588
648
  }
589
649
  else if (source.type === "background" && source.sessionKey) {
@@ -593,36 +653,46 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
593
653
  sessionKey = "default";
594
654
  }
595
655
  const channelKey = source.type === "web" ? source.connectionId : "default";
596
- // Pass projectRoot to parseAtMention only for project sessions so the default
597
- // chat does not get Squad roster injection.
598
656
  const projectRoot = sessionKey.startsWith("project:") ? sessionKey.slice("project:".length) : undefined;
599
- // Parse @mention routing (e.g., "@coder fix the bug" → target "coder")
600
657
  const mention = parseAtMention(prompt, projectRoot);
601
658
  const targetAgent = mention?.agentSlug;
602
659
  const routedPrompt = mention ? mention.message : prompt;
603
- // Tag the prompt with its source channel
604
660
  const taggedPrompt = source.type === "background"
605
661
  ? routedPrompt
606
662
  : `[via ${sourceLabel}] ${routedPrompt}`;
607
- // Log role: background events are "system", user messages are "user"
608
663
  const logRole = source.type === "background" ? "system" : "user";
609
- // Determine the source channel for agent origin tracking
610
664
  const sourceChannel = source.type === "web" ? "web" : undefined;
611
- // Enqueue and process
665
+ // Capture auth context at enqueue time — prevents cross-session contamination
666
+ // when concurrent sessions are processing simultaneously.
667
+ const authUser = source.type === "web" ? source.user : undefined;
668
+ const authHeader = source.type === "web" ? source.authorizationHeader?.trim() || undefined : undefined;
669
+ const manager = registry.getOrCreate(sessionKey);
612
670
  void (async () => {
613
671
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
614
672
  try {
615
673
  const finalContent = await new Promise((resolve, reject) => {
616
- messageQueue.push({ prompt: taggedPrompt, attachments, callback, onActivity, sourceChannel, targetAgent, channelKey, sessionKey, resolve, reject });
617
- processQueue();
674
+ manager.enqueue({
675
+ prompt: taggedPrompt,
676
+ attachments,
677
+ callback,
678
+ // Cast: QueuedMessage.onActivity uses a wide event type to avoid circular
679
+ // type dependencies. orchestrator.ts always passes valid ActivityEvent objects.
680
+ onActivity: onActivity,
681
+ sourceChannel,
682
+ targetAgent,
683
+ channelKey,
684
+ sessionKey,
685
+ authUser,
686
+ authHeader,
687
+ resolve,
688
+ reject,
689
+ });
618
690
  });
619
- // Deliver response to user FIRST, then log best-effort
620
691
  callback(finalContent, true);
621
692
  try {
622
693
  logMessage("out", sourceLabel, finalContent);
623
694
  }
624
695
  catch { /* best-effort */ }
625
- // Log both sides of the conversation, scoped to the session
626
696
  try {
627
697
  logConversation(logRole, prompt, sourceLabel, sessionKey);
628
698
  }
@@ -631,9 +701,6 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
631
701
  logConversation("assistant", finalContent, sourceLabel, sessionKey);
632
702
  }
633
703
  catch { /* best-effort */ }
634
- // Episodic memory: if enough turns have accumulated since the last
635
- // summary, kick off a background write. Fire-and-forget — never blocks
636
- // the user reply path.
637
704
  if (copilotClient) {
638
705
  maybeWriteEpisode(copilotClient).catch((err) => {
639
706
  log.error({ err: err instanceof Error ? err.message : err }, "Episode write failed (non-fatal)");
@@ -643,7 +710,6 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
643
710
  }
644
711
  catch (err) {
645
712
  const msg = err instanceof Error ? err.message : String(err);
646
- // Don't retry cancelled messages
647
713
  if (/cancelled|abort/i.test(msg)) {
648
714
  return;
649
715
  }
@@ -651,7 +717,6 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
651
717
  const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
652
718
  log.warn({ msg, attempt: attempt + 1, maxRetries: MAX_RETRIES, delayMs: delay }, "Recoverable error, retrying");
653
719
  await sleep(delay);
654
- // Reset client before retry in case the connection is stale
655
720
  try {
656
721
  await ensureClient();
657
722
  }
@@ -665,34 +730,28 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
665
730
  }
666
731
  })();
667
732
  }
668
- /** Cancel the in-flight message and drain the queue. */
733
+ /** Cancel all queued and in-flight messages across all active sessions. */
669
734
  export async function cancelCurrentMessage() {
670
- // Drain any queued messages
671
- const drained = messageQueue.length;
672
- while (messageQueue.length > 0) {
673
- const item = messageQueue.shift();
674
- item.reject(new Error("Cancelled"));
675
- }
676
- // Abort the active session request
677
- const activeSession = currentProcessingSessionKey ? sessionMap.get(currentProcessingSessionKey) : undefined;
678
- if (activeSession && currentCallback) {
679
- try {
680
- await activeSession.abort();
681
- log.info({ sessionKey: currentProcessingSessionKey }, "Aborted in-flight request");
682
- return true;
683
- }
684
- catch (err) {
685
- log.error({ err: err instanceof Error ? err.message : err }, "Abort failed");
735
+ if (!registry)
736
+ return false;
737
+ let drained = 0;
738
+ const aborts = [];
739
+ for (const [, manager] of registry.getAll()) {
740
+ drained += manager.cancelQueued();
741
+ if (manager.isProcessing) {
742
+ aborts.push(manager.abortCurrentTurn());
686
743
  }
687
744
  }
688
- return drained > 0;
745
+ const results = await Promise.all(aborts);
746
+ const aborted = results.some(Boolean);
747
+ return aborted || drained > 0;
689
748
  }
690
749
  /** Switch the model on the live default orchestrator session without destroying it. */
691
750
  export function switchSessionModel(newModel) {
692
- const session = sessionMap.get("default");
693
- if (session) {
694
- return session.setModel(newModel).then(() => {
695
- sessionModelMap.set("default", newModel);
751
+ const manager = registry?.get("default");
752
+ if (manager?.session) {
753
+ return manager.session.setModel(newModel).then(() => {
754
+ manager.currentModel = newModel;
696
755
  });
697
756
  }
698
757
  return Promise.resolve();
@@ -700,9 +759,9 @@ export function switchSessionModel(newModel) {
700
759
  /** Return a snapshot of currently running workers for API/UI consumers. */
701
760
  export function getAgentInfo() {
702
761
  const allTasks = getActiveTasks().filter((t) => t.status === "running");
703
- const registry = getAgentRegistry();
762
+ const reg = getAgentRegistry();
704
763
  return allTasks.map((t) => {
705
- const agent = registry.find((a) => a.slug === t.agentSlug);
764
+ const agent = reg.find((a) => a.slug === t.agentSlug);
706
765
  return {
707
766
  slug: t.agentSlug,
708
767
  name: agent?.name || t.agentSlug,
@@ -714,16 +773,11 @@ export function getAgentInfo() {
714
773
  }
715
774
  /** Clean up on shutdown/restart. */
716
775
  export async function shutdownAgents() {
717
- for (const [key, session] of sessionMap) {
718
- try {
719
- await session.disconnect();
720
- }
721
- catch (err) {
722
- log.error({ sessionKey: key, err: err instanceof Error ? err.message : err }, "Error disconnecting session during shutdown");
723
- }
776
+ if (!registry) {
777
+ await clearActiveTasks();
778
+ return;
724
779
  }
725
- sessionMap.clear();
726
- sessionModelMap.clear();
780
+ await registry.shutdown();
727
781
  await clearActiveTasks();
728
782
  }
729
783
  //# sourceMappingURL=orchestrator.js.map