chapterhouse 0.4.2 → 0.5.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 (36) hide show
  1. package/agents/bellonda.agent.md +11 -0
  2. package/agents/hwi-noree.agent.md +12 -0
  3. package/dist/api/server.js +39 -2
  4. package/dist/api/server.test.js +20 -0
  5. package/dist/api/turn-sse.integration.test.js +12 -0
  6. package/dist/copilot/agents.js +16 -4
  7. package/dist/copilot/agents.test.js +43 -1
  8. package/dist/copilot/orchestrator.js +173 -32
  9. package/dist/copilot/orchestrator.test.js +236 -20
  10. package/dist/copilot/session-manager.js +11 -2
  11. package/dist/copilot/session-manager.test.js +25 -0
  12. package/dist/copilot/tools.agent.test.js +52 -4
  13. package/dist/copilot/tools.js +265 -18
  14. package/dist/copilot/tools.memory.test.js +175 -2
  15. package/dist/daemon.js +6 -0
  16. package/dist/memory/action-items.js +100 -0
  17. package/dist/memory/action-items.test.js +83 -0
  18. package/dist/memory/active-scope.js +9 -0
  19. package/dist/memory/eot.js +28 -3
  20. package/dist/memory/eot.test.js +108 -0
  21. package/dist/memory/hot-tier.js +60 -1
  22. package/dist/memory/hot-tier.test.js +38 -0
  23. package/dist/memory/housekeeping-scheduler.js +152 -0
  24. package/dist/memory/housekeeping-scheduler.test.js +187 -0
  25. package/dist/memory/index.js +2 -1
  26. package/dist/memory/recall.js +59 -0
  27. package/dist/memory/recall.test.js +27 -0
  28. package/dist/memory/tiering.js +33 -3
  29. package/dist/store/db.js +130 -17
  30. package/dist/store/db.test.js +61 -5
  31. package/package.json +1 -1
  32. package/web/dist/assets/{index-B_cCSHan.js → index-BfHqP3-C.js} +87 -87
  33. package/web/dist/assets/{index-B_cCSHan.js.map → index-BfHqP3-C.js.map} +1 -1
  34. package/web/dist/assets/index-_O6AoWOS.css +10 -0
  35. package/web/dist/index.html +2 -2
  36. package/web/dist/assets/index-DhY5yWmC.css +0 -10
@@ -29,6 +29,9 @@ function createFakeClient(state) {
29
29
  if (state.sendErrorMessage) {
30
30
  throw new Error(state.sendErrorMessage);
31
31
  }
32
+ for (const delta of state.sendDeltas ?? []) {
33
+ this.emit("assistant.message_delta", { deltaContent: delta });
34
+ }
32
35
  return { data: { content: state.sendResult } };
33
36
  }
