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
@@ -1,7 +1,6 @@
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";
5
4
  import { createTools } from "./tools.js";
6
5
  import { getOrchestratorSystemMessage } from "./system-message.js";
7
6
  import { CHAPTERHOUSE_VERSION } from "../version.js";
@@ -9,16 +8,14 @@ import { config, DEFAULT_MODEL } from "../config.js";
9
8
  import { loadMcpConfig } from "./mcp-config.js";
10
9
  import { getSkillDirectories } from "./skills.js";
11
10
  import { resetClient } from "./client.js";
12
- import { logConversation, getState, setState, deleteState, getCopilotSession, upsertCopilotSession, getTaskSessionKey, getDb, bumpProjectLastUsed, appendTaskEvent } from "../store/db.js";
11
+ import { logConversation, getState, setState, deleteState, getCopilotSession, upsertCopilotSession, getTaskSessionKey, getDb, appendTaskEvent } from "../store/db.js";
13
12
  import { maybeWriteEpisode } from "./episode-writer.js";
14
13
  import { getWikiSummary } from "../wiki/context.js";
15
14
  import { SESSIONS_DIR } from "../paths.js";
16
15
  import { resolveModel } from "./router.js";
17
16
  import { loadAgents, ensureDefaultAgents, clearActiveTasks, getAgentRegistry, setActiveAgent, parseAtMention, buildAgentRoster, getActiveTasks, } from "./agents.js";
18
- import { normalizeProjectPath, setChannelProject } from "../squad/context.js";
19
- import { getSquadCoordinatorSystemMessage } from "../squad/charter.js";
20
17
  import { childLogger } from "../util/logger.js";
21
- import { squadEventBus } from "./squad-event-bus.js";
18
+ import { agentEventBus } from "./agent-event-bus.js";
22
19
  import { initTaskEventLog } from "./task-event-log.js";
23
20
  import { emitTurnEvent, persistTurnEvents, scheduleClearTurnLog, } from "./turn-event-log.js";
24
21
  import { SessionManager, SessionRegistry, SESSION_IDLE_TTL_MS, SESSION_MAX_ACTIVE, } from "./session-manager.js";
@@ -56,7 +53,7 @@ export function getLastRouteResult() {
56
53
  return lastRouteResult;
57
54
  }
