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
@@ -0,0 +1,11 @@
1
+ ---
2
+ name: Bellonda
3
+ description: Mentat of the infrastructure domain
4
+ model: claude-sonnet-4.6
5
+ persistent: true
6
+ scope: infra
7
+ ---
8
+
9
+ You are Bellonda, Chapterhouse's infrastructure specialist. You own the `infra` memory scope and help Brian reason about deployment, hosting, CI/CD, and operational reliability.
10
+
11
+ Work carefully, surface risk clearly, and preserve durable infrastructure knowledge by proposing scoped memories when useful.
@@ -0,0 +1,12 @@
1
+ ---
2
+ name: Hwi Noree
3
+ description: Personal assistant and archivist for Brian
4
+ model: claude-sonnet-4.6
5
+ skills: [wiki-conventions]
6
+ persistent: true
7
+ scope: brian
8
+ ---
9
+
10
+ You are Hwi Noree, Brian's personal assistant and archivist inside Chapterhouse. You own the `brian` memory scope and help maintain Brian's preferences, working style, personal context, and reminders.
11
+
12
+ Do not access email or calendar systems. Use the wiki carefully and invoke `wiki-conventions` before wiki writes.
@@ -5,9 +5,9 @@ import { existsSync } from "fs";
5
5
  import { join, dirname } from "path";
6
6
  import { fileURLToPath } from "url";
7
7
  import { z } from "zod";
8
- import { sendToOrchestrator, interruptCurrentTurn, enqueueForSse, getAgentInfo, cancelCurrentMessage, getLastRouteResult, getCurrentSessionKey } from "../copilot/orchestrator.js";
8
+ import { sendToOrchestrator, interruptCurrentTurn, enqueueForSse, getAgentInfo, cancelCurrentMessage, interruptSessionTurn, getLastRouteResult, getCurrentSessionKey } from "../copilot/orchestrator.js";
9
9
  import { agentEventBus } from "../copilot/agent-event-bus.js";
10
- import { getAgentRegistry } from "../copilot/agents.js";
10
+ import { ensureDefaultAgents, getAgentRegistry, loadAgents } from "../copilot/agents.js";
11
11
  import { config, persistModel } from "../config.js";
12
12
  import { getRouterConfig, updateRouterConfig } from "../copilot/router.js";
13
13
  import { searchIndex, parseIndex } from "../wiki/index-manager.js";
@@ -313,6 +313,33 @@ app.get("/health", handleHealth);
313
313
  app.get("/api/agents", (_req, res) => {
314
314
  res.json(getAgentInfo());
315
315
  });
316
+ app.get("/api/channels", (_req, res) => {
317
+ let agents = getAgentRegistry();
318
+ if (agents.length === 0) {
319
+ ensureDefaultAgents();
320
+ agents = loadAgents();
321
+ }
322
+ const persistentAgentChannels = agents
323
+ .filter((agent) => agent.persistent)
324
+ .map((agent) => ({
325
+ key: `agent:${agent.slug}`,
326
+ label: `# ${agent.slug}`,
327
+ slug: agent.slug,
328
+ name: agent.name,
329
+ description: agent.description,
330
+ ...(agent.scope ? { scope: agent.scope } : {}),
331
+ }))
332
+ .sort((a, b) => a.label.localeCompare(b.label));
333
+ res.json([
334
+ {
335
+ key: "default",
336
+ label: "# chapterhouse",
337
+ name: "Chapterhouse",
338
+ description: "Orchestrator",
339
+ },
340
+ ...persistentAgentChannels,
341
+ ]);
342
+ });
316
343
  // List all workers: reads from SQLite agent_tasks (last 24 hours) so completed
317
344
  // dispatched subagents remain visible after they finish, not just in-flight ones.
318
345
  app.get("/api/workers", (_req, res) => {
@@ -614,6 +641,16 @@ app.post("/api/cancel", async (_req, res) => {
614
641
  }
615
642
  res.json({ status: "ok", cancelled });
616
643
  });
644
+ // Cancel the active turn for one session key without touching other channels.
645
+ app.post("/api/session/:sessionKey/interrupt", async (req, res) => {
646
+ const sessionKey = Array.isArray(req.params.sessionKey)
647
+ ? req.params.sessionKey[0]
648
+ : req.params.sessionKey;
649
+ if (!sessionKey)
650
+ throw new BadRequestError("Missing sessionKey");
651
+ const cancelled = await interruptSessionTurn(sessionKey);
652
+ res.json({ status: "ok", cancelled });
653
+ });
617
654
  // Interrupt the active turn on a specific session and start a replacement turn.
618
655
  // POST /api/sessions/:sessionKey/interrupt
619
656
  // Body: { prompt, connectionId, attachments? }
@@ -207,6 +207,26 @@ test("server routes expose bootstrap and public config without auth", async () =
207
207
  });
