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.
@@ -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,
@@ -293,10 +301,13 @@ export function getCurrentToolAgentSlug() {
293
301
  export function getCurrentToolTaskId() {
294
302
  return toolTaskContext.getStore();
295
303
  }
304
+ export function withToolTaskContext(taskId, fn) {
305
+ return toolTaskContext.run(taskId, fn);
306
+ }
296
307
  export function bindToolsToAgent(agentSlug, allTools, taskId) {
297
308
  return allTools.map((tool) => ({
298
309
  ...tool,
299
- 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))),
300
311
  }));
301
312
  }
302
313
  /** Filter tools based on agent config. */
@@ -304,7 +315,7 @@ export function filterToolsForAgent(agent, allTools) {
304
315
  if (agent.tools && agent.tools.length > 0) {
305
316
  // Agent specifies an explicit allowlist — give those + wiki tools
306
317
  const allowed = new Set([...agent.tools, ...WIKI_TOOL_NAMES]);
307
- 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)));
308
319
  }
309
320
  // Default: all tools except management (only @chapterhouse gets those)
310
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;
@@ -392,8 +420,7 @@ export function feedAgentResult(taskId, agentSlug, result) {
392
420
  agentSlug,
393
421
  agentDisplayName,
394
422
  });
395
- const chunkSize = 500;
396
- const chunks = result.length === 0 ? [""] : Array.from({ length: Math.ceil(result.length / chunkSize) }, (_, index) => result.slice(index * chunkSize, (index + 1) * chunkSize));
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));
397
424
  for (const chunk of chunks) {
398
425
  emitTurnEvent(sessionKey, {
399
426
  type: "turn:delta",
@@ -417,12 +444,15 @@ export function feedAgentResult(taskId, agentSlug, result) {
417
444
  catch (err) {
418
445
  log.warn({ err: err instanceof Error ? err.message : err, taskId, agentSlug }, "Failed to persist agent completion");
419
446
  }
420
- const prompt = `[Agent task completed] @${agentSlug} finished task ${taskId}. Their reply has been shown to the user. Acknowledge briefly.`;
421
- sendToOrchestrator(prompt, { type: "background", sessionKey }, (text, done) => {
422
- if (done && proactiveNotifyFn) {
423
- proactiveNotifyFn(text);
424
- }
425
- }, undefined, undefined, undefined, undefined, undefined, { suppressPromptLog: true });
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
+ })();
426
456
  }
427
457
  function sleep(ms) {
428
458
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -467,25 +497,48 @@ function startHealthCheck() {
467
497
  /** Internal: create or resume a CopilotSession. Called by SessionManager.ensureSession(). */
468
498
  async function createOrResumeSession(sessionKey, projectRoot) {
469
499
  const client = await ensureClient();
470
- const { tools, mcpServers, skillDirectories } = getSessionConfig();
500
+ const baseConfig = getSessionConfig();
501
+ let { tools, mcpServers, skillDirectories } = baseConfig;
471
502
  const isProjectSession = sessionKey.startsWith("project:");
503
+ const persistentAgent = getPersistentAgentForSessionKey(sessionKey);
504
+ const agentScope = persistentAgent?.scope ? getScope(persistentAgent.scope) ?? null : null;
472
505
  const infiniteSessions = {
473
506
  enabled: true,
474
507
  backgroundCompactionThreshold: 0.80,
475
508
  bufferExhaustionThreshold: 0.95,
476
509
  };
477
- const memorySummary = getWikiSummary();
478
- const systemMessageContent = getOrchestratorSystemMessage({
479
- ...getSystemMessageOptions(memorySummary),
480
- version: CHAPTERHOUSE_VERSION,
481
- });
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
+ }
482
535
  const stored = getCopilotSession(sessionKey);
483
536
  const savedSessionId = stored?.copilotSessionId ?? (sessionKey === "default" ? getState(ORCHESTRATOR_SESSION_KEY) : undefined);
484
537
  if (savedSessionId) {
485
538
  try {
486
539
  log.info({ sessionKey, sessionId: savedSessionId.slice(0, 8) }, "Resuming session");
487
540
  const session = await client.resumeSession(savedSessionId, {
488
- model: config.copilotModel,
541
+ model,
489
542
  configDir: SESSIONS_DIR,
490
543
  streaming: true,
491
544
  systemMessage: { content: systemMessageContent },
@@ -497,10 +550,10 @@ async function createOrResumeSession(sessionKey, projectRoot) {
497
550
  });
498
551
  log.info({ sessionKey }, "Session resumed successfully");
499
552
  resetCheckpointSessionState(sessionKey);
500
- upsertCopilotSession(sessionKey, isProjectSession ? "project" : "default", session.sessionId, projectRoot, config.copilotModel);
553
+ upsertCopilotSession(sessionKey, sessionMode, session.sessionId, projectRoot, model);
501
554
  const mgr = registry?.get(sessionKey);
502
555
  if (mgr)
503
- mgr.currentModel = config.copilotModel;
556
+ mgr.currentModel = model;
504
557
  return session;
505
558
  }
506
559
  catch (err) {
@@ -511,7 +564,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
511
564
  }
512
565
  log.info({ sessionKey }, "Creating new session");
513
566
  const session = await client.createSession({
514
- model: config.copilotModel,
567
+ model,
515
568
  configDir: SESSIONS_DIR,
516
569
  streaming: true,
517
570
  systemMessage: { content: systemMessageContent },
@@ -523,12 +576,12 @@ async function createOrResumeSession(sessionKey, projectRoot) {
523
576
  });
524
577
  log.info({ sessionKey, sessionId: session.sessionId.slice(0, 8) }, "Session created");
525
578
  resetCheckpointSessionState(sessionKey);
526
- upsertCopilotSession(sessionKey, isProjectSession ? "project" : "default", session.sessionId, projectRoot, config.copilotModel);
579
+ upsertCopilotSession(sessionKey, sessionMode, session.sessionId, projectRoot, model);
527
580
  if (sessionKey === "default")
528
581
  setState(ORCHESTRATOR_SESSION_KEY, session.sessionId);
529
582
  const mgr = registry?.get(sessionKey);
530
583
  if (mgr)
531
- mgr.currentModel = config.copilotModel;
584
+ mgr.currentModel = model;
532
585
  return session;
533
586
  }
534
587
  export async function initOrchestrator(client) {
@@ -564,6 +617,9 @@ export async function initOrchestrator(client) {
564
617
  try {
565
618
  const defaultManager = registry.getOrCreate("default");
566
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()));
567
623
  }
568
624
  catch (err) {
569
625
  log.error({ err: err instanceof Error ? err.message : err }, "Failed to create initial session (will retry on first message)");
@@ -603,7 +659,7 @@ async function executeOnSession(manager, item) {
603
659
  // Update last-seen globals (backwards compat — for callers that inspect after a turn ends)
604
660
  currentAuthenticatedUser = item.authUser;
605
661
  currentAuthorizationHeader = item.authHeader;
606
- return turnContextStorage.run({
662
+ const runTurn = () => turnContextStorage.run({
607
663
  sessionKey,
608
664
  sourceChannel: item.sourceChannel,
609
665
  channelKey: item.channelKey,
@@ -1003,6 +1059,14 @@ async function executeOnSession(manager, item) {
1003
1059
  unsubTurnReasoning();
1004
1060
  }
1005
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();
1006
1070
  }
1007
1071
  /**
1008
1072
  * Process a single queued item: route model, handle @mentions, execute.
@@ -1010,6 +1074,10 @@ async function executeOnSession(manager, item) {
1010
1074
  */
1011
1075
  async function processItem(item, manager) {
1012
1076
  const { sessionKey } = manager;
1077
+ const persistentAgent = getPersistentAgentForSessionKey(sessionKey);
1078
+ if (persistentAgent) {
1079
+ return executeOnSession(manager, item);
1080
+ }
1013
1081
  if (item.targetAgent && item.targetAgent !== "chapterhouse") {
1014
1082
  setActiveAgent(item.channelKey || "default", item.targetAgent);
1015
1083
  return executeOnSession(manager, item);
@@ -1040,6 +1108,24 @@ async function processItem(item, manager) {
1040
1108
  lastRouteResult = routeResult;
1041
1109
  return executeOnSession(manager, item);
1042
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
+ }
1043
1129
  function getActiveProjectRules(prompt, projectPath) {
1044
1130
  const registry = loadRegistry();
1045
1131
  const project = resolveProject(prompt, { projectPath }, registry);
@@ -1077,6 +1163,7 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
1077
1163
  // returned to the client to match every emitted event — Fix 1 root cause).
1078
1164
  const turnId = externalTurnId ?? randomUUID();
1079
1165
  const sourceLabel = source.type === "background" ? "background" : "web";
1166
+ const logSource = options?.logSource ?? sourceLabel;
1080
1167
  logMessage("in", sourceLabel, prompt);
1081
1168
  let sessionKey;
1082
1169
  if ((source.type === "background" || source.type === "sse-web") && source.sessionKey) {
@@ -1086,12 +1173,13 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
1086
1173
  sessionKey = "default";
1087
1174
  }
1088
1175
  const channelKey = source.type === "web" ? source.connectionId : "default";
1089
- const mention = parseAtMention(prompt);
1176
+ const isPersistentAgentSession = sessionKey.startsWith("agent:");
1177
+ const mention = isPersistentAgentSession ? undefined : parseAtMention(prompt);
1090
1178
  const targetAgent = mention?.agentSlug;
1091
1179
  const routedPrompt = mention ? mention.message : prompt;
1092
1180
  const taggedPrompt = source.type === "background"
1093
1181
  ? routedPrompt
1094
- : `[via ${sourceLabel}] ${routedPrompt}`;
1182
+ : `[via ${options?.viaLabel ?? sourceLabel}] ${routedPrompt}`;
1095
1183
  const logRole = source.type === "background" ? "agent_completion" : "user";
1096
1184
  const sourceChannel = source.type === "web" ? "web" : undefined;
1097
1185
  // Capture auth context at enqueue time — prevents cross-session contamination
@@ -1125,6 +1213,7 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
1125
1213
  targetAgent,
1126
1214
  channelKey,
1127
1215
  sessionKey,
1216
+ taskId: options?.taskId,
1128
1217
  authUser,
1129
1218
  authHeader,
1130
1219
  resolve,
@@ -1141,12 +1230,12 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
1141
1230
  catch { /* best-effort */ }
1142
1231
  if (!options?.suppressPromptLog) {
1143
1232
  try {
1144
- logConversation(logRole, prompt, sourceLabel, sessionKey, { turnId });
1233
+ logConversation(logRole, prompt, logSource, sessionKey, { turnId });
1145
1234
  }
1146
1235
  catch { /* best-effort */ }
1147
1236
  }
1148
1237
  try {
1149
- logConversation("assistant", finalContent, sourceLabel, sessionKey, { turnId });
1238
+ logConversation("assistant", finalContent, logSource, sessionKey, { turnId });
1150
1239
  }
1151
1240
  catch { /* best-effort */ }
1152
1241
  scheduleCheckpointExtraction(sessionKey, prompt, finalContent, source);
@@ -1350,6 +1439,20 @@ export async function cancelCurrentMessage() {
1350
1439
  }
1351
1440
  return aborted || drained > 0;
1352
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
+ }
1353
1456
  /** Switch the model on the live default orchestrator session without destroying it. */
1354
1457
  export function switchSessionModel(newModel) {
1355
1458
  const manager = registry?.get("default");