chapterhouse 0.3.19 → 0.3.21

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.
@@ -19,6 +19,11 @@ import { agentEventBus } from "./agent-event-bus.js";
19
19
  import { initTaskEventLog } from "./task-event-log.js";
20
20
  import { emitTurnEvent, persistTurnEvents, scheduleClearTurnLog, } from "./turn-event-log.js";
21
21
  import { SessionManager, SessionRegistry, SESSION_IDLE_TTL_MS, SESSION_MAX_ACTIVE, } from "./session-manager.js";
22
+ import { ActiveProjectRulesLoadError, renderActiveProjectRulesBlock, } from "./project-rules-injection.js";
23
+ import { detectProjectRuleWarnings } from "./project-rule-warnings.js";
24
+ import { loadRegistry } from "../wiki/project-registry.js";
25
+ import { loadProjectRules } from "../wiki/project-rules.js";
26
+ import { resolveProject } from "./project-resolution.js";
22
27
  const log = childLogger("orchestrator");
23
28
  const MAX_RETRIES = 3;
24
29
  const RECONNECT_DELAYS_MS = [1_000, 3_000, 10_000];
@@ -33,6 +38,19 @@ let proactiveNotifyFn;
33
38
  export function setProactiveNotify(fn) {
34
39
  proactiveNotifyFn = fn;
35
40
  }
41
+ function usesSessionTurnLifecycle(source) {
42
+ return source.type === "background" || source.type === "sse-web";
43
+ }
44
+ function finalizeTurnEvent(sessionKey, event) {
45
+ if (event.type === "turn:complete") {
46
+ emitTurnEvent(sessionKey, { type: "turn:complete", turnId: event.turnId, sessionKey, finalMessage: event.finalMessage });
47
+ }
48
+ else {
49
+ emitTurnEvent(sessionKey, { type: "turn:error", turnId: event.turnId, sessionKey, error: event.error });
50
+ }
51
+ persistTurnEvents(event.turnId, sessionKey);
52
+ scheduleClearTurnLog(event.turnId);
53
+ }
36
54
  const turnContextStorage = new AsyncLocalStorage();
37
55
  // ---------------------------------------------------------------------------
38
56
  // Module-level state (not per-session)
