chapterhouse 0.3.6 → 0.3.8

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,315 @@
1
+ /**
2
+ * Unit tests for src/copilot/hooks.ts
3
+ *
4
+ * Tests per Mal's spec §6:
5
+ * - One test per hook firing (block cases)
6
+ * - One test per hook bypass attempt (allow cases)
7
+ * - Integration: createSessionHooks adapter translates HookPipeline results to SDK format
8
+ */
9
+ import { describe, it, before, after } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { HookPipeline } from "@bradygaster/squad-sdk/hooks";
12
+ import { initHookPipeline, getHookPipeline, createSessionHooks, DEFAULT_ALLOWED_PATHS } from "./hooks.js";
13
+ // ---------------------------------------------------------------------------
14
+ // HookPipeline unit tests — directly exercising the squad-sdk class
15
+ // ---------------------------------------------------------------------------
16
+ describe("HookPipeline — file-write guard", () => {
17
+ it("blocks write to path outside allowlist", async () => {
18
+ const pipeline = new HookPipeline({ allowedWritePaths: ["src/**"] });
19
+ const result = await pipeline.runPreToolHooks({
20
+ toolName: "edit",
21
+ arguments: { path: "/etc/passwd" },
22
+ agentName: "test-agent",
23
+ sessionId: "test-1",
24
+ });
25
+ assert.equal(result.action, "block");
26
+ assert.ok(result.reason?.includes("/etc/passwd"), `reason should mention path, got: ${result.reason}`);
27
+ });
28
+ it("allows write to path inside allowlist", async () => {
29
+ const pipeline = new HookPipeline({ allowedWritePaths: ["src/**"] });
30
+ const result = await pipeline.runPreToolHooks({
31
+ toolName: "edit",
32
+ arguments: { path: "src/copilot/hooks.ts" },
33
+ agentName: "test-agent",
34
+ sessionId: "test-1",
35
+ });
36
+ assert.equal(result.action, "allow");
37
+ });
38
+ it("allows non-write tools regardless of path", async () => {
39
+ const pipeline = new HookPipeline({ allowedWritePaths: ["src/**"] });
40
+ const result = await pipeline.runPreToolHooks({
41
+ toolName: "bash",
42
+ arguments: { command: "echo hello", path: "/etc/passwd" },
43
+ agentName: "test-agent",
44
+ sessionId: "test-1",
45
+ });
46
+ // File-write guard only fires on edit/create/write_file/create_file
47
+ // This will hit the shell restriction instead (echo is safe)
48
+ assert.ok(result.action === "allow" || result.action === "block");
49
+ if (result.action === "block") {
50
+ // Should be shell restriction, not file-write
51
+ assert.ok(!result.reason?.includes("/etc/passwd"));
52
+ }
53
+ });
54
+ });
55
+ describe("HookPipeline — shell command restriction", () => {
56
+ it("blocks rm -rf", async () => {
57
+ const pipeline = new HookPipeline({});
58
+ const result = await pipeline.runPreToolHooks({
59
+ toolName: "bash",
60
+ arguments: { command: "rm -rf /important" },
61
+ agentName: "test-agent",
62
+ sessionId: "test-2",
63
+ });
64
+ assert.equal(result.action, "block");
65
+ assert.ok(result.reason?.toLowerCase().includes("rm -rf"), `reason should mention rm -rf, got: ${result.reason}`);
66
+ });
67
+ it("blocks git push --force", async () => {
68
+ const pipeline = new HookPipeline({});
69
+ const result = await pipeline.runPreToolHooks({
70
+ toolName: "bash",
71
+ arguments: { command: "git push --force origin main" },
72
+ agentName: "test-agent",
73
+ sessionId: "test-2",
74
+ });
75
+ assert.equal(result.action, "block");
76
+ assert.ok(result.reason?.toLowerCase().includes("git push --force"), `reason should mention force push, got: ${result.reason}`);
77
+ });
78
+ it("allows safe shell commands", async () => {
79
+ const pipeline = new HookPipeline({});
80
+ const result = await pipeline.runPreToolHooks({
81
+ toolName: "bash",
82
+ arguments: { command: "npm test" },
83
+ agentName: "test-agent",
84
+ sessionId: "test-2",
85
+ });
86
+ assert.equal(result.action, "allow");
87
+ });
88
+ it("allows non-shell tools regardless of args", async () => {
89
+ const pipeline = new HookPipeline({});
90
+ const result = await pipeline.runPreToolHooks({
91
+ toolName: "read_file",
92
+ arguments: { command: "rm -rf /" },
93
+ agentName: "test-agent",
94
+ sessionId: "test-2",
95
+ });
96
+ // Shell restriction only fires on bash/powershell/shell/exec
97
+ assert.equal(result.action, "allow");
98
+ });
99
+ });
100
+ describe("HookPipeline — PII scrubber", () => {
101
+ it("redacts email addresses in tool output", async () => {
102
+ const pipeline = new HookPipeline({ scrubPii: true });
103
+ const result = await pipeline.runPostToolHooks({
104
+ toolName: "bash",
105
+ arguments: {},
106
+ result: "User email is brian@example.com and also admin@test.org",
107
+ agentName: "test-agent",
108
+ sessionId: "test-3",
109
+ });
110
+ assert.ok(typeof result.result === "string" && !result.result.includes("brian@example.com"), `Email should be redacted, got: ${result.result}`);
111
+ assert.ok(typeof result.result === "string" && result.result.includes("[EMAIL_REDACTED]"), `Should contain redaction marker, got: ${result.result}`);
112
+ });
113
+ it("passes through clean output unchanged", async () => {
114
+ const pipeline = new HookPipeline({ scrubPii: true });
115
+ const original = "Build succeeded. 405 tests passed.";
116
+ const result = await pipeline.runPostToolHooks({
117
+ toolName: "bash",
118
+ arguments: {},
119
+ result: original,
120
+ agentName: "test-agent",
121
+ sessionId: "test-3",
122
+ });
123
+ assert.equal(result.result, original);
124
+ });
125
+ it("does not scrub when scrubPii is false", async () => {
126
+ const pipeline = new HookPipeline({ scrubPii: false });
127
+ const original = "Email: test@example.com";
128
+ const result = await pipeline.runPostToolHooks({
129
+ toolName: "bash",
130
+ arguments: {},
131
+ result: original,
132
+ agentName: "test-agent",
133
+ sessionId: "test-3",
134
+ });
135
+ assert.equal(result.result, original);
136
+ });
137
+ });
138
+ describe("HookPipeline — reviewer lockout", () => {
139
+ it("blocks locked-out agent from editing artifact", async () => {
140
+ const pipeline = new HookPipeline({ reviewerLockout: true });
141
+ pipeline.getReviewerLockout().lockout("src/auth.ts", "kaylee");
142
+ const result = await pipeline.runPreToolHooks({
143
+ toolName: "edit",
144
+ arguments: { path: "src/auth.ts" },
145
+ agentName: "kaylee",
146
+ sessionId: "test-4",
147
+ });
148
+ assert.equal(result.action, "block");
149
+ assert.ok(result.reason?.includes("kaylee"), `reason should name agent, got: ${result.reason}`);
150
+ });
151
+ it("allows non-locked-out agent to edit same artifact", async () => {
152
+ const pipeline = new HookPipeline({ reviewerLockout: true, allowedWritePaths: ["src/**"] });
153
+ pipeline.getReviewerLockout().lockout("src/auth.ts", "kaylee");
154
+ const result = await pipeline.runPreToolHooks({
155
+ toolName: "edit",
156
+ arguments: { path: "src/auth.ts" },
157
+ agentName: "zoe",
158
+ sessionId: "test-4",
159
+ });
160
+ assert.equal(result.action, "allow");
161
+ });
162
+ it("allows locked-out agent to edit different artifact", async () => {
163
+ const pipeline = new HookPipeline({ reviewerLockout: true, allowedWritePaths: ["src/**"] });
164
+ pipeline.getReviewerLockout().lockout("src/auth.ts", "kaylee");
165
+ const result = await pipeline.runPreToolHooks({
166
+ toolName: "edit",
167
+ arguments: { path: "src/copilot/hooks.ts" },
168
+ agentName: "kaylee",
169
+ sessionId: "test-4",
170
+ });
171
+ assert.equal(result.action, "allow");
172
+ });
173
+ });
174
+ // ---------------------------------------------------------------------------
175
+ // initHookPipeline / getHookPipeline — singleton lifecycle
176
+ // ---------------------------------------------------------------------------
177
+ describe("initHookPipeline", () => {
178
+ it("initializes the singleton pipeline", () => {
179
+ const p = initHookPipeline();
180
+ assert.ok(p instanceof HookPipeline);
181
+ assert.equal(getHookPipeline(), p);
182
+ });
183
+ it("reinitializes on second call (returns new instance)", () => {
184
+ const first = initHookPipeline();
185
+ const second = initHookPipeline();
186
+ assert.ok(second instanceof HookPipeline);
187
+ // Both are valid instances; the singleton points to the latest
188
+ assert.equal(getHookPipeline(), second);
189
+ });
190
+ it("respects policy overrides", async () => {
191
+ // Override: disable PII scrubbing
192
+ initHookPipeline({ scrubPii: false });
193
+ const p = getHookPipeline();
194
+ const result = await p.runPostToolHooks({
195
+ toolName: "bash",
196
+ arguments: {},
197
+ result: "email: test@example.com",
198
+ agentName: "test-agent",
199
+ sessionId: "test-5",
200
+ });
201
+ // With scrubPii: false, email is NOT redacted
202
+ assert.equal(result.result, "email: test@example.com");
203
+ });
204
+ });
205
+ // ---------------------------------------------------------------------------
206
+ // createSessionHooks — adapter tests (SDK interface bridging)
207
+ // ---------------------------------------------------------------------------
208
+ describe("createSessionHooks", () => {
209
+ before(() => {
210
+ // Fresh pipeline with defaults for adapter tests
211
+ initHookPipeline({
212
+ allowedWritePaths: DEFAULT_ALLOWED_PATHS,
213
+ scrubPii: true,
214
+ reviewerLockout: true,
215
+ });
216
+ });
217
+ const fakeInvocation = { sessionId: "adapter-test-session" };
218
+ it("returns deny for blocked write (SDK adapter output)", async () => {
219
+ const hooks = createSessionHooks("test-agent");
220
+ const result = await hooks.onPreToolUse({
221
+ toolName: "edit",
222
+ toolArgs: { path: "/etc/passwd" },
223
+ timestamp: Date.now(),
224
+ cwd: process.cwd(),
225
+ }, fakeInvocation);
226
+ assert.ok(result, "Should return a result object");
227
+ assert.equal(result.permissionDecision, "deny");
228
+ assert.ok(result.permissionDecisionReason?.includes("[BLOCKED]"), `reason should start with [BLOCKED], got: ${result.permissionDecisionReason}`);
229
+ });
230
+ it("returns undefined (allow) for safe write inside default paths", async () => {
231
+ const hooks = createSessionHooks("test-agent");
232
+ const result = await hooks.onPreToolUse({
233
+ toolName: "edit",
234
+ toolArgs: { path: "src/copilot/hooks.ts" },
235
+ timestamp: Date.now(),
236
+ cwd: process.cwd(),
237
+ }, fakeInvocation);
238
+ // Allowed — should return void/undefined
239
+ assert.ok(result == null || result.permissionDecision !== "deny");
240
+ });
241
+ it("returns deny for blocked shell command", async () => {
242
+ const hooks = createSessionHooks("test-agent");
243
+ const result = await hooks.onPreToolUse({
244
+ toolName: "bash",
245
+ toolArgs: { command: "rm -rf /" },
246
+ timestamp: Date.now(),
247
+ cwd: process.cwd(),
248
+ }, fakeInvocation);
249
+ assert.ok(result, "Should return a result object");
250
+ assert.equal(result.permissionDecision, "deny");
251
+ });
252
+ it("strips CWD prefix from absolute paths before hook check", async () => {
253
+ const hooks = createSessionHooks("test-agent");
254
+ const absPath = `${process.cwd()}/src/copilot/hooks.ts`;
255
+ const result = await hooks.onPreToolUse({
256
+ toolName: "edit",
257
+ toolArgs: { path: absPath },
258
+ timestamp: Date.now(),
259
+ cwd: process.cwd(),
260
+ }, fakeInvocation);
261
+ // After stripping CWD, "src/copilot/hooks.ts" matches "src/**" → allow
262
+ assert.ok(result == null || result.permissionDecision !== "deny", "Absolute path within project should be allowed");
263
+ });
264
+ it("scrubs PII from tool output via onPostToolUse adapter", async () => {
265
+ const hooks = createSessionHooks("test-agent");
266
+ const fakeToolResult = {
267
+ textResultForLlm: "Contact admin@example.com for support",
268
+ resultType: "success",
269
+ };
270
+ const result = await hooks.onPostToolUse({
271
+ toolName: "bash",
272
+ toolArgs: {},
273
+ toolResult: fakeToolResult,
274
+ timestamp: Date.now(),
275
+ cwd: process.cwd(),
276
+ }, fakeInvocation);
277
+ assert.ok(result, "Should return a modified result");
278
+ assert.ok(result.modifiedResult?.textResultForLlm.includes("[EMAIL_REDACTED]"), `Should redact email, got: ${result.modifiedResult?.textResultForLlm}`);
279
+ });
280
+ it("returns undefined from onPostToolUse when nothing to scrub", async () => {
281
+ const hooks = createSessionHooks("test-agent");
282
+ const fakeToolResult = {
283
+ textResultForLlm: "All 405 tests passed.",
284
+ resultType: "success",
285
+ };
286
+ const result = await hooks.onPostToolUse({
287
+ toolName: "bash",
288
+ toolArgs: {},
289
+ toolResult: fakeToolResult,
290
+ timestamp: Date.now(),
291
+ cwd: process.cwd(),
292
+ }, fakeInvocation);
293
+ // No PII → no modification → return undefined
294
+ assert.ok(result == null, "Should return undefined when no scrubbing needed");
295
+ });
296
+ it("uses per-agent allowedPaths when provided", async () => {
297
+ // Agent restricted to .squad/** only
298
+ const hooks = createSessionHooks("restricted-agent", [".squad/**"]);
299
+ const result = await hooks.onPreToolUse({
300
+ toolName: "edit",
301
+ toolArgs: { path: "src/copilot/hooks.ts" },
302
+ timestamp: Date.now(),
303
+ cwd: process.cwd(),
304
+ }, fakeInvocation);
305
+ // src/** is in DEFAULT_ALLOWED_PATHS (global) but NOT in restricted agent's paths
306
+ // The per-agent check runs first and should block it
307
+ assert.ok(result, "Should return a result object");
308
+ assert.equal(result.permissionDecision, "deny");
309
+ });
310
+ after(() => {
311
+ // Restore default pipeline for any tests that follow
312
+ initHookPipeline();
313
+ });
314
+ });
315
+ //# sourceMappingURL=hooks.test.js.map
@@ -1,6 +1,7 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { approveAll } from "@github/copilot-sdk";
4
+ import { initHookPipeline, createSessionHooks } from "./hooks.js";
4
5
  import { createTools } from "./tools.js";
