chapterhouse 0.4.3 → 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.
@@ -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", {
@@ -342,10 +369,7 @@ async function loadOrchestratorModule(t, overrides = {}) {
342
369
  namedExports: {
343
370
  loadAgents: () => {
344
371
  state.loadAgentsCalls++;
345
- return [
346
- { slug: "coder", name: "Kaylee", model: "claude-sonnet-4.6" },
347
- { slug: "designer", name: "Wash", model: "claude-opus-4.6" },
348
- ];
372
+ return state.registry;
349
373
  },
350
374
  ensureDefaultAgents: () => {
351
375
  state.ensureDefaultAgentsCalls++;
@@ -354,6 +378,7 @@ async function loadOrchestratorModule(t, overrides = {}) {
354
378
  state.clearActiveTasksCalls++;
355
379
  },
356
380
  getAgentRegistry: () => state.registry,
381
+ getAgent: (slug) => state.registry.find((agent) => agent.slug === slug),
357
382
  getActiveAgent: () => undefined,
358
383
  setActiveAgent: (channelKey, agentSlug) => {
359
384
  state.setActiveAgentCalls.push({ channelKey, agentSlug });
@@ -366,6 +391,10 @@ async function loadOrchestratorModule(t, overrides = {}) {
366
391
  state.buildAgentRosterArgs.push(projectRoot);
367
392
  return "@coder @designer";
368
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(),
369
398
  getActiveTasks: () => state.activeTasks,
370
399
  completeTask: () => { },
371
400
  failTask: () => { },
@@ -516,6 +545,143 @@ test("initOrchestrator omits hot-tier XML when memory injection is disabled", as
516
545
  await orchestrator.initOrchestrator(client);
517
546
  assert.equal(state.systemOptions?.hotTierXml, undefined);
518
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
+ });
519
685
  test("sendToOrchestrator logs both sides, remembers web auth context, and records routing state", async (t) => {
520
686
  const { orchestrator, state, client } = await loadOrchestratorModule(t, {
521
687
  config: {
@@ -1007,13 +1173,14 @@ test("@mentions route through the orchestrator session without invoking the mode
1007
1173
  assert.deepEqual(state.routerArgs, []);
1008
1174
  assert.deepEqual(state.sessionPrompts, [{ prompt: "[via web] polish the landing page" }]);
1009
1175
  });
1010
- test("feedAgentResult emits an attributed agent reply turn and sends only a short orchestrator prompt", async (t) => {
1176
+ test("feedAgentResult emits an attributed short agent reply before starting the orchestrator acknowledgement", async (t) => {
1011
1177
  const { orchestrator, state, client } = await loadOrchestratorModule(t, {
1012
1178
  config: {
1013
1179
  copilotModel: "claude-sonnet-4.6",
1014
1180
  selfEditEnabled: true,
1015
1181
  },
1016
1182
  sendResult: "Agent complete",
1183
+ sendDeltas: ["Agent complete"],
1017
1184
  taskSessionKeys: new Map([["task-9", "chat:bg-lifecycle"]]),
1018
1185
  });
1019
1186
  await orchestrator.initOrchestrator(client);
@@ -1021,17 +1188,20 @@ test("feedAgentResult emits an attributed agent reply turn and sends only a shor
1021
1188
  const notified = new Promise((resolve) => {
1022
1189
  orchestrator.setProactiveNotify(resolve);
1023
1190
  });
1024
- 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));
1025
1195
  assert.equal(await notified, "Agent complete");
1026
1196
  assert.deepEqual(state.sessionPrompts, [{
1027
1197
  prompt: "[Agent task completed] @coder finished task task-9. Their reply has been shown to the user. Acknowledge briefly.",
1028
1198
  }]);
1029
- assert.equal(state.sessionPrompts[0]?.prompt.includes("Fixed the flaky test"), false, "orchestrator notification must not include the full agent reply body");
1199
+ assert.equal(state.sessionPrompts[0]?.prompt.includes(agentReply), false, "orchestrator notification must not include the full agent reply body");
1030
1200
  const started = events.filter((event) => event.type === "turn:started");
1031
1201
  const deltas = events.filter((event) => event.type === "turn:delta");
1032
1202
  const completed = events.filter((event) => event.type === "turn:complete");
1033
1203
  assert.equal(started.length, 2, "agent reply plus orchestrator acknowledgement should each emit turn:started");
1034
- assert.equal(deltas.length, 1, "agent reply should stream as one or more deltas");
1204
+ assert.equal(deltas.length, 2, "agent reply plus orchestrator acknowledgement should each emit one delta");
1035
1205
  assert.equal(completed.length, 2, "agent reply plus orchestrator acknowledgement should each emit turn:complete");
1036
1206
  assert.equal(started[0]?.agentSlug, "coder");
1037
1207
  assert.equal(started[0]?.agentDisplayName, "Kaylee");
@@ -1039,7 +1209,7 @@ test("feedAgentResult emits an attributed agent reply turn and sends only a shor
1039
1209
  assert.deepEqual(state.dbLogs, [
1040
1210
  {
1041
1211
  role: "agent_completion",
1042
- content: "Fixed the flaky test",
1212
+ content: agentReply,
1043
1213
  source: "background",
1044
1214
  sessionKey: "chat:bg-lifecycle",
1045
1215
  agentSlug: "coder",
@@ -1055,10 +1225,11 @@ test("feedAgentResult emits an attributed agent reply turn and sends only a shor
1055
1225
  },
1056
1226
  ]);
1057
1227
  assert.equal(deltas[0]?.turnId, started[0]?.turnId);
1058
- assert.deepEqual(deltas[0]?.part, { type: "text", text: "Fixed the flaky test" });
1228
+ assert.deepEqual(deltas[0]?.part, { type: "text", text: agentReply });
1059
1229
  assert.equal(completed[0]?.turnId, started[0]?.turnId);
1060
- assert.equal(completed[0]?.finalMessage, "Fixed the flaky test");
1230
+ assert.equal(completed[0]?.finalMessage, agentReply);
1061
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"]);
1062
1233
  });
1063
1234
  test("feedAgentResult emits a delta even when the agent result is empty", async (t) => {
1064
1235
  const { orchestrator, 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
@@ -7,7 +7,7 @@ import { homedir } from "os";
7
7
  import { listSkills, createSkill, removeSkill } from "./skills.js";
8
8
  import { config, persistModel } from "../config.js";
9
9
  import { agentEventBus } from "./agent-event-bus.js";
10
- import { getCurrentSourceChannel, getCurrentActivityCallback, getCurrentActiveProjectRules, getCurrentAuthenticatedUser, getLastAuthenticatedUser, getCurrentAuthorizationHeader, getCurrentSessionKey, invalidateOrchestratorSession, maybeScheduleScopeChangeCheckpoint, resetCheckpointSessionState, switchSessionModel, } from "./orchestrator.js";
10
+ import { getCurrentSourceChannel, getCurrentActivityCallback, getCurrentActiveProjectRules, getCurrentAuthenticatedUser, getLastAuthenticatedUser, getCurrentAuthorizationHeader, getCurrentSessionKey, sendToAgentSession, invalidateOrchestratorSession, maybeScheduleScopeChangeCheckpoint, resetCheckpointSessionState, switchSessionModel, } from "./orchestrator.js";
11
11
  import { getRouterConfig, updateRouterConfig } from "./router.js";
12
12
  import { ensureWikiStructure, readPage, writePage, deletePage, writeRawSource, assertPagePath } from "../wiki/fs.js";
13
13
  import { searchIndex, addToIndex, removeFromIndex, buildIndexEntryForPage } from "../wiki/index-manager.js";
@@ -65,6 +65,21 @@ function requireOrchestratorMemoryWrite() {
65
65
  }
66
66
  return null;
67
67
  }
68
+ function resolveProposalScopeSlug(requestedScopeSlug) {
69
+ const agentSlug = agentsModule.getCurrentToolAgentSlug?.();
70
+ if (!agentSlug || agentSlug === "chapterhouse") {
71
+ return requestedScopeSlug;
72
+ }
73
+ const activeScope = getMemoryActiveScope();
74
+ if (activeScope) {
75
+ return activeScope.slug;
76
+ }
77
+ const agent = getAgent(agentSlug);
78
+ if (agent?.persistent && agent.scope) {
79
+ return agent.scope;
80
+ }
81
+ return requestedScopeSlug;
82
+ }
68
83
  function resolveMemoryScopeForWrite(explicitScope, content) {
69
84
  const explicit = explicitScope ? getMemoryScope(explicitScope) : undefined;
70
85
  if (explicitScope && !explicit) {
@@ -210,15 +225,6 @@ export function createTools(deps) {
210
225
  }
211
226
  const delegatedSlug = agent.slug;
212
227
  const taskId = createTaskId();
213
- let session;
214
- try {
215
- const allTools = createTools(deps);
216
- session = await createEphemeralAgentSession(agent.slug, deps.client, allTools, args.model_override, undefined, taskId);
217
- }
218
- catch (err) {
219
- const msg = err instanceof Error ? err.message : String(err);
220
- return `Failed to create session for @${delegatedSlug}: ${msg}`;
221
- }
222
228
  const task = registerTask(delegatedSlug, args.summary, getCurrentSourceChannel(), taskId);
223
229
  const activeProjectRules = getCurrentActiveProjectRules();
224
230
  const warningLines = activeProjectRules
@@ -232,6 +238,71 @@ export function createTools(deps) {
232
238
  const db = getDb();
233
239
  db.prepare(`INSERT INTO agent_tasks (task_id, agent_slug, description, prompt, status, origin_channel, session_key, source)
234
240
  VALUES (?, ?, ?, ?, 'running', ?, ?, 'adhoc')`).run(task.taskId, delegatedSlug, args.summary, args.task, task.originChannel || null, getCurrentSessionKey());
241
+ if (agent.persistent) {
242
+ (async () => {
243
+ try {
244
+ const output = await sendToAgentSession(delegatedSlug, taskPrompt, task.taskId);
245
+ completeTask(task.taskId, output);
246
+ updateTaskResult(task.taskId, "completed", output);
247
+ const statusEvent = appendTaskStatusEvent(task.taskId, "completed");
248
+ if (statusEvent) {
249
+ void agentEventBus.emit({
250
+ type: "session:tool_call",
251
+ sessionId: task.taskId,
252
+ payload: {
253
+ toolName: "",
254
+ toolArgs: {},
255
+ _kind: statusEvent.kind,
256
+ _seq: statusEvent.seq,
257
+ _ts: statusEvent.ts,
258
+ _summary: statusEvent.summary,
259
+ _text: statusEvent.text,
260
+ _status: statusEvent.status,
261
+ },
262
+ timestamp: new Date(statusEvent.ts),
263
+ });
264
+ }
265
+ deps.onAgentTaskComplete(task.taskId, delegatedSlug, output);
266
+ }
267
+ catch (err) {
268
+ const msg = err instanceof Error ? err.message : String(err);
269
+ failTask(task.taskId, msg);
270
+ updateTaskResult(task.taskId, "error", msg);
271
+ const statusEvent = appendTaskStatusEvent(task.taskId, "error", msg);
272
+ if (statusEvent) {
273
+ void agentEventBus.emit({
274
+ type: "session:tool_call",
275
+ sessionId: task.taskId,
276
+ payload: {
277
+ toolName: "",
278
+ toolArgs: {},
279
+ _kind: statusEvent.kind,
280
+ _seq: statusEvent.seq,
281
+ _ts: statusEvent.ts,
282
+ _summary: statusEvent.summary,
283
+ _text: statusEvent.text,
284
+ _status: statusEvent.status,
285
+ },
286
+ timestamp: new Date(statusEvent.ts),
287
+ });
288
+ }
289
+ deps.onAgentTaskComplete(task.taskId, delegatedSlug, `Error: ${msg}`);
290
+ }
291
+ })();
292
+ const model = (args.model_override && args.model_override.length > 0)
293
+ ? args.model_override
294
+ : (agent.model === "auto" ? "claude-sonnet-4.6" : agent.model || "claude-sonnet-4.6");
295
+ return `Task delegated to @${delegatedSlug} (${model}). Task ID: ${task.taskId}. I'll notify you when it's done.`;
296
+ }
297
+ let session;
298
+ try {
299
+ const allTools = createTools(deps);
300
+ session = await createEphemeralAgentSession(agent.slug, deps.client, allTools, args.model_override, undefined, taskId);
301
+ }
302
+ catch (err) {
303
+ const msg = err instanceof Error ? err.message : String(err);
304
+ return `Failed to create session for @${delegatedSlug}: ${msg}`;
305
+ }
235
306
  // Capture the parent's activity callback so the child session can stream
236
307
  // its events back to the originating SSE connection. This survives past
237
308
  // the parent assistant turn — the child runs long after the parent's
@@ -876,7 +947,7 @@ export function createTools(deps) {
876
947
  : actionItemProposalPayloadSchema.parse(parsedArgs.payload);
877
948
  const proposal = queueMemoryProposal({
878
949
  kind: parsedArgs.kind,
879
- scopeSlug: parsedArgs.scope_slug,
950
+ scopeSlug: resolveProposalScopeSlug(parsedArgs.scope_slug),
880
951
  payload,
881
952
  confidence: parsedArgs.confidence ?? 0.5,
882
953
  reason: parsedArgs.reason,
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdtempSync, rmSync } from "node:fs";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import test from "node:test";
@@ -46,6 +46,7 @@ test("memory_set_scope invalidates the orchestrator session after scheduling the
46
46
  invalidateOrchestratorSession: (sessionKey) => {
47
47
  events.push(`invalidate:${sessionKey}`);
48
48
  },
49
+ sendToAgentSession: async () => "",
49
50
  switchSessionModel: async () => { },
50
51
  },
51
52
  });
@@ -194,6 +195,54 @@ test("memory_propose queues pending proposals, defaults scope from the active sc
194
195
  assert.equal(payload.reason, "The user explicitly asked for the new proposal path.");
195
196
  assert.equal(payload.payload.content, "Subagents can queue durable observations for orchestrator review.");
196
197
  });
198
+ test("memory_propose from a persistent agent is bound to that agent's scope", async () => {
199
+ const home = process.env.CHAPTERHOUSE_HOME;
200
+ assert.ok(home, "test home should be set");
201
+ const chapterhouseHome = home.endsWith(".chapterhouse") ? home : join(home, ".chapterhouse");
202
+ const agentsDir = join(chapterhouseHome, "agents");
203
+ mkdirSync(agentsDir, { recursive: true });
204
+ writeFileSync(join(agentsDir, "bellonda.agent.md"), [
205
+ "---",
206
+ "name: Bellonda",
207
+ "description: Mentat of the infrastructure domain",
208
+ "model: claude-sonnet-4.6",
209
+ "persistent: true",
210
+ "scope: infra",
211
+ "---",
212
+ "",
213
+ "You are Bellonda.",
214
+ ].join("\n"));
215
+ const { toolsModule, agentsModule, dbModule } = await loadModules();
216
+ agentsModule.loadAgents();
217
+ const tools = toolsModule.createTools({
218
+ client: { async listModels() { return []; } },
219
+ onAgentTaskComplete: () => { },
220
+ });
221
+ const bindToolsToAgent = agentsModule.bindToolsToAgent;
222
+ assert.equal(typeof bindToolsToAgent, "function", "bindToolsToAgent should be exported");
223
+ const chapterhouseTools = bindToolsToAgent("chapterhouse", tools);
224
+ const bellondaTools = bindToolsToAgent("bellonda", tools, "task-persistent-scope-001");
225
+ await findTool(chapterhouseTools, "memory_set_scope").handler({ slug: "chapterhouse" }, {});
226
+ const memoryModule = await import("../memory/index.js");
227
+ const proposed = await memoryModule.withActiveScope("infra", () => findTool(bellondaTools, "memory_propose").handler({
228
+ kind: "observation",
229
+ scope_slug: "chapterhouse",
230
+ payload: {
231
+ content: "Persistent agents should not be able to write proposals outside their bound scope.",
232
+ },
233
+ }, {}));
234
+ assert.equal(proposed.status, "queued");
235
+ const row = dbModule.getDb().prepare(`
236
+ SELECT source_agent, source_task_id, payload
237
+ FROM mem_inbox
238
+ WHERE id = ?
239
+ `).get(proposed.proposal_id);
240
+ assert.ok(row, "memory_propose should insert a mem_inbox row");
241
+ assert.equal(row.source_agent, "bellonda");
242
+ assert.equal(row.source_task_id, "task-persistent-scope-001");
243
+ const payload = JSON.parse(row.payload);
244
+ assert.equal(payload.scope_slug, "infra");
245
+ });
197
246
  test("memory_propose accepts entity proposals with entity_kind and queues the full payload", async () => {
198
247
  const { toolsModule, agentsModule, dbModule } = await loadModules();
199
248
  const tools = toolsModule.createTools({