34
37
  async setModel(model) {
@@ -102,7 +105,7 @@ async function loadOrchestratorModule(t, overrides = {}) {
102
105
  { taskId: "task-2", agentSlug: "designer", status: "done", description: "Ignore me" },
103
106
  ],
104
107
  registry: [
105
- { slug: "coder", name: "Kaylee", model: "claude-sonnet-4.6" },
108
+ { slug: "coder", name: "Kaylee", model: "claude-sonnet-4.6", systemMessage: "You are Kaylee." },
106
109
  ],
107
110
  sendResult: "Finished successfully",
108
111
  taskEvents: new Map(),
@@ -150,11 +153,35 @@ async function loadOrchestratorModule(t, overrides = {}) {
150
153
  t.mock.module("../memory/hot-tier.js", {
151
154
  namedExports: {
152
155
  renderHotTierForActiveScope: () => state.hotTierXml ?? "",
156
+ getHotTierEntries: (scopeId) => ({
157
+ scope: scopeId !== undefined
158
+ ? makeScope(scopeId, "infra", "Infra", "Infrastructure work.")
159
+ : state.activeScope ?? null,
160
+ entities: [],
161
+ observations: [],
162
+ decisions: [],
163
+ actionItems: [],
164
+ }),
165
+ renderHotTierXML: (entries) => entries.scope ? state.hotTierByScope?.get(entries.scope.slug) ?? "" : "",
153
166
  },
154
167
  });
155
168
  t.mock.module("../memory/active-scope.js", {
156
169
  namedExports: {
157
170
  getActiveScope: () => state.activeScope ?? null,
171
+ withActiveScope: async (_slug, fn) => fn(),
172
+ },
173
+ });
174
+ t.mock.module("../memory/scopes.js", {
175
+ namedExports: {
176
+ getScope: (slugOrId) => {
177
+ if (slugOrId === "infra" || slugOrId === 3) {
178
+ return makeScope(3, "infra", "Infra", "Infrastructure work.");
179
+ }
180
+ if (slugOrId === "brian" || slugOrId === 5) {
181
+ return makeScope(5, "brian", "Brian", "Brian's personal context.");
182
+ }
183
+ return state.activeScope ?? null;
184
+ },
158
185
  },
159
186
  });
160
187
  t.mock.module("../memory/checkpoint.js", {
@@ -249,8 +276,14 @@ async function loadOrchestratorModule(t, overrides = {}) {
249
276
  });
250
277
  t.mock.module("../store/db.js", {
251
278
  namedExports: {
252
- logConversation: (role, content, source) => {
253
- state.dbLogs.push({ role, content, source });
279
+ logConversation: (role, content, source, sessionKey, metadata) => {
280
+ state.dbLogs.push({
281
+ role,
282
+ content,
283
+ source,
284
+ ...(sessionKey && sessionKey !== "default" ? { sessionKey } : {}),
285
+ ...metadata,
286
+ });
254
287
  },
255
288
  getState: (key) => state.store.get(key),
256
289
  setState: (key, value) => {
@@ -336,10 +369,7 @@ async function loadOrchestratorModule(t, overrides = {}) {
336
369
  namedExports: {
337
370
  loadAgents: () => {
338
371
  state.loadAgentsCalls++;
339
- return [
340
- { slug: "coder", name: "Kaylee", model: "claude-sonnet-4.6" },
341
- { slug: "designer", name: "Wash", model: "claude-opus-4.6" },
342
- ];
372
+ return state.registry;
343
373
  },
344
374
  ensureDefaultAgents: () => {
345
375
  state.ensureDefaultAgentsCalls++;
@@ -348,6 +378,7 @@ async function loadOrchestratorModule(t, overrides = {}) {
348
378
  state.clearActiveTasksCalls++;
349
379
  },
350
380
  getAgentRegistry: () => state.registry,
381
+ getAgent: (slug) => state.registry.find((agent) => agent.slug === slug),
351
382
  getActiveAgent: () => undefined,
352
383
  setActiveAgent: (channelKey, agentSlug) => {
353
384
  state.setActiveAgentCalls.push({ channelKey, agentSlug });
@@ -360,6 +391,10 @@ async function loadOrchestratorModule(t, overrides = {}) {
360
391
  state.buildAgentRosterArgs.push(projectRoot);
361
392
  return "@coder @designer";
362
393
  },
394
+ composeAgentSystemMessage: (agent) => agent.systemMessage ?? `You are ${agent.slug}.`,
395
+ filterToolsForAgent: (_agent, tools) => tools,
396
+ bindToolsToAgent: (_agentSlug, tools) => tools,
397
+ withToolTaskContext: (_taskId, fn) => fn(),
363
398
  getActiveTasks: () => state.activeTasks,
364
399
  completeTask: () => { },
365
400
  failTask: () => { },
@@ -510,6 +545,143 @@ test("initOrchestrator omits hot-tier XML when memory injection is disabled", as
510
545
  await orchestrator.initOrchestrator(client);
511
546
  assert.equal(state.systemOptions?.hotTierXml, undefined);
512
547
  });
548
+ test("initOrchestrator prewarms persistent agent sessions with scoped hot-tier context", async (t) => {
549
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
550
+ registry: [
551
+ { slug: "coder", name: "Kaylee", model: "claude-sonnet-4.6", systemMessage: "You are Kaylee." },
552
+ {
553
+ slug: "bellonda",
554
+ name: "Bellonda",
555
+ model: "claude-sonnet-4.6",
556
+ systemMessage: "You are Bellonda.",
557
+ persistent: true,
558
+ scope: "infra",
559
+ },
560
+ ],
561
+ hotTierByScope: new Map([
562
+ ["infra", "<memory_context scope=\"infra\"><observation>terraform drift</observation></memory_context>"],
563
+ ]),
564
+ });
565
+ await orchestrator.initOrchestrator(client);
566
+ assert.equal(state.createSessionCalls.length, 2);
567
+ const persistentCall = state.createSessionCalls.find((call) => String(call.systemMessage?.content ?? "").includes("Bellonda"));
568
+ assert.ok(persistentCall, "expected a prewarmed Bellonda session");
569
+ assert.equal(persistentCall.systemMessage.content.includes("Bellonda"), true);
570
+ assert.equal(persistentCall.systemMessage.content.includes("scope=\"infra\""), true);
571
+ assert.deepEqual(state.dbWrites.filter((write) => write.sql.includes("copilot_sessions")), []);
572
+ });
573
+ test("sendToOrchestrator routes agent session keys directly to persistent agent sessions", async (t) => {
574
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
575
+ config: {
576
+ copilotModel: "claude-sonnet-4.6",
577
+ selfEditEnabled: true,
578
+ memoryInjectEnabled: true,
579
+ },
580
+ registry: [
581
+ {
582
+ slug: "bellonda",
583
+ name: "Bellonda",
584
+ model: "claude-sonnet-4.6",
585
+ systemMessage: "You are Bellonda.",
586
+ persistent: true,
587
+ scope: "infra",
588
+ },
589
+ ],
590
+ sendResult: "Infra answer",
591
+ hotTierByScope: new Map([
592
+ ["infra", "<memory_context scope=\"infra\"><observation>vpc state</observation></memory_context>"],
593
+ ]),
594
+ });
595
+ await orchestrator.initOrchestrator(client);
596
+ state.sessionPrompts.length = 0;
597
+ state.routerArgs.length = 0;
598
+ const final = await new Promise((resolve) => {
599
+ orchestrator.sendToOrchestrator("What changed in prod?", { type: "sse-web", sessionKey: "agent:bellonda" }, (text, done) => {
600
+ if (done)
601
+ resolve(text);
602
+ });
603
+ });
604
+ assert.equal(final, "Infra answer");
605
+ assert.deepEqual(state.routerArgs, [], "direct persistent-agent chat must not use orchestrator model routing");
606
+ assert.deepEqual(state.sessionPrompts, [{ prompt: "[via web] What changed in prod?" }]);
607
+ assert.deepEqual(state.dbLogs.map((entry) => ({
608
+ role: entry.role,
609
+ content: entry.content,
610
+ source: entry.source,
611
+ sessionKey: entry.sessionKey,
612
+ })), [
613
+ { role: "user", content: "What changed in prod?", source: "web", sessionKey: "agent:bellonda" },
614
+ { role: "assistant", content: "Infra answer", source: "web", sessionKey: "agent:bellonda" },
615
+ ]);
616
+ });
617
+ test("sendToAgentSession annotates delegated turns as via chapterhouse", async (t) => {
618
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
619
+ config: {
620
+ copilotModel: "claude-sonnet-4.6",
621
+ selfEditEnabled: true,
622
+ memoryInjectEnabled: true,
623
+ },
624
+ registry: [
625
+ {
626
+ slug: "bellonda",
627
+ name: "Bellonda",
628
+ model: "claude-sonnet-4.6",
629
+ systemMessage: "You are Bellonda.",
630
+ persistent: true,
631
+ scope: "infra",
632
+ },
633
+ ],
634
+ sendResult: "Delegated infra answer",
635
+ });
636
+ await orchestrator.initOrchestrator(client);
637
+ state.sessionPrompts.length = 0;
638
+ const final = await orchestrator.sendToAgentSession("bellonda", "Check deploy health", "task-bellonda-1");
639
+ assert.equal(final, "Delegated infra answer");
640
+ assert.deepEqual(state.sessionPrompts, [{ prompt: "[via @chapterhouse] Check deploy health" }]);
641
+ assert.deepEqual(state.dbLogs.map((entry) => ({
642
+ role: entry.role,
643
+ content: entry.content,
644
+ source: entry.source,
645
+ sessionKey: entry.sessionKey,
646
+ })), [
647
+ { role: "user", content: "Check deploy health", source: "delegated", sessionKey: "agent:bellonda" },
648
+ { role: "assistant", content: "Delegated infra answer", source: "delegated", sessionKey: "agent:bellonda" },
649
+ ]);
650
+ });
651
+ test("sendToOrchestrator preserves literal mentions inside persistent agent sessions", async (t) => {
652
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
653
+ registry: [
654
+ {
655
+ slug: "bellonda",
656
+ name: "Bellonda",
657
+ model: "claude-sonnet-4.6",
658
+ systemMessage: "You are Bellonda.",
659
+ persistent: true,
660
+ scope: "infra",
661
+ },
662
+ {
663
+ slug: "hwi-noree",
664
+ name: "Hwi Noree",
665
+ model: "claude-sonnet-4.6",
666
+ systemMessage: "You are Hwi Noree.",
667
+ persistent: true,
668
+ scope: "brian",
669
+ },
670
+ ],
671
+ parseMentionResult: { agentSlug: "hwi-noree", message: "please archive this" },
672
+ });
673
+ await orchestrator.initOrchestrator(client);
674
+ state.sessionPrompts.length = 0;
675
+ state.setActiveAgentCalls.length = 0;
676
+ await new Promise((resolve) => {
677
+ orchestrator.sendToOrchestrator("@hwi-noree please archive this", { type: "sse-web", sessionKey: "agent:bellonda" }, (text, done) => {
678
+ if (done)
679
+ resolve(text);
680
+ });
681
+ });
682
+ assert.deepEqual(state.sessionPrompts, [{ prompt: "[via web] @hwi-noree please archive this" }]);
683
+ assert.deepEqual(state.setActiveAgentCalls, []);
684
+ });
513
685
  test("sendToOrchestrator logs both sides, remembers web auth context, and records routing state", async (t) => {
514
686
  const { orchestrator, state, client } = await loadOrchestratorModule(t, {
515
687
  config: {
@@ -559,9 +731,11 @@ test("sendToOrchestrator logs both sides, remembers web auth context, and record
559
731
  { direction: "in", source: "web", text: "Summarize the deployment" },
560
732
  { direction: "out", source: "web", text: "All green" },
561
733
  ]);
734
+ const loggedTurnId = state.dbLogs[0]?.turnId;
735
+ assert.equal(typeof loggedTurnId, "string");
562
736
  assert.deepEqual(state.dbLogs, [
563
- { role: "user", content: "Summarize the deployment", source: "web" },
564
- { role: "assistant", content: "All green", source: "web" },
737
+ { role: "user", content: "Summarize the deployment", source: "web", turnId: loggedTurnId },
738
+ { role: "assistant", content: "All green", source: "web", turnId: loggedTurnId },
565
739
  ]);
566
740
  assert.equal(state.episodeWrites, 1);
567
741
  });
@@ -999,13 +1173,14 @@ test("@mentions route through the orchestrator session without invoking the mode
999
1173
  assert.deepEqual(state.routerArgs, []);
1000
1174
  assert.deepEqual(state.sessionPrompts, [{ prompt: "[via web] polish the landing page" }]);
1001
1175
  });
1002
- test("feedAgentResult injects a background completion turn and proactively notifies listeners", async (t) => {
1176
+ test("feedAgentResult emits an attributed short agent reply before starting the orchestrator acknowledgement", async (t) => {
1003
1177
  const { orchestrator, state, client } = await loadOrchestratorModule(t, {
1004
1178
  config: {
1005
1179
  copilotModel: "claude-sonnet-4.6",
1006
1180
  selfEditEnabled: true,
1007
1181
  },
1008
1182
  sendResult: "Agent complete",
1183
+ sendDeltas: ["Agent complete"],
1009
1184
  taskSessionKeys: new Map([["task-9", "chat:bg-lifecycle"]]),
1010
1185
  });
1011
1186
  await orchestrator.initOrchestrator(client);
@@ -1013,28 +1188,69 @@ test("feedAgentResult injects a background completion turn and proactively notif
1013
1188
  const notified = new Promise((resolve) => {
1014
1189
  orchestrator.setProactiveNotify(resolve);
1015
1190
  });
1016
- orchestrator.feedAgentResult("task-9", "coder", "Fixed the flaky test");
1191
+ const agentReply = "Fixed the flaky test. ".repeat(40);
1192
+ orchestrator.feedAgentResult("task-9", "coder", agentReply);
1193
+ assert.deepEqual(events.map((event) => event.type), ["turn:started", "turn:delta", "turn:complete"], "short agent replies should fully emit before the orchestrator acknowledgement starts");
1194
+ await new Promise((resolve) => setImmediate(resolve));
1017
1195
  assert.equal(await notified, "Agent complete");
1018
1196
  assert.deepEqual(state.sessionPrompts, [{
1019
- prompt: "[Agent task completed] @coder finished task task-9:\n\nFixed the flaky test",
1197
+ prompt: "[Agent task completed] @coder finished task task-9. Their reply has been shown to the user. Acknowledge briefly.",
1020
1198
  }]);
1199
+ assert.equal(state.sessionPrompts[0]?.prompt.includes(agentReply), false, "orchestrator notification must not include the full agent reply body");
1200
+ const started = events.filter((event) => event.type === "turn:started");
1201
+ const deltas = events.filter((event) => event.type === "turn:delta");
1202
+ const completed = events.filter((event) => event.type === "turn:complete");
1203
+ assert.equal(started.length, 2, "agent reply plus orchestrator acknowledgement should each emit turn:started");
1204
+ assert.equal(deltas.length, 2, "agent reply plus orchestrator acknowledgement should each emit one delta");
1205
+ assert.equal(completed.length, 2, "agent reply plus orchestrator acknowledgement should each emit turn:complete");
1206
+ assert.equal(started[0]?.agentSlug, "coder");
1207
+ assert.equal(started[0]?.agentDisplayName, "Kaylee");
1208
+ assert.equal(started[0]?.prompt, "");
1021
1209
  assert.deepEqual(state.dbLogs, [
1022
1210
  {
1023
1211
  role: "agent_completion",
1024
- content: "[Agent task completed] @coder finished task task-9:\n\nFixed the flaky test",
1212
+ content: agentReply,
1025
1213
  source: "background",
1214
+ sessionKey: "chat:bg-lifecycle",
1215
+ agentSlug: "coder",
1216
+ agentDisplayName: "Kaylee",
1217
+ turnId: started[0]?.turnId,
1026
1218
  },
1027
1219
  {
1028
1220
  role: "assistant",
1029
1221
  content: "Agent complete",
1030
1222
  source: "background",
1223
+ sessionKey: "chat:bg-lifecycle",
1224
+ turnId: started[1]?.turnId,
1031
1225
  },
1032
1226
  ]);
1033
- const started = events.filter((event) => event.type === "turn:started");
1034
- const completed = events.filter((event) => event.type === "turn:complete");
1035
- assert.equal(started.length, 1, "background turn should emit one turn:started event");
1036
- assert.equal(completed.length, 1, "background turn should emit one turn:complete event");
1037
- assert.equal(started[0]?.turnId, completed[0]?.turnId, "background lifecycle events must share the same turnId");
1227
+ assert.equal(deltas[0]?.turnId, started[0]?.turnId);
1228
+ assert.deepEqual(deltas[0]?.part, { type: "text", text: agentReply });
1229
+ assert.equal(completed[0]?.turnId, started[0]?.turnId);
1230
+ assert.equal(completed[0]?.finalMessage, agentReply);
1231
+ assert.notEqual(started[0]?.turnId, started[1]?.turnId, "agent reply and orchestrator acknowledgement need distinct turns");
1232
+ assert.deepEqual(events.map((event) => event.type), ["turn:started", "turn:delta", "turn:complete", "turn:started", "turn:delta", "turn:complete"]);
1233
+ });
1234
+ test("feedAgentResult emits a delta even when the agent result is empty", async (t) => {
1235
+ const { orchestrator, client } = await loadOrchestratorModule(t, {
1236
+ config: { copilotModel: "claude-sonnet-4.6", selfEditEnabled: true },
1237
+ sendResult: "Acknowledged",
1238
+ taskSessionKeys: new Map([["task-empty", "chat:bg-empty"]]),
1239
+ });
1240
+ await orchestrator.initOrchestrator(client);
1241
+ const events = captureSessionEvents(t, "chat:bg-empty");
1242
+ const notified = new Promise((resolve) => {
1243
+ orchestrator.setProactiveNotify(resolve);
1244
+ });
1245
+ orchestrator.feedAgentResult("task-empty", "coder", "");
1246
+ assert.equal(await notified, "Acknowledged");
1247
+ const agentStarted = events.find((event) => event.type === "turn:started" && event.agentSlug === "coder");
1248
+ assert.ok(agentStarted, "agent reply should emit a started event");
1249
+ const deltas = events
1250
+ .filter((event) => event.type === "turn:delta")
1251
+ .filter((event) => event.turnId === agentStarted.turnId);
1252
+ assert.equal(deltas.length, 1);
1253
+ assert.deepEqual(deltas[0]?.part, { type: "text", text: "" });
1038
1254
  });
1039
1255
  test("enqueueForSse emits exactly one turn lifecycle pair for sse-web turns", async (t) => {
1040
1256
  const { orchestrator, client } = await loadOrchestratorModule(t, {
@@ -1227,11 +1443,11 @@ test("feedAgentResult routes to a non-default session when the task's session_ke
1227
1443
  // A second createSession call proves the orchestrator opened a fresh non-default session
1228
1444
  // rather than reusing the already-open default session.
1229
1445
  assert.equal(state.createSessionCalls.length, sessionsAfterInit + 1, "feedAgentResult should spin up a non-default session, not recycle the default one");
1230
- // The prompt must reference the task and agent
1446
+ // The prompt must reference the task and agent but not include the full reply body.
1231
1447
  const prompt = state.sessionPrompts.at(-1);
1232
1448
  assert.ok(prompt?.prompt.includes("chat-task-1"), "prompt should reference the task id");
1233
1449
  assert.ok(prompt?.prompt.includes("coder"), "prompt should reference the agent slug");
1234
- assert.ok(prompt?.prompt.includes("Feature done"), "prompt should include the result text");
1450
+ assert.equal(prompt?.prompt.includes("Feature done"), false, "prompt should not include the result text");
1235
1451
  });
1236
1452
  test("ensureOrchestratorSession cleans up in-flight promise on session creation failure", async (t) => {
1237
1453
  const { orchestrator, state, client } = await loadOrchestratorModule(t, {
@@ -76,6 +76,9 @@ export class SessionManager {
76
76
  get canEvict() {
77
77
  return !this._processing && this._queue.length === 0;
78
78
  }
79
+ get isPersistent() {
80
+ return this.sessionKey.startsWith("agent:");
81
+ }
79
82
  get lastActivityAt() {
80
83
  return this._lastActivityAt;
81
84
  }
@@ -273,7 +276,7 @@ export class SessionRegistry {
273
276
  const existing = this.managers.get(sessionKey);
274
277
  if (existing)
275
278
  return existing;
276
- if (this.managers.size >= this.options.maxActive) {
279
+ if (this.nonPersistentSize() >= this.options.maxActive) {
277
280
  this.evictLRU();
278
281
  }
279
282
  const manager = this.createManager(sessionKey);
@@ -330,6 +333,9 @@ export class SessionRegistry {
330
333
  runTtlEviction() {
331
334
  const now = Date.now();
332
335
  for (const [sessionKey, manager] of [...this.managers.entries()]) {
336
+ if (manager.isPersistent) {
337
+ continue;
338
+ }
333
339
  if (manager.canEvict && now - manager.lastActivityAt > this.options.idleTtlMs) {
334
340
  const idleMs = now - manager.lastActivityAt;
335
341
  this.managers.delete(sessionKey);
@@ -340,7 +346,7 @@ export class SessionRegistry {
340
346
  }
341
347
  evictLRU() {
342
348
  const evictable = [...this.managers.entries()]
343
- .filter(([, m]) => m.canEvict)
349
+ .filter(([, m]) => m.canEvict && !m.isPersistent)
344
350
  .sort(([, a], [, b]) => a.lastActivityAt - b.lastActivityAt);
345
351
  if (evictable.length === 0) {
346
352
  log.warn({ size: this.managers.size, max: this.options.maxActive }, "At max active sessions and no idle sessions available for LRU eviction");
@@ -351,6 +357,9 @@ export class SessionRegistry {
351
357
  void manager.evict("lru-bumped");
352
358
  log.info({ sessionKey, reason: "lru-bumped" }, "session.evicted");
353
359
  }
360
+ nonPersistentSize() {
361
+ return [...this.managers.values()].filter((manager) => !manager.isPersistent).length;
362
+ }
354
363
  /** Shut down all sessions. Stops the eviction timer and disconnects every session. */
355
364
  async shutdown() {
356
365
  this.stopEvictionTimer();
@@ -312,6 +312,31 @@ test("SessionRegistry: TTL eviction removes sessions idle beyond the TTL", async
312
312
  assert.ok(disconnectLog.includes("idle-session"), "idle session must be evicted after TTL");
313
313
  assert.ok(!registry.get("idle-session"), "idle session must be removed");
314
314
  });
315
+ test("SessionRegistry: TTL eviction does not remove persistent agent sessions", async () => {
316
+ const SHORT_TTL = 40;
317
+ const { registry, disconnectLog } = makeRegistry({ idleTtlMs: SHORT_TTL });
318
+ const manager = registry.getOrCreate("agent:bellonda");
319
+ await manager.ensureSession();
320
+ registry.startEvictionTimer();
321
+ await new Promise((r) => setTimeout(r, SHORT_TTL * 5));
322
+ registry.stopEvictionTimer();
323
+ assert.ok(registry.get("agent:bellonda"), "persistent agent session must stay registered after TTL");
324
+ assert.equal(disconnectLog.includes("agent:bellonda"), false, "persistent agent session must not disconnect on TTL");
325
+ });
326
+ test("SessionRegistry: LRU eviction skips persistent agent sessions", async () => {
327
+ const { registry, disconnectLog } = makeRegistry({ maxActive: 1, idleTtlMs: 60_000 });
328
+ const persistent = registry.getOrCreate("agent:bellonda");
329
+ await persistent.ensureSession();
330
+ await new Promise((r) => setTimeout(r, 2));
331
+ const regular = registry.getOrCreate("regular-session");
332
+ await regular.ensureSession();
333
+ registry.getOrCreate("new-session");
334
+ await new Promise((r) => setTimeout(r, 5));
335
+ assert.ok(registry.get("agent:bellonda"), "persistent agent session must not be LRU-evicted");
336
+ assert.ok(!registry.get("regular-session"), "oldest non-persistent idle session should be evicted");
337
+ assert.equal(disconnectLog.includes("agent:bellonda"), false);
338
+ assert.equal(disconnectLog.includes("regular-session"), true);
339
+ });
315
340
  test("SessionRegistry: shutdown disconnects all sessions", async () => {
316
341
  const { registry, disconnectLog } = makeRegistry();
317
342
  for (const sk of ["a", "b", "c"]) {
@@ -64,6 +64,7 @@ function expectedDelegatedPrompt(task, warningLines = []) {
64
64
  }
65
65
  async function loadToolsModule(t, options) {
66
66
  const sentPrompts = [];
67
+ const persistentSends = [];
67
68
  const taskId = options?.taskId ?? `delegated-task-${Date.now()}-${Math.random()}`;
68
69
  const fakeSession = {
69
70
  on: () => () => { },
@@ -86,15 +87,29 @@ async function loadToolsModule(t, options) {
86
87
  invalidateOrchestratorSession: () => { },
87
88
  resetCheckpointSessionState: () => { },
88
89
  switchSessionModel: async () => { },
90
+ sendToAgentSession: async (slug, prompt, delegatedTaskId) => {
91
+ persistentSends.push({ slug, prompt, taskId: delegatedTaskId });
92
+ return `persistent handled: ${prompt}`;
93
+ },
89
94
  },
90
95
  });
91
96
  t.mock.module("./agents.js", {
92
97
  namedExports: {
93
- getAgentRegistry: () => [{ slug: "coder", name: "Coder", model: "claude-sonnet-4.6" }],
98
+ getAgentRegistry: () => [
99
+ { slug: "coder", name: "Coder", model: "claude-sonnet-4.6" },
100
+ { slug: "bellonda", name: "Bellonda", model: "claude-sonnet-4.6", persistent: true, scope: "infra" },
101
+ ],
94
102
  getAgent: (name) => name === "coder"
95
103
  ? { slug: "coder", name: "Coder", model: "claude-sonnet-4.6" }
96
- : undefined,
97
- createEphemeralAgentSession: async () => fakeSession,
104
+ : name === "bellonda"
105
+ ? { slug: "bellonda", name: "Bellonda", model: "claude-sonnet-4.6", persistent: true, scope: "infra" }
106
+ : undefined,
107
+ createEphemeralAgentSession: async () => {
108
+ if (options?.persistentAgent) {
109
+ throw new Error("persistent agent should not use ephemeral session");
110
+ }
111
+ return fakeSession;
112
+ },
98
113
  getAgentSessionStatus: () => ({ tasks: [] }),
99
114
  getActiveTasks: () => [],
100
115
  getTask: () => undefined,
@@ -115,7 +130,7 @@ async function loadToolsModule(t, options) {
115
130
  },
116
131
  });
117
132
  const module = await import(new URL(`./tools.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
118
- return { module, sentPrompts, taskId };
133
+ return { module, sentPrompts, taskId, persistentSends };
119
134
  }
120
135
  test.beforeEach(() => {
121
136
  process.env.CHAPTERHOUSE_HOME = mkdtempSync(join(tmpdir(), "chapterhouse-tools-agent-"));
@@ -235,4 +250,37 @@ test("delegate_to_agent does not inject orchestrator memory_context into subagen
235
250
  assert.deepEqual(sentPrompts, [expectedDelegatedPrompt(task)]);
236
251
  assert.equal(sentPrompts.some((prompt) => prompt.includes("<memory_context>")), false);
237
252
  });
253
+ test("delegate_to_agent sends persistent agents through their backend session and returns result to orchestrator", async (t) => {
254
+ const { module, sentPrompts, persistentSends, taskId } = await loadToolsModule(t, {
255
+ taskId: "delegated-persistent-001",
256
+ persistentAgent: true,
257
+ });
258
+ const completions = [];
259
+ const tools = module.createTools({
260
+ client: { async listModels() { return []; } },
261
+ onAgentTaskComplete: (completedTaskId, agentSlug, result) => {
262
+ completions.push({ taskId: completedTaskId, agentSlug, result });
263
+ },
264
+ });
265
+ const tool = tools.find((entry) => entry.name === "delegate_to_agent");
266
+ assert.ok(tool, "delegate_to_agent tool should be registered");
267
+ const response = await tool.handler({
268
+ agent_name: "bellonda",
269
+ summary: "Inspect infra drift",
270
+ task: "Run terraform plan for the VPC.",
271
+ }, {});
272
+ await new Promise((resolve) => setTimeout(resolve, 0));
273
+ assert.match(String(response), /Task delegated to @bellonda/);
274
+ assert.deepEqual(sentPrompts, [], "persistent delegation must not create/use an ephemeral session");
275
+ assert.deepEqual(persistentSends, [{
276
+ slug: "bellonda",
277
+ prompt: "Run terraform plan for the VPC.",
278
+ taskId,
279
+ }]);
280
+ assert.deepEqual(completions, [{
281
+ taskId,
282
+ agentSlug: "bellonda",
283
+ result: "persistent handled: Run terraform plan for the VPC.",
284
+ }]);
285
+ });
238
286
  //# sourceMappingURL=tools.agent.test.js.map