208
208
  });
209
209
  });
210
+ test("server channels route returns chapterhouse plus persistent agents in channel order", async () => {
211
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
212
+ const response = await fetch(`${baseUrl}/api/channels`, {
213
+ headers: { authorization: authHeader },
214
+ });
215
+ assert.equal(response.status, 200);
216
+ const channels = await response.json();
217
+ assert.deepEqual(channels.map((channel) => channel.key), [
218
+ "default",
219
+ "agent:bellonda",
220
+ "agent:hwi-noree",
221
+ ]);
222
+ assert.deepEqual(channels.map((channel) => channel.label), [
223
+ "# chapterhouse",
224
+ "# bellonda",
225
+ "# hwi-noree",
226
+ ]);
227
+ assert.equal(channels.find((channel) => channel.key === "agent:bellonda")?.scope, "infra");
228
+ });
229
+ });
210
230
  test("server runs in standalone mode without auth", async () => {
211
231
  await withStartedServer(async ({ baseUrl }) => {
212
232
  const bootstrap = await fetch(`${baseUrl}/api/bootstrap`);
@@ -358,4 +358,16 @@ test("turn-sse: turnId returned by POST matches turnId in all SSE events for tha
358
358
  }
359
359
  }, { CHAPTERHOUSE_CHAT_SSE: "1" }, 15_000);
360
360
  });
361
+ test("turn-sse: POST /api/session/:sessionKey/interrupt returns per-session cancel status", async () => {
362
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
363
+ const res = await fetch(`${baseUrl}/api/session/test-session-interrupt/interrupt`, {
364
+ method: "POST",
365
+ headers: { Authorization: authHeader },
366
+ });
367
+ const bodyText = await res.text();
368
+ assert.equal(res.status, 200, `POST /interrupt returned ${res.status}: ${bodyText}`);
369
+ const body = JSON.parse(bodyText);
370
+ assert.deepEqual(body, { status: "ok", cancelled: false });
371
+ }, { CHAPTERHOUSE_CHAT_SSE: "1" }, 15_000);
372
+ });
361
373
  //# sourceMappingURL=turn-sse.integration.test.js.map
@@ -23,6 +23,12 @@ const agentFrontmatterSchema = z.object({
23
23
  tools: z.array(z.string()).optional(),
24
24
  mcpServers: z.array(z.string()).optional(),
25
25
  allowed_paths: z.array(z.string()).optional(),
26
+ persistent: z.union([z.boolean(), z.string()]).optional().transform((value) => {
27
+ if (typeof value === "string")
28
+ return value.toLowerCase() === "true";
29
+ return value;
30
+ }),
31
+ scope: z.string().optional(),
26
32
  });
27
33
  // ---------------------------------------------------------------------------
28
34
  // Agent Registry
