chapterhouse 0.3.7 → 0.3.9

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,10 @@ 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";
22
+ import { initTaskEventLog } from "./task-event-log.js";
20
23
  import { SessionManager, SessionRegistry, SESSION_IDLE_TTL_MS, SESSION_MAX_ACTIVE, } from "./session-manager.js";
21
24
  const log = childLogger("orchestrator");
22
- const orchestratorPermissionHandler = approveAll;
23
25
  const MAX_RETRIES = 3;
24
26
  const RECONNECT_DELAYS_MS = [1_000, 3_000, 10_000];
25
27
  const HEALTH_CHECK_INTERVAL_MS = 30_000;
@@ -52,31 +54,36 @@ let lastRouteResult;
52
54
  export function getLastRouteResult() {
53
55
  return lastRouteResult;
54
56
  }
55
- const taskEventListeners = new Map();
56
57
  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
- };
58
+ return squadEventBus.subscribe("session:tool_call", (event) => {
59
+ if (event.sessionId !== taskId)
60
+ return;
61
+ const p = event.payload;
62
+ listener({
63
+ seq: p._seq ?? 0,
64
+ ts: p._ts ?? Date.now(),
65
+ kind: p._kind === "tool_complete" ? "tool_complete" : "tool_start",
66
+ toolName: p.toolName ?? null,
67
+ summary: p._summary ?? null,
68
+ });
69
+ });
69
70
  }
70
71
  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
- }
72
+ void squadEventBus.emit({
73
+ type: "session:tool_call",
74
+ sessionId: taskId,
75
+ payload: {
76
+ toolName: event.toolName ?? "",
77
+ toolArgs: {},
78
+ resultType: event.kind === "tool_complete" ? "success" : undefined,
79
+ // Internal fields threaded through payload so subscribeTaskEvents can reconstruct
80
+ _kind: event.kind,
81
+ _seq: event.seq,
82
+ _ts: event.ts,
83
+ _summary: event.summary,
84
+ },
85
+ timestamp: new Date(event.ts),
86
+ });
80
87
  }
81
88
  // ---------------------------------------------------------------------------
82
89
  // SessionRegistry — the single owner of all per-session orchestrators
@@ -250,7 +257,8 @@ async function createOrResumeSession(sessionKey, projectRoot) {
250
257
  tools,
251
258
  mcpServers,
252
259
  skillDirectories,
253
- onPermissionRequest: orchestratorPermissionHandler,
260
+ onPermissionRequest: approveAll,
261
+ hooks: createSessionHooks("orchestrator"),
254
262
  infiniteSessions,
255
263
  });
256
264
  log.info({ sessionKey }, "Session resumed successfully");
@@ -275,7 +283,8 @@ async function createOrResumeSession(sessionKey, projectRoot) {
275
283
  tools,
276
284
  mcpServers,
277
285
  skillDirectories,
278
- onPermissionRequest: orchestratorPermissionHandler,
286
+ onPermissionRequest: approveAll,
287
+ hooks: createSessionHooks("orchestrator"),
279
288
  infiniteSessions,
280
289
  });
281
290
  log.info({ sessionKey, sessionId: session.sessionId.slice(0, 8) }, "Session created");
@@ -289,6 +298,10 @@ async function createOrResumeSession(sessionKey, projectRoot) {
289
298
  }
290
299
  export async function initOrchestrator(client) {
291
300
  copilotClient = client;
301
+ // Initialize governance hook pipeline before any session is created.
302
+ initHookPipeline();
303
+ // Initialize per-task ring buffer — subscribes to squadEventBus for session:tool_call events.
304
+ initTaskEventLog();
292
305
  // (Re-)create the registry — supports multiple initOrchestrator calls in tests
293
306
  if (registry) {
294
307
  await registry.shutdown();
@@ -477,6 +490,13 @@ async function executeOnSession(manager, item) {
477
490
  : data.agentDescription || data.agentDisplayName || `Squad dispatch: ${agentSlug}`).slice(0, 500);
478
491
  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
492
  activeSubagentTaskIds.add(data.toolCallId);
493
+ void squadEventBus.emit({
494
+ type: "session:created",
495
+ sessionId: data.toolCallId,
496
+ agentName: agentSlug,
497
+ payload: { agentName: agentSlug, priority: "normal" },
498
+ timestamp: new Date(),
499
+ });
480
500
  }
481
501
  catch { /* non-fatal */ }
482
502
  });
@@ -485,6 +505,15 @@ async function executeOnSession(manager, item) {
485
505
  spawnArgsMap.delete(event.data.toolCallId);
486
506
  activeSubagentTaskIds.delete(event.data.toolCallId);
487
507
  db.prepare(`UPDATE agent_tasks SET status = 'completed', completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(event.data.toolCallId);
508
+ const taskId = event.data.toolCallId;
509
+ const taskRow = db.prepare(`SELECT agent_slug FROM agent_tasks WHERE task_id = ?`).get(taskId);
510
+ void squadEventBus.emit({
511
+ type: "session:destroyed",
512
+ sessionId: taskId,
513
+ agentName: taskRow?.agent_slug,
514
+ payload: { agentName: taskRow?.agent_slug ?? "", reason: "complete" },
515
+ timestamp: new Date(),
516
+ });
488
517
  }
489
518
  catch { /* non-fatal */ }
490
519
  });
@@ -494,6 +523,14 @@ async function executeOnSession(manager, item) {
494
523
  spawnArgsMap.delete(data.toolCallId);
495
524
  activeSubagentTaskIds.delete(data.toolCallId);
496
525
  db.prepare(`UPDATE agent_tasks SET status = 'error', result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(data.error || "Subagent failed", data.toolCallId);
526
+ const taskRow = db.prepare(`SELECT agent_slug FROM agent_tasks WHERE task_id = ?`).get(data.toolCallId);
527
+ void squadEventBus.emit({
528
+ type: "session:error",
529
+ sessionId: data.toolCallId,
530
+ agentName: taskRow?.agent_slug,
531
+ payload: { agentName: taskRow?.agent_slug ?? "", error: data.error ?? "Subagent failed" },
532
+ timestamp: new Date(),
533
+ });
497
534
  }
498
535
  catch { /* non-fatal */ }
499
536
  });
@@ -636,7 +673,7 @@ function isRecoverableError(err) {
636
673
  return false;
637
674
  return /disconnect|connection|EPIPE|ECONNRESET|ECONNREFUSED|socket|closed|ENOENT|spawn|not found|expired|stale/i.test(msg);
638
675
  }
639
- export async function sendToOrchestrator(prompt, source, callback, attachments, onActivity, onQueued) {
676
+ export async function sendToOrchestrator(prompt, source, callback, attachments, onActivity, onQueued, onAdvance) {
640
677
  updateUserContext(source);
641
678
  updateRequestContext(source);
642
679
  // Generate a unique ID for this orchestrator turn. Every SSE event emitted
@@ -686,6 +723,7 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
686
723
  turnId,
687
724
  // Background messages skip queue visibility — only web/user messages need it.
688
725
  onQueued: source.type === "web" ? onQueued : undefined,
726
+ onAdvance: source.type === "web" ? onAdvance : undefined,
689
727
  sourceChannel,
690
728
  targetAgent,
691
729
  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