5
6
  import { getOrchestratorSystemMessage } from "./system-message.js";
6
7
  import { CHAPTERHOUSE_VERSION } from "../version.js";
@@ -17,9 +18,9 @@ import { loadAgents, ensureDefaultAgents, clearActiveTasks, getAgentRegistry, se
17
18
  import { normalizeProjectPath, setChannelProject } from "../squad/context.js";
18
19
  import { getSquadCoordinatorSystemMessage } from "../squad/charter.js";
19
20
  import { childLogger } from "../util/logger.js";
21
+ import { squadEventBus } from "./squad-event-bus.js";
20
22
  import { SessionManager, SessionRegistry, SESSION_IDLE_TTL_MS, SESSION_MAX_ACTIVE, } from "./session-manager.js";
21
23
  const log = childLogger("orchestrator");
22
- const orchestratorPermissionHandler = approveAll;
23
24
  const MAX_RETRIES = 3;
24
25
  const RECONNECT_DELAYS_MS = [1_000, 3_000, 10_000];
25
26
  const HEALTH_CHECK_INTERVAL_MS = 30_000;
@@ -52,31 +53,36 @@ let lastRouteResult;
52
53
  export function getLastRouteResult() {
53
54
  return lastRouteResult;
54
55
  }
55
- const taskEventListeners = new Map();
56
56
  export function subscribeTaskEvents(taskId, listener) {
57
- if (!taskEventListeners.has(taskId)) {
58
- taskEventListeners.set(taskId, new Set());
59
- }
60
- taskEventListeners.get(taskId).add(listener);
61
- return () => {
62
- const set = taskEventListeners.get(taskId);
63
- if (set) {
64
- set.delete(listener);
65
- if (set.size === 0)
66
- taskEventListeners.delete(taskId);
67
- }
68
- };
57
+ return squadEventBus.subscribe("session:tool_call", (event) => {
58
+ if (event.sessionId !== taskId)
59
+ return;
60
+ const p = event.payload;
61
+ listener({
62
+ seq: p._seq ?? 0,
63
+ ts: p._ts ?? Date.now(),
64
+ kind: p._kind === "tool_complete" ? "tool_complete" : "tool_start",
65
+ toolName: p.toolName ?? null,
66
+ summary: p._summary ?? null,
67
+ });
68
+ });
69
69
  }
70
70
  function emitTaskEvent(taskId, event) {
71
- const set = taskEventListeners.get(taskId);
72
- if (set) {
73
- for (const listener of set) {
74
- try {
75
- listener(event);
76
- }
77
- catch { /* non-fatal */ }
78
- }
79
- }
71
+ void squadEventBus.emit({
72
+ type: "session:tool_call",
73
+ sessionId: taskId,
74
+ payload: {
75
+ toolName: event.toolName ?? "",
76
+ toolArgs: {},
77
+ resultType: event.kind === "tool_complete" ? "success" : undefined,
78
+ // Internal fields threaded through payload so subscribeTaskEvents can reconstruct
79
+ _kind: event.kind,
80
+ _seq: event.seq,
81
+ _ts: event.ts,
82
+ _summary: event.summary,
83
+ },
84
+ timestamp: new Date(event.ts),
85
+ });
80
86
  }
81
87
  // ---------------------------------------------------------------------------
82
88
  // SessionRegistry — the single owner of all per-session orchestrators
@@ -250,7 +256,8 @@ async function createOrResumeSession(sessionKey, projectRoot) {
250
256
  tools,
251
257
  mcpServers,
252
258
  skillDirectories,
253
- onPermissionRequest: orchestratorPermissionHandler,
259
+ onPermissionRequest: approveAll,
260
+ hooks: createSessionHooks("orchestrator"),
254
261
  infiniteSessions,
255
262
  });