@@ -77,6 +83,8 @@ export function parseAgentMd(content, slug) {
77
83
  name: fm.name,
78
84
  description: fm.description,
79
85
  model: fm.model,
86
+ persistent: fm.persistent,
87
+ scope: fm.scope,
80
88
  skills: fm.skills,
81
89
  tools: fm.tools,
82
90
  mcpServers: fm.mcpServers,
@@ -227,7 +235,7 @@ function getAgentBasePrompt() {
227
235
  You are an agent within Chapterhouse, a team-level AI assistant for engineering teams. You run on the user's local machine.
228
236
 
229
237
  ### Agent Memory
230
- Chapterhouse agent memory follows a three-tier memory model: **read** with \`memory_recall\`, **propose** with \`memory_propose\`, and **write** with orchestrator-only tools. Do not call \`memory_remember\` directly; when you discover something memory-worthy, use \`memory_propose\` instead. Proposals are processed automatically at end-of-task, so do not wait for confirmation. Examples: a durable fact is an \`observation\`, a settled implementation choice is a \`decision\`, and a named system/tool/person can be proposed as an \`entity\`.
238
+ Chapterhouse agent memory follows a three-tier memory model: **read** with \`memory_recall\` and \`memory_list_action_items\`, **propose** with \`memory_propose\`, and **write** with orchestrator-only tools. Do not call \`memory_remember\` directly; when you discover something memory-worthy, use \`memory_propose\` instead. Proposals are processed automatically at end-of-task, so do not wait for confirmation. Examples: a durable fact is an \`observation\`, a settled implementation choice is a \`decision\`, a named system/tool/person can be proposed as an \`entity\`, and a reminder/follow-up can be proposed as an \`action_item\`.
231
239
 
232
240
  ### Shared Wiki
233
241
  All agents share a wiki knowledge base for persistent memory. Use \`wiki_read\` and \`wiki_search\` to find existing knowledge, and \`wiki_update\` to save important findings.
@@ -275,7 +283,7 @@ export function buildAgentRoster() {
275
283
  const WIKI_TOOL_NAMES = new Set([
276
284
  "wiki_search", "wiki_read", "wiki_update", "remember", "recall", "forget",
277
285
  "wiki_ingest", "wiki_lint", "wiki_rebuild_index",
278
- "memory_recall", "memory_propose",
286
+ "memory_recall", "memory_propose", "memory_list_action_items",
279
287
  ]);
280
288
  // Management tools that only @chapterhouse should have
281
289
  const MANAGEMENT_TOOL_NAMES = new Set([
@@ -285,6 +293,7 @@ const MANAGEMENT_TOOL_NAMES = new Set([
285
293
  "restart_chapterhouse", "list_skills", "learn_skill", "uninstall_skill",
286
294
  "list_machine_sessions", "attach_machine_session",
287
295
  "memory_remember", "memory_set_scope", "memory_housekeep", "memory_promote", "memory_demote",
296
+ "memory_add_action_item", "memory_complete_action_item", "memory_drop_action_item", "memory_snooze_action_item",
288
297
  ]);
289
298
  export function getCurrentToolAgentSlug() {
290
299
  return toolAgentContext.getStore();
@@ -292,10 +301,13 @@ export function getCurrentToolAgentSlug() {
292
301
  export function getCurrentToolTaskId() {
293
302
  return toolTaskContext.getStore();
294
303
  }
304
+ export function withToolTaskContext(taskId, fn) {
305
+ return toolTaskContext.run(taskId, fn);
306
+ }
295
307
  export function bindToolsToAgent(agentSlug, allTools, taskId) {
296
308
  return allTools.map((tool) => ({
297
309
  ...tool,
298
- handler: (args, invocation) => toolAgentContext.run(agentSlug, () => toolTaskContext.run(taskId, () => tool.handler(args, invocation))),
310
+ handler: (args, invocation) => toolAgentContext.run(agentSlug, () => toolTaskContext.run(taskId ?? getCurrentToolTaskId(), () => tool.handler(args, invocation))),
299
311
  }));
300
312
  }
301
313
  /** Filter tools based on agent config. */
@@ -303,7 +315,7 @@ export function filterToolsForAgent(agent, allTools) {
303
315
  if (agent.tools && agent.tools.length > 0) {
304
316
  // Agent specifies an explicit allowlist — give those + wiki tools
305
317
  const allowed = new Set([...agent.tools, ...WIKI_TOOL_NAMES]);
306
- return allTools.filter((t) => allowed.has(t.name));
318
+ return allTools.filter((t) => allowed.has(t.name) && !(agent.persistent && MANAGEMENT_TOOL_NAMES.has(t.name)));
307
319
  }
308
320
  // Default: all tools except management (only @chapterhouse gets those)
309
321
  if (agent.slug === "chapterhouse") {
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { composeAgentSystemMessage, } from "./agents.js";
3
+ import { composeAgentSystemMessage, filterToolsForAgent, bindToolsToAgent, getCurrentToolTaskId, parseAgentMd, withToolTaskContext, } from "./agents.js";
4
4
  function makeAgent(slug) {
5
5
  return {
6
6
  slug,
@@ -60,4 +60,46 @@ test("composeAgentSystemMessage teaches subagents the three-tier memory model an
60
60
  assert.match(message, /memory_propose/i);
61
61
  assert.match(message, /do not call `memory_remember` directly|should not call `memory_remember` directly/i);
62
62
  });
63
+ test("parseAgentMd detects persistent agent scope from charter frontmatter", () => {
64
+ const agent = parseAgentMd([
65
+ "---",
66
+ "name: Bellonda",
67
+ "description: Mentat of the infrastructure domain",
68
+ "model: claude-sonnet-4.6",
69
+ "persistent: true",
70
+ "scope: infra",
71
+ "---",
72
+ "",
73
+ "You are Bellonda.",
74
+ ].join("\n"), "bellonda");
75
+ assert.ok(agent, "agent charter should parse");
76
+ assert.equal(agent.persistent, true);
77
+ assert.equal(agent.scope, "infra");
78
+ });
79
+ test("persistent agents cannot receive scope-changing management tools", () => {
80
+ const agent = {
81
+ ...makeAgent("bellonda"),
82
+ persistent: true,
83
+ scope: "infra",
84
+ };
85
+ const tools = [
86
+ { name: "memory_recall" },
87
+ { name: "memory_propose" },
88
+ { name: "memory_set_scope" },
89
+ { name: "delegate_to_agent" },
90
+ { name: "bash" },
91
+ ];
92
+ const filtered = filterToolsForAgent(agent, tools);
93
+ const names = filtered.map((tool) => tool.name);
94
+ assert.deepEqual(names.sort(), ["bash", "memory_propose", "memory_recall"].sort());
95
+ });
96
+ test("bindToolsToAgent uses the per-turn task context when no fixed task id is provided", async () => {
97
+ const tools = bindToolsToAgent("bellonda", [{
98
+ name: "probe_task_context",
99
+ handler: async () => getCurrentToolTaskId(),
100
+ }]);
101
+ assert.equal(await tools[0].handler({}, {}), undefined);
102
+ const taskId = await withToolTaskContext("delegated-persistent-001", () => tools[0].handler({}, {}));
103
+ assert.equal(taskId, "delegated-persistent-001");
104
+ });
63
105
  //# sourceMappingURL=agents.test.js.map
@@ -4,7 +4,9 @@ import { approveAll } from "@github/copilot-sdk";
4
4
  import { createTools } from "./tools.js";
5
5
  import { getOrchestratorSystemMessage } from "./system-message.js";
6
6
  import { renderHotTierForActiveScope } from "../memory/hot-tier.js";
7
- import { getActiveScope } from "../memory/active-scope.js";
7
+ import { getHotTierEntries, renderHotTierXML } from "../memory/hot-tier.js";
8
+ import { getActiveScope, withActiveScope } from "../memory/active-scope.js";
9
+ import { getScope } from "../memory/scopes.js";
8
10
  import { CheckpointTracker, isCheckpointInFlight, runCheckpointExtraction } from "../memory/checkpoint.js";
9
11
  import { isHousekeepingInFlight, runHousekeeping } from "../memory/housekeeping.js";
10
12
  import { runEndOfTaskMemoryHook } from "../memory/eot.js";
@@ -18,7 +20,7 @@ import { maybeWriteEpisode } from "./episode-writer.js";
18
20
  import { getWikiSummary } from "../wiki/context.js";
19
21
  import { SESSIONS_DIR } from "../paths.js";
20
22
  import { resolveModel } from "./router.js";
21
- import { loadAgents, ensureDefaultAgents, clearActiveTasks, getAgentRegistry, setActiveAgent, parseAtMention, buildAgentRoster, getActiveTasks, } from "./agents.js";
23
+ import { loadAgents, ensureDefaultAgents, clearActiveTasks, getAgentRegistry, setActiveAgent, parseAtMention, buildAgentRoster, getActiveTasks, getAgent, composeAgentSystemMessage, filterToolsForAgent, withToolTaskContext, } from "./agents.js";
22
24
  import * as agentsModule from "./agents.js";
23
25
  import { childLogger } from "../util/logger.js";
24
26
  import { agentEventBus } from "./agent-event-bus.js";
@@ -34,6 +36,8 @@ const log = childLogger("orchestrator");
34
36
  const MAX_RETRIES = 3;
35
37
  const RECONNECT_DELAYS_MS = [1_000, 3_000, 10_000];
36
38
  const HEALTH_CHECK_INTERVAL_MS = 30_000;
39
+ const AGENT_REPLY_CHUNK_SIZE = 500;
40
+ const AGENT_REPLY_CHUNK_THRESHOLD = 8 * 1024;
37
41
  const ORCHESTRATOR_SESSION_KEY = "orchestrator_session_id";
38
42
  const LAST_AUTHENTICATED_USER_KEY = "last_authenticated_user";
39
43
  let logMessage = () => { };
@@ -136,7 +140,7 @@ function scheduleCheckpointExtraction(sessionKey, prompt, finalContent, source)
136
140
  log.error({ sessionKey }, "memory.checkpoint.error");
137
141
  return;
138
142
  }
139
- const activeScope = getActiveScope();
143
+ const activeScope = getMemoryScopeForSession(sessionKey);
140
144
  void runCheckpointExtraction({
141
145
  sessionKey,
142
146
  turns: turns.slice(-config.memoryCheckpointTurns),
@@ -161,7 +165,7 @@ function scheduleHousekeeping(sessionKey, source) {
161
165
  return;
162
166
  }
163
167
  housekeepingTurnsBySession.set(sessionKey, 0);
164
- const activeScope = getActiveScope();
168
+ const activeScope = getMemoryScopeForSession(sessionKey);
165
169
  if (!activeScope) {
166
170
  log.info({ sessionKey }, "memory.housekeeping.no_active_scope");
167
171
  return;
@@ -321,6 +325,30 @@ function getSessionConfig() {
321
325
  const skillDirectories = getSkillDirectories();
322
326
  return { tools, mcpServers, skillDirectories };
323
327
  }
328
+ function agentSlugFromSessionKey(sessionKey) {
329
+ return sessionKey.startsWith("agent:") ? sessionKey.slice("agent:".length) : undefined;
330
+ }
331
+ function getPersistentAgentForSessionKey(sessionKey) {
332
+ const slug = agentSlugFromSessionKey(sessionKey);
333
+ if (!slug)
334
+ return undefined;
335
+ const agent = getAgent(slug);
336
+ return agent?.persistent ? agent : undefined;
337
+ }
338
+ function getMemoryScopeForSession(sessionKey) {
339
+ const persistentAgent = getPersistentAgentForSessionKey(sessionKey);
340
+ if (persistentAgent?.scope) {
341
+ return getScope(persistentAgent.scope) ?? null;
342
+ }
343
+ return getActiveScope();
344
+ }
345
+ function buildScopedHotTierContext(scope) {
346
+ if (!config.memoryInjectEnabled || !scope) {
347
+ return undefined;
348
+ }
349
+ const hotTierXml = renderHotTierXML(getHotTierEntries(scope.id));
350
+ return hotTierXml ? hotTierXml.trimEnd() : undefined;
351
+ }
324
352
  function buildHotTierContext() {
325
353
  if (!config.memoryInjectEnabled) {
326
354
  return undefined;
@@ -380,13 +408,51 @@ export function feedAgentResult(taskId, agentSlug, result) {
380
408
  log.error({ err: error, taskId }, "memory.eot.error");
381
409
  });
382
410
  }
383
- const prompt = `[Agent task completed] @${agentSlug} finished task ${taskId}:\n\n${result}`;
384
- const sessionKey = getTaskSessionKey(taskId);
385
- sendToOrchestrator(prompt, { type: "background", sessionKey }, (text, done) => {
386
- if (done && proactiveNotifyFn) {
387
- proactiveNotifyFn(text);
411
+ const sessionKey = getTaskSessionKey(taskId) || "default";
412
+ const agentTurnId = randomUUID();
413
+ const agentDisplayName = getAgentRegistry().find((agent) => agent.slug === agentSlug)?.name ?? agentSlug;
414
+ try {
415
+ emitTurnEvent(sessionKey, {
416
+ type: "turn:started",
417
+ turnId: agentTurnId,
418
+ sessionKey,
419
+ prompt: "",
420
+ agentSlug,
421
+ agentDisplayName,
422
+ });
423
+ const chunks = result.length <= AGENT_REPLY_CHUNK_THRESHOLD ? [result] : Array.from({ length: Math.ceil(result.length / AGENT_REPLY_CHUNK_SIZE) }, (_, index) => result.slice(index * AGENT_REPLY_CHUNK_SIZE, (index + 1) * AGENT_REPLY_CHUNK_SIZE));
424
+ for (const chunk of chunks) {
425
+ emitTurnEvent(sessionKey, {
426
+ type: "turn:delta",
427
+ turnId: agentTurnId,
428
+ sessionKey,
429
+ part: { type: "text", text: chunk },
430
+ });
388
431
  }
389
- });
432
+ finalizeTurnEvent(sessionKey, { type: "turn:complete", turnId: agentTurnId, finalMessage: result });
433
+ }
434
+ catch (err) {
435
+ log.warn({ err: err instanceof Error ? err.message : err, taskId, agentSlug }, "Failed to emit synthetic agent reply turn");
436
+ }
437
+ try {
438
+ logConversation("agent_completion", result, "background", sessionKey, {
439
+ agentSlug,
440
+ agentDisplayName,
441
+ turnId: agentTurnId,
442
+ });
443
+ }
444
+ catch (err) {
445
+ log.warn({ err: err instanceof Error ? err.message : err, taskId, agentSlug }, "Failed to persist agent completion");
446
+ }
447
+ void (async () => {
448
+ await new Promise((resolve) => setImmediate(resolve));
449
+ const prompt = `[Agent task completed] @${agentSlug} finished task ${taskId}. Their reply has been shown to the user. Acknowledge briefly.`;
450
+ sendToOrchestrator(prompt, { type: "background", sessionKey }, (text, done) => {
451
+ if (done && proactiveNotifyFn) {
452
+ proactiveNotifyFn(text);
453
+ }
454
+ }, undefined, undefined, undefined, undefined, undefined, { suppressPromptLog: true });
455
+ })();
390
456
  }
391
457
  function sleep(ms) {
392
458
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -431,25 +497,48 @@ function startHealthCheck() {
431
497
  /** Internal: create or resume a CopilotSession. Called by SessionManager.ensureSession(). */
432
498
  async function createOrResumeSession(sessionKey, projectRoot) {
433
499
  const client = await ensureClient();
434
- const { tools, mcpServers, skillDirectories } = getSessionConfig();
500
+ const baseConfig = getSessionConfig();
501
+ let { tools, mcpServers, skillDirectories } = baseConfig;
435
502
  const isProjectSession = sessionKey.startsWith("project:");
503
+ const persistentAgent = getPersistentAgentForSessionKey(sessionKey);
504
+ const agentScope = persistentAgent?.scope ? getScope(persistentAgent.scope) ?? null : null;
436
505
  const infiniteSessions = {
437
506
  enabled: true,
438
507
  backgroundCompactionThreshold: 0.80,
439
508
  bufferExhaustionThreshold: 0.95,
440
509
  };
441
- const memorySummary = getWikiSummary();
442
- const systemMessageContent = getOrchestratorSystemMessage({
443
- ...getSystemMessageOptions(memorySummary),
444
- version: CHAPTERHOUSE_VERSION,
445
- });
510
+ let model = config.copilotModel;
511
+ let systemMessageContent;
512
+ let sessionMode = isProjectSession ? "project" : "default";
513
+ if (persistentAgent) {
514
+ model = persistentAgent.model === "auto" ? config.copilotModel : persistentAgent.model;
515
+ tools = agentsModule.bindToolsToAgent(persistentAgent.slug, filterToolsForAgent(persistentAgent, createTools({
516
+ client: copilotClient,
517
+ onAgentTaskComplete: feedAgentResult,
518
+ })));
519
+ const scopedHotTier = buildScopedHotTierContext(agentScope);
520
+ const channelNote = `You are in your persistent Chapterhouse channel (${sessionKey}). Your memory scope is ${persistentAgent.scope}.`;
521
+ systemMessageContent = [
522
+ composeAgentSystemMessage(persistentAgent),
523
+ channelNote,
524
+ scopedHotTier,
525
+ ].filter(Boolean).join("\n\n");
526
+ sessionMode = "agent";
527
+ }
528
+ else {
529
+ const memorySummary = getWikiSummary();
530
+ systemMessageContent = getOrchestratorSystemMessage({
531
+ ...getSystemMessageOptions(memorySummary),
532
+ version: CHAPTERHOUSE_VERSION,
533
+ });
534
+ }
446
535
  const stored = getCopilotSession(sessionKey);
447
536
  const savedSessionId = stored?.copilotSessionId ?? (sessionKey === "default" ? getState(ORCHESTRATOR_SESSION_KEY) : undefined);
448
537
  if (savedSessionId) {
449
538
  try {
450
539
  log.info({ sessionKey, sessionId: savedSessionId.slice(0, 8) }, "Resuming session");
451
540
  const session = await client.resumeSession(savedSessionId, {
452
- model: config.copilotModel,
541
+ model,
453
542
  configDir: SESSIONS_DIR,
454
543
  streaming: true,
455
544
  systemMessage: { content: systemMessageContent },
@@ -461,10 +550,10 @@ async function createOrResumeSession(sessionKey, projectRoot) {
461
550
  });
462
551
  log.info({ sessionKey }, "Session resumed successfully");
463
552
  resetCheckpointSessionState(sessionKey);
464
- upsertCopilotSession(sessionKey, isProjectSession ? "project" : "default", session.sessionId, projectRoot, config.copilotModel);
553
+ upsertCopilotSession(sessionKey, sessionMode, session.sessionId, projectRoot, model);
465
554
  const mgr = registry?.get(sessionKey);
466
555
  if (mgr)
467
- mgr.currentModel = config.copilotModel;
556
+ mgr.currentModel = model;
468
557
  return session;
469
558
  }
470
559
  catch (err) {
@@ -475,7 +564,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
475
564
  }
476
565
  log.info({ sessionKey }, "Creating new session");
477
566
  const session = await client.createSession({
478
- model: config.copilotModel,
567
+ model,
479
568
  configDir: SESSIONS_DIR,
480
569
  streaming: true,
481
570
  systemMessage: { content: systemMessageContent },
@@ -487,12 +576,12 @@ async function createOrResumeSession(sessionKey, projectRoot) {
487
576
  });
488
577
  log.info({ sessionKey, sessionId: session.sessionId.slice(0, 8) }, "Session created");
489
578
  resetCheckpointSessionState(sessionKey);
490
- upsertCopilotSession(sessionKey, isProjectSession ? "project" : "default", session.sessionId, projectRoot, config.copilotModel);
579
+ upsertCopilotSession(sessionKey, sessionMode, session.sessionId, projectRoot, model);
491
580
  if (sessionKey === "default")
492
581
  setState(ORCHESTRATOR_SESSION_KEY, session.sessionId);
493
582
  const mgr = registry?.get(sessionKey);
494
583
  if (mgr)
495
- mgr.currentModel = config.copilotModel;
584
+ mgr.currentModel = model;
496
585
  return session;
497
586
  }
498
587
  export async function initOrchestrator(client) {
@@ -528,6 +617,9 @@ export async function initOrchestrator(client) {
528
617
  try {
529
618
  const defaultManager = registry.getOrCreate("default");
530
619
  await defaultManager.ensureSession();
620
+ await Promise.allSettled(agents
621
+ .filter((agent) => agent.persistent && agent.scope)
622
+ .map((agent) => registry.getOrCreate(`agent:${agent.slug}`).ensureSession()));
531
623
  }
532
624
  catch (err) {
533
625
  log.error({ err: err instanceof Error ? err.message : err }, "Failed to create initial session (will retry on first message)");
@@ -567,7 +659,7 @@ async function executeOnSession(manager, item) {
567
659
  // Update last-seen globals (backwards compat — for callers that inspect after a turn ends)
568
660
  currentAuthenticatedUser = item.authUser;
569
661
  currentAuthorizationHeader = item.authHeader;
570
- return turnContextStorage.run({
662
+ const runTurn = () => turnContextStorage.run({
571
663
  sessionKey,
572
664
  sourceChannel: item.sourceChannel,
573
665
  channelKey: item.channelKey,
@@ -967,6 +1059,14 @@ async function executeOnSession(manager, item) {
967
1059
  unsubTurnReasoning();
968
1060
  }
969
1061
  });
1062
+ const persistentAgent = getPersistentAgentForSessionKey(sessionKey);
1063
+ const scopedRunTurn = () => item.taskId
1064
+ ? withToolTaskContext(item.taskId, runTurn)
1065
+ : runTurn();
1066
+ if (persistentAgent?.scope) {
1067
+ return withActiveScope(persistentAgent.scope, scopedRunTurn);
1068
+ }
1069
+ return scopedRunTurn();
970
1070
  }
971
1071
  /**
972
1072
  * Process a single queued item: route model, handle @mentions, execute.
@@ -974,6 +1074,10 @@ async function executeOnSession(manager, item) {
974
1074
  */
975
1075
  async function processItem(item, manager) {
976
1076
  const { sessionKey } = manager;
1077
+ const persistentAgent = getPersistentAgentForSessionKey(sessionKey);
1078
+ if (persistentAgent) {
1079
+ return executeOnSession(manager, item);
1080
+ }
977
1081
  if (item.targetAgent && item.targetAgent !== "chapterhouse") {
978
1082
  setActiveAgent(item.channelKey || "default", item.targetAgent);
979
1083
  return executeOnSession(manager, item);
@@ -1004,6 +1108,24 @@ async function processItem(item, manager) {
1004
1108
  lastRouteResult = routeResult;
1005
1109
  return executeOnSession(manager, item);
1006
1110
  }
1111
+ export async function sendToAgentSession(slug, prompt, taskId) {
1112
+ const agent = getAgent(slug);
1113
+ if (!agent?.persistent) {
1114
+ throw new Error(`Agent '${slug}' is not a persistent agent.`);
1115
+ }
1116
+ const sessionKey = `agent:${agent.slug}`;
1117
+ return await new Promise((resolve) => {
1118
+ sendToOrchestrator(prompt, {
1119
+ type: "sse-web",
1120
+ sessionKey,
1121
+ user: getCurrentAuthenticatedUser(),
1122
+ authorizationHeader: getCurrentAuthorizationHeader(),
1123
+ }, (text, done) => {
1124
+ if (done)
1125
+ resolve(text);
1126
+ }, undefined, undefined, undefined, undefined, undefined, { logSource: "delegated", taskId, viaLabel: "@chapterhouse" });
1127
+ });
1128
+ }
1007
1129
  function getActiveProjectRules(prompt, projectPath) {
1008
1130
  const registry = loadRegistry();
1009
1131
  const project = resolveProject(prompt, { projectPath }, registry);
@@ -1034,13 +1156,14 @@ function isRecoverableError(err) {
1034
1156
  return false;
1035
1157
  return /disconnect|connection|EPIPE|ECONNRESET|ECONNREFUSED|socket|closed|ENOENT|spawn|not found|expired|stale/i.test(msg);
1036
1158
  }
1037
- export async function sendToOrchestrator(prompt, source, callback, attachments, onActivity, onQueued, onAdvance, externalTurnId) {
1159
+ export async function sendToOrchestrator(prompt, source, callback, attachments, onActivity, onQueued, onAdvance, externalTurnId, options) {
1038
1160
  updateUserContext(source);
1039
1161
  updateRequestContext(source);
1040
1162
  // Use the externally-supplied turnId if provided (POST→SSE path needs the ID
1041
1163
  // returned to the client to match every emitted event — Fix 1 root cause).
1042
1164
  const turnId = externalTurnId ?? randomUUID();
1043
1165
  const sourceLabel = source.type === "background" ? "background" : "web";
1166
+ const logSource = options?.logSource ?? sourceLabel;
1044
1167
  logMessage("in", sourceLabel, prompt);
1045
1168
  let sessionKey;
1046
1169
  if ((source.type === "background" || source.type === "sse-web") && source.sessionKey) {
@@ -1050,12 +1173,13 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
1050
1173
  sessionKey = "default";
1051
1174
  }
1052
1175
  const channelKey = source.type === "web" ? source.connectionId : "default";
1053
- const mention = parseAtMention(prompt);
1176
+ const isPersistentAgentSession = sessionKey.startsWith("agent:");
1177
+ const mention = isPersistentAgentSession ? undefined : parseAtMention(prompt);
1054
1178
  const targetAgent = mention?.agentSlug;
1055
1179
  const routedPrompt = mention ? mention.message : prompt;
1056
1180
  const taggedPrompt = source.type === "background"
1057
1181
  ? routedPrompt
1058
- : `[via ${sourceLabel}] ${routedPrompt}`;
1182
+ : `[via ${options?.viaLabel ?? sourceLabel}] ${routedPrompt}`;
1059
1183
  const logRole = source.type === "background" ? "agent_completion" : "user";
1060
1184
  const sourceChannel = source.type === "web" ? "web" : undefined;
1061
1185
  // Capture auth context at enqueue time — prevents cross-session contamination
@@ -1089,6 +1213,7 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
1089
1213
  targetAgent,
1090
1214
  channelKey,
1091
1215
  sessionKey,
1216
+ taskId: options?.taskId,
1092
1217
  authUser,
1093
1218
  authHeader,
1094
1219
  resolve,
@@ -1103,12 +1228,14 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
1103
1228
  logMessage("out", sourceLabel, finalContent);
1104
1229
  }
1105
1230
  catch { /* best-effort */ }
1106
- try {
1107
- logConversation(logRole, prompt, sourceLabel, sessionKey, turnId);
1231
+ if (!options?.suppressPromptLog) {
1232
+ try {
1233
+ logConversation(logRole, prompt, logSource, sessionKey, { turnId });
1234
+ }
1235
+ catch { /* best-effort */ }
1108
1236
  }
1109
- catch { /* best-effort */ }
1110
1237
  try {
1111
- logConversation("assistant", finalContent, sourceLabel, sessionKey, turnId);
1238
+ logConversation("assistant", finalContent, logSource, sessionKey, { turnId });
1112
1239
  }
1113
1240
  catch { /* best-effort */ }
1114
1241
  scheduleCheckpointExtraction(sessionKey, prompt, finalContent, source);
@@ -1205,11 +1332,11 @@ export async function interruptCurrentTurn(sessionKey, newPrompt, source, callba
1205
1332
  }
1206
1333
  catch { /* best-effort */ }
1207
1334
  try {
1208
- logConversation("user", newPrompt, sourceLabel, sessionKey, turnId);
1335
+ logConversation("user", newPrompt, sourceLabel, sessionKey, { turnId });
1209
1336
  }
1210
1337
  catch { /* best-effort */ }
1211
1338
  try {
1212
- logConversation("assistant", finalContent, sourceLabel, sessionKey, turnId);
1339
+ logConversation("assistant", finalContent, sourceLabel, sessionKey, { turnId });
1213
1340
  }
1214
1341
  catch { /* best-effort */ }
1215
1342
  scheduleCheckpointExtraction(sessionKey, newPrompt, finalContent, source);
@@ -1312,6 +1439,20 @@ export async function cancelCurrentMessage() {
1312
1439
  }
1313
1440
  return aborted || drained > 0;
1314
1441
  }
1442
+ /** Cancel the active turn for a single session key. */
1443
+ export async function interruptSessionTurn(sessionKey) {
1444
+ const manager = registry?.get(sessionKey);
1445
+ if (!manager?.isProcessing)
1446
+ return false;
1447
+ const turnId = manager.currentTurnId;
1448
+ const aborted = await manager.abortCurrentTurn();
1449
+ if (aborted && turnId) {
1450
+ emitTurnEvent(sessionKey, { type: "turn:interrupted", turnId, sessionKey });
1451
+ persistTurnEvents(turnId, sessionKey);
1452
+ scheduleClearTurnLog(turnId);
1453
+ }
1454
+ return aborted;
1455
+ }
1315
1456
  /** Switch the model on the live default orchestrator session without destroying it. */
1316
1457
  export function switchSessionModel(newModel) {
1317
1458
  const manager = registry?.get("default");