chapterhouse 0.4.3 → 0.5.1

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.
@@ -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";
@@ -32,6 +32,7 @@ import { assertAuthenticationConfigured, createHealthPayload, createPublicConfig
32
32
  import { BadRequestError, ForbiddenError, InternalServerError, NotFoundError, apiNotFoundHandler, asBadRequest, createApiErrorHandler, parseRequest, } from "./errors.js";
33
33
  import { childLogger } from "../util/logger.js";
34
34
  import { getActiveScope } from "../memory/active-scope.js";
35
+ import { createScope, getScope } from "../memory/scopes.js";
35
36
  const log = childLogger("server");
36
37
  void searchIndex; // re-exported by index-manager; reference here documents the dep
37
38
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -81,6 +82,12 @@ const projectCreateSchema = z.object({
81
82
  cwd: requiredString("Missing 'cwd' in request body")
82
83
  .refine((value) => value.startsWith("/"), "Project cwd must be an absolute path"),
83
84
  }).strict();
85
+ const scopeCreateSchema = z.object({
86
+ slug: requiredString("Missing 'slug' in request body")
87
+ .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Scope slug must be unique kebab-case"),
88
+ title: requiredString("Missing 'title' in request body"),
89
+ description: z.string().optional(),
90
+ }).strict();
84
91
  const projectHardRulesSchema = z.object({
85
92
  hardRules: z.object({
86
93
  auto_pr: z.boolean({ error: "hardRules.auto_pr must be a boolean" }),
@@ -313,6 +320,33 @@ app.get("/health", handleHealth);
313
320
  app.get("/api/agents", (_req, res) => {
314
321
  res.json(getAgentInfo());
315
322
  });
323
+ app.get("/api/channels", (_req, res) => {
324
+ let agents = getAgentRegistry();
325
+ if (agents.length === 0) {
326
+ ensureDefaultAgents();
327
+ agents = loadAgents();
328
+ }
329
+ const persistentAgentChannels = agents
330
+ .filter((agent) => agent.persistent)
331
+ .map((agent) => ({
332
+ key: `agent:${agent.slug}`,
333
+ label: `# ${agent.slug}`,
334
+ slug: agent.slug,
335
+ name: agent.name,
336
+ description: agent.description,
337
+ ...(agent.scope ? { scope: agent.scope } : {}),
338
+ }))
339
+ .sort((a, b) => a.label.localeCompare(b.label));
340
+ res.json([
341
+ {
342
+ key: "default",
343
+ label: "# chapterhouse",
344
+ name: "Chapterhouse",
345
+ description: "Orchestrator",
346
+ },
347
+ ...persistentAgentChannels,
348
+ ]);
349
+ });
316
350
  // List all workers: reads from SQLite agent_tasks (last 24 hours) so completed
317
351
  // dispatched subagents remain visible after they finish, not just in-flight ones.
318
352
  app.get("/api/workers", (_req, res) => {
@@ -614,6 +648,16 @@ app.post("/api/cancel", async (_req, res) => {
614
648
  }
615
649
  res.json({ status: "ok", cancelled });
616
650
  });
651
+ // Cancel the active turn for one session key without touching other channels.
652
+ app.post("/api/session/:sessionKey/interrupt", async (req, res) => {
653
+ const sessionKey = Array.isArray(req.params.sessionKey)
654
+ ? req.params.sessionKey[0]
655
+ : req.params.sessionKey;
656
+ if (!sessionKey)
657
+ throw new BadRequestError("Missing sessionKey");
658
+ const cancelled = await interruptSessionTurn(sessionKey);
659
+ res.json({ status: "ok", cancelled });
660
+ });
617
661
  // Interrupt the active turn on a specific session and start a replacement turn.
618
662
  // POST /api/sessions/:sessionKey/interrupt
619
663
  // Body: { prompt, connectionId, attachments? }
@@ -855,6 +899,25 @@ app.get("/api/memory/active-scope", (_req, res) => {
855
899
  title: activeScope.title,
856
900
  });
857
901
  });
902
+ app.post("/api/scopes", (req, res) => {
903
+ const body = parseRequest(scopeCreateSchema, req.body ?? {});
904
+ if (getScope(body.slug)) {
905
+ res.status(409).json({ error: `Memory scope '${body.slug}' already exists` });
906
+ return;
907
+ }
908
+ const scope = createScope({
909
+ slug: body.slug,
910
+ title: body.title,
911
+ description: body.description ?? "",
912
+ keywords: [body.slug],
913
+ });
914
+ res.status(201).json({
915
+ slug: scope.slug,
916
+ title: scope.title,
917
+ description: scope.description,
918
+ active: scope.active,
919
+ });
920
+ });
858
921
  app.post("/api/auto", (req, res) => {
859
922
  const body = parseRequest(autoRequestSchema, req.body ?? {});
860
923
  const updated = updateRouterConfig(body);
@@ -207,6 +207,21 @@ 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
+ ]);
220
+ assert.deepEqual(channels.map((channel) => channel.label), [
221
+ "# chapterhouse",
222
+ ]);
223
+ });
224
+ });
210
225
  test("server runs in standalone mode without auth", async () => {
211
226
  await withStartedServer(async ({ baseUrl }) => {
212
227
  const bootstrap = await fetch(`${baseUrl}/api/bootstrap`);
@@ -247,6 +262,54 @@ test("server exposes the active memory scope API and requires auth", async () =>
247
262
  });
248
263
  });
249
264
  });