58
55
  export function subscribeTaskEvents(taskId, listener) {
59
- return squadEventBus.subscribe("session:tool_call", (event) => {
56
+ return agentEventBus.subscribe("session:tool_call", (event) => {
60
57
  if (event.sessionId !== taskId)
61
58
  return;
62
59
  const p = event.payload;
@@ -70,7 +67,7 @@ export function subscribeTaskEvents(taskId, listener) {
70
67
  });
71
68
  }
72
69
  function emitTaskEvent(taskId, event) {
73
- void squadEventBus.emit({
70
+ void agentEventBus.emit({
74
71
  type: "session:tool_call",
75
72
  sessionId: taskId,
76
73
  payload: {
@@ -140,11 +137,11 @@ function getSessionConfig() {
140
137
  const skillDirectories = getSkillDirectories();
141
138
  return { tools, mcpServers, skillDirectories };
142
139
  }
143
- function getSystemMessageOptions(memorySummary, projectRoot) {
140
+ function getSystemMessageOptions(memorySummary) {
144
141
  return {
145
142
  selfEditEnabled: config.selfEditEnabled,
146
143
  memorySummary: memorySummary || undefined,
147
- agentRoster: buildAgentRoster(projectRoot),
144
+ agentRoster: buildAgentRoster(),
148
145
  userContext: currentUserContext,
149
146
  };
150
147
  }
@@ -152,7 +149,7 @@ function sameUserContext(a, b) {
152
149
  return a?.name === b?.name && a?.role === b?.role;
153
150
  }
154
151
  function updateUserContext(source) {
155
- if (source.type !== "web")
152
+ if (source.type !== "web" && source.type !== "sse-web")
156
153
  return;
157
154
  const nextContext = source.user
158
155
  ? { name: source.user.name, role: source.user.role }
@@ -164,7 +161,7 @@ function updateUserContext(source) {
164
161
  registry?.get("default")?.invalidateSession();
165
162
  }
166
163
  function updateRequestContext(source) {
167
- if (source.type !== "web") {
164
+ if (source.type !== "web" && source.type !== "sse-web") {
168
165
  currentAuthenticatedUser = undefined;
169
166
  currentAuthorizationHeader = undefined;
170
167
  return;
@@ -234,17 +231,11 @@ async function createOrResumeSession(sessionKey, projectRoot) {
234
231
  backgroundCompactionThreshold: 0.80,
235
232
  bufferExhaustionThreshold: 0.95,
236
233
  };
237
- let systemMessageContent;
238
- if (isProjectSession && projectRoot) {
239
- systemMessageContent = await getSquadCoordinatorSystemMessage(projectRoot);
240
- }
241
- else {
242
- const memorySummary = getWikiSummary();
243
- systemMessageContent = getOrchestratorSystemMessage({
244
- ...getSystemMessageOptions(memorySummary, isProjectSession ? projectRoot : undefined),
245
- version: CHAPTERHOUSE_VERSION,
246
- });
247
- }
234
+ const memorySummary = getWikiSummary();
235
+ const systemMessageContent = getOrchestratorSystemMessage({
236
+ ...getSystemMessageOptions(memorySummary),
237
+ version: CHAPTERHOUSE_VERSION,
238
+ });
248
239
  const stored = getCopilotSession(sessionKey);
249
240
  const savedSessionId = stored?.copilotSessionId ?? (sessionKey === "default" ? getState(ORCHESTRATOR_SESSION_KEY) : undefined);
250
241
  if (savedSessionId) {
@@ -259,7 +250,6 @@ async function createOrResumeSession(sessionKey, projectRoot) {
259
250
  mcpServers,
260
251
  skillDirectories,
261
252
  onPermissionRequest: approveAll,
262
- hooks: createSessionHooks("orchestrator"),
263
253
  infiniteSessions,
264
254
  });
265
255
  log.info({ sessionKey }, "Session resumed successfully");
@@ -285,7 +275,6 @@ async function createOrResumeSession(sessionKey, projectRoot) {
285
275
  mcpServers,
286
276
  skillDirectories,
287
277
  onPermissionRequest: approveAll,
288
- hooks: createSessionHooks("orchestrator"),
289
278
  infiniteSessions,
290
279
  });
291
280
  log.info({ sessionKey, sessionId: session.sessionId.slice(0, 8) }, "Session created");
@@ -299,9 +288,7 @@ async function createOrResumeSession(sessionKey, projectRoot) {
299
288
  }
300
289
  export async function initOrchestrator(client) {
301
290
  copilotClient = client;
302
- // Initialize governance hook pipeline before any session is created.
303
- initHookPipeline();
304
- // Initialize per-task ring buffer — subscribes to squadEventBus for session:tool_call events.
291
+ // Initialize per-task ring buffer subscribes to agentEventBus for session:tool_call events.
305
292
  initTaskEventLog();
306
293
  // (Re-)create the registry — supports multiple initOrchestrator calls in tests
307
294
  if (registry) {
@@ -450,7 +437,7 @@ async function executeOnSession(manager, item) {
450
437
  .replace(/\s+/g, "-");
451
438
  const resolvedDescription = (typeof spawnArgs?.description === "string"
452
439
  ? spawnArgs.description
453
- : data.agentDescription || data.agentDisplayName || `Squad dispatch: ${agentSlug}`).slice(0, 500);
440
+ : data.agentDescription || data.agentDisplayName || `Agent dispatch: ${agentSlug}`).slice(0, 500);
454
441
  item.onActivity({
455
442
  kind: "subagent_started",
456
443
  toolCallId: data.toolCallId,
@@ -521,10 +508,10 @@ async function executeOnSession(manager, item) {
521
508
  .replace(/\s+/g, "-");
522
509
  const description = (typeof spawnArgs?.description === "string"
523
510
  ? spawnArgs.description
524
- : data.agentDescription || data.agentDisplayName || `Squad dispatch: ${agentSlug}`).slice(0, 500);
525
- 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);
511
+ : data.agentDescription || data.agentDisplayName || `Agent dispatch: ${agentSlug}`).slice(0, 500);
512
+ db.prepare(`INSERT OR IGNORE INTO agent_tasks (task_id, agent_slug, description, status, origin_channel, session_key, source) VALUES (?, ?, ?, 'running', ?, ?, 'adhoc')`).run(data.toolCallId, agentSlug, description, item.sourceChannel || null, sessionKey);
526
513
  activeSubagentTaskIds.add(data.toolCallId);
527
- void squadEventBus.emit({
514
+ void agentEventBus.emit({
528
515
  type: "session:created",
529
516
  sessionId: data.toolCallId,
530
517
  agentName: agentSlug,
@@ -552,7 +539,7 @@ async function executeOnSession(manager, item) {
552
539
  db.prepare(`UPDATE agent_tasks SET status = 'completed', completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(event.data.toolCallId);
553
540
  const taskId = event.data.toolCallId;
554
541
  const taskRow = db.prepare(`SELECT agent_slug FROM agent_tasks WHERE task_id = ?`).get(taskId);
555
- void squadEventBus.emit({
542
+ void agentEventBus.emit({
556
543
  type: "session:destroyed",
557
544
  sessionId: taskId,
558
545
  agentName: taskRow?.agent_slug,
@@ -580,7 +567,7 @@ async function executeOnSession(manager, item) {
580
567
  activeSubagentTaskIds.delete(data.toolCallId);
581
568
  db.prepare(`UPDATE agent_tasks SET status = 'error', result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(data.error || "Subagent failed", data.toolCallId);
582
569
  const taskRow = db.prepare(`SELECT agent_slug FROM agent_tasks WHERE task_id = ?`).get(data.toolCallId);
583
- void squadEventBus.emit({
570
+ void agentEventBus.emit({
584
571
  type: "session:error",
585
572
  sessionId: data.toolCallId,
586
573
  agentName: taskRow?.agent_slug,
@@ -749,30 +736,23 @@ function isRecoverableError(err) {
749
736
  return false;
750
737
  return /disconnect|connection|EPIPE|ECONNRESET|ECONNREFUSED|socket|closed|ENOENT|spawn|not found|expired|stale/i.test(msg);
751
738
  }
752
- export async function sendToOrchestrator(prompt, source, callback, attachments, onActivity, onQueued, onAdvance) {
739
+ export async function sendToOrchestrator(prompt, source, callback, attachments, onActivity, onQueued, onAdvance, externalTurnId) {
753
740
  updateUserContext(source);
754
741
  updateRequestContext(source);
755
- // Generate a unique ID for this orchestrator turn. Every SSE event emitted
756
- // during this turn carries this ID so the frontend can detect turn boundaries
757
- // and create a new assistant bubble when it changes (fixes #92).
758
- const turnId = randomUUID();
759
- const sourceLabel = source.type === "web" ? "web" : "background";
742
+ // Use the externally-supplied turnId if provided (POST→SSE path needs the ID
743
+ // returned to the client to match every emitted event Fix 1 root cause).
744
+ const turnId = externalTurnId ?? randomUUID();
745
+ const sourceLabel = source.type === "background" ? "background" : "web";
760
746
  logMessage("in", sourceLabel, prompt);
761
747
  let sessionKey;
762
- if (source.type === "web" && source.projectPath && config.squadEnabled) {
763
- sessionKey = "project:" + normalizeProjectPath(source.projectPath);
764
- setChannelProject(source.connectionId, normalizeProjectPath(source.projectPath));
765
- bumpProjectLastUsed(normalizeProjectPath(source.projectPath));
766
- }
767
- else if (source.type === "background" && source.sessionKey) {
748
+ if ((source.type === "background" || source.type === "sse-web") && source.sessionKey) {
768
749
  sessionKey = source.sessionKey;
769
750
  }
770
751
  else {
771
752
  sessionKey = "default";
772
753
  }
773
754
  const channelKey = source.type === "web" ? source.connectionId : "default";
774
- const projectRoot = sessionKey.startsWith("project:") ? sessionKey.slice("project:".length) : undefined;
775
- const mention = parseAtMention(prompt, projectRoot);
755
+ const mention = parseAtMention(prompt);
776
756
  const targetAgent = mention?.agentSlug;
777
757
  const routedPrompt = mention ? mention.message : prompt;
778
758
  const taggedPrompt = source.type === "background"
@@ -782,8 +762,9 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
782
762
  const sourceChannel = source.type === "web" ? "web" : undefined;
783
763
  // Capture auth context at enqueue time — prevents cross-session contamination
784
764
  // when concurrent sessions are processing simultaneously.
785
- const authUser = source.type === "web" ? source.user : undefined;
786
- const authHeader = source.type === "web" ? source.authorizationHeader?.trim() || undefined : undefined;
765
+ // sse-web carries user identity just like web (Fix 3).
766
+ const authUser = (source.type === "web" || source.type === "sse-web") ? source.user : undefined;
767
+ const authHeader = (source.type === "web" || source.type === "sse-web") ? source.authorizationHeader?.trim() || undefined : undefined;
787
768
  const manager = registry.getOrCreate(sessionKey);
788
769
  void (async () => {
789
770
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
@@ -797,9 +778,10 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
797
778
  // type dependencies. orchestrator.ts always passes valid ActivityEvent objects.
798
779
  onActivity: onActivity,
799
780
  turnId,
800
- // Background messages skip queue visibility — only web/user messages need it.
801
- onQueued: source.type === "web" ? onQueued : undefined,
802
- onAdvance: source.type === "web" ? onAdvance : undefined,
781
+ // Background messages skip queue visibility — only user-initiated messages need it.
782
+ // sse-web is user-initiated (Fix 2).
783
+ onQueued: source.type !== "background" ? onQueued : undefined,
784
+ onAdvance: source.type !== "background" ? onAdvance : undefined,
803
785
  sourceChannel,
804
786
  targetAgent,
805
787
  channelKey,
@@ -864,19 +846,19 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
864
846
  * replacement turn starts. Use to emit a `turn-interrupted` SSE event so the
865
847
  * frontend can drop the partial in-flight bubble.
866
848
  */
867
- export async function interruptCurrentTurn(sessionKey, newPrompt, source, callback, attachments, onActivity, onInterrupted) {
849
+ export async function interruptCurrentTurn(sessionKey, newPrompt, source, callback, attachments, onActivity, onInterrupted, externalTurnId) {
868
850
  const manager = registry?.get(sessionKey);
869
851
  // If no session exists or it isn't processing, fall back to a normal send.
870
852
  if (!manager || !manager.isProcessing) {
871
- return sendToOrchestrator(newPrompt, source, callback, attachments, onActivity);
853
+ return sendToOrchestrator(newPrompt, source, callback, attachments, onActivity, undefined, undefined, externalTurnId);
872
854
  }
873
855
  updateUserContext(source);
874
856
  updateRequestContext(source);
875
- const turnId = randomUUID();
876
- const sourceLabel = source.type === "web" ? "web" : "background";
857
+ const turnId = externalTurnId ?? randomUUID();
858
+ const sourceLabel = source.type === "background" ? "background" : "web";
877
859
  const sourceChannel = source.type === "web" ? "web" : undefined;
878
- const authUser = source.type === "web" ? source.user : undefined;
879
- const authHeader = source.type === "web" ? source.authorizationHeader?.trim() || undefined : undefined;
860
+ const authUser = (source.type === "web" || source.type === "sse-web") ? source.user : undefined;
861
+ const authHeader = (source.type === "web" || source.type === "sse-web") ? source.authorizationHeader?.trim() || undefined : undefined;
880
862
  const taggedPrompt = source.type === "background"
881
863
  ? newPrompt
882
864
  : `[via ${sourceLabel}] ${newPrompt}`;
@@ -965,8 +947,8 @@ export async function interruptCurrentTurn(sessionKey, newPrompt, source, callba
965
947
  export function enqueueForSse(opts) {
966
948
  const { sessionKey, prompt, attachments, authUser, authHeader, interrupt } = opts;
967
949
  const turnId = randomUUID();
968
- const source = { type: "background", sessionKey };
969
- const taggedPrompt = `[via sse] ${prompt}`;
950
+ // sse-web carries auth and enables onQueued — unlike "background" (Fixes 2 & 3).
951
+ const source = { type: "sse-web", sessionKey, user: authUser, authorizationHeader: authHeader };
970
952
  // Emit turn:started immediately so the SSE client sees it before any delta
971
953
  emitTurnEvent(sessionKey, { type: "turn:started", turnId, sessionKey, prompt, attachments });
972
954
  const callback = (text, done, tid) => {
@@ -986,10 +968,12 @@ export function enqueueForSse(opts) {
986
968
  scheduleClearTurnLog(abortedTurnId);
987
969
  };
988
970
  if (interrupt) {
989
- void interruptCurrentTurn(sessionKey, taggedPrompt, source, callback, attachments, undefined, onInterrupted);
971
+ // Pass the outer turnId so the replacement turn uses the same ID (Fix 1).
972
+ void interruptCurrentTurn(sessionKey, prompt, source, callback, attachments, undefined, onInterrupted, turnId);
990
973
  }
991
974
  else {
992
- void sendToOrchestrator(taggedPrompt, source, callback, attachments, undefined, onQueued);
975
+ // Pass the outer turnId so sendToOrchestrator uses it instead of generating a new one (Fix 1).
976
+ void sendToOrchestrator(prompt, source, callback, attachments, undefined, onQueued, undefined, turnId);
993
977
  }
994
978
  return turnId;
995
979
  }
@@ -998,15 +982,26 @@ export async function cancelCurrentMessage() {
998
982
  if (!registry)
999
983
  return false;
1000
984
  let drained = 0;
985
+ // Capture (sessionKey, turnId) before aborting so we can emit terminal events.
1001
986
  const aborts = [];
1002
- for (const [, manager] of registry.getAll()) {
987
+ for (const [sessionKey, manager] of registry.getAll()) {
1003
988
  drained += manager.cancelQueued();
1004
989
  if (manager.isProcessing) {
1005
- aborts.push(manager.abortCurrentTurn());
990
+ const turnId = manager.currentTurnId;
991
+ aborts.push({ promise: manager.abortCurrentTurn(), sessionKey, turnId });
1006
992
  }
1007
993
  }
1008
- const results = await Promise.all(aborts);
994
+ const results = await Promise.all(aborts.map((a) => a.promise));
1009
995
  const aborted = results.some(Boolean);
996
+ // Emit turn:interrupted on per-session SSE streams for any turn that was aborted (Fix 4).
997
+ for (let i = 0; i < aborts.length; i++) {
998
+ if (results[i] && aborts[i].turnId) {
999
+ const { sessionKey, turnId } = aborts[i];
1000
+ emitTurnEvent(sessionKey, { type: "turn:interrupted", turnId: turnId, sessionKey });
1001
+ persistTurnEvents(turnId, sessionKey);
1002
+ scheduleClearTurnLog(turnId);
1003
+ }
1004
+ }
1010
1005
  return aborted || drained > 0;
1011
1006
  }
1012
1007
  /** Switch the model on the live default orchestrator session without destroying it. */