256
263
  log.info({ sessionKey }, "Session resumed successfully");
@@ -275,7 +282,8 @@ async function createOrResumeSession(sessionKey, projectRoot) {
275
282
  tools,
276
283
  mcpServers,
277
284
  skillDirectories,
278
- onPermissionRequest: orchestratorPermissionHandler,
285
+ onPermissionRequest: approveAll,
286
+ hooks: createSessionHooks("orchestrator"),
279
287
  infiniteSessions,
280
288
  });
281
289
  log.info({ sessionKey, sessionId: session.sessionId.slice(0, 8) }, "Session created");
@@ -289,6 +297,8 @@ async function createOrResumeSession(sessionKey, projectRoot) {
289
297
  }
290
298
  export async function initOrchestrator(client) {
291
299
  copilotClient = client;
300
+ // Initialize governance hook pipeline before any session is created.
301
+ initHookPipeline();
292
302
  // (Re-)create the registry — supports multiple initOrchestrator calls in tests
293
303
  if (registry) {
294
304
  await registry.shutdown();
@@ -477,6 +487,13 @@ async function executeOnSession(manager, item) {
477
487
  : data.agentDescription || data.agentDisplayName || `Squad dispatch: ${agentSlug}`).slice(0, 500);
478
488
  db.prepare(`INSERT OR IGNORE INTO agent_tasks (task_id, agent_slug, description, status, origin_channel, session_key, source) VALUES (?, ?, ?, 'running', ?, ?, 'squad')`).run(data.toolCallId, agentSlug, description, item.sourceChannel || null, sessionKey);
479
489
  activeSubagentTaskIds.add(data.toolCallId);
490
+ void squadEventBus.emit({
491
+ type: "session:created",
492
+ sessionId: data.toolCallId,
493
+ agentName: agentSlug,
494
+ payload: { agentName: agentSlug, priority: "normal" },
495
+ timestamp: new Date(),
496
+ });
480
497
  }
481
498
  catch { /* non-fatal */ }
482
499
  });
