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.
- package/dist/api/ralph.js +153 -0
- package/dist/api/ralph.test.js +101 -0
- package/dist/api/server.js +89 -7
- package/dist/copilot/agents.js +6 -1
- package/dist/copilot/hooks.js +157 -0
- package/dist/copilot/hooks.test.js +315 -0
- package/dist/copilot/orchestrator.js +64 -26
- package/dist/copilot/session-manager.js +3 -0
- package/dist/copilot/session-manager.test.js +70 -0
- package/dist/copilot/squad-event-bus.js +27 -0
- package/dist/copilot/task-event-log.js +162 -0
- package/dist/copilot/task-event-log.test.js +275 -0
- package/dist/copilot/tools.js +2 -0
- package/dist/daemon.js +9 -0
- package/dist/squad/charter.js +18 -1
- package/package.json +1 -1
- package/web/dist/assets/index-BtAcw3EP.css +10 -0
- package/web/dist/assets/{index-Dp72-ITT.js → index-vL9s_H8H.js} +83 -78
- package/web/dist/assets/index-vL9s_H8H.js.map +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-C6ZKr0jC.css +0 -10
- package/web/dist/assets/index-Dp72-ITT.js.map +0 -1
|
@@ -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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
|
|
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:
|
|
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:
|
|
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
|