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.
Files changed (53) hide show
  1. package/README.md +2 -69
  2. package/dist/api/server.js +15 -157
  3. package/dist/api/server.test.js +1 -1
  4. package/dist/api/turn-sse.integration.test.js +36 -0
  5. package/dist/cli.js +0 -30
  6. package/dist/config.js +0 -3
  7. package/dist/copilot/agent-event-bus.js +41 -0
  8. package/dist/copilot/agent-event-bus.test.js +23 -0
  9. package/dist/copilot/agents.js +4 -59
  10. package/dist/copilot/orchestrator.js +60 -65
  11. package/dist/copilot/orchestrator.test.js +73 -158
  12. package/dist/copilot/task-event-log.js +5 -5
  13. package/dist/copilot/task-event-log.test.js +68 -142
  14. package/dist/copilot/tools.js +9 -85
  15. package/dist/daemon.js +0 -22
  16. package/dist/store/db.js +2 -50
  17. package/dist/store/db.test.js +0 -45
  18. package/package.json +1 -3
  19. package/web/dist/assets/index-BlIWCM11.js +217 -0
  20. package/web/dist/assets/index-BlIWCM11.js.map +1 -0
  21. package/web/dist/assets/{index-BtAcw3EP.css → index-lvHFM_ut.css} +1 -1
  22. package/web/dist/index.html +2 -2
  23. package/dist/api/ralph.js +0 -153
  24. package/dist/api/ralph.test.js +0 -101
  25. package/dist/copilot/agents.squad.test.js +0 -72
  26. package/dist/copilot/hooks.js +0 -157
  27. package/dist/copilot/hooks.test.js +0 -315
  28. package/dist/copilot/squad-event-bus.js +0 -27
  29. package/dist/copilot/tools.squad.test.js +0 -168
  30. package/dist/squad/charter.js +0 -125
  31. package/dist/squad/charter.test.js +0 -89
  32. package/dist/squad/context.js +0 -48
  33. package/dist/squad/context.test.js +0 -59
  34. package/dist/squad/discovery.js +0 -268
  35. package/dist/squad/discovery.test.js +0 -154
  36. package/dist/squad/index.js +0 -9
  37. package/dist/squad/init-cli.js +0 -109
  38. package/dist/squad/init.js +0 -395
  39. package/dist/squad/init.test.js +0 -351
  40. package/dist/squad/mirror.js +0 -83
  41. package/dist/squad/mirror.scheduler.js +0 -80
  42. package/dist/squad/mirror.scheduler.test.js +0 -197
  43. package/dist/squad/mirror.test.js +0 -172
  44. package/dist/squad/registry.js +0 -162
  45. package/dist/squad/registry.test.js +0 -31
  46. package/dist/squad/squad-coordinator-system-message.test.js +0 -190
  47. package/dist/squad/squad-session-routing.test.js +0 -260
  48. package/dist/squad/types.js +0 -4
  49. package/dist/squad/worktree.js +0 -295
  50. package/dist/squad/worktree.test.js +0 -189
  51. package/dist/store/squad-sessions.test.js +0 -341
  52. package/web/dist/assets/index-BR2cks94.js +0 -219
  53. 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, squad worktree) maps to a separate `SessionManager` with its own queue and SDK session. Two env vars control how long sessions live:
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 (including `.squad/` metadata edits) before bumping the version.
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
 
@@ -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, readdirSync } from "fs";
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 { squadEventBus } from "../copilot/squad-event-bus.js";
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, normalizeSqliteTsToIso } from "../store/db.js";
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
- // squad-dispatched tasks remain visible after they finish, not just in-flight ones.
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 squad EventBus SSE stream — thin pass-through of SDK SquadEvents.
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/squad/stream", (req, res) => {
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 = squadEventBus.subscribeAll((event) => {
359
- res.write(formatSseData({ type: "squad_event", squadEvent: event }));
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
- }, lastSeq);
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) => {
@@ -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 .squad/decisions/ for Kaylee (backend fix: lazy-import or
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
@@ -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(projectRoot) {
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
- let squadLines = [];
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 allLines.join("\n");
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, projectRoot) {
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