@@ -485,6 +502,15 @@ async function executeOnSession(manager, item) {
485
502
  spawnArgsMap.delete(event.data.toolCallId);
486
503
  activeSubagentTaskIds.delete(event.data.toolCallId);
487
504
  db.prepare(`UPDATE agent_tasks SET status = 'completed', completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(event.data.toolCallId);
505
+ const taskId = event.data.toolCallId;
506
+ const taskRow = db.prepare(`SELECT agent_slug FROM agent_tasks WHERE task_id = ?`).get(taskId);
507
+ void squadEventBus.emit({
508
+ type: "session:destroyed",
509
+ sessionId: taskId,
510
+ agentName: taskRow?.agent_slug,
511
+ payload: { agentName: taskRow?.agent_slug ?? "", reason: "complete" },
512
+ timestamp: new Date(),
513
+ });
488
514
  }
489
515
  catch { /* non-fatal */ }
490
516
  });
@@ -494,6 +520,14 @@ async function executeOnSession(manager, item) {
494
520
  spawnArgsMap.delete(data.toolCallId);
495
521
  activeSubagentTaskIds.delete(data.toolCallId);
496
522
  db.prepare(`UPDATE agent_tasks SET status = 'error', result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(data.error || "Subagent failed", data.toolCallId);
523
+ const taskRow = db.prepare(`SELECT agent_slug FROM agent_tasks WHERE task_id = ?`).get(data.toolCallId);
524
+ void squadEventBus.emit({
525
+ type: "session:error",
526
+ sessionId: data.toolCallId,
527
+ agentName: taskRow?.agent_slug,
528
+ payload: { agentName: taskRow?.agent_slug ?? "", error: data.error ?? "Subagent failed" },
529
+ timestamp: new Date(),
530
+ });
497
531
  }
498
532
  catch { /* non-fatal */ }
499
533
  });