@@ -105,6 +123,9 @@ export function getCurrentChannelKey() {
105
123
  export function getCurrentActivityCallback() {
106
124
  return turnContextStorage.getStore()?.activityCallback;
107
125
  }
126
+ export function getCurrentActiveProjectRules() {
127
+ return turnContextStorage.getStore()?.activeProjectRules ?? null;
128
+ }
108
129
  export function getCurrentAuthenticatedUser() {
109
130
  return turnContextStorage.getStore()?.authUser ?? currentAuthenticatedUser;
110
131
  }
@@ -347,6 +368,14 @@ export const ORCHESTRATOR_TIMEOUT_MS = (() => {
347
368
  async function executeOnSession(manager, item) {
348
369
  const { sessionKey } = manager;
349
370
  const session = await manager.ensureSession();
371
+ const activeProjectRules = getActiveProjectRules(item.prompt, item.projectPath);
372
+ const warningLines = activeProjectRules
373
+ ? detectProjectRuleWarnings(item.prompt, activeProjectRules.rules.hard)
374
+ : [];
375
+ const warningBlock = warningLines.length > 0 ? `${warningLines.join("\n")}\n\n` : "";
376
+ const sessionPrompt = activeProjectRules
377
+ ? `${warningBlock}${renderActiveProjectRulesBlock(activeProjectRules.project.slug, activeProjectRules.project.path, activeProjectRules.rules.path, activeProjectRules.rules.hard, activeProjectRules.rules.soft)}\n\n${item.prompt}`
378
+ : item.prompt;
350
379
  // Update last-seen globals (backwards compat — for callers that inspect after a turn ends)
351
380
  currentAuthenticatedUser = item.authUser;
352
381
  currentAuthorizationHeader = item.authHeader;
@@ -357,6 +386,7 @@ async function executeOnSession(manager, item) {
357
386
  authUser: item.authUser,
358
387
  authHeader: item.authHeader,
359
388
  activityCallback: item.onActivity,
389
+ activeProjectRules,
360
390
  }, async () => {
361
391
  let accumulated = "";
362
392
  let toolCallExecuted = false;
@@ -373,6 +403,11 @@ async function executeOnSession(manager, item) {
373
403
  spawnArgsMap.set(data.toolCallId, {
374
404
  name: typeof args.name === "string" ? args.name : undefined,
375
405
  description: typeof args.description === "string" ? args.description : undefined,
406
+ prompt: typeof args.prompt === "string"
407
+ ? args.prompt
408
+ : typeof args.task === "string"
409
+ ? args.task
410
+ : undefined,
376
411
  });
377
412
  }
378
413
  });
@@ -509,7 +544,12 @@ async function executeOnSession(manager, item) {
509
544
  const description = (typeof spawnArgs?.description === "string"
510
545
  ? spawnArgs.description
511
546
  : 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);
547
+ const prompt = typeof spawnArgs?.prompt === "string" ? spawnArgs.prompt : null;
548
+ db.prepare(`INSERT OR IGNORE INTO agent_tasks (task_id, agent_slug, description, prompt, status, origin_channel, session_key, source)
549
+ VALUES (?, ?, ?, ?, 'running', ?, ?, 'adhoc')`).run(data.toolCallId, agentSlug, description, prompt, item.sourceChannel || null, sessionKey);
550
+ if (prompt) {
551
+ db.prepare(`UPDATE agent_tasks SET prompt = COALESCE(prompt, ?) WHERE task_id = ?`).run(prompt, data.toolCallId);
552
+ }
513
553
  activeSubagentTaskIds.add(data.toolCallId);
514
554
  void agentEventBus.emit({
515
555
  type: "session:created",
@@ -649,8 +689,22 @@ async function executeOnSession(manager, item) {
649
689
  });
650
690
  });
651
691
  try {
652
- const result = await session.sendAndWait({ prompt: item.prompt, ...(item.attachments && item.attachments.length > 0 ? { attachments: item.attachments } : {}) }, ORCHESTRATOR_TIMEOUT_MS);
653
- const finalContent = result?.data?.content || accumulated || "(No response)";
692
+ if (warningBlock) {
693
+ accumulated = warningBlock;
694
+ item.callback(accumulated, false, item.turnId);
695
+ emitTurnEvent(sessionKey, {
696
+ type: "turn:delta",
697
+ turnId: item.turnId,
698
+ sessionKey,
699
+ part: { type: "text", text: warningBlock },
700
+ });
701
+ }
702
+ const result = await session.sendAndWait({ prompt: sessionPrompt, ...(item.attachments && item.attachments.length > 0 ? { attachments: item.attachments } : {}) }, ORCHESTRATOR_TIMEOUT_MS);
703
+ const streamedContent = warningBlock && accumulated.startsWith(warningBlock)
704
+ ? accumulated.slice(warningBlock.length)
705
+ : accumulated;
706
+ const responseContent = result?.data?.content || streamedContent || "(No response)";
707
+ const finalContent = warningBlock ? `${warningBlock}${responseContent}` : responseContent;
654
708
  return finalContent;
655
709
  }
656
710
  catch (err) {
@@ -730,6 +784,30 @@ async function processItem(item, manager) {
730
784
  lastRouteResult = routeResult;
731
785
  return executeOnSession(manager, item);
732
786
  }
787
+ function getActiveProjectRules(prompt, projectPath) {
788
+ const registry = loadRegistry();
789
+ const project = resolveProject(prompt, { projectPath }, registry);
790
+ if (!project) {
791
+ return null;
792
+ }
793
+ try {
794
+ const rules = loadProjectRules(project.slug);
795
+ if (!rules.found) {
796
+ return null;
797
+ }
798
+ return { project, rules };
799
+ }
800
+ catch (err) {
801
+ if (err instanceof ActiveProjectRulesLoadError || err instanceof Error) {
802
+ log.warn({
803
+ slug: err instanceof ActiveProjectRulesLoadError ? err.slug : project.slug,
804
+ err: err.message,
805
+ }, "Project rules could not be loaded; continuing without injection");
806
+ return null;
807
+ }
808
+ throw err;
809
+ }
810
+ }
733
811
  function isRecoverableError(err) {
734
812
  const msg = err instanceof Error ? err.message : String(err);
735
813
  if (/timeout/i.test(msg))
@@ -765,6 +843,10 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
765
843
  // sse-web carries user identity just like web (Fix 3).
766
844
  const authUser = (source.type === "web" || source.type === "sse-web") ? source.user : undefined;
767
845
  const authHeader = (source.type === "web" || source.type === "sse-web") ? source.authorizationHeader?.trim() || undefined : undefined;
846
+ const emitSessionLifecycle = usesSessionTurnLifecycle(source);
847
+ if (emitSessionLifecycle) {
848
+ emitTurnEvent(sessionKey, { type: "turn:started", turnId, sessionKey, prompt, attachments });
849
+ }
768
850
  const manager = registry.getOrCreate(sessionKey);
769
851
  void (async () => {
770
852
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
@@ -772,6 +854,7 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
772
854
  const finalContent = await new Promise((resolve, reject) => {
773
855
  manager.enqueue({
774
856
  prompt: taggedPrompt,
857
+ projectPath: source.type === "web" ? source.projectPath : undefined,
775
858
  attachments,
776
859
  callback,
777
860
  // Cast: QueuedMessage.onActivity uses a wide event type to avoid circular
@@ -793,6 +876,9 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
793
876
  });
794
877
  });
795
878
  callback(finalContent, true, turnId);
879
+ if (emitSessionLifecycle) {
880
+ finalizeTurnEvent(sessionKey, { type: "turn:complete", turnId, finalMessage: finalContent });
881
+ }
796
882
  try {
797
883
  logMessage("out", sourceLabel, finalContent);
798
884
  }
@@ -829,6 +915,9 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
829
915
  }
830
916
  log.error({ msg }, "Error processing message");
831
917
  callback(`Error: ${msg}`, true, turnId);
918
+ if (emitSessionLifecycle) {
919
+ finalizeTurnEvent(sessionKey, { type: "turn:error", turnId, error: msg });
920
+ }
832
921
  return;
833
922
  }
834
923
  }
@@ -873,6 +962,7 @@ export async function interruptCurrentTurn(sessionKey, newPrompt, source, callba
873
962
  const finalContent = await new Promise((resolve, reject) => {
874
963
  manager.enqueue({
875
964
  prompt: taggedPrompt,
965
+ projectPath: source.type === "web" ? source.projectPath : undefined,
876
966
  attachments,
877
967
  callback,
878
968
  onActivity: onActivity,
@@ -938,7 +1028,7 @@ export async function interruptCurrentTurn(sessionKey, newPrompt, source, callba
938
1028
  *
939
1029
  * Unlike `sendToOrchestrator`, this function:
940
1030
  * - Returns the `turnId` immediately without waiting for the turn to complete.
941
- * - Emits turn:started, turn:complete, and turn:error events to the turn event log.
1031
+ * - Routes through the shared lifecycle emitter in sendToOrchestrator.
942
1032
  * - Does NOT write to sseClients — the SSE channel delivers events via subscribeSession().
943
1033
  * - Supports interrupt: true which calls interruptCurrentTurn under the hood.
944
1034
  *
@@ -949,15 +1039,9 @@ export function enqueueForSse(opts) {
949
1039
  const turnId = randomUUID();
950
1040
  // sse-web carries auth and enables onQueued — unlike "background" (Fixes 2 & 3).
951
1041
  const source = { type: "sse-web", sessionKey, user: authUser, authorizationHeader: authHeader };
952
- // Emit turn:started immediately so the SSE client sees it before any delta
953
- emitTurnEvent(sessionKey, { type: "turn:started", turnId, sessionKey, prompt, attachments });
954
- const callback = (text, done, tid) => {
955
- if (done) {
956
- emitTurnEvent(sessionKey, { type: "turn:complete", turnId: tid, sessionKey, finalMessage: text });
957
- persistTurnEvents(tid, sessionKey);
958
- scheduleClearTurnLog(tid);
959
- }
960
- // Note: mid-turn text deltas are emitted by executeOnSession's delta handler
1042
+ const callback = (_text, _done, _tid) => {
1043
+ // Note: sendToOrchestrator now owns turn:started/turn:complete/turn:error emission.
1044
+ // Mid-turn text deltas are still emitted by executeOnSession's delta handler.
961
1045
  };
962
1046
  const onQueued = (position, tid) => {
963
1047
  emitTurnEvent(sessionKey, { type: "turn:queued", turnId: tid, sessionKey, position });