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.
- package/dist/api/ralph.js +153 -0
- package/dist/api/ralph.test.js +101 -0
- package/dist/api/server.js +76 -1
- 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 +61 -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/tools.js +2 -0
- package/dist/daemon.js +9 -0
- package/dist/squad/charter.js +18 -1
- package/dist/squad/discovery.js +31 -43
- package/package.json +1 -1
- package/web/dist/assets/{index-Dp72-ITT.js → index-0dDxvEWK.js} +82 -79
- package/web/dist/assets/index-0dDxvEWK.js.map +1 -0
- package/web/dist/assets/index-26ooi9MH.css +10 -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,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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
|
|
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:
|
|
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:
|
|
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
|
package/dist/copilot/tools.js
CHANGED
|
@@ -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)], {
|