265
+ test("server creates memory scopes with duplicate and slug validation", async () => {
266
+ await withStartedServer(async ({ baseUrl, authHeader }) => {
267
+ const unauthorized = await fetch(`${baseUrl}/api/scopes`, {
268
+ method: "POST",
269
+ headers: { "content-type": "application/json" },
270
+ body: JSON.stringify({ slug: "docs-site", title: "Docs Site" }),
271
+ });
272
+ assert.equal(unauthorized.status, 401);
273
+ const created = await fetch(`${baseUrl}/api/scopes`, {
274
+ method: "POST",
275
+ headers: {
276
+ authorization: authHeader,
277
+ "content-type": "application/json",
278
+ },
279
+ body: JSON.stringify({
280
+ slug: "docs-site",
281
+ title: "Docs Site",
282
+ description: "Documentation publishing and content workflows",
283
+ }),
284
+ });
285
+ assert.equal(created.status, 201);
286
+ assert.deepEqual(await created.json(), {
287
+ slug: "docs-site",
288
+ title: "Docs Site",
289
+ description: "Documentation publishing and content workflows",
290
+ active: true,
291
+ });
292
+ const duplicate = await fetch(`${baseUrl}/api/scopes`, {
293
+ method: "POST",
294
+ headers: {
295
+ authorization: authHeader,
296
+ "content-type": "application/json",
297
+ },
298
+ body: JSON.stringify({ slug: "docs-site", title: "Docs Site Again" }),
299
+ });
300
+ assert.equal(duplicate.status, 409);
301
+ assert.deepEqual(await duplicate.json(), { error: "Memory scope 'docs-site' already exists" });
302
+ const invalid = await fetch(`${baseUrl}/api/scopes`, {
303
+ method: "POST",
304
+ headers: {
305
+ authorization: authHeader,
306
+ "content-type": "application/json",
307
+ },
308
+ body: JSON.stringify({ slug: "Docs Site", title: "Docs Site" }),
309
+ });
310
+ assert.equal(invalid.status, 400);
311
+ });
312
+ });
250
313
  test("server bootstrap rejects non-loopback origins", async () => {
251
314
  await withStartedServer(async ({ baseUrl }) => {
252
315
  const response = 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;
@@ -331,6 +359,17 @@ function buildHotTierContext() {
331
359
  }
332
360
  return hotTierXml.trimEnd();
333
361
  }
362
+ function buildPerTurnMemoryHooks(sessionKey) {
363
+ if (!config.memoryInjectEnabled) {
364
+ return undefined;
365
+ }
366
+ return {
367
+ onUserPromptSubmitted: () => {
368
+ const hotTierXml = buildScopedHotTierContext(getMemoryScopeForSession(sessionKey));
369
+ return hotTierXml ? { additionalContext: hotTierXml } : undefined;
370
+ },
371
+ };
372
+ }
334
373
  function getSystemMessageOptions(memorySummary) {
335
374
  return {
336
375
  selfEditEnabled: config.selfEditEnabled,
@@ -392,8 +431,7 @@ export function feedAgentResult(taskId, agentSlug, result) {
392
431
  agentSlug,
393
432
  agentDisplayName,
394
433
  });
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));
434
+ 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
435
  for (const chunk of chunks) {
398
436
  emitTurnEvent(sessionKey, {
399
437
  type: "turn:delta",
@@ -417,12 +455,15 @@ export function feedAgentResult(taskId, agentSlug, result) {
417
455
  catch (err) {
418
456
  log.warn({ err: err instanceof Error ? err.message : err, taskId, agentSlug }, "Failed to persist agent completion");
419
457
  }
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 });
458
+ void (async () => {
459
+ await new Promise((resolve) => setImmediate(resolve));
460
+ const prompt = `[Agent task completed] @${agentSlug} finished task ${taskId}. The user has already seen this reply in the agent's own bubble. Acknowledge briefly without restating content.`;
461
+ sendToOrchestrator(prompt, { type: "background", sessionKey }, (text, done) => {
462
+ if (done && proactiveNotifyFn) {
463
+ proactiveNotifyFn(text);
464
+ }
465
+ }, undefined, undefined, undefined, undefined, undefined, { suppressPromptLog: true });
466
+ })();
426
467
  }
427
468
  function sleep(ms) {
428
469
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -467,28 +508,53 @@ function startHealthCheck() {
467
508
  /** Internal: create or resume a CopilotSession. Called by SessionManager.ensureSession(). */
468
509
  async function createOrResumeSession(sessionKey, projectRoot) {
469
510
  const client = await ensureClient();
470
- const { tools, mcpServers, skillDirectories } = getSessionConfig();
511
+ const baseConfig = getSessionConfig();
512
+ let { tools, mcpServers, skillDirectories } = baseConfig;
471
513
  const isProjectSession = sessionKey.startsWith("project:");
514
+ const persistentAgent = getPersistentAgentForSessionKey(sessionKey);
515
+ const agentScope = persistentAgent?.scope ? getScope(persistentAgent.scope) ?? null : null;
472
516
  const infiniteSessions = {
473
517
  enabled: true,
474
518
  backgroundCompactionThreshold: 0.80,
475
519
  bufferExhaustionThreshold: 0.95,
476
520
  };
477
- const memorySummary = getWikiSummary();
478
- const systemMessageContent = getOrchestratorSystemMessage({
479
- ...getSystemMessageOptions(memorySummary),
480
- version: CHAPTERHOUSE_VERSION,
481
- });
521
+ const memoryHooks = buildPerTurnMemoryHooks(sessionKey);
522
+ let model = config.copilotModel;
523
+ let systemMessageContent;
524
+ let sessionMode = isProjectSession ? "project" : "default";
525
+ if (persistentAgent) {
526
+ model = persistentAgent.model === "auto" ? config.copilotModel : persistentAgent.model;
527
+ tools = agentsModule.bindToolsToAgent(persistentAgent.slug, filterToolsForAgent(persistentAgent, createTools({
528
+ client: copilotClient,
529
+ onAgentTaskComplete: feedAgentResult,
530
+ })));
531
+ const scopedHotTier = buildScopedHotTierContext(agentScope);
532
+ const channelNote = `You are in your persistent Chapterhouse channel (${sessionKey}). Your memory scope is ${persistentAgent.scope}.`;
533
+ systemMessageContent = [
534
+ composeAgentSystemMessage(persistentAgent),
535
+ channelNote,
536
+ scopedHotTier,
537
+ ].filter(Boolean).join("\n\n");
538
+ sessionMode = "agent";
539
+ }
540
+ else {
541
+ const memorySummary = getWikiSummary();
542
+ systemMessageContent = getOrchestratorSystemMessage({
543
+ ...getSystemMessageOptions(memorySummary),
544
+ version: CHAPTERHOUSE_VERSION,
545
+ });
546
+ }
482
547
  const stored = getCopilotSession(sessionKey);
483
548
  const savedSessionId = stored?.copilotSessionId ?? (sessionKey === "default" ? getState(ORCHESTRATOR_SESSION_KEY) : undefined);
484
549
  if (savedSessionId) {
485
550
  try {
486
551
  log.info({ sessionKey, sessionId: savedSessionId.slice(0, 8) }, "Resuming session");
487
552
  const session = await client.resumeSession(savedSessionId, {
488
- model: config.copilotModel,
553
+ model,
489
554
  configDir: SESSIONS_DIR,
490
555
  streaming: true,
491
556
  systemMessage: { content: systemMessageContent },
557
+ hooks: memoryHooks,
492
558
  tools,
493
559
  mcpServers,
494
560
  skillDirectories,
@@ -497,10 +563,10 @@ async function createOrResumeSession(sessionKey, projectRoot) {
497
563
  });
498
564
  log.info({ sessionKey }, "Session resumed successfully");
499
565
  resetCheckpointSessionState(sessionKey);
500
- upsertCopilotSession(sessionKey, isProjectSession ? "project" : "default", session.sessionId, projectRoot, config.copilotModel);
566
+ upsertCopilotSession(sessionKey, sessionMode, session.sessionId, projectRoot, model);
501
567
  const mgr = registry?.get(sessionKey);
502
568
  if (mgr)
503
- mgr.currentModel = config.copilotModel;
569
+ mgr.currentModel = model;
504
570
  return session;
505
571
  }
506
572
  catch (err) {
@@ -511,10 +577,11 @@ async function createOrResumeSession(sessionKey, projectRoot) {
511
577
  }
512
578
  log.info({ sessionKey }, "Creating new session");
513
579
  const session = await client.createSession({
514
- model: config.copilotModel,
580
+ model,
515
581
  configDir: SESSIONS_DIR,
516
582
  streaming: true,
517
583
  systemMessage: { content: systemMessageContent },
584
+ hooks: memoryHooks,
518
585
  tools,
519
586
  mcpServers,
520
587
  skillDirectories,
@@ -523,12 +590,12 @@ async function createOrResumeSession(sessionKey, projectRoot) {
523
590
  });
524
591
  log.info({ sessionKey, sessionId: session.sessionId.slice(0, 8) }, "Session created");
525
592
  resetCheckpointSessionState(sessionKey);
526
- upsertCopilotSession(sessionKey, isProjectSession ? "project" : "default", session.sessionId, projectRoot, config.copilotModel);
593
+ upsertCopilotSession(sessionKey, sessionMode, session.sessionId, projectRoot, model);
527
594
  if (sessionKey === "default")
528
595
  setState(ORCHESTRATOR_SESSION_KEY, session.sessionId);
529
596
  const mgr = registry?.get(sessionKey);
530
597
  if (mgr)
531
- mgr.currentModel = config.copilotModel;
598
+ mgr.currentModel = model;
532
599
  return session;
533
600
  }
534
601
  export async function initOrchestrator(client) {
@@ -564,6 +631,9 @@ export async function initOrchestrator(client) {
564
631
  try {
565
632
  const defaultManager = registry.getOrCreate("default");
566
633
  await defaultManager.ensureSession();
634
+ await Promise.allSettled(agents
635
+ .filter((agent) => agent.persistent && agent.scope)
636
+ .map((agent) => registry.getOrCreate(`agent:${agent.slug}`).ensureSession()));
567
637
  }
568
638
  catch (err) {
569
639
  log.error({ err: err instanceof Error ? err.message : err }, "Failed to create initial session (will retry on first message)");
@@ -603,7 +673,7 @@ async function executeOnSession(manager, item) {
603
673
  // Update last-seen globals (backwards compat — for callers that inspect after a turn ends)
604
674
  currentAuthenticatedUser = item.authUser;
605
675
  currentAuthorizationHeader = item.authHeader;
606
- return turnContextStorage.run({
676
+ const runTurn = () => turnContextStorage.run({
607
677
  sessionKey,
608
678
  sourceChannel: item.sourceChannel,
609
679
  channelKey: item.channelKey,
@@ -1003,6 +1073,14 @@ async function executeOnSession(manager, item) {
1003
1073
  unsubTurnReasoning();
1004
1074
  }
1005
1075
  });
1076
+ const persistentAgent = getPersistentAgentForSessionKey(sessionKey);
1077
+ const scopedRunTurn = () => item.taskId
1078
+ ? withToolTaskContext(item.taskId, runTurn)
1079
+ : runTurn();
1080
+ if (persistentAgent?.scope) {
1081
+ return withActiveScope(persistentAgent.scope, scopedRunTurn);
1082
+ }
1083
+ return scopedRunTurn();
1006
1084
  }
1007
1085
  /**
1008
1086
  * Process a single queued item: route model, handle @mentions, execute.
@@ -1010,6 +1088,10 @@ async function executeOnSession(manager, item) {
1010
1088
  */
1011
1089
  async function processItem(item, manager) {
1012
1090
  const { sessionKey } = manager;
1091
+ const persistentAgent = getPersistentAgentForSessionKey(sessionKey);
1092
+ if (persistentAgent) {
1093
+ return executeOnSession(manager, item);
1094
+ }
1013
1095
  if (item.targetAgent && item.targetAgent !== "chapterhouse") {
1014
1096
  setActiveAgent(item.channelKey || "default", item.targetAgent);
1015
1097
  return executeOnSession(manager, item);
@@ -1040,6 +1122,24 @@ async function processItem(item, manager) {
1040
1122
  lastRouteResult = routeResult;
1041
1123
  return executeOnSession(manager, item);
1042
1124
  }
1125
+ export async function sendToAgentSession(slug, prompt, taskId) {
1126
+ const agent = getAgent(slug);
1127
+ if (!agent?.persistent) {
1128
+ throw new Error(`Agent '${slug}' is not a persistent agent.`);
1129
+ }
1130
+ const sessionKey = `agent:${agent.slug}`;
1131
+ return await new Promise((resolve) => {
1132
+ sendToOrchestrator(prompt, {
1133
+ type: "sse-web",
1134
+ sessionKey,
1135
+ user: getCurrentAuthenticatedUser(),
1136
+ authorizationHeader: getCurrentAuthorizationHeader(),
1137
+ }, (text, done) => {
1138
+ if (done)
1139
+ resolve(text);
1140
+ }, undefined, undefined, undefined, undefined, undefined, { logSource: "delegated", taskId, viaLabel: "@chapterhouse" });
1141
+ });
1142
+ }
1043
1143
  function getActiveProjectRules(prompt, projectPath) {
1044
1144
  const registry = loadRegistry();
1045
1145
  const project = resolveProject(prompt, { projectPath }, registry);
@@ -1077,6 +1177,7 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
1077
1177
  // returned to the client to match every emitted event — Fix 1 root cause).
1078
1178
  const turnId = externalTurnId ?? randomUUID();
1079
1179
  const sourceLabel = source.type === "background" ? "background" : "web";
1180
+ const logSource = options?.logSource ?? sourceLabel;
1080
1181
  logMessage("in", sourceLabel, prompt);
1081
1182
  let sessionKey;
1082
1183
  if ((source.type === "background" || source.type === "sse-web") && source.sessionKey) {
@@ -1086,12 +1187,13 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
1086
1187
  sessionKey = "default";
1087
1188
  }
1088
1189
  const channelKey = source.type === "web" ? source.connectionId : "default";
1089
- const mention = parseAtMention(prompt);
1190
+ const isPersistentAgentSession = sessionKey.startsWith("agent:");
1191
+ const mention = isPersistentAgentSession ? undefined : parseAtMention(prompt);
1090
1192
  const targetAgent = mention?.agentSlug;
1091
1193
  const routedPrompt = mention ? mention.message : prompt;
1092
1194
  const taggedPrompt = source.type === "background"
1093
1195
  ? routedPrompt
1094
- : `[via ${sourceLabel}] ${routedPrompt}`;
1196
+ : `[via ${options?.viaLabel ?? sourceLabel}] ${routedPrompt}`;
1095
1197
  const logRole = source.type === "background" ? "agent_completion" : "user";
1096
1198
  const sourceChannel = source.type === "web" ? "web" : undefined;
1097
1199
  // Capture auth context at enqueue time — prevents cross-session contamination
@@ -1125,6 +1227,7 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
1125
1227
  targetAgent,
1126
1228
  channelKey,
1127
1229
  sessionKey,
1230
+ taskId: options?.taskId,
1128
1231
  authUser,
1129
1232
  authHeader,
1130
1233
  resolve,
@@ -1141,12 +1244,12 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
1141
1244
  catch { /* best-effort */ }
1142
1245
  if (!options?.suppressPromptLog) {
1143
1246
  try {
1144
- logConversation(logRole, prompt, sourceLabel, sessionKey, { turnId });
1247
+ logConversation(logRole, prompt, logSource, sessionKey, { turnId });
1145
1248
  }
1146
1249
  catch { /* best-effort */ }
1147
1250
  }
1148
1251
  try {
1149
- logConversation("assistant", finalContent, sourceLabel, sessionKey, { turnId });
1252
+ logConversation("assistant", finalContent, logSource, sessionKey, { turnId });
1150
1253
  }
1151
1254
  catch { /* best-effort */ }
1152
1255
  scheduleCheckpointExtraction(sessionKey, prompt, finalContent, source);
@@ -1350,6 +1453,20 @@ export async function cancelCurrentMessage() {
1350
1453
  }
1351
1454
  return aborted || drained > 0;
1352
1455
  }
1456
+ /** Cancel the active turn for a single session key. */
1457
+ export async function interruptSessionTurn(sessionKey) {
1458
+ const manager = registry?.get(sessionKey);
1459
+ if (!manager?.isProcessing)
1460
+ return false;
1461
+ const turnId = manager.currentTurnId;
1462
+ const aborted = await manager.abortCurrentTurn();
1463
+ if (aborted && turnId) {
1464
+ emitTurnEvent(sessionKey, { type: "turn:interrupted", turnId, sessionKey });
1465
+ persistTurnEvents(turnId, sessionKey);
1466
+ scheduleClearTurnLog(turnId);
1467
+ }
1468
+ return aborted;
1469
+ }
1353
1470
  /** Switch the model on the live default orchestrator session without destroying it. */
1354
1471
  export function switchSessionModel(newModel) {
1355
1472
  const manager = registry?.get("default");