@@ -636,7 +670,7 @@ function isRecoverableError(err) {
636
670
  return false;
637
671
  return /disconnect|connection|EPIPE|ECONNRESET|ECONNREFUSED|socket|closed|ENOENT|spawn|not found|expired|stale/i.test(msg);
638
672
  }
639
- export async function sendToOrchestrator(prompt, source, callback, attachments, onActivity, onQueued) {
673
+ export async function sendToOrchestrator(prompt, source, callback, attachments, onActivity, onQueued, onAdvance) {
640
674
  updateUserContext(source);
641
675
  updateRequestContext(source);
642
676
  // Generate a unique ID for this orchestrator turn. Every SSE event emitted
@@ -686,6 +720,7 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
686
720
  turnId,
687
721
  // Background messages skip queue visibility — only web/user messages need it.
688
722
  onQueued: source.type === "web" ? onQueued : undefined,
723
+ onAdvance: source.type === "web" ? onAdvance : undefined,
689
724
  sourceChannel,
690
725
  targetAgent,
691
726
  channelKey,
@@ -146,6 +146,9 @@ export class SessionManager {
146
146
  this._processing = true;
147
147
  while (this._queue.length > 0) {
148
148
  const item = this._queue.shift();
149
+ // Notify before the worker starts — closing the window where backend queue
150
+ // length has dropped but the frontend still shows stale "N ahead" counts.
151
+ item.onAdvance?.(this._queue.length);
149
152
  const start = Date.now();
150
153
  log.info({ sessionKey: this.sessionKey, sourceChannel: item.sourceChannel }, "session.turn.started");
151
154
  try {
@@ -426,4 +426,74 @@ test("SessionManager: onQueued does NOT fire when queue is empty (no active turn
426
426
  // Let the worker complete to avoid unresolved promises.
427
427
  await promise.catch(() => { });
428
428
  });
429
+ // ---------------------------------------------------------------------------
430
+ // onAdvance tests (#97 — queue-advance event timing)
431
+ // ---------------------------------------------------------------------------
432
+ test("SessionManager: onAdvance fires after each dequeue with remaining queue length", async () => {
433
+ const { session } = makeFakeSession();
434
+ const resolvers = [];
435
+ const worker = () => new Promise((r) => { resolvers.push(() => r("done")); });
436
+ const manager = new SessionManager("default", worker, factory(session));
437
+ // item1: no onAdvance — in real usage the first in-flight message is not a
438
+ // queued-behind message. onAdvance is set only on items that arrive while
439
+ // another turn is already in-flight.
440
+ let resolve1;
441
+ let reject1;
442
+ const p1 = new Promise((res, rej) => { resolve1 = res; reject1 = rej; });
443
+ const item1 = {
444
+ prompt: "first", callback: () => { }, sessionKey: "default",
445
+ turnId: "adv-turn-1", resolve: resolve1, reject: reject1,
446
+ };
447
+ manager.enqueue(item1);
448
+ // Wait one tick so drain() suspends at `await worker(item1)`.
449
+ await new Promise((r) => setTimeout(r, 0));
450
+ assert.equal(manager.isProcessing, true, "item1 should be processing");
451
+ // Enqueue items 2 and 3 WHILE item1 is in-flight.
452
+ const advanceLengths = [];
453
+ let resolve2;
454
+ let reject2;
455
+ const p2 = new Promise((res, rej) => { resolve2 = res; reject2 = rej; });
456
+ const item2 = {
457
+ prompt: "second", callback: () => { }, sessionKey: "default",
458
+ turnId: "adv-turn-2", resolve: resolve2, reject: reject2,
459
+ onAdvance: (len) => { advanceLengths.push(len); },
460
+ };
461
+ let resolve3;
462
+ let reject3;
463
+ const p3 = new Promise((res, rej) => { resolve3 = res; reject3 = rej; });
464
+ const item3 = {
465
+ prompt: "third", callback: () => { }, sessionKey: "default",
466
+ turnId: "adv-turn-3", resolve: resolve3, reject: reject3,
467
+ onAdvance: (len) => { advanceLengths.push(len); },
468
+ };
469
+ manager.enqueue(item2); // queue: [item2]
470
+ manager.enqueue(item3); // queue: [item2, item3]
471
+ // Unblock item1 → drain shifts item2 → item2.onAdvance(1) since item3 remains.
472
+ resolvers[0]();
473
+ await new Promise((r) => setTimeout(r, 0));
474
+ assert.equal(advanceLengths[0], 1, "item2 dequeue: 1 item remains (item3)");
475
+ // Unblock item2 → drain shifts item3 → item3.onAdvance(0) — queue empty.
476
+ resolvers[1]();
477
+ await new Promise((r) => setTimeout(r, 0));
478
+ assert.equal(advanceLengths[1], 0, "item3 dequeue: 0 items remain");
479
+ assert.equal(advanceLengths.length, 2, "onAdvance fires exactly once per dequeue");
480
+ // Clean up.
481
+ resolvers[2]();
482
+ await Promise.all([p1, p2, p3]);
483
+ });
484
+ test("SessionManager: onAdvance fires before worker starts (timing guarantee)", async () => {
485
+ const { session } = makeFakeSession();
486
+ const events = [];
487
+ const worker = () => {
488
+ events.push("worker-started");
489
+ return Promise.resolve("done");
490
+ };
491
+ const manager = new SessionManager("default", worker, factory(session));
492
+ const { item, promise } = makeDeferred();
493
+ item.onAdvance = () => { events.push("advance-fired"); };
494
+ manager.enqueue(item);
495
+ await promise.catch(() => { });
496
+ // advance must precede worker-started
497
+ assert.deepEqual(events, ["advance-fired", "worker-started"], "onAdvance must fire before the worker begins executing");
498
+ });
429
499
  //# sourceMappingURL=session-manager.test.js.map
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Process-scoped singleton EventBus for squad agent lifecycle events.
3
+ *
4
+ * The SDK `EventBus` is a plain class — no hidden global state. We own
5
+ * the singleton here so the entire daemon process shares one bus. All
6
+ * code that emits or subscribes to squad events imports from this module.
7
+ *
8
+ * Cross-project reach: NOT supported with a single bus instance. Requires
9
+ * the SDK's WS bridge (`startWSBridge`) or a shared IPC channel. Out of
10
+ * scope for #101 — see mal-101-architecture.md §Forward Implications.
11
+ *
12
+ * Exported event types mirror `SquadEventPayloadMap` from the SDK:
13
+ * session:created — sub-agent spawned
14
+ * session:idle — sub-agent waiting / between turns
15
+ * session:error — sub-agent failed
16
+ * session:destroyed — sub-agent finished (reason: complete | error | abort)
17
+ * session:tool_call — tool invoked (resultType undefined=start, defined=end)
18
+ * agent:milestone — model fallback / exhaustion
19
+ * coordinator:routing — coordinator routing decision phases
20
+ * pool:health — session pool stats
21
+ *
22
+ * @module copilot/squad-event-bus
23
+ */
24
+ import { EventBus } from "@bradygaster/squad-sdk/runtime/event-bus";
25
+ /** Process-level singleton. Import this everywhere instead of newing EventBus. */
26
+ export const squadEventBus = new EventBus();
27
+ //# sourceMappingURL=squad-event-bus.js.map
@@ -6,6 +6,7 @@ import { join } from "path";
6
6
  import { homedir } from "os";
7
7
  import { listSkills, createSkill, removeSkill } from "./skills.js";
8
8
  import { config, persistModel } from "../config.js";
9
+ import { createSessionHooks } from "./hooks.js";
9
10
  import { getCurrentSourceChannel, getCurrentActivityCallback, getCurrentAuthenticatedUser, getLastAuthenticatedUser, getCurrentAuthorizationHeader, getCurrentChannelKey, getCurrentSessionKey, switchSessionModel, } from "./orchestrator.js";
10
11
  import { getRouterConfig, updateRouterConfig } from "./router.js";
11
12
  import { ensureWikiStructure, readPage, writePage, deletePage, listPages, writeRawSource, listSources, assertPagePath } from "../wiki/fs.js";
@@ -583,6 +584,7 @@ export function createTools(deps) {
583
584
  const session = await deps.client.resumeSession(args.session_id, {
584
585
  model: config.copilotModel,
585
586
  onPermissionRequest: approveAll,
587
+ hooks: createSessionHooks(args.name),
586
588
  });
587
589
  const db = getDb();
588
590
  db.prepare(`INSERT OR REPLACE INTO agent_sessions (slug, copilot_session_id, model, status)
package/dist/daemon.js CHANGED
@@ -2,6 +2,7 @@ import { getClient, stopClient } from "./copilot/client.js";
2
2
  import { initOrchestrator, setMessageLogger, setProactiveNotify, getAgentInfo, shutdownAgents } from "./copilot/orchestrator.js";
3
3
  import { stopEpisodeWriter } from "./copilot/episode-writer.js";
4
4
  import { startApiServer, broadcastToSSE } from "./api/server.js";
5
+ import { killRalphOnShutdown } from "./api/ralph.js";
5
6
  import { getDb, closeDb, getState } from "./store/db.js";
6
7
  import { config } from "./config.js";
7
8
  import { spawn } from "child_process";
@@ -213,6 +214,10 @@ async function shutdown() {
213
214
  await stopClient();
214
215
  }
215
216
  catch { /* best effort */ }
217
+ try {
218
+ killRalphOnShutdown();
219
+ }
220
+ catch { /* best effort */ }
216
221
  closeDb();
217
222
  log.info("Goodbye");
218
223
  process.exit(0);
@@ -238,6 +243,10 @@ export async function restartDaemon() {
238
243
  await stopClient();
239
244
  }
240
245
  catch { /* best effort */ }
246
+ try {
247
+ killRalphOnShutdown();
248
+ }
249
+ catch { /* best effort */ }
241
250
  closeDb();
242
251
  // Spawn a detached replacement process with the same args (include execArgv for tsx/loaders)
243
252
  const child = spawn(process.execPath, [...process.execArgv, ...process.argv.slice(1)], {