@cohortapp/agent-sdk 2.16.0 → 2.17.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.
@@ -12,12 +12,38 @@ import yaml from "js-yaml";
12
12
  const ORG_AGENT_DIR = mkdtempSync(join(tmpdir(), "maestro-pb-org-"));
13
13
  mkdirSync(join(ORG_AGENT_DIR, "config"), { recursive: true });
14
14
  process.env.AGENT_DIR = ORG_AGENT_DIR;
15
- const { buildPrompt, buildInboxContext, _resetOrgConfigCache } = await import("./prompt-builder.mjs");
15
+ const { buildPrompt, buildInboxContext, _resetOrgConfigCache, contextCompilerEnabled } = await import("./prompt-builder.mjs");
16
+ const { _resetOrgCfgCache } = await import("./context-compiler.mjs");
17
+
18
+ /**
19
+ * Run `fn` with the compiled-context path OFF.
20
+ *
21
+ * The three org-knowledge tests below assert the LEGACY `--- ORG SHARED
22
+ * KNOWLEDGE ---` block, which `buildPrompt` emits only on that path. The flag
23
+ * now defaults ON (design §3 R11), so the legacy assertions have to say which
24
+ * path they are about instead of relying on a default that has moved. The
25
+ * compiled path's own org injection is pinned separately, below.
26
+ */
27
+ async function withLegacyContext(fn) {
28
+ const prior = process.env.DAEMON_CONTEXT_COMPILER;
29
+ process.env.DAEMON_CONTEXT_COMPILER = "0";
30
+ try {
31
+ return await fn();
32
+ } finally {
33
+ if (prior === undefined) delete process.env.DAEMON_CONTEXT_COMPILER;
34
+ else process.env.DAEMON_CONTEXT_COMPILER = prior;
35
+ }
36
+ }
16
37
 
17
38
  /** Write config/org.yaml into the temp agent dir and drop the cached read. */
18
39
  function writeOrgConfig(doc) {
19
40
  writeFileSync(join(ORG_AGENT_DIR, "config/org.yaml"), doc == null ? "" : yaml.dump(doc));
20
41
  _resetOrgConfigCache();
42
+ // The compiled-context path caches its OWN read of config/org.yaml. Both
43
+ // caches have to drop, or a test that disables the integration is served the
44
+ // previous test's enabled config and "proves" a network call that the config
45
+ // on disk forbids.
46
+ _resetOrgCfgCache();
21
47
  }
22
48
 
23
49
  /** Stub global fetch to serve a knowledge.search frame, restore on cleanup. */
@@ -85,9 +111,9 @@ test("buildInboxContext fences thread context too", () => {
85
111
  const ITEM = { service: "slack", channel: "C123", channel_id: "C123", channel_type: "channel", sender: "Dana", content: "What is our refund policy?" };
86
112
  const CLASS = { priority: "normal", action: "respond", category: "ops", summary: "refund policy question" };
87
113
 
88
- test("buildPrompt injects ORG SHARED KNOWLEDGE when org.cohort is enabled", async () => {
114
+ test("buildPrompt injects ORG SHARED KNOWLEDGE when org.cohort is enabled (legacy path)", async () => {
89
115
  writeOrgConfig({ org: { cohort: { enabled: true, base: "https://cohort.example", token: "tok" } } });
90
- await withFetch(
116
+ await withLegacyContext(() => withFetch(
91
117
  fetchReturningFacts([
92
118
  { id: "k1", text: "Refunds are issued within 14 days of purchase." },
93
119
  { id: "k2", text: "Enterprise contracts are non-refundable after onboarding." },
@@ -103,7 +129,7 @@ test("buildPrompt injects ORG SHARED KNOWLEDGE when org.cohort is enabled", asyn
103
129
  assert.ok(prompt.includes("--- INCOMING MESSAGE ---"));
104
130
  assert.match(prompt, /Log all actions taken/);
105
131
  }
106
- );
132
+ ));
107
133
  });
108
134
 
109
135
  test("buildPrompt does NOT inject org knowledge when the integration is disabled", async () => {
@@ -121,6 +147,50 @@ test("buildPrompt does NOT inject org knowledge when the integration is disabled
121
147
  );
122
148
  });
123
149
 
150
+ // ---------------------------------------------------------------------------
151
+ // DAEMON_CONTEXT_COMPILER — ONE path ships (design §3 R11)
152
+ //
153
+ // The code read `=== "1"` while .env.example shipped `=1`, so which of two
154
+ // materially different context paths ran depended on whether the operator had
155
+ // copied the example env. These pin the default and the escape hatch.
156
+ // ---------------------------------------------------------------------------
157
+
158
+ test("the compiled-context path is ON by default, matching .env.example", () => {
159
+ assert.equal(contextCompilerEnabled({}), true, "unset must mean on");
160
+ assert.equal(contextCompilerEnabled({ DAEMON_CONTEXT_COMPILER: "1" }), true);
161
+ assert.equal(contextCompilerEnabled({ DAEMON_CONTEXT_COMPILER: "" }), true);
162
+ });
163
+
164
+ test("the compiled-context path can still be turned off in a hurry", () => {
165
+ for (const v of ["0", "false", "off", "no", "OFF"]) {
166
+ assert.equal(contextCompilerEnabled({ DAEMON_CONTEXT_COMPILER: v }), false, v);
167
+ }
168
+ });
169
+
170
+ test("on the DEFAULT path the org's facts still reach the prompt", async () => {
171
+ writeOrgConfig({ org: { cohort: { enabled: true, base: "https://cohort.example", token: "tok" } } });
172
+ await withFetch(
173
+ fetchReturningFacts([{ id: "k1", text: "Refunds are issued within 14 days of purchase." }]),
174
+ async () => {
175
+ const prompt = await buildPrompt(ITEM, CLASS, { type: "inbox" });
176
+ assert.ok(prompt.includes("Refunds are issued within 14 days"), "org recall must survive the default path");
177
+ assert.ok(prompt.includes("--- INCOMING MESSAGE ---"));
178
+ }
179
+ );
180
+ });
181
+
182
+ test("on the DEFAULT path a disabled integration still makes no network call", async () => {
183
+ writeOrgConfig({ org: { cohort: { enabled: false } } });
184
+ let fetched = false;
185
+ await withFetch(
186
+ async () => { fetched = true; return { ok: true, status: 200, json: async () => ({ ok: true, result: { facts: [] } }), headers: { get: () => null } }; },
187
+ async () => {
188
+ await buildPrompt(ITEM, CLASS, { type: "inbox" });
189
+ assert.equal(fetched, false, "disabled integration must not hit the network on either path");
190
+ }
191
+ );
192
+ });
193
+
124
194
  test("buildPrompt is unchanged with NO org config file at all", async () => {
125
195
  writeOrgConfig(null); // empty file → parses to {} → disabled
126
196
  const prompt = await buildPrompt(ITEM, CLASS, { type: "inbox" });
@@ -198,10 +268,10 @@ test("buildPrompt never asks a PRIVATE turn for the OUTCOMES block", async () =>
198
268
  }
199
269
  });
200
270
 
201
- test("buildPrompt caps the number of injected org facts (bounded)", async () => {
271
+ test("buildPrompt caps the number of injected org facts (bounded, legacy path)", async () => {
202
272
  writeOrgConfig({ org: { cohort: { enabled: true, base: "https://cohort.example", token: "tok" } } });
203
273
  const many = Array.from({ length: 50 }, (_, i) => ({ id: `k${i}`, text: `fact number ${i}` }));
204
- await withFetch(fetchReturningFacts(many), async () => {
274
+ await withLegacyContext(() => withFetch(fetchReturningFacts(many), async () => {
205
275
  const prompt = await buildPrompt(ITEM, CLASS, { type: "inbox" });
206
276
  const block = prompt.slice(
207
277
  prompt.indexOf("--- ORG SHARED KNOWLEDGE ---"),
@@ -209,7 +279,7 @@ test("buildPrompt caps the number of injected org facts (bounded)", async () =>
209
279
  );
210
280
  const bullets = (block.match(/^- fact number /gm) || []).length;
211
281
  assert.ok(bullets > 0 && bullets <= 6, `expected <=6 injected facts, got ${bullets}`);
212
- });
282
+ }));
213
283
  });
214
284
 
215
285
  // ---------------------------------------------------------------------------
@@ -342,3 +412,145 @@ test("buildPrompt treats an unspecified holdingSent as delivered (back-compat)",
342
412
  });
343
413
  assert.match(prompt, /A HOLDING MESSAGE has ALREADY been sent/);
344
414
  });
415
+
416
+ // ---------------------------------------------------------------------------
417
+ // Interaction history is read per SERVICE (design §3 R12)
418
+ //
419
+ // `responder.mjs#logInteraction` files under `memory/interactions/<service>/…`;
420
+ // this reader was pinned to `…/slack/…`, so a Cohort exchange was written down
421
+ // and then never found.
422
+ // ---------------------------------------------------------------------------
423
+
424
+ test("the legacy history block finds a Cohort conversation, not just a Slack one", async () => {
425
+ writeOrgConfig(null);
426
+ const dir = join(ORG_AGENT_DIR, "memory", "interactions", "cohort", "chan-cohort-9");
427
+ mkdirSync(dir, { recursive: true });
428
+ writeFileSync(
429
+ join(dir, "2026-04-06.jsonl"),
430
+ JSON.stringify({ ts: "1", from: "Dana", content: "the retainer number is wrong on page two", received_at: "2026-04-06T10:00:00Z" }) + "\n",
431
+ );
432
+
433
+ const item = { service: "cohort", channel: "engineering", channel_id: "chan-cohort-9", sender: "Dana", content: "and now?" };
434
+ const prompt = await withLegacyContext(() => buildPrompt(item, CLASS, { type: "inbox" }));
435
+ assert.ok(prompt.includes("the retainer number is wrong on page two"), "Cohort history must reach the prompt");
436
+ });
437
+
438
+ test("the legacy history block does not read another service's tree for the same channel id", async () => {
439
+ writeOrgConfig(null);
440
+ const dir = join(ORG_AGENT_DIR, "memory", "interactions", "whatsapp", "chan-crossed");
441
+ mkdirSync(dir, { recursive: true });
442
+ writeFileSync(
443
+ join(dir, "2026-04-06.jsonl"),
444
+ JSON.stringify({ ts: "1", from: "Dana", content: "WHATSAPP ONLY SENTINEL", received_at: "2026-04-06T10:00:00Z" }) + "\n",
445
+ );
446
+
447
+ const item = { service: "cohort", channel: "engineering", channel_id: "chan-crossed", sender: "Dana", content: "?" };
448
+ const prompt = await withLegacyContext(() => buildPrompt(item, CLASS, { type: "inbox" }));
449
+ assert.ok(!prompt.includes("WHATSAPP ONLY SENTINEL"), "one service must not read another's history");
450
+ });
451
+
452
+ // ---------------------------------------------------------------------------
453
+ // Resource scope: a per-person DM never reaches a shared room (design §5.5)
454
+ //
455
+ // `logInteraction` files each exchange under BOTH the room and
456
+ // `dm-<sender-slug>`. Merging every candidate directory into one "history with
457
+ // this sender" block meant that the moment this read stopped being Slack-only,
458
+ // a sentence Dana said in a DM appeared in the prompt built for a PUBLIC
459
+ // channel. Cohort is the service where that bites: one namespace for DMs and
460
+ // rooms. Pinned on BOTH context paths, because both merged.
461
+ // ---------------------------------------------------------------------------
462
+
463
+ const DM_SECRET = "between us - we are letting Marco go on Friday, do not mention it yet";
464
+
465
+ /** Seed a private DM log and a public-room log for the same person. */
466
+ function seedDmAndRoom(service, channelId) {
467
+ const dmDir = join(ORG_AGENT_DIR, "memory", "interactions", service, "dm-dana");
468
+ mkdirSync(dmDir, { recursive: true });
469
+ writeFileSync(
470
+ join(dmDir, "2026-09-11.jsonl"),
471
+ JSON.stringify({ ts: "1", from: "Dana", content: DM_SECRET, received_at: "2026-09-11T10:00:00Z" }) + "\n",
472
+ );
473
+ const roomDir = join(ORG_AGENT_DIR, "memory", "interactions", service, channelId);
474
+ mkdirSync(roomDir, { recursive: true });
475
+ writeFileSync(
476
+ join(roomDir, "2026-09-11.jsonl"),
477
+ JSON.stringify({ ts: "2", from: "Dana", content: "PUBLIC ROOM SENTINEL", received_at: "2026-09-11T11:00:00Z" }) + "\n",
478
+ );
479
+ }
480
+
481
+ test("a public-channel prompt never carries the sender's DM history (legacy path)", async () => {
482
+ writeOrgConfig(null);
483
+ seedDmAndRoom("cohort", "c-capital-formation");
484
+ const item = {
485
+ service: "cohort", channel: "capital-formation", channel_id: "c-capital-formation",
486
+ is_dm: false, sender: "Dana", content: "what's the runway number?",
487
+ };
488
+ const prompt = await withLegacyContext(() => buildPrompt(item, CLASS, { type: "inbox" }));
489
+ assert.ok(!prompt.includes(DM_SECRET), "a per-person DM must never be quoted into a shared room");
490
+ assert.ok(prompt.includes("PUBLIC ROOM SENTINEL"), "the room's own history still reaches the prompt");
491
+ });
492
+
493
+ test("a public-channel prompt never carries the sender's DM history (compiled path)", async () => {
494
+ writeOrgConfig(null);
495
+ seedDmAndRoom("cohort", "c-capital-formation-2");
496
+ const item = {
497
+ service: "cohort", channel: "capital-formation", channel_id: "c-capital-formation-2",
498
+ is_dm: false, sender: "Dana", content: "what's the runway number?",
499
+ };
500
+ const prompt = await buildPrompt(item, CLASS, { type: "inbox" });
501
+ assert.ok(contextCompilerEnabled({}), "this test is about the compiled path, which is the default");
502
+ assert.ok(!prompt.includes(DM_SECRET), "a per-person DM must never be quoted into a shared room");
503
+ assert.ok(prompt.includes("PUBLIC ROOM SENTINEL"), "the room's own history still reaches the prompt");
504
+ });
505
+
506
+ test("the DM itself still reads its own per-person history (both paths)", async () => {
507
+ writeOrgConfig(null);
508
+ seedDmAndRoom("cohort", "c-dm-dana");
509
+ const item = {
510
+ service: "cohort", channel: "dm/dana", channel_id: "c-dm-dana",
511
+ is_dm: true, sender: "Dana", content: "still ok for Friday?",
512
+ };
513
+ const legacy = await withLegacyContext(() => buildPrompt(item, CLASS, { type: "inbox" }));
514
+ assert.ok(legacy.includes(DM_SECRET), "a private reply may see the private record");
515
+ const compiled = await buildPrompt(item, CLASS, { type: "inbox" });
516
+ assert.ok(compiled.includes(DM_SECRET), "…on the compiled path too");
517
+ });
518
+
519
+ test("an item with no privacy signal at all is treated as a ROOM", async () => {
520
+ writeOrgConfig(null);
521
+ seedDmAndRoom("cohort", "c-unknown-kind");
522
+ // No is_dm, no channel_type, a cuid channel id that says nothing either way.
523
+ const item = { service: "cohort", channel: "unknown", channel_id: "c-unknown-kind", sender: "Dana", content: "?" };
524
+ const legacy = await withLegacyContext(() => buildPrompt(item, CLASS, { type: "inbox" }));
525
+ assert.ok(!legacy.includes(DM_SECRET), "unknown is not permission");
526
+ const compiled = await buildPrompt(item, CLASS, { type: "inbox" });
527
+ assert.ok(!compiled.includes(DM_SECRET), "unknown is not permission (compiled path)");
528
+ });
529
+
530
+ // ---------------------------------------------------------------------------
531
+ // DAEMON_CONTEXT_COMPILER — the flag that picks between two context paths
532
+ // ---------------------------------------------------------------------------
533
+
534
+ test("the context-compiler flag reads the words operators actually write", async () => {
535
+ const { contextCompilerSetting } = await import("./prompt-builder.mjs");
536
+
537
+ // Default ON (design §3 R11) — .env.example ships 1, the code used to ship 0.
538
+ assert.equal(contextCompilerEnabled({}), true);
539
+ assert.equal(contextCompilerEnabled({ DAEMON_CONTEXT_COMPILER: "" }), true);
540
+
541
+ for (const on of ["1", "true", "on", "yes", "Y", "ENABLED"]) {
542
+ assert.equal(contextCompilerEnabled({ DAEMON_CONTEXT_COMPILER: on }), true, `"${on}" means on`);
543
+ }
544
+ // The off words are wider than "0": an operator who writes `disabled` meant
545
+ // off, and the earlier list would have given them the opposite — on the one
546
+ // flag that selects between two materially different context paths.
547
+ for (const off of ["0", "false", "off", "no", "n", "disabled", "disable", "none", " OFF "]) {
548
+ assert.equal(contextCompilerEnabled({ DAEMON_CONTEXT_COMPILER: off }), false, `"${off}" means off`);
549
+ }
550
+
551
+ // A word we do not know still resolves ON — but it is reported as unknown, so
552
+ // the caller can say so rather than resolving a typo in silence.
553
+ const odd = contextCompilerSetting({ DAEMON_CONTEXT_COMPILER: "2" });
554
+ assert.deepEqual(odd, { enabled: true, recognised: false, raw: "2" });
555
+ assert.equal(contextCompilerSetting({ DAEMON_CONTEXT_COMPILER: "off" }).recognised, true);
556
+ });
@@ -22,7 +22,8 @@ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
22
22
  import { tmpdir } from "node:os";
23
23
  import { join } from "node:path";
24
24
 
25
- import { loadConversationHistory } from "./responder.mjs";
25
+ import { loadConversationHistory, deriveRouterItem } from "./responder.mjs";
26
+ import { routingKey } from "./lib/session-router.mjs";
26
27
 
27
28
  const cohortItem = {
28
29
  sender: "Dana Okafor",
@@ -183,3 +184,38 @@ test("cohort: server history wins over the local mirror", async () => {
183
184
  assert.match(text, /authoritative line/, "the server is the source of truth");
184
185
  assert.doesNotMatch(text, /outdated mirror line/, "the stale mirror must not be mixed in");
185
186
  });
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Session continuity: the responder's router adapter (design §3 R10)
190
+ //
191
+ // `deriveRouterItem` returned null for `cohort`, so every Cohort turn fell back
192
+ // to a fresh pre-minted UUID and the session router — which exists, and which
193
+ // Slack and Gmail have used all along — never saw the one surface the owner
194
+ // actually uses.
195
+ // ---------------------------------------------------------------------------
196
+
197
+ test("deriveRouterItem keys a Cohort item on its room, so a follow-up can resume", () => {
198
+ const out = deriveRouterItem(cohortItem);
199
+ assert.deepEqual(out, {
200
+ source: "cohort",
201
+ channel: "cmql1802601iihhzpf774vwbk",
202
+ thread_root_id: null,
203
+ });
204
+ assert.equal(routingKey(out), "cohort:cmql1802601iihhzpf774vwbk");
205
+ });
206
+
207
+ test("deriveRouterItem narrows to the thread when there is one", () => {
208
+ const out = deriveRouterItem({ ...cohortItem, thread_id: "root-42" });
209
+ assert.equal(routingKey(out), "cohort:cmql1802601iihhzpf774vwbk:root-42");
210
+ });
211
+
212
+ test("deriveRouterItem refuses the display label as a key", () => {
213
+ // `task/<title>` and `doc/<name>` are labels; two rooms can share one.
214
+ assert.equal(deriveRouterItem({ sender: "Dana", service: "cohort", channel: "task/Ship the thing" }), null);
215
+ });
216
+
217
+ test("deriveRouterItem still keys slack, gmail and calendar exactly as before", () => {
218
+ assert.equal(routingKey(deriveRouterItem({ service: "slack", channel: "D099", thread_id: null })), "slack:D099");
219
+ assert.equal(routingKey(deriveRouterItem({ service: "gmail", thread_id: "t-1" })), "gmail:t-1");
220
+ assert.equal(routingKey(deriveRouterItem({ service: "calendar", event_id: "e-1" })), "calendar:e-1");
221
+ });
@@ -28,7 +28,13 @@ import { spawn } from "child_process";
28
28
  import { join } from "path";
29
29
  import { randomUUID } from "crypto";
30
30
  import { checkRecentlySent, registerSent } from "./session-lock.mjs";
31
- import { routingKey as deriveRoutingKey, createRouter } from "./lib/session-router.mjs";
31
+ import {
32
+ routingKey as deriveRoutingKey,
33
+ createRouter,
34
+ routerItemFromDaemonItem,
35
+ claimSession,
36
+ releaseSession,
37
+ } from "./lib/session-router.mjs";
32
38
  // Permission scoping (security CRITICAL / audit H1). sessionPermissionArgs()
33
39
  // preserves the historical "--dangerously-skip-permissions" by default and only
34
40
  // scopes tools when the operator opts in (MAESTRO_SCOPED_PERMISSIONS=1), so the
@@ -71,6 +77,7 @@ const AGENT_REPO_DIR = process.env.AGENT_DIR || join(new URL(".", import.meta.ur
71
77
  const SONNET_MODEL = "claude-sonnet-4-6";
72
78
  // Resolve claude against the agent's PATH (not launchd's bare env).
73
79
  import { resolveClaudeBin, augmentedPath, daemonClaudeArgs } from "../../lib/claude-bin.mjs";
80
+ import { isPrivateConversation, interactionWriteDirNames } from "../../lib/context/history-scope.mjs";
74
81
  const CLAUDE_BIN = resolveClaudeBin();
75
82
  const CLAUDE_CLI_TIMEOUT_MS = 60_000;
76
83
  const SESSION_REGISTRY_PATH = join(AGENT_REPO_DIR, "state", "daemon", "session-router-registry.json");
@@ -101,45 +108,15 @@ function getRouter() {
101
108
  }
102
109
 
103
110
  /**
104
- * Translate a daemon-shaped item into the source/channel/thread_ts shape
105
- * that session-router's routingKey() expects.
111
+ * Translate a daemon-shaped item into the `{source, channel, …}` shape
112
+ * `session-router`'s `routingKey()` expects.
106
113
  *
107
- * Daemon items use {service, channel, thread_id, sender_email, ...}; the
108
- * router's pure key fn was specced against {source, channel, thread_ts,
109
- * thread_id, ...} per memo §4.2. This adapter is the seam between them.
110
- *
111
- * Returns null for items the router can't key (e.g. service we don't yet
112
- * support, or missing required fields). Caller falls back to a fresh
113
- * pre-minted UUID + EPHEMERAL semantics.
114
+ * The adapter itself now lives in `lib/session-router.mjs` beside the key
115
+ * function, because `dispatcher.mjs` needs the SAME translation: two copies
116
+ * would let one room be keyed two ways, which is the same thing as having no
117
+ * router. Exported here for the responder's own tests.
114
118
  */
115
- function deriveRouterItem(item) {
116
- if (!item || typeof item !== "object") return null;
117
-
118
- if (item.service === "slack") {
119
- const channel = item.channel || item.channel_id;
120
- if (!channel) return null;
121
- return {
122
- source: "slack",
123
- channel,
124
- thread_ts: item.thread_id || item.thread_ts || null,
125
- ts: item.ts || item.timestamp || null,
126
- };
127
- }
128
-
129
- if (item.service === "gmail") {
130
- const tid = item.thread_id || item.threadId;
131
- if (!tid) return null;
132
- return { source: "gmail", thread_id: tid };
133
- }
134
-
135
- if (item.service === "calendar") {
136
- const eid = item.event_id || item.eventId;
137
- if (!eid) return null;
138
- return { source: "calendar", event_id: eid };
139
- }
140
-
141
- return null;
142
- }
119
+ export const deriveRouterItem = routerItemFromDaemonItem;
143
120
 
144
121
  // Lazy token access — dotenv loads in daemon main before these are called
145
122
  function getSlackToken() {
@@ -438,10 +415,17 @@ function logInteraction(item, responseText, o = {}) {
438
415
  ? resolveSlackChannel(item)
439
416
  : (item.channel_id || item.channel || null);
440
417
 
441
- // Write to both channel-ID and sender-slug directories
442
- const dirs = [];
443
- if (channelId) dirs.push(join(AGENT_REPO_DIR, "memory", "interactions", service, String(channelId)));
444
- dirs.push(join(AGENT_REPO_DIR, "memory", "interactions", service, `dm-${senderSlug}`));
418
+ // The room always; the PERSON only when the exchange was actually private.
419
+ // This used to write both unconditionally, which made `dm-<slug>` a mixed
420
+ // store of private and public-room content — inert only for as long as
421
+ // nothing read it per-service. It is read now, so the gate belongs at both
422
+ // ends (`lib/context/history-scope.mjs`, design §5.5).
423
+ const dirs = interactionWriteDirNames({
424
+ channelId,
425
+ senderSlug,
426
+ isPrivate: isPrivateConversation(item),
427
+ }).map((name) => join(AGENT_REPO_DIR, "memory", "interactions", service, String(name)));
428
+ if (dirs.length === 0) return;
445
429
 
446
430
  const incomingEntry = {
447
431
  ts: item.ts || item.timestamp || new Date().toISOString(),
@@ -738,14 +722,25 @@ ${MESSAGE_CRAFT}`;
738
722
  let sessionId = null;
739
723
  if (routerItem) {
740
724
  try {
741
- key = deriveRoutingKey(routerItem);
742
- const decision = router.route(key);
743
- if (decision.decision === "RESUME" && decision.resumeId) {
744
- sessionId = decision.resumeId;
725
+ const candidateKey = deriveRoutingKey(routerItem);
726
+ const decision = router.route(candidateKey);
727
+ // One CLI process per key. `claimSession` is the same lease the dispatcher
728
+ // takes, in the same process, so a 60-second quick reply can never be
729
+ // handed the session id of a long agentic run that is still going — and
730
+ // two quick replies from one burst cannot share a transcript either.
731
+ if (claimSession(candidateKey)) {
732
+ key = candidateKey;
733
+ if (decision.decision === "RESUME" && decision.resumeId) {
734
+ sessionId = decision.resumeId;
735
+ } else {
736
+ // EPHEMERAL or EPHEMERAL_REPLACE — pre-mint a fresh UUID. Reusing
737
+ // the same key on next call (with a different sessionId) is fine;
738
+ // touch() will overwrite the registry entry.
739
+ sessionId = randomUUID();
740
+ }
745
741
  } else {
746
- // EPHEMERAL or EPHEMERAL_REPLACE — pre-mint a fresh UUID. Reusing
747
- // the same key on next call (with a different sessionId) is fine;
748
- // touch() will overwrite the registry entry.
742
+ // Already in flight: spawn cold, and stay out of the registry so the
743
+ // running session keeps ownership of the row.
749
744
  sessionId = randomUUID();
750
745
  }
751
746
  } catch (err) {
@@ -757,30 +752,37 @@ ${MESSAGE_CRAFT}`;
757
752
  }
758
753
  }
759
754
 
760
- const cliResult = await runClaudeCLI(systemPrompt, userContent, model, {
761
- sessionId,
762
- router: key ? router : null,
763
- routingKey: key,
764
- });
755
+ // The claim is held across the spawn AND the registry write that follows it,
756
+ // and released in a `finally`: a throw anywhere in between must not leave the
757
+ // room's key claimed forever, which would silently disable continuity for it.
758
+ try {
759
+ const cliResult = await runClaudeCLI(systemPrompt, userContent, model, {
760
+ sessionId,
761
+ router: key ? router : null,
762
+ routingKey: key,
763
+ });
765
764
 
766
- const text = (cliResult.text || "").trim();
767
- if (!text) {
768
- throw new Error("claude CLI returned empty result text in generateResponse");
769
- }
765
+ const text = (cliResult.text || "").trim();
766
+ if (!text) {
767
+ throw new Error("claude CLI returned empty result text in generateResponse");
768
+ }
770
769
 
771
- // (b3) On success, touch the registry with the CLI-resolved session_id so
772
- // the next call routed to this key gets a RESUME decision. Skip touch on
773
- // is_error responses or when JSON parsing failed (legacy fallback path).
774
- if (key && cliResult.jsonResult && cliResult.jsonResult.is_error !== true) {
775
- const claudeSessionId = cliResult.jsonResult.session_id || sessionId;
776
- try {
777
- await router.touch(key, { claudeSessionId, model });
778
- } catch (err) {
779
- console.warn(`[responder] router.touch failed for ${key}: ${err.message}`);
770
+ // (b3) On success, touch the registry with the CLI-resolved session_id so
771
+ // the next call routed to this key gets a RESUME decision. Skip touch on
772
+ // is_error responses or when JSON parsing failed (legacy fallback path).
773
+ if (key && cliResult.jsonResult && cliResult.jsonResult.is_error !== true) {
774
+ const claudeSessionId = cliResult.jsonResult.session_id || sessionId;
775
+ try {
776
+ await router.touch(key, { claudeSessionId, model });
777
+ } catch (err) {
778
+ console.warn(`[responder] router.touch failed for ${key}: ${err.message}`);
779
+ }
780
780
  }
781
- }
782
781
 
783
- return text;
782
+ return text;
783
+ } finally {
784
+ if (key) releaseSession(key);
785
+ }
784
786
  }
785
787
 
786
788
  // Default the generator slot to the real CLI-backed implementation. (Declared