chapterhouse 0.5.2 → 0.7.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 (40) hide show
  1. package/.pr-types.json +14 -0
  2. package/README.md +6 -0
  3. package/dist/api/agent-edit-access.js +11 -0
  4. package/dist/api/agents.api.test.js +48 -0
  5. package/dist/api/server.js +182 -11
  6. package/dist/api/server.test.js +334 -3
  7. package/dist/config.test.js +29 -0
  8. package/dist/copilot/agent-event-bus.js +1 -0
  9. package/dist/copilot/agents.js +114 -46
  10. package/dist/copilot/agents.mcp-servers.test.js +87 -0
  11. package/dist/copilot/agents.parse.test.js +69 -0
  12. package/dist/copilot/agents.test.js +125 -1
  13. package/dist/copilot/memory-coordinator.js +234 -0
  14. package/dist/copilot/memory-coordinator.test.js +257 -0
  15. package/dist/copilot/orchestrator.js +81 -221
  16. package/dist/copilot/orchestrator.test.js +238 -1
  17. package/dist/copilot/pr-title.js +92 -0
  18. package/dist/copilot/pr-title.test.js +54 -0
  19. package/dist/copilot/router.test.js +30 -0
  20. package/dist/copilot/session-manager.js +34 -0
  21. package/dist/copilot/threat-model.js +50 -0
  22. package/dist/copilot/threat-model.test.js +129 -0
  23. package/dist/copilot/tools.js +61 -37
  24. package/dist/copilot/tools.wiki.test.js +15 -6
  25. package/dist/setup.js +15 -5
  26. package/dist/setup.test.js +20 -3
  27. package/dist/sprint-merge.js +168 -0
  28. package/dist/sprint-merge.test.js +131 -0
  29. package/dist/store/db.js +63 -0
  30. package/dist/store/db.test.js +279 -0
  31. package/dist/test/setup-env.js +2 -1
  32. package/dist/test/setup-env.test.js +8 -1
  33. package/package.json +8 -1
  34. package/web/dist/assets/index-DuKYxMIR.css +10 -0
  35. package/web/dist/assets/index-DytB69KC.js +223 -0
  36. package/web/dist/assets/index-DytB69KC.js.map +1 -0
  37. package/web/dist/index.html +2 -2
  38. package/web/dist/assets/index-CPaILy2j.js +0 -223
  39. package/web/dist/assets/index-CPaILy2j.js.map +0 -1
  40. package/web/dist/assets/index-Cs7AGeaL.css +0 -10
@@ -158,6 +158,117 @@ async function loadOrchestratorModule(t, overrides = {}) {
158
158
  DEFAULT_MODEL: "fallback-model",
159
159
  },
160
160
  });
161
+ t.mock.module("./memory-coordinator.js", {
162
+ namedExports: {
163
+ MemoryCoordinator: class {
164
+ checkpointTrackers = new Map();
165
+ checkpointTurnsBySession = new Map();
166
+ housekeepingTurnsBySession = new Map();
167
+ constructor(_options) { }
168
+ getCheckpointTracker(sessionKey) {
169
+ let tracker = this.checkpointTrackers.get(sessionKey);
170
+ if (!tracker) {
171
+ tracker = { turns: 0 };
172
+ this.checkpointTrackers.set(sessionKey, tracker);
173
+ }
174
+ return tracker;
175
+ }
176
+ async onTurnComplete(sessionKey, prompt, response, source) {
177
+ if (source === "background") {
178
+ return;
179
+ }
180
+ const tracker = this.getCheckpointTracker(sessionKey);
181
+ state.checkpointTickCalls++;
182
+ tracker.turns++;
183
+ const turns = this.checkpointTurnsBySession.get(sessionKey) ?? [];
184
+ turns.push({ user: prompt.trim(), assistant: response.trim() });
185
+ this.checkpointTurnsBySession.set(sessionKey, turns);
186
+ if (state.config.memoryCheckpointEnabled !== false && tracker.turns >= state.checkpointShouldFireAfter && !state.checkpointInFlight) {
187
+ state.checkpointMarkFiredCalls++;
188
+ tracker.turns = 0;
189
+ state.checkpointRuns.push({
190
+ sessionKey,
191
+ turns: turns.slice(-5),
192
+ activeScope: state.activeScope ?? null,
193
+ trigger: "cadence",
194
+ });
195
+ }
196
+ if (state.config.memoryHousekeepingEnabled === false) {
197
+ return;
198
+ }
199
+ const count = (this.housekeepingTurnsBySession.get(sessionKey) ?? 0) + 1;
200
+ const cadence = state.config.memoryHousekeepingTurns ?? 50;
201
+ if (count < cadence) {
202
+ this.housekeepingTurnsBySession.set(sessionKey, count);
203
+ return;
204
+ }
205
+ this.housekeepingTurnsBySession.set(sessionKey, 0);
206
+ if (!state.activeScope || state.housekeepingInFlight) {
207
+ return;
208
+ }
209
+ state.housekeepingRuns.push({ scopeIds: [state.activeScope.id] });
210
+ }
211
+ async onScopeChange(sessionKey, prev, next) {
212
+ if (!prev || state.config.memoryCheckpointOnScopeChange === false || state.checkpointInFlight) {
213
+ return;
214
+ }
215
+ const tracker = this.getCheckpointTracker(sessionKey);
216
+ if (tracker.turns < (state.config.memoryCheckpointMinTurnsForScopeFire ?? 2)) {
217
+ return;
218
+ }
219
+ const turns = this.checkpointTurnsBySession.get(sessionKey) ?? [];
220
+ if (turns.length === 0) {
221
+ return;
222
+ }
223
+ state.checkpointMarkScopeChangeFireCalls++;
224
+ tracker.turns = 0;
225
+ state.checkpointRuns.push({
226
+ sessionKey,
227
+ turns: turns.slice(-5),
228
+ activeScope: { slug: prev },
229
+ trigger: "scope_change",
230
+ scopeChangeContext: { from: prev, to: next || "no active scope" },
231
+ });
232
+ }
233
+ async buildHotTierContext(sessionKey) {
234
+ if (state.config.memoryInjectEnabled === false) {
235
+ return "";
236
+ }
237
+ if (sessionKey.startsWith("agent:")) {
238
+ const agent = state.registry.find((entry) => `agent:${entry.slug}` === sessionKey);
239
+ if (agent?.scope) {
240
+ return (state.hotTierByScope?.get(agent.scope) ?? "").trimEnd();
241
+ }
242
+ }
243
+ const xml = state.hotTierXml ?? state.hotTierByScope?.get(state.activeScope?.slug ?? "") ?? "";
244
+ return xml.trimEnd();
245
+ }
246
+ buildPerTurnHooks(sessionKey) {
247
+ if (state.config.memoryInjectEnabled === false) {
248
+ return undefined;
249
+ }
250
+ return {
251
+ onUserPromptSubmitted: async () => {
252
+ const additionalContext = await this.buildHotTierContext(sessionKey);
253
+ return additionalContext ? { additionalContext } : undefined;
254
+ },
255
+ };
256
+ }
257
+ async onAgentTaskComplete(_taskId, _result) { }
258
+ reset(sessionKey) {
259
+ state.checkpointResetCalls++;
260
+ this.getCheckpointTracker(sessionKey).turns = 0;
261
+ this.checkpointTurnsBySession.delete(sessionKey);
262
+ this.housekeepingTurnsBySession.delete(sessionKey);
263
+ }
264
+ shutdown() {
265
+ this.checkpointTrackers.clear();
266
+ this.checkpointTurnsBySession.clear();
267
+ this.housekeepingTurnsBySession.clear();
268
+ }
269
+ },
270
+ },
271
+ });
161
272
  t.mock.module("../memory/hot-tier.js", {
162
273
  namedExports: {
163
274
  renderHotTierForActiveScope: () => state.hotTierXml ?? "",
@@ -271,7 +382,10 @@ async function loadOrchestratorModule(t, overrides = {}) {
271
382
  });
272
383
  t.mock.module("./mcp-config.js", {
273
384
  namedExports: {
274
- loadMcpConfig: () => ({ filesystem: { command: "filesystem" } }),
385
+ loadMcpConfig: () => ({
386
+ filesystem: { command: "filesystem" },
387
+ truenas: { command: "truenas" },
388
+ }),
275
389
  },
276
390
  });
277
391
  t.mock.module("./skills.js", {
@@ -402,6 +516,13 @@ async function loadOrchestratorModule(t, overrides = {}) {
402
516
  return "@coder @designer";
403
517
  },
404
518
  composeAgentSystemMessage: (agent) => agent.systemMessage ?? `You are ${agent.slug}.`,
519
+ filterMcpServersForAgent: (agent, mcpServers) => {
520
+ if (agent.mcpServers && agent.mcpServers.length > 0) {
521
+ const allowed = new Set(agent.mcpServers);
522
+ return Object.fromEntries(Object.entries(mcpServers).filter(([name]) => allowed.has(name)));
523
+ }
524
+ return mcpServers;
525
+ },
405
526
  filterToolsForAgent: (_agent, tools) => tools,
406
527
  bindToolsToAgent: (_agentSlug, tools) => tools,
407
528
  withToolTaskContext: (_taskId, fn) => fn(),
@@ -617,6 +738,28 @@ test("initOrchestrator prewarms persistent agent sessions with scoped hot-tier c
617
738
  assert.equal(persistentCall.systemMessage.content.includes("scope=\"infra\""), true);
618
739
  assert.deepEqual(state.dbWrites.filter((write) => write.sql.includes("copilot_sessions")), []);
619
740
  });
741
+ test("initOrchestrator prewarms persistent agent sessions with only allowed MCP servers", async (t) => {
742
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
743
+ registry: [
744
+ { slug: "coder", name: "Kaylee", model: "claude-sonnet-4.6", systemMessage: "You are Kaylee." },
745
+ {
746
+ slug: "bellonda",
747
+ name: "Bellonda",
748
+ model: "claude-sonnet-4.6",
749
+ systemMessage: "You are Bellonda.",
750
+ persistent: true,
751
+ scope: "infra",
752
+ mcpServers: ["truenas"],
753
+ },
754
+ ],
755
+ });
756
+ await orchestrator.initOrchestrator(client);
757
+ const persistentCall = state.createSessionCalls.find((call) => String(call.systemMessage?.content ?? "").includes("Bellonda"));
758
+ assert.ok(persistentCall, "expected a prewarmed Bellonda session");
759
+ assert.deepEqual(persistentCall.mcpServers, {
760
+ truenas: { command: "truenas" },
761
+ });
762
+ });
620
763
  test("sendToOrchestrator routes agent session keys directly to persistent agent sessions", async (t) => {
621
764
  const { orchestrator, state, client } = await loadOrchestratorModule(t, {
622
765
  config: {
@@ -1406,6 +1549,100 @@ test("cancelCurrentMessage aborts the active request and agent helpers expose ru
1406
1549
  await orchestrator.shutdownAgents();
1407
1550
  assert.equal(state.clearActiveTasksCalls, 1);
1408
1551
  });
1552
+ test("persistent agent helpers report session state and reload idle sessions", async (t) => {
1553
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
1554
+ config: {
1555
+ copilotModel: "claude-sonnet-4.6",
1556
+ selfEditEnabled: true,
1557
+ },
1558
+ registry: [
1559
+ {
1560
+ slug: "bellonda",
1561
+ name: "Bellonda",
1562
+ model: "claude-sonnet-4.6",
1563
+ systemMessage: "You are Bellonda.",
1564
+ persistent: true,
1565
+ scope: "infra",
1566
+ },
1567
+ ],
1568
+ });
1569
+ await orchestrator.initOrchestrator(client);
1570
+ const createCallsBeforeReload = state.createSessionCalls.length;
1571
+ assert.equal(orchestrator.getPersistentAgentSessionState("bellonda"), "idle");
1572
+ const reloaded = await orchestrator.reloadPersistentAgent("bellonda");
1573
+ assert.equal(reloaded, "reloaded");
1574
+ assert.equal(orchestrator.getPersistentAgentSessionState("bellonda"), "idle");
1575
+ assert.equal(state.disconnectCalls, 1);
1576
+ assert.equal(state.createSessionCalls.length, createCallsBeforeReload + 1);
1577
+ });
1578
+ test("persistent agent helpers detect in-flight turns", async (t) => {
1579
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
1580
+ config: {
1581
+ copilotModel: "claude-sonnet-4.6",
1582
+ selfEditEnabled: true,
1583
+ },
1584
+ registry: [
1585
+ {
1586
+ slug: "bellonda",
1587
+ name: "Bellonda",
1588
+ model: "claude-sonnet-4.6",
1589
+ systemMessage: "You are Bellonda.",
1590
+ persistent: true,
1591
+ scope: "infra",
1592
+ },
1593
+ ],
1594
+ sendResult: "__PENDING__",
1595
+ });
1596
+ await orchestrator.initOrchestrator(client);
1597
+ orchestrator.sendToOrchestrator("check deploy health", { type: "background", sessionKey: "agent:bellonda" }, () => { });
1598
+ await new Promise((resolve) => setTimeout(resolve, 10));
1599
+ assert.equal(orchestrator.getPersistentAgentSessionState("bellonda"), "in_flight");
1600
+ state.pendingReject?.(new Error("test teardown"));
1601
+ await new Promise((resolve) => setTimeout(resolve, 0));
1602
+ });
1603
+ test("reloadPersistentAgent defers reloads until the in-flight turn finishes and preserves queued turns", async (t) => {
1604
+ const { orchestrator, state, client } = await loadOrchestratorModule(t, {
1605
+ config: {
1606
+ copilotModel: "claude-sonnet-4.6",
1607
+ selfEditEnabled: true,
1608
+ },
1609
+ registry: [
1610
+ {
1611
+ slug: "bellonda",
1612
+ name: "Bellonda",
1613
+ model: "claude-sonnet-4.6",
1614
+ systemMessage: "You are Bellonda.",
1615
+ persistent: true,
1616
+ scope: "infra",
1617
+ },
1618
+ ],
1619
+ sendResult: "__PENDING__",
1620
+ });
1621
+ await orchestrator.initOrchestrator(client);
1622
+ const createCallsBeforeReload = state.createSessionCalls.length;
1623
+ const reloadEvents = [];
1624
+ orchestrator.sendToOrchestrator("check deploy health", { type: "background", sessionKey: "agent:bellonda" }, () => { });
1625
+ await new Promise((resolve) => setTimeout(resolve, 10));
1626
+ const queuedTurn = new Promise((resolve) => {
1627
+ orchestrator.sendToOrchestrator("summarize the queued follow-up", { type: "background", sessionKey: "agent:bellonda" }, (text, done) => {
1628
+ if (done)
1629
+ resolve(text);
1630
+ });
1631
+ });
1632
+ await new Promise((resolve) => setTimeout(resolve, 10));
1633
+ const reloadResult = await orchestrator.reloadPersistentAgent("bellonda", () => {
1634
+ reloadEvents.push("reloaded");
1635
+ });
1636
+ assert.equal(reloadResult, "scheduled");
1637
+ assert.equal(state.disconnectCalls, 0, "deferred reload should wait for the active turn to finish");
1638
+ state.sendResult = "Queued follow-up complete";
1639
+ state.pendingReject?.(new Error("forced restart"));
1640
+ assert.equal(await queuedTurn, "Queued follow-up complete");
1641
+ await new Promise((resolve) => setTimeout(resolve, 0));
1642
+ assert.deepEqual(reloadEvents, ["reloaded"]);
1643
+ assert.equal(state.disconnectCalls, 1, "deferred reload should restart the session once the turn finishes");
1644
+ assert.equal(state.createSessionCalls.length, createCallsBeforeReload + 1);
1645
+ });
1409
1646
  // ---------------------------------------------------------------------------
1410
1647
  // REGRESSION: #35 — per-session isolation
1411
1648
  // This test would have caught the original bug. With a global shared queue,
@@ -0,0 +1,92 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ const __dirname = dirname(fileURLToPath(import.meta.url));
5
+ const PR_TYPES_CONFIG_PATH = join(__dirname, "..", "..", ".pr-types.json");
6
+ function loadPrTitleTypes() {
7
+ const parsed = JSON.parse(readFileSync(PR_TYPES_CONFIG_PATH, "utf-8"));
8
+ if (!Array.isArray(parsed) || parsed.some((value) => typeof value !== "string" || value.trim().length === 0)) {
9
+ throw new Error(`Invalid PR title types config at ${PR_TYPES_CONFIG_PATH}`);
10
+ }
11
+ return parsed;
12
+ }
13
+ const VALID_PR_TITLE_TYPES = loadPrTitleTypes();
14
+ const PR_TITLE_PATTERN = new RegExp(`^(?<type>${VALID_PR_TITLE_TYPES.join("|")})(?:\\((?<scope>[^()\\r\\n]+)\\))?: (?<description>.+)$`);
15
+ function examplesBlock() {
16
+ return [
17
+ "Examples:",
18
+ " Valid: feat: add user search",
19
+ " Valid: fix(auth): handle token expiry",
20
+ " Valid: test: memory tiering edge cases",
21
+ " Invalid: adding new thing",
22
+ " Invalid: WIP",
23
+ " Invalid: HOTFIX",
24
+ ].join("\n");
25
+ }
26
+ function allowedTypesLine() {
27
+ return `Allowed types: ${VALID_PR_TITLE_TYPES.join(", ")}.`;
28
+ }
29
+ function isAllCapsDescription(description) {
30
+ const lettersOnly = description.replace(/[^A-Za-z]/g, "");
31
+ return lettersOnly.length > 0 && lettersOnly === lettersOnly.toUpperCase();
32
+ }
33
+ export function explainPrTitleValidation(title) {
34
+ const normalizedTitle = title.trim();
35
+ if (!normalizedTitle) {
36
+ return {
37
+ valid: false,
38
+ message: [
39
+ "PR title is required.",
40
+ "Use conventional commit format: type(optional-scope): description",
41
+ allowedTypesLine(),
42
+ examplesBlock(),
43
+ ].join("\n"),
44
+ };
45
+ }
46
+ const match = PR_TITLE_PATTERN.exec(normalizedTitle);
47
+ if (!match?.groups) {
48
+ return {
49
+ valid: false,
50
+ message: [
51
+ `PR title \"${normalizedTitle}\" must match conventional commit format: type(optional-scope): description`,
52
+ allowedTypesLine(),
53
+ examplesBlock(),
54
+ ].join("\n"),
55
+ };
56
+ }
57
+ const description = match.groups.description.trim();
58
+ if (!description) {
59
+ return {
60
+ valid: false,
61
+ message: [
62
+ "PR title description must be non-empty.",
63
+ examplesBlock(),
64
+ ].join("\n"),
65
+ };
66
+ }
67
+ if (isAllCapsDescription(description)) {
68
+ return {
69
+ valid: false,
70
+ message: [
71
+ `PR title description must not be ALL CAPS: \"${description}\".`,
72
+ "Use a short, sentence-style description after the conventional type prefix.",
73
+ examplesBlock(),
74
+ ].join("\n"),
75
+ };
76
+ }
77
+ return {
78
+ valid: true,
79
+ message: `PR title is valid: ${normalizedTitle}`,
80
+ };
81
+ }
82
+ export function isValidPrTitle(title) {
83
+ return explainPrTitleValidation(title).valid;
84
+ }
85
+ export function assertValidPrTitle(title) {
86
+ const result = explainPrTitleValidation(title);
87
+ if (!result.valid) {
88
+ throw new Error(result.message);
89
+ }
90
+ }
91
+ export { VALID_PR_TITLE_TYPES };
92
+ //# sourceMappingURL=pr-title.js.map
@@ -0,0 +1,54 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import test from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+ import { explainPrTitleValidation, isValidPrTitle } from "./pr-title.js";
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const PR_TYPES_CONFIG_PATH = join(__dirname, "..", "..", ".pr-types.json");
9
+ test("accepts conventional PR titles with and without scopes", () => {
10
+ assert.equal(isValidPrTitle("feat: add user search"), true);
11
+ assert.equal(isValidPrTitle("fix(auth): handle token expiry"), true);
12
+ assert.equal(isValidPrTitle("test: memory tiering edge cases"), true);
13
+ assert.equal(isValidPrTitle("release: v1.2.3"), true);
14
+ });
15
+ test("rejects blank or malformed PR titles with clear guidance", () => {
16
+ const blank = explainPrTitleValidation(" ");
17
+ assert.equal(blank.valid, false);
18
+ assert.match(blank.message, /PR title is required/i);
19
+ assert.match(blank.message, /feat: add user search/);
20
+ const malformed = explainPrTitleValidation("adding new thing");
21
+ assert.equal(malformed.valid, false);
22
+ assert.match(malformed.message, /must match conventional commit format/i);
23
+ assert.match(malformed.message, /type\(optional-scope\): description/i);
24
+ });
25
+ test("rejects all-caps descriptions even when the prefix is valid", () => {
26
+ const result = explainPrTitleValidation("fix: HOTFIX");
27
+ assert.equal(result.valid, false);
28
+ assert.match(result.message, /description must not be all caps/i);
29
+ });
30
+ test("rejects unsupported types", () => {
31
+ const result = explainPrTitleValidation("hotfix: patch prod issue");
32
+ assert.equal(result.valid, false);
33
+ assert.match(result.message, /allowed types/i);
34
+ assert.match(result.message, /feat, fix, docs, style, refactor, perf, test, chore, build, ci, revert, release/);
35
+ });
36
+ test("loads PR title types from the shared config file", () => {
37
+ const raw = readFileSync(PR_TYPES_CONFIG_PATH, "utf-8");
38
+ const types = JSON.parse(raw);
39
+ assert.deepEqual(types, [
40
+ "feat",
41
+ "fix",
42
+ "docs",
43
+ "style",
44
+ "refactor",
45
+ "perf",
46
+ "test",
47
+ "chore",
48
+ "build",
49
+ "ci",
50
+ "revert",
51
+ "release",
52
+ ]);
53
+ });
54
+ //# sourceMappingURL=pr-title.test.js.map
@@ -29,6 +29,7 @@ async function loadRouterModule(t, options = {}) {
29
29
  return { router, state };
30
30
  }
31
31
  test("router config defaults personal-mode auto-routing to standard-cost routes before opt-in", async (t) => {
32
+ // Security/Billing: personal mode must not silently spend premium quota until the user explicitly opts in.
32
33
  const { router } = await loadRouterModule(t, { mode: "personal" });
33
34
  assert.deepEqual(router.getRouterConfig(), {
34
35
  enabled: true,
@@ -55,6 +56,7 @@ test("router config keeps auto-routing off by default in team mode", async (t) =
55
56
  assert.equal(router.getRouterConfig().enabled, false);
56
57
  });
57
58
  test("saving router config in personal mode opts into premium defaults and deep-merges tier model updates", async (t) => {
59
+ // Security/Billing: persisted router_config is the explicit consent boundary for premium model usage.
58
60
  const { router, state } = await loadRouterModule(t, { mode: "personal" });
59
61
  const saved = router.updateRouterConfig({ enabled: true });
60
62
  assert.equal(saved.enabled, true);
@@ -84,6 +86,7 @@ test("resolveModel stays in manual mode when the router is disabled", async (t)
84
86
  });
85
87
  });
86
88
  test("resolveModel applies safe design overrides before personal-mode opt-in and ignores partial-word matches", async (t) => {
89
+ // Security/Billing: premium-looking prompts like design work must still stay on standard-cost models until opt-in is stored.
87
90
  const { router } = await loadRouterModule(t, {
88
91
  classify: async () => "fast",
89
92
  });
@@ -96,7 +99,34 @@ test("resolveModel applies safe design overrides before personal-mode opt-in and
96
99
  assert.equal(noOverride.model, "gpt-4.1");
97
100
  assert.equal(noOverride.tier, "fast");
98
101
  });
102
+ test("stored router opt-in re-enables premium design overrides in personal mode", async (t) => {
103
+ // Security/Billing: once the user explicitly opts in, premium routing should activate so future regressions do not strand consented users on downgraded routing.
104
+ const { router } = await loadRouterModule(t, {
105
+ mode: "personal",
106
+ storedConfig: JSON.stringify({
107
+ enabled: true,
108
+ tierModels: {
109
+ fast: "gpt-4.1",
110
+ standard: "claude-sonnet-4.6",
111
+ premium: "claude-opus-4.6",
112
+ },
113
+ overrides: [
114
+ {
115
+ name: "design",
116
+ keywords: ["design", "ui"],
117
+ model: "claude-opus-4.6",
118
+ },
119
+ ],
120
+ cooldownMessages: 2,
121
+ }),
122
+ });
123
+ const result = await router.resolveModel("Need a UI refresh", "claude-sonnet-4.6", [], {});
124
+ assert.equal(result.overrideName, "design");
125
+ assert.equal(result.model, "claude-opus-4.6");
126
+ assert.equal(result.switched, true);
127
+ });
99
128
  test("short follow-ups inherit the previous tier without forcing premium before opt-in", async (t) => {
129
+ // Security/Billing: follow-up replies must not become a backdoor that silently upgrades personal-mode users to premium routing.
100
130
  const { router } = await loadRouterModule(t);
101
131
  const result = await router.resolveModel("yes", "claude-sonnet-4.6", ["premium"]);
102
132
  assert.deepEqual(result, {
@@ -60,6 +60,9 @@ export class SessionManager {
60
60
  * never-evict-mid-turn invariant. */
61
61
  _pendingClose = false;
62
62
  _onPendingCloseEvict;
63
+ /** Set when a persistent session should be recreated before the next queued turn runs. */
64
+ _pendingReload = false;
65
+ _onPendingReload = [];
63
66
  constructor(sessionKey, worker, sessionFactory) {
64
67
  this.worker = worker;
65
68
  this.sessionFactory = sessionFactory;
@@ -178,6 +181,19 @@ export class SessionManager {
178
181
  item.reject(err);
179
182
  }
180
183
  this._lastActivityAt = Date.now();
184
+ if (this._pendingReload) {
185
+ await this.restartSession();
186
+ const callbacks = this._onPendingReload.splice(0);
187
+ this._pendingReload = false;
188
+ for (const callback of callbacks) {
189
+ try {
190
+ callback();
191
+ }
192
+ catch (err) {
193
+ log.warn({ sessionKey: this.sessionKey, err: err instanceof Error ? err.message : String(err) }, "session.reload.callback.failed");
194
+ }
195
+ }
196
+ }
181
197
  }
182
198
  this._currentTurnId = undefined;
183
199
  this._processing = false;
@@ -213,6 +229,24 @@ export class SessionManager {
213
229
  this._session = undefined;
214
230
  this._sessionCreatePromise = undefined;
215
231
  }
232
+ async restartSession() {
233
+ if (this._session) {
234
+ try {
235
+ await this._session.disconnect();
236
+ }
237
+ catch {
238
+ // best effort
239
+ }
240
+ }
241
+ this.invalidateSession();
242
+ await this.ensureSession();
243
+ }
244
+ requestSessionReload(onReloaded) {
245
+ this._pendingReload = true;
246
+ if (onReloaded) {
247
+ this._onPendingReload.push(onReloaded);
248
+ }
249
+ }
216
250
  /** Reject all queued messages without evicting the session. Returns count drained. */
217
251
  cancelQueued() {
218
252
  const count = this._queue.length;
@@ -0,0 +1,50 @@
1
+ const THREAT_MODEL_PATTERNS = [
2
+ /(^|[\/._-])auth([\/._-]|$)/i,
3
+ /credential/i,
4
+ /(auth|access|bearer|refresh|session|payment|api)[-_.]?token|token[-_.]?(auth|key|secret|refresh|access|session|payment)/i,
5
+ /billing/i,
6
+ /subscription/i,
7
+ /api[-_]?key/i,
8
+ /(^|[\/._-])tiers?([\/._-]|$)/i,
9
+ ];
10
+ function normalizeChangedFiles(changedFiles) {
11
+ return changedFiles.map((file) => file.trim()).filter(Boolean);
12
+ }
13
+ export function findThreatModelMatches(changedFiles) {
14
+ return normalizeChangedFiles(changedFiles).filter((file) => THREAT_MODEL_PATTERNS.some((pattern) => pattern.test(file)));
15
+ }
16
+ export function hasThreatModelSection(prBody) {
17
+ const stripped = (prBody ?? "").replace(/<!--[\s\S]*?-->/g, "");
18
+ return /^##+\s+Threat Model\b/im.test(stripped);
19
+ }
20
+ export function evaluateThreatModelCheck(input) {
21
+ const matchedFiles = findThreatModelMatches(input.changedFiles);
22
+ if (matchedFiles.length === 0) {
23
+ return {
24
+ required: false,
25
+ valid: true,
26
+ matchedFiles: [],
27
+ message: "✅ No auth/credentials/billing file changes detected — a threat model section is not required.",
28
+ };
29
+ }
30
+ if (hasThreatModelSection(input.prBody)) {
31
+ return {
32
+ required: true,
33
+ valid: true,
34
+ matchedFiles,
35
+ message: "✅ Threat model section present for auth/credentials/billing changes.",
36
+ };
37
+ }
38
+ return {
39
+ required: true,
40
+ valid: false,
41
+ matchedFiles,
42
+ message: [
43
+ "⚠️ This PR modifies auth/credentials/billing files. Add a '## Threat Model' section to the PR description explaining the security implications.",
44
+ "",
45
+ "Matched files:",
46
+ ...matchedFiles.map((file) => `- ${file}`),
47
+ ].join("\n"),
48
+ };
49
+ }
50
+ //# sourceMappingURL=threat-model.js.map