chapterhouse 0.3.12 → 0.3.14
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/README.md +2 -69
- package/dist/api/server.js +15 -157
- package/dist/api/server.test.js +1 -1
- package/dist/api/turn-sse.integration.test.js +36 -0
- package/dist/cli.js +0 -30
- package/dist/config.js +0 -3
- package/dist/copilot/agent-event-bus.js +41 -0
- package/dist/copilot/agent-event-bus.test.js +23 -0
- package/dist/copilot/agents.js +4 -59
- package/dist/copilot/orchestrator.js +60 -65
- package/dist/copilot/orchestrator.test.js +73 -158
- package/dist/copilot/task-event-log.js +5 -5
- package/dist/copilot/task-event-log.test.js +68 -142
- package/dist/copilot/tools.js +9 -85
- package/dist/daemon.js +0 -22
- package/dist/store/db.js +2 -50
- package/dist/store/db.test.js +0 -45
- package/package.json +1 -3
- package/web/dist/assets/index-BlIWCM11.js +217 -0
- package/web/dist/assets/index-BlIWCM11.js.map +1 -0
- package/web/dist/assets/{index-BtAcw3EP.css → index-lvHFM_ut.css} +1 -1
- package/web/dist/index.html +2 -2
- package/dist/api/ralph.js +0 -153
- package/dist/api/ralph.test.js +0 -101
- package/dist/copilot/agents.squad.test.js +0 -72
- package/dist/copilot/hooks.js +0 -157
- package/dist/copilot/hooks.test.js +0 -315
- package/dist/copilot/squad-event-bus.js +0 -27
- package/dist/copilot/tools.squad.test.js +0 -168
- package/dist/squad/charter.js +0 -125
- package/dist/squad/charter.test.js +0 -89
- package/dist/squad/context.js +0 -48
- package/dist/squad/context.test.js +0 -59
- package/dist/squad/discovery.js +0 -268
- package/dist/squad/discovery.test.js +0 -154
- package/dist/squad/index.js +0 -9
- package/dist/squad/init-cli.js +0 -109
- package/dist/squad/init.js +0 -395
- package/dist/squad/init.test.js +0 -351
- package/dist/squad/mirror.js +0 -83
- package/dist/squad/mirror.scheduler.js +0 -80
- package/dist/squad/mirror.scheduler.test.js +0 -197
- package/dist/squad/mirror.test.js +0 -172
- package/dist/squad/registry.js +0 -162
- package/dist/squad/registry.test.js +0 -31
- package/dist/squad/squad-coordinator-system-message.test.js +0 -190
- package/dist/squad/squad-session-routing.test.js +0 -260
- package/dist/squad/types.js +0 -4
- package/dist/squad/worktree.js +0 -295
- package/dist/squad/worktree.test.js +0 -189
- package/dist/store/squad-sessions.test.js +0 -341
- package/web/dist/assets/index-BR2cks94.js +0 -219
- package/web/dist/assets/index-BR2cks94.js.map +0 -1
package/README.md
CHANGED
|
@@ -54,12 +54,6 @@ ADO_ORG=https://dev.azure.com/your-org
|
|
|
54
54
|
ADO_PROJECT=your-project
|
|
55
55
|
ADO_PAT=your-ado-pat-here
|
|
56
56
|
|
|
57
|
-
# Optional — Squad (multi-agent team) integration
|
|
58
|
-
ENABLE_SQUAD=1 # set to 1 to enable squad agent routing
|
|
59
|
-
|
|
60
|
-
# Optional — periodic decisions→wiki sync (requires ENABLE_SQUAD=1)
|
|
61
|
-
CHAPTERHOUSE_DECISIONS_SYNC_INTERVAL_MS=300000 # default 5 minutes (300 000 ms)
|
|
62
|
-
|
|
63
57
|
# Optional — logging
|
|
64
58
|
LOG_LEVEL=info # trace | debug | info | warn | error | fatal | silent (default: info)
|
|
65
59
|
# Set to "debug" to see chat message content and routing decisions.
|
|
@@ -254,69 +248,8 @@ The deployment assets for the shared instance set Entra auth and `CHAPTERHOUSE_M
|
|
|
254
248
|
| `chapterhouse update --check-only` | Print current/latest version without updating |
|
|
255
249
|
| `chapterhouse update --ref <ver>` | Install a specific version |
|
|
256
250
|
| `chapterhouse daemon <sub>` | Manage the persistent background service |
|
|
257
|
-
| `chapterhouse squad init` | Initialize a new Squad team for this project (guided dialog) |
|
|
258
|
-
| `chapterhouse squad worktree <sub>` | Manage per-agent git worktrees for concurrent squad work |
|
|
259
251
|
| `chapterhouse help` | Show available commands |
|
|
260
252
|
|
|
261
|
-
### Squad Init Command
|
|
262
|
-
|
|
263
|
-
Bootstrap a Squad team for a project that has no `.squad/` directory yet. The command runs an interactive dialog and writes the full `.squad/` scaffold:
|
|
264
|
-
|
|
265
|
-
```sh
|
|
266
|
-
chapterhouse squad init
|
|
267
|
-
# Runs the guided dialog: project name, stack, goal, team size, universe.
|
|
268
|
-
# Proposes a named roster, then writes .squad/ on confirmation.
|
|
269
|
-
|
|
270
|
-
chapterhouse squad init --force
|
|
271
|
-
# Re-scaffolds even if .squad/ already exists (overwrites existing files).
|
|
272
|
-
|
|
273
|
-
chapterhouse squad init --yes
|
|
274
|
-
# Skip the confirmation prompt (non-interactive / CI mode).
|
|
275
|
-
|
|
276
|
-
chapterhouse squad init --universe Firefly
|
|
277
|
-
# Use a specific naming universe instead of the default (Dune).
|
|
278
|
-
# Available: Dune (default), Firefly, Star Wars, The Matrix, Breaking Bad
|
|
279
|
-
```
|
|
280
|
-
|
|
281
|
-
**What gets written:**
|
|
282
|
-
|
|
283
|
-
| Path | Purpose |
|
|
284
|
-
|------|---------|
|
|
285
|
-
| `.squad/team.md` | Roster with `## Members` table (required by GitHub workflows) |
|
|
286
|
-
| `.squad/routing.md` | Task routing rules by role |
|
|
287
|
-
| `.squad/ceremonies.md` | Sprint rhythm and review gates |
|
|
288
|
-
| `.squad/decisions.md` | Append-only decision log |
|
|
289
|
-
| `.squad/agents/{name}/charter.md` | Per-agent charter with project context |
|
|
290
|
-
| `.squad/agents/{name}/history.md` | Per-agent day-1 seed history |
|
|
291
|
-
| `.squad/casting/policy.json` | Universe allowlist and capacity |
|
|
292
|
-
| `.squad/casting/registry.json` | Persistent name registry (cast names survive re-runs) |
|
|
293
|
-
| `.squad/casting/history.json` | Universe assignment audit trail |
|
|
294
|
-
| `.gitattributes` | Union merge rules for append-only Squad files |
|
|
295
|
-
|
|
296
|
-
**Naming universes:** By default, Squad uses the **Dune** universe (Leto, Jessica, Stilgar, Chani, Gurney, Duncan, …). This is configurable with `--universe` or via the dialog. Scribe and Ralph are always exempt from casting — they keep their names regardless of universe.
|
|
297
|
-
|
|
298
|
-
### Squad Worktree Commands
|
|
299
|
-
|
|
300
|
-
Squad agents that work on GitHub issues each get a dedicated git worktree so they never step on each other. The `chapterhouse squad worktree` subcommands manage these worktrees:
|
|
301
|
-
|
|
302
|
-
```sh
|
|
303
|
-
chapterhouse squad worktree create <agent> <issue> [--base main] [--slug <slug>]
|
|
304
|
-
# Creates .worktrees/{agent}-{issue}/ and branch squad/{issue}-{slug}
|
|
305
|
-
# Prints the worktree path to stdout. Reuses existing worktree if present.
|
|
306
|
-
|
|
307
|
-
chapterhouse squad worktree list
|
|
308
|
-
# Shows all active squad worktrees: agent, issue, branch, status, path
|
|
309
|
-
|
|
310
|
-
chapterhouse squad worktree remove <agent> <issue> [--force] [--delete-branch]
|
|
311
|
-
# Removes a worktree. Refuses if dirty unless --force is passed.
|
|
312
|
-
|
|
313
|
-
chapterhouse squad worktree prune [--base main] [--dry-run]
|
|
314
|
-
# Removes all worktrees whose branch has been merged into main.
|
|
315
|
-
# Skips dirty worktrees with a warning.
|
|
316
|
-
```
|
|
317
|
-
|
|
318
|
-
**How it works:** Worktrees live at `.worktrees/{agent}-{issue}/` inside the repo (gitignored). The Squad coordinator creates each worktree *before* spawning an agent and passes the path as `WORKTREE_PATH` in the spawn prompt. Agents do all their work—reads, edits, commits—inside that path. No agent ever runs `git checkout` in a working tree it doesn't own.
|
|
319
|
-
|
|
320
253
|
### Flags
|
|
321
254
|
|
|
322
255
|
| Flag | Description |
|
|
@@ -374,7 +307,7 @@ Chapterhouse enforces a **3-layer timing contract** so in-flight LLM streams can
|
|
|
374
307
|
|
|
375
308
|
#### Session lifecycle
|
|
376
309
|
|
|
377
|
-
Each conversation window (browser tab, terminal session
|
|
310
|
+
Each conversation window (browser tab, terminal session) maps to a separate `SessionManager` with its own queue and SDK session. Two env vars control how long sessions live:
|
|
378
311
|
|
|
379
312
|
| Config | What it controls | Default |
|
|
380
313
|
|--------|-----------------|---------|
|
|
@@ -546,7 +479,7 @@ git push origin main --follow-tags
|
|
|
546
479
|
|
|
547
480
|
`npm version` handles the commit and tag automatically. `prepublishOnly` runs `npm run build` before publish so the tarball always contains a fresh build. If you don't have CI set up, publish manually with `npm publish` after the tag push.
|
|
548
481
|
|
|
549
|
-
> **Pre-release gate:** `preversion` runs `npm run release:check` automatically before any `npm version` call. The script aborts with a clear error if the git working tree is dirty. Stash or commit all changes
|
|
482
|
+
> **Pre-release gate:** `preversion` runs `npm run release:check` automatically before any `npm version` call. The script aborts with a clear error if the git working tree is dirty. Stash or commit all changes before bumping the version.
|
|
550
483
|
|
|
551
484
|
### Commit message convention
|
|
552
485
|
|
package/dist/api/server.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import cors from "cors";
|
|
2
2
|
import express from "express";
|
|
3
3
|
import helmet from "helmet";
|
|
4
|
-
import { existsSync, statSync
|
|
4
|
+
import { existsSync, statSync } from "fs";
|
|
5
5
|
import { join, dirname } from "path";
|
|
6
6
|
import { fileURLToPath } from "url";
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { sendToOrchestrator, interruptCurrentTurn, enqueueForSse, getAgentInfo, cancelCurrentMessage, getLastRouteResult, getCurrentSessionKey } from "../copilot/orchestrator.js";
|
|
9
|
-
import {
|
|
9
|
+
import { agentEventBus } from "../copilot/agent-event-bus.js";
|
|
10
10
|
import { getAgentRegistry } from "../copilot/agents.js";
|
|
11
11
|
import { config, persistModel } from "../config.js";
|
|
12
12
|
import { getRouterConfig, updateRouterConfig } from "../copilot/router.js";
|
|
@@ -20,17 +20,14 @@ import { withWikiWrite } from "../wiki/lock.js";
|
|
|
20
20
|
import { listSkills, removeSkill } from "../copilot/skills.js";
|
|
21
21
|
import { restartDaemon } from "../daemon.js";
|
|
22
22
|
import { API_TOKEN_PATH, resolveWikiRelativePath } from "../paths.js";
|
|
23
|
-
import { getDb, getSessionMessages, getTaskEvents
|
|
23
|
+
import { getDb, getSessionMessages, getTaskEvents } from "../store/db.js";
|
|
24
24
|
import { getTaskLogEvents, subscribeTaskLog } from "../copilot/task-event-log.js";
|
|
25
25
|
import { subscribeSession, getSessionEventsFromDb, oldestSessionSeq, } from "../copilot/turn-event-log.js";
|
|
26
26
|
import { getStatus, onStatusChange } from "../status.js";
|
|
27
27
|
import { formatSseData, formatSseEvent } from "./sse.js";
|
|
28
|
-
import { syncDecisionsFileToWiki } from "../squad/mirror.js";
|
|
29
|
-
import { resolveProjectSquad } from "../squad/discovery.js";
|
|
30
28
|
import { assertAuthenticationConfigured, createHealthPayload, createPublicConfigPayload, buildHistoryEntries, getDisplayHost, resolveApiToken, shouldServeSpaPath, } from "./server-runtime.js";
|
|
31
29
|
import { BadRequestError, ForbiddenError, InternalServerError, NotFoundError, apiNotFoundHandler, asBadRequest, createApiErrorHandler, parseRequest, } from "./errors.js";
|
|
32
30
|
import { childLogger } from "../util/logger.js";
|
|
33
|
-
import { getRalphStatus, startRalph, stopRalph, getRalphQueue } from "./ralph.js";
|
|
34
31
|
const log = childLogger("server");
|
|
35
32
|
void searchIndex; // re-exported by index-manager; reference here documents the dep
|
|
36
33
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -249,7 +246,7 @@ app.get("/api/agents", (_req, res) => {
|
|
|
249
246
|
res.json(getAgentInfo());
|
|
250
247
|
});
|
|
251
248
|
// List all workers: reads from SQLite agent_tasks (last 24 hours) so completed
|
|
252
|
-
//
|
|
249
|
+
// dispatched subagents remain visible after they finish, not just in-flight ones.
|
|
253
250
|
app.get("/api/workers", (_req, res) => {
|
|
254
251
|
const rows = getDb()
|
|
255
252
|
.prepare(`SELECT task_id, agent_slug, description, status, started_at, completed_at
|
|
@@ -341,13 +338,13 @@ app.get("/api/workers/:taskId/events/stream", (req, res) => {
|
|
|
341
338
|
});
|
|
342
339
|
});
|
|
343
340
|
// ---------------------------------------------------------------------------
|
|
344
|
-
// Global
|
|
341
|
+
// Global agent EventBus SSE stream — thin pass-through of AgentEvents.
|
|
345
342
|
// Replaces the 4-second poll in the Workers frontend: clients subscribe once
|
|
346
343
|
// and receive push notifications on session:created / session:destroyed /
|
|
347
344
|
// session:error so the worker list updates in real time.
|
|
348
345
|
// Chat-specific events (delta, message, queued) are NOT emitted here.
|
|
349
346
|
// ---------------------------------------------------------------------------
|
|
350
|
-
app.get("/api/
|
|
347
|
+
app.get("/api/agents/stream", (req, res) => {
|
|
351
348
|
res.writeHead(200, {
|
|
352
349
|
"Content-Type": "text/event-stream",
|
|
353
350
|
"Cache-Control": "no-cache",
|
|
@@ -355,58 +352,14 @@ app.get("/api/squad/stream", (req, res) => {
|
|
|
355
352
|
});
|
|
356
353
|
res.write(formatSseData({ type: "connected" }));
|
|
357
354
|
const heartbeat = setInterval(() => { res.write(`:ping\n\n`); }, 20_000);
|
|
358
|
-
const unsub =
|
|
359
|
-
res.write(formatSseData({ type: "
|
|
355
|
+
const unsub = agentEventBus.subscribeAll((event) => {
|
|
356
|
+
res.write(formatSseData({ type: "agent_event", agentEvent: event }));
|
|
360
357
|
});
|
|
361
358
|
req.on("close", () => {
|
|
362
359
|
clearInterval(heartbeat);
|
|
363
360
|
unsub();
|
|
364
361
|
});
|
|
365
362
|
});
|
|
366
|
-
// ---------------------------------------------------------------------------
|
|
367
|
-
// Ralph watch panel endpoints — /api/squad/ralph/*
|
|
368
|
-
// ---------------------------------------------------------------------------
|
|
369
|
-
const ralphStartSchema = z.object({
|
|
370
|
-
projectRoot: z.string().min(1, "projectRoot is required"),
|
|
371
|
-
interval: z.number().int().min(1).max(120).default(10),
|
|
372
|
-
}).strict();
|
|
373
|
-
app.get("/api/squad/ralph/status", (_req, res) => {
|
|
374
|
-
res.json(getRalphStatus());
|
|
375
|
-
});
|
|
376
|
-
app.post("/api/squad/ralph/start", (req, res) => {
|
|
377
|
-
const { projectRoot, interval } = parseRequest(ralphStartSchema, req.body);
|
|
378
|
-
const result = startRalph(projectRoot, interval);
|
|
379
|
-
if (!result.started) {
|
|
380
|
-
res.status(409).json({ error: result.error });
|
|
381
|
-
return;
|
|
382
|
-
}
|
|
383
|
-
res.status(201).json({ pid: result.pid, projectRoot, interval });
|
|
384
|
-
});
|
|
385
|
-
app.post("/api/squad/ralph/stop", (_req, res) => {
|
|
386
|
-
const result = stopRalph();
|
|
387
|
-
if (!result.stopped) {
|
|
388
|
-
res.status(409).json({ error: result.error });
|
|
389
|
-
return;
|
|
390
|
-
}
|
|
391
|
-
res.json({ stopped: true });
|
|
392
|
-
});
|
|
393
|
-
app.get("/api/squad/ralph/queue", async (req, res) => {
|
|
394
|
-
const projectRoot = Array.isArray(req.query.projectRoot)
|
|
395
|
-
? req.query.projectRoot[0]
|
|
396
|
-
: req.query.projectRoot;
|
|
397
|
-
if (!projectRoot || typeof projectRoot !== "string") {
|
|
398
|
-
res.status(400).json({ error: "Missing required query param: projectRoot" });
|
|
399
|
-
return;
|
|
400
|
-
}
|
|
401
|
-
try {
|
|
402
|
-
const issues = await getRalphQueue(projectRoot);
|
|
403
|
-
res.json({ projectRoot, issues });
|
|
404
|
-
}
|
|
405
|
-
catch (err) {
|
|
406
|
-
log.warn({ err: err instanceof Error ? err.message : err }, "getRalphQueue failed");
|
|
407
|
-
res.status(502).json({ error: err instanceof Error ? err.message : "gh issue list failed" });
|
|
408
|
-
}
|
|
409
|
-
});
|
|
410
363
|
app.get("/stream", (req, res) => {
|
|
411
364
|
const connectionId = `web-${++connectionCounter}`;
|
|
412
365
|
res.writeHead(200, {
|
|
@@ -644,6 +597,7 @@ if (config.chatSseEnabled) {
|
|
|
644
597
|
};
|
|
645
598
|
// If Last-Event-ID is present and the session ring buffer doesn't cover it,
|
|
646
599
|
// fall back to SQLite for replay of completed turns.
|
|
600
|
+
let replayHighSeq = lastSeq;
|
|
647
601
|
if (lastSeq !== undefined) {
|
|
648
602
|
const oldestBuf = oldestSessionSeq(sessionKey);
|
|
649
603
|
const bufferMissesRange = oldestBuf === undefined || oldestBuf > lastSeq + 1;
|
|
@@ -652,13 +606,17 @@ if (config.chatSseEnabled) {
|
|
|
652
606
|
const dbEvents = getSessionEventsFromDb(sessionKey, lastSeq);
|
|
653
607
|
for (const e of dbEvents) {
|
|
654
608
|
sendEvent(e, e._seq);
|
|
609
|
+
if (replayHighSeq === undefined || e._seq > replayHighSeq)
|
|
610
|
+
replayHighSeq = e._seq;
|
|
655
611
|
}
|
|
656
612
|
}
|
|
657
613
|
}
|
|
658
|
-
// Subscribe to session events (replays ring buffer for afterSeq, then live)
|
|
614
|
+
// Subscribe to session events (replays ring buffer for afterSeq, then live).
|
|
615
|
+
// Use replayHighSeq (not lastSeq) so ring-buffer replay starts after any DB
|
|
616
|
+
// events we already sent — avoids double-replay overlap (Fix 5).
|
|
659
617
|
const unsub = subscribeSession(sessionKey, (e) => {
|
|
660
618
|
sendEvent(e, e._seq);
|
|
661
|
-
},
|
|
619
|
+
}, replayHighSeq);
|
|
662
620
|
// Send connected event
|
|
663
621
|
res.write(`: connected session=${sessionKey}\n\n`);
|
|
664
622
|
// Keep-alive every 15 s
|
|
@@ -842,106 +800,6 @@ app.post("/api/restart", (_req, res) => {
|
|
|
842
800
|
}, 500);
|
|
843
801
|
});
|
|
844
802
|
// ---------------------------------------------------------------------------
|
|
845
|
-
// Projects (Squad integration)
|
|
846
|
-
// ---------------------------------------------------------------------------
|
|
847
|
-
const projectRegisterSchema = z.object({
|
|
848
|
-
projectRoot: requiredString("projectRoot must be a non-empty string"),
|
|
849
|
-
}).strict();
|
|
850
|
-
/**
|
|
851
|
-
* Count squad agents on disk for a project.
|
|
852
|
-
* Authoritative source: each subdirectory of <projectRoot>/.squad/agents/ that
|
|
853
|
-
* contains a charter.md is one agent. Never relies on the SQLite cache so the
|
|
854
|
-
* badge is always accurate even before the cache is warm.
|
|
855
|
-
*/
|
|
856
|
-
function countAgentsOnDisk(projectRoot) {
|
|
857
|
-
const agentsDir = join(projectRoot, ".squad", "agents");
|
|
858
|
-
if (!existsSync(agentsDir))
|
|
859
|
-
return 0;
|
|
860
|
-
try {
|
|
861
|
-
return readdirSync(agentsDir).filter((entry) => {
|
|
862
|
-
try {
|
|
863
|
-
return statSync(join(agentsDir, entry)).isDirectory() &&
|
|
864
|
-
existsSync(join(agentsDir, entry, "charter.md"));
|
|
865
|
-
}
|
|
866
|
-
catch {
|
|
867
|
-
return false;
|
|
868
|
-
}
|
|
869
|
-
}).length;
|
|
870
|
-
}
|
|
871
|
-
catch {
|
|
872
|
-
return 0;
|
|
873
|
-
}
|
|
874
|
-
}
|
|
875
|
-
app.get("/api/projects", (_req, res) => {
|
|
876
|
-
if (!config.squadEnabled) {
|
|
877
|
-
res.status(503).json({ error: "Squad integration is disabled. Set ENABLE_SQUAD=1 to enable." });
|
|
878
|
-
return;
|
|
879
|
-
}
|
|
880
|
-
const db = getDb();
|
|
881
|
-
const rows = db.prepare(`
|
|
882
|
-
SELECT project_root, squad_dir, loaded_at, last_used_at
|
|
883
|
-
FROM project_squads
|
|
884
|
-
WHERE registered = 1
|
|
885
|
-
ORDER BY COALESCE(last_used_at, 0) DESC
|
|
886
|
-
`).all();
|
|
887
|
-
res.json(rows.map((r) => ({
|
|
888
|
-
projectRoot: r.project_root,
|
|
889
|
-
squadDir: r.squad_dir,
|
|
890
|
-
// Count from live filesystem — authoritative per Squad SDK rule: repo files win over cache.
|
|
891
|
-
agentCount: countAgentsOnDisk(r.project_root),
|
|
892
|
-
loadedAt: normalizeSqliteTsToIso(r.loaded_at),
|
|
893
|
-
lastUsedAt: r.last_used_at != null ? new Date(r.last_used_at).toISOString() : undefined,
|
|
894
|
-
})));
|
|
895
|
-
});
|
|
896
|
-
app.post("/api/projects", async (req, res) => {
|
|
897
|
-
if (!config.squadEnabled) {
|
|
898
|
-
res.status(503).json({ error: "Squad integration is disabled. Set ENABLE_SQUAD=1 to enable." });
|
|
899
|
-
return;
|
|
900
|
-
}
|
|
901
|
-
const { projectRoot } = parseRequest(projectRegisterSchema, req.body);
|
|
902
|
-
if (!existsSync(projectRoot)) {
|
|
903
|
-
throw new BadRequestError(`Directory not found: ${projectRoot}`);
|
|
904
|
-
}
|
|
905
|
-
const squadDir = join(projectRoot, ".squad");
|
|
906
|
-
if (!existsSync(squadDir)) {
|
|
907
|
-
res.status(400).json({ error: "No .squad directory found at this path" });
|
|
908
|
-
return;
|
|
909
|
-
}
|
|
910
|
-
const db = getDb();
|
|
911
|
-
db.prepare(`INSERT OR REPLACE INTO project_squads (project_root, squad_dir, team_dir, mode, registered) VALUES (?, ?, ?, 'local', 1)`)
|
|
912
|
-
.run(projectRoot, squadDir, squadDir);
|
|
913
|
-
// Fire-and-forget: sync decisions.md to the wiki. Non-fatal if it fails.
|
|
914
|
-
syncDecisionsFileToWiki(projectRoot).then(result => {
|
|
915
|
-
if (result) {
|
|
916
|
-
log.info({ entriesSynced: result.entriesSynced, projectRoot }, "Synced squad decisions to wiki");
|
|
917
|
-
}
|
|
918
|
-
}).catch(err => {
|
|
919
|
-
log.warn({ err: err instanceof Error ? err.message : err }, "syncDecisionsFileToWiki failed during registration (non-fatal)");
|
|
920
|
-
});
|
|
921
|
-
// Fire-and-forget: populate squad_agents cache from disk so future queries have
|
|
922
|
-
// something to work with (non-fatal — GET /api/projects counts live from disk anyway).
|
|
923
|
-
resolveProjectSquad(projectRoot).catch(err => {
|
|
924
|
-
log.warn({ err: err instanceof Error ? err.message : err }, "resolveProjectSquad failed during registration (non-fatal)");
|
|
925
|
-
});
|
|
926
|
-
res.status(201).json({ projectRoot, message: "Project registered successfully" });
|
|
927
|
-
});
|
|
928
|
-
app.delete("/api/projects/:projectRoot", (req, res) => {
|
|
929
|
-
if (!config.squadEnabled) {
|
|
930
|
-
res.status(503).json({ error: "Squad integration is disabled. Set ENABLE_SQUAD=1 to enable." });
|
|
931
|
-
return;
|
|
932
|
-
}
|
|
933
|
-
const raw = Array.isArray(req.params.projectRoot) ? req.params.projectRoot[0] : req.params.projectRoot;
|
|
934
|
-
const projectRoot = decodeURIComponent(raw);
|
|
935
|
-
const db = getDb();
|
|
936
|
-
const existing = db.prepare(`SELECT project_root FROM project_squads WHERE project_root = ?`).get(projectRoot);
|
|
937
|
-
if (!existing) {
|
|
938
|
-
throw new NotFoundError("Project not found");
|
|
939
|
-
}
|
|
940
|
-
db.prepare(`DELETE FROM project_squads WHERE project_root = ?`).run(projectRoot);
|
|
941
|
-
db.prepare(`DELETE FROM squad_agents WHERE project_root = ?`).run(projectRoot);
|
|
942
|
-
res.json({ message: "Project removed" });
|
|
943
|
-
});
|
|
944
|
-
// ---------------------------------------------------------------------------
|
|
945
803
|
// Session messages — frontend rehydration on reload
|
|
946
804
|
// ---------------------------------------------------------------------------
|
|
947
805
|
app.get("/api/session/:sessionKey/messages", (req, res) => {
|
package/dist/api/server.test.js
CHANGED
|
@@ -124,7 +124,7 @@ async function stopChild(child) {
|
|
|
124
124
|
// The Copilot SDK's client.start() then blocks the event loop while authenticating,
|
|
125
125
|
// so the HTTP server cannot respond until SDK init completes (~10-20 s depending on
|
|
126
126
|
// session state). Other modes complete in ~330 ms because the SDK yields quickly.
|
|
127
|
-
// Root cause tracked in
|
|
127
|
+
// Root cause tracked in decisions notes (backend fix: lazy-import or
|
|
128
128
|
// guard env var in daemon.ts). Interim fix: 30 s timeout for standalone, 10 s elsewhere.
|
|
129
129
|
// Additionally, strip COPILOT_* env vars from the child so it doesn't accidentally
|
|
130
130
|
// piggy-back on the running agent session, which worsens the blocking behaviour.
|
|
@@ -349,4 +349,40 @@ test("turn-sse: reconnect with Last-Event-ID replays buffered events", async ()
|
|
|
349
349
|
}
|
|
350
350
|
}, { CHAPTERHOUSE_CHAT_SSE: "1" }, 15_000);
|
|
351
351
|
});
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
// Regression test: turn ID contract (Fix 1 — root cause of blank SSE bubbles)
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
test("turn-sse: turnId returned by POST matches turnId in all SSE events for that turn", async () => {
|
|
356
|
+
await withStartedServer(async ({ baseUrl, authHeader }) => {
|
|
357
|
+
const sessionKey = "test-session-turnid-contract";
|
|
358
|
+
// Open SSE stream first
|
|
359
|
+
const sseRes = await fetch(`${baseUrl}/api/sessions/${sessionKey}/stream`, {
|
|
360
|
+
headers: { Authorization: authHeader, Accept: "text/event-stream" },
|
|
361
|
+
});
|
|
362
|
+
assert.ok(sseRes.body, "SSE body must be readable");
|
|
363
|
+
const reader = createSseReader(sseRes.body);
|
|
364
|
+
// POST the turn — this is the canonical turnId the frontend tags user messages with
|
|
365
|
+
const turnRes = await fetch(`${baseUrl}/api/sessions/${sessionKey}/turn`, {
|
|
366
|
+
method: "POST",
|
|
367
|
+
headers: { Authorization: authHeader, "Content-Type": "application/json" },
|
|
368
|
+
body: JSON.stringify({ prompt: "turn-id-contract test" }),
|
|
369
|
+
});
|
|
370
|
+
assert.equal(turnRes.status, 200, `POST /turn returned ${turnRes.status}`);
|
|
371
|
+
const bodyText = await turnRes.text();
|
|
372
|
+
const { turnId: postedTurnId } = JSON.parse(bodyText);
|
|
373
|
+
assert.ok(typeof postedTurnId === "string" && postedTurnId.length > 0, "POST must return a non-empty turnId");
|
|
374
|
+
// Read all events for up to 3 s (no real Copilot token — only turn:started and
|
|
375
|
+
// possibly turn:error will arrive; that is sufficient to verify ID plumbing).
|
|
376
|
+
const frames = await reader.readFrames(3, 3_000);
|
|
377
|
+
await reader.cancel();
|
|
378
|
+
assert.ok(frames.length >= 1, `Expected at least 1 SSE frame, got ${frames.length}`);
|
|
379
|
+
// THE CONTRACT: every turn:* event must carry the same turnId the POST returned.
|
|
380
|
+
for (const frame of frames) {
|
|
381
|
+
const data = frame.data;
|
|
382
|
+
if (typeof data?.type === "string" && data.type.startsWith("turn:")) {
|
|
383
|
+
assert.equal(data.turnId, postedTurnId, `Event type=${data.type} has turnId=${String(data.turnId)} but POST returned turnId=${postedTurnId}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}, { CHAPTERHOUSE_CHAT_SSE: "1" }, 15_000);
|
|
387
|
+
});
|
|
352
388
|
//# sourceMappingURL=turn-sse.integration.test.js.map
|
package/dist/cli.js
CHANGED
|
@@ -25,7 +25,6 @@ Commands:
|
|
|
25
25
|
setup Pick a default model and write ~/.chapterhouse/.env
|
|
26
26
|
update Check for updates and install the latest version
|
|
27
27
|
daemon <sub> Manage the persistent background service (install/uninstall/start/stop/restart/status/logs)
|
|
28
|
-
squad <sub> Squad agent tools (init, worktree management)
|
|
29
28
|
help Show this help message
|
|
30
29
|
|
|
31
30
|
Flags (start):
|
|
@@ -179,35 +178,6 @@ switch (command) {
|
|
|
179
178
|
}
|
|
180
179
|
break;
|
|
181
180
|
}
|
|
182
|
-
case "squad": {
|
|
183
|
-
const squadSub = args[1];
|
|
184
|
-
if (squadSub === 'worktree') {
|
|
185
|
-
const { runWorktreeCli } = await import("./squad/worktree.js");
|
|
186
|
-
await runWorktreeCli(args.slice(2));
|
|
187
|
-
}
|
|
188
|
-
else if (squadSub === 'init') {
|
|
189
|
-
const { runInitCli } = await import("./squad/init-cli.js");
|
|
190
|
-
await runInitCli(args.slice(2));
|
|
191
|
-
}
|
|
192
|
-
else {
|
|
193
|
-
if (squadSub) {
|
|
194
|
-
console.error(`Unknown squad subcommand: ${squadSub}\n`);
|
|
195
|
-
}
|
|
196
|
-
console.log(`
|
|
197
|
-
chapterhouse squad — Squad agent tools
|
|
198
|
-
|
|
199
|
-
Subcommands:
|
|
200
|
-
init Initialize a new Squad team for this project (guided dialog)
|
|
201
|
-
worktree Manage per-agent git worktrees (create / list / remove / prune)
|
|
202
|
-
|
|
203
|
-
Run \`chapterhouse squad init\` to scaffold a .squad/ directory with a named team.
|
|
204
|
-
Run \`chapterhouse squad worktree\` for worktree subcommand help.
|
|
205
|
-
`.trim());
|
|
206
|
-
if (squadSub)
|
|
207
|
-
process.exit(1);
|
|
208
|
-
}
|
|
209
|
-
break;
|
|
210
|
-
}
|
|
211
181
|
case "help":
|
|
212
182
|
case "--help":
|
|
213
183
|
case "-h":
|
package/dist/config.js
CHANGED
|
@@ -45,7 +45,6 @@ const configSchema = z.object({
|
|
|
45
45
|
API_RATE_LIMIT_GENERAL_MAX: z.string().optional(),
|
|
46
46
|
API_RATE_LIMIT_AUTH_MAX: z.string().optional(),
|
|
47
47
|
API_RATE_LIMIT_SSE_MAX_CONNECTIONS: z.string().optional(),
|
|
48
|
-
ENABLE_SQUAD: z.string().optional(),
|
|
49
48
|
CHAPTERHOUSE_WORKIQ_AUTO_INSTALL: z.string().optional(),
|
|
50
49
|
CHAPTERHOUSE_CHAT_SSE: z.string().optional(),
|
|
51
50
|
});
|
|
@@ -220,7 +219,6 @@ export function parseRuntimeConfig(env, options = {}) {
|
|
|
220
219
|
apiRateLimitGeneralMax,
|
|
221
220
|
apiRateLimitAuthMax,
|
|
222
221
|
apiRateLimitSseMaxConnections,
|
|
223
|
-
squadEnabled: raw.ENABLE_SQUAD === "1",
|
|
224
222
|
workiqAutoInstall: parseBooleanEnv("CHAPTERHOUSE_WORKIQ_AUTO_INSTALL", raw.CHAPTERHOUSE_WORKIQ_AUTO_INSTALL, true),
|
|
225
223
|
chatSseEnabled: raw.CHAPTERHOUSE_CHAT_SSE === "1",
|
|
226
224
|
};
|
|
@@ -261,7 +259,6 @@ export const config = {
|
|
|
261
259
|
apiRateLimitGeneralMax: runtimeConfig.apiRateLimitGeneralMax,
|
|
262
260
|
apiRateLimitAuthMax: runtimeConfig.apiRateLimitAuthMax,
|
|
263
261
|
apiRateLimitSseMaxConnections: runtimeConfig.apiRateLimitSseMaxConnections,
|
|
264
|
-
squadEnabled: runtimeConfig.squadEnabled,
|
|
265
262
|
workiqAutoInstall: runtimeConfig.workiqAutoInstall,
|
|
266
263
|
chatSseEnabled: runtimeConfig.chatSseEnabled,
|
|
267
264
|
copilotAuthToken: runtimeConfig.copilotAuthToken,
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-scoped singleton event bus for agent (subagent) lifecycle events.
|
|
3
|
+
*
|
|
4
|
+
* Backed by Node's EventEmitter. The entire daemon process shares one bus.
|
|
5
|
+
* Keeps the same surface used by
|
|
6
|
+
* task-event-log.ts (subscribe by type), orchestrator.ts (emit), and the
|
|
7
|
+
* /api/agents/stream SSE endpoint (subscribeAll).
|
|
8
|
+
*
|
|
9
|
+
* Event types:
|
|
10
|
+
* session:created — subagent spawned payload: { agentName, priority }
|
|
11
|
+
* session:tool_call — tool invoked payload: { toolName, toolArgs, resultType?, _kind?, _seq?, _ts?, _summary? }
|
|
12
|
+
* session:destroyed — subagent finished payload: { agentName, reason: "complete" | "error" | "abort" }
|
|
13
|
+
* session:error — subagent failed payload: { agentName, error }
|
|
14
|
+
*
|
|
15
|
+
* @module copilot/agent-event-bus
|
|
16
|
+
*/
|
|
17
|
+
import { EventEmitter } from "node:events";
|
|
18
|
+
const ALL = "*";
|
|
19
|
+
class AgentEventBus {
|
|
20
|
+
emitter = new EventEmitter();
|
|
21
|
+
constructor() {
|
|
22
|
+
// Subagent fan-out can exceed the default 10 listeners; lift the cap.
|
|
23
|
+
this.emitter.setMaxListeners(0);
|
|
24
|
+
}
|
|
25
|
+
emit(event) {
|
|
26
|
+
const e = { timestamp: new Date(), ...event };
|
|
27
|
+
this.emitter.emit(e.type, e);
|
|
28
|
+
this.emitter.emit(ALL, e);
|
|
29
|
+
}
|
|
30
|
+
subscribe(type, handler) {
|
|
31
|
+
this.emitter.on(type, handler);
|
|
32
|
+
return () => this.emitter.off(type, handler);
|
|
33
|
+
}
|
|
34
|
+
subscribeAll(handler) {
|
|
35
|
+
this.emitter.on(ALL, handler);
|
|
36
|
+
return () => this.emitter.off(ALL, handler);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Process-level singleton. Import this everywhere instead of newing a bus. */
|
|
40
|
+
export const agentEventBus = new AgentEventBus();
|
|
41
|
+
//# sourceMappingURL=agent-event-bus.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { agentEventBus } from "./agent-event-bus.js";
|
|
4
|
+
test("subscribe receives only matching event type", () => {
|
|
5
|
+
const got = [];
|
|
6
|
+
const unsub = agentEventBus.subscribe("session:created", (e) => got.push(e));
|
|
7
|
+
agentEventBus.emit({ type: "session:created", sessionId: "a", payload: { agentName: "x", priority: "normal" } });
|
|
8
|
+
agentEventBus.emit({ type: "session:destroyed", sessionId: "a", payload: { agentName: "x", reason: "complete" } });
|
|
9
|
+
unsub();
|
|
10
|
+
assert.equal(got.length, 1);
|
|
11
|
+
assert.equal(got[0].sessionId, "a");
|
|
12
|
+
assert.ok(got[0].timestamp instanceof Date);
|
|
13
|
+
});
|
|
14
|
+
test("subscribeAll receives every event; unsub stops delivery", () => {
|
|
15
|
+
const got = [];
|
|
16
|
+
const unsub = agentEventBus.subscribeAll((e) => got.push(e));
|
|
17
|
+
agentEventBus.emit({ type: "session:created", sessionId: "b", payload: {} });
|
|
18
|
+
agentEventBus.emit({ type: "session:tool_call", sessionId: "b", payload: { toolName: "Read" } });
|
|
19
|
+
unsub();
|
|
20
|
+
agentEventBus.emit({ type: "session:error", sessionId: "b", payload: { agentName: "x", error: "boom" } });
|
|
21
|
+
assert.equal(got.length, 2);
|
|
22
|
+
});
|
|
23
|
+
//# sourceMappingURL=agent-event-bus.test.js.map
|
package/dist/copilot/agents.js
CHANGED
|
@@ -8,8 +8,6 @@ import { AGENTS_DIR, SESSIONS_DIR } from "../paths.js";
|
|
|
8
8
|
import { getState, setState } from "../store/db.js";
|
|
9
9
|
import { loadMcpConfig } from "./mcp-config.js";
|
|
10
10
|
import { getSkillDirectories } from "./skills.js";
|
|
11
|
-
import { createSessionHooks } from "./hooks.js";
|
|
12
|
-
import { findSquadAgent, renderProjectAgentRoster } from "../squad/registry.js";
|
|
13
11
|
import { childLogger } from "../util/logger.js";
|
|
14
12
|
const log = childLogger("agents");
|
|
15
13
|
// Frontmatter schema
|
|
@@ -246,7 +244,7 @@ export function composeAgentSystemMessage(agent, rosterInfo) {
|
|
|
246
244
|
return `${agentPrompt}\n\n${base}`;
|
|
247
245
|
}
|
|
248
246
|
/** Build a roster description of all agents for @chapterhouse's system prompt. */
|
|
249
|
-
export function buildAgentRoster(
|
|
247
|
+
export function buildAgentRoster() {
|
|
250
248
|
const agents = getAgentRegistry();
|
|
251
249
|
const chLines = agents
|
|
252
250
|
.filter((a) => a.slug !== "chapterhouse")
|
|
@@ -255,22 +253,9 @@ export function buildAgentRoster(projectRoot) {
|
|
|
255
253
|
const skills = a.skills?.length ? ` | skills: ${a.skills.join(", ")}` : "";
|
|
256
254
|
return `- **@${a.slug}** — ${a.description} (model: ${model}${skills})`;
|
|
257
255
|
});
|
|
258
|
-
|
|
259
|
-
if (projectRoot) {
|
|
260
|
-
try {
|
|
261
|
-
const squadRoster = renderProjectAgentRoster(projectRoot);
|
|
262
|
-
if (squadRoster) {
|
|
263
|
-
squadLines = squadRoster.split("\n").filter(Boolean);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
catch {
|
|
267
|
-
// squad roster unavailable — skip
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
const allLines = [...chLines, ...squadLines];
|
|
271
|
-
if (allLines.length === 0)
|
|
256
|
+
if (chLines.length === 0)
|
|
272
257
|
return "No agents registered.";
|
|
273
|
-
return
|
|
258
|
+
return chLines.join("\n");
|
|
274
259
|
}
|
|
275
260
|
// The wiki tools that every agent gets regardless of tool config
|
|
276
261
|
const WIKI_TOOL_NAMES = new Set([
|
|
@@ -325,7 +310,6 @@ export async function createEphemeralAgentSession(slug, client, allTools, modelO
|
|
|
325
310
|
mcpServers,
|
|
326
311
|
skillDirectories,
|
|
327
312
|
onPermissionRequest: approveAll,
|
|
328
|
-
hooks: createSessionHooks(slug, agent.allowedPaths),
|
|
329
313
|
infiniteSessions: {
|
|
330
314
|
enabled: true,
|
|
331
315
|
backgroundCompactionThreshold: 0.80,
|
|
@@ -335,34 +319,6 @@ export async function createEphemeralAgentSession(slug, client, allTools, modelO
|
|
|
335
319
|
log.info({ agentSlug: agent.slug, model }, "Created ephemeral agent session");
|
|
336
320
|
return session;
|
|
337
321
|
}
|
|
338
|
-
/** Create an ephemeral session for a squad virtual agent (not in CH registry). */
|
|
339
|
-
export async function createSquadAgentSession(slug, client, allTools, systemMessagePrefix, modelOverride) {
|
|
340
|
-
const model = (modelOverride && modelOverride.length > 0)
|
|
341
|
-
? modelOverride
|
|
342
|
-
: "claude-sonnet-4.6";
|
|
343
|
-
const squadTools = allTools.filter((t) => !MANAGEMENT_TOOL_NAMES.has(t.name));
|
|
344
|
-
const mcpServers = loadMcpConfig();
|
|
345
|
-
const skillDirectories = getSkillDirectories();
|
|
346
|
-
const session = await client.createSession({
|
|
347
|
-
model,
|
|
348
|
-
configDir: SESSIONS_DIR,
|
|
349
|
-
workingDirectory: process.cwd(),
|
|
350
|
-
streaming: true,
|
|
351
|
-
systemMessage: { content: systemMessagePrefix },
|
|
352
|
-
tools: squadTools,
|
|
353
|
-
mcpServers,
|
|
354
|
-
skillDirectories,
|
|
355
|
-
onPermissionRequest: approveAll,
|
|
356
|
-
hooks: createSessionHooks(slug),
|
|
357
|
-
infiniteSessions: {
|
|
358
|
-
enabled: true,
|
|
359
|
-
backgroundCompactionThreshold: 0.80,
|
|
360
|
-
bufferExhaustionThreshold: 0.95,
|
|
361
|
-
},
|
|
362
|
-
});
|
|
363
|
-
log.info({ agentSlug: slug, model }, "Created squad virtual agent session");
|
|
364
|
-
return session;
|
|
365
|
-
}
|
|
366
322
|
/** Clean up active task tracking (for shutdown/restart). */
|
|
367
323
|
export async function clearActiveTasks() {
|
|
368
324
|
activeTasks.clear();
|
|
@@ -428,7 +384,7 @@ export function setActiveAgent(channel, slug) {
|
|
|
428
384
|
activeAgentByChannel.set(channel, slug);
|
|
429
385
|
}
|
|
430
386
|
/** Parse @mention from message text. Returns agent slug and remaining message, or null. */
|
|
431
|
-
export function parseAtMention(text
|
|
387
|
+
export function parseAtMention(text) {
|
|
432
388
|
const match = text.match(/^@([a-zA-Z0-9-]+)\s*([\s\S]*)$/);
|
|
433
389
|
if (!match)
|
|
434
390
|
return null;
|
|
@@ -438,17 +394,6 @@ export function parseAtMention(text, projectRoot) {
|
|
|
438
394
|
if (agent) {
|
|
439
395
|
return { agentSlug: agent.slug, message: message || "" };
|
|
440
396
|
}
|
|
441
|
-
if (projectRoot) {
|
|
442
|
-
try {
|
|
443
|
-
const squadAgent = findSquadAgent(projectRoot, mentionedName);
|
|
444
|
-
if (squadAgent) {
|
|
445
|
-
return { agentSlug: mentionedName, message: message || "" };
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
catch {
|
|
449
|
-
// squad lookup failed — fall through
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
397
|
return null;
|
|
453
398
|
}
|
|
454
399
|
//# sourceMappingURL=agents.js.map
|