@cohortapp/agent-sdk 2.11.14 → 2.12.0

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 (172) hide show
  1. package/.env.example +37 -22
  2. package/README.md +2 -0
  3. package/bin/maestro.mjs +113 -39
  4. package/bin/maestro.test.mjs +175 -5
  5. package/docs/guides/front-door-session.md +264 -0
  6. package/docs/guides/mac-mini.md +100 -28
  7. package/docs/guides/org-onboarding.md +1 -1
  8. package/docs/guides/setup-wizard.md +9 -5
  9. package/docs/runbooks/cohort-cutover.md +11 -1
  10. package/docs/runbooks/mac-mini-bootstrap.md +38 -63
  11. package/lib/cadence-bus-requeue.test.mjs +83 -0
  12. package/lib/cadence-bus.mjs +43 -7
  13. package/lib/channels/inbox-item.mjs +59 -2
  14. package/lib/cli/board.mjs +285 -0
  15. package/lib/cli/board.test.mjs +227 -0
  16. package/lib/cli/doctor-checks.mjs +441 -0
  17. package/lib/cli/doctor-checks.test.mjs +336 -0
  18. package/lib/cli/global-setup-extras.mjs +410 -0
  19. package/lib/cli/global-setup-extras.test.mjs +367 -0
  20. package/lib/cli/inbox.mjs +304 -0
  21. package/lib/cli/inbox.test.mjs +230 -0
  22. package/lib/cli/session-ack.mjs +63 -0
  23. package/lib/cli/session-ack.test.mjs +63 -0
  24. package/lib/cli/session.mjs +750 -0
  25. package/lib/cli/session.test.mjs +602 -0
  26. package/lib/collective/global-config.mjs +204 -6
  27. package/lib/collective/global-config.test.mjs +140 -0
  28. package/lib/collective/global-skills.mjs +145 -0
  29. package/lib/collective/global-skills.test.mjs +126 -0
  30. package/lib/collective/presence.mjs +4 -3
  31. package/lib/comms/send-gate.mjs +115 -0
  32. package/lib/comms/send-gate.test.mjs +113 -0
  33. package/lib/feature-init.mjs +2 -2
  34. package/lib/identity/persona.mjs +29 -0
  35. package/lib/identity/persona.test.mjs +26 -1
  36. package/lib/mcp/server.test.mjs +9 -4
  37. package/lib/model-router/spawn.test.mjs +21 -0
  38. package/lib/org/board-mine-cache.mjs +99 -0
  39. package/lib/org/board-mine-cache.test.mjs +53 -0
  40. package/lib/org/board.mjs +11 -0
  41. package/lib/org/board.test.mjs +11 -1
  42. package/lib/org/client.mjs +36 -0
  43. package/lib/org/client.test.mjs +46 -0
  44. package/lib/org/inbound/directedness.mjs +18 -2
  45. package/lib/org/inbound/directedness.test.mjs +58 -0
  46. package/lib/org/inbound/index.mjs +8 -1
  47. package/lib/org/inbound/index.test.mjs +22 -0
  48. package/lib/org/mesh-directives.test.mjs +110 -0
  49. package/lib/org/mesh.mjs +61 -1
  50. package/lib/org/protocol.checksum +1 -1
  51. package/lib/org/protocol.mjs +52 -0
  52. package/lib/org/protocol.test.mjs +12 -1
  53. package/lib/org/registry.mjs +3 -2
  54. package/lib/org/tool-surface.mjs +120 -0
  55. package/lib/org/tool-surface.test.mjs +118 -5
  56. package/lib/security/external-content.mjs +1 -1
  57. package/lib/security/external-content.test.mjs +17 -0
  58. package/lib/session/config.mjs +137 -0
  59. package/lib/session/config.test.mjs +92 -0
  60. package/lib/session/feed-core.mjs +229 -0
  61. package/lib/session/feed-core.test.mjs +198 -0
  62. package/lib/session/first-run.mjs +126 -0
  63. package/lib/session/first-run.test.mjs +121 -0
  64. package/lib/session/frontdoor.mjs +266 -0
  65. package/lib/session/frontdoor.test.mjs +205 -0
  66. package/lib/session/handoffs.mjs +295 -0
  67. package/lib/session/handoffs.test.mjs +183 -0
  68. package/lib/session/identity.mjs +220 -0
  69. package/lib/session/identity.test.mjs +180 -0
  70. package/lib/session/inbox-claims.mjs +434 -0
  71. package/lib/session/inbox-claims.test.mjs +286 -0
  72. package/lib/session/launch-args.mjs +161 -0
  73. package/lib/session/launch-args.test.mjs +157 -0
  74. package/lib/session/liveness.mjs +174 -0
  75. package/lib/session/liveness.test.mjs +100 -0
  76. package/lib/session/status-summary.mjs +172 -0
  77. package/lib/session/status-summary.test.mjs +118 -0
  78. package/lib/session-permissions.mjs +39 -3
  79. package/lib/session-permissions.test.mjs +20 -0
  80. package/lib/setup/claude-probe.mjs +161 -24
  81. package/lib/setup/claude-probe.test.mjs +187 -0
  82. package/lib/setup/sections/learning.mjs +2 -1
  83. package/lib/setup/sections/model.mjs +104 -24
  84. package/lib/setup/sections/model.test.mjs +240 -0
  85. package/lib/setup/sections/org.mjs +27 -2
  86. package/lib/setup/sections/org.test.mjs +35 -2
  87. package/lib/setup/sections/verify.mjs +5 -0
  88. package/lib/setup/state.mjs +30 -10
  89. package/lib/setup/state.test.mjs +24 -1
  90. package/lib/singleton.js +11 -3
  91. package/lib/singleton.test.mjs +16 -0
  92. package/lib/subagents/lock.mjs +1 -1
  93. package/lib/telemetry/collect.mjs +270 -6
  94. package/lib/telemetry/collect.test.mjs +196 -1
  95. package/lib/upgrade/global-refresh.mjs +108 -0
  96. package/lib/upgrade/global-refresh.test.mjs +65 -0
  97. package/lib/upgrade/launchd-reconcile.mjs +327 -0
  98. package/lib/upgrade/launchd-reconcile.test.mjs +272 -0
  99. package/lib/upgrade/post-steps.mjs +151 -0
  100. package/lib/upgrade/post-steps.test.mjs +200 -0
  101. package/lib/upgrade/verify.mjs +215 -0
  102. package/lib/upgrade/verify.test.mjs +164 -0
  103. package/lib/voice/outbound.mjs +3 -2
  104. package/lib/voice/post-call-brief.mjs +2 -1
  105. package/lib/voice/session-rotation.mjs +6 -1
  106. package/lib/voice/session-rotation.test.mjs +114 -0
  107. package/package.json +3 -3
  108. package/plugins/maestro-skills/plugin.json +21 -1
  109. package/plugins/maestro-skills/skills/board-work.md +63 -0
  110. package/plugins/maestro-skills/skills/inbound-triage.md +80 -0
  111. package/plugins/maestro-skills/skills/main-session.md +102 -0
  112. package/plugins/maestro-skills/skills/peer-sessions.md +65 -0
  113. package/plugins/maestro-skills/skills/persona-discipline.md +75 -0
  114. package/scaffold/CLAUDE.md +34 -0
  115. package/scripts/ci/check-durable-write-seam.mjs +147 -0
  116. package/scripts/ci/check-durable-write-seam.test.mjs +90 -0
  117. package/scripts/ci/check.mjs +3 -0
  118. package/scripts/collective/hook-runner.mjs +39 -4
  119. package/scripts/collective/hook-runner.test.mjs +85 -2
  120. package/scripts/daemon/agent-daemon-board-mine.test.mjs +96 -0
  121. package/scripts/daemon/agent-daemon-frontdoor.test.mjs +60 -0
  122. package/scripts/daemon/agent-daemon.mjs +141 -10
  123. package/scripts/daemon/agent-daemon.test.mjs +73 -0
  124. package/scripts/daemon/assurance-e2e.test.mjs +141 -6
  125. package/scripts/daemon/assurance.mjs +461 -37
  126. package/scripts/daemon/assurance.test.mjs +408 -43
  127. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +334 -0
  128. package/scripts/daemon/cadence-consumer.mjs +254 -78
  129. package/scripts/daemon/cadence-handlers.mjs +53 -0
  130. package/scripts/daemon/classifier.mjs +1 -1
  131. package/scripts/daemon/dispatcher-resume.test.mjs +166 -0
  132. package/scripts/daemon/dispatcher.mjs +127 -19
  133. package/scripts/daemon/health.mjs +12 -1
  134. package/scripts/daemon/inbox-deferral-session.test.mjs +49 -0
  135. package/scripts/daemon/inbox-deferral.mjs +6 -0
  136. package/scripts/daemon/lib/self-echo.mjs +201 -0
  137. package/scripts/daemon/lib/self-echo.test.mjs +153 -0
  138. package/scripts/daemon/maestro-daemon.mjs +3 -0
  139. package/scripts/daemon/prompt-builder.mjs +9 -1
  140. package/scripts/daemon/prompt-builder.test.mjs +22 -0
  141. package/scripts/daemon/responder.mjs +61 -41
  142. package/scripts/daemon/sdk-version.mjs +51 -0
  143. package/scripts/daemon/sdk-version.test.mjs +31 -0
  144. package/scripts/hooks/pre-send-audit.sh +97 -4
  145. package/scripts/hooks/pre-send-audit.test.mjs +140 -1
  146. package/scripts/local-triggers/autoupdate.sh +243 -19
  147. package/scripts/local-triggers/autoupdate.test.mjs +488 -0
  148. package/scripts/local-triggers/generate-plists.sh +24 -1
  149. package/scripts/local-triggers/generate-plists.test.mjs +49 -11
  150. package/scripts/org/send-orgmail.first-contact.test.mjs +102 -0
  151. package/scripts/org/send-orgmail.mjs +27 -3
  152. package/scripts/poller/inbox-privilege-injection.test.mjs +167 -0
  153. package/scripts/poller/slack-poller.mjs +13 -1
  154. package/scripts/poller/utils.mjs +46 -1
  155. package/scripts/poller-launchd/install.sh +19 -11
  156. package/scripts/poller-launchd/install.test.mjs +243 -0
  157. package/scripts/poller-launchd/launchd-poller-wrapper.sh +92 -0
  158. package/scripts/poller-launchd/migrate.sh +66 -0
  159. package/scripts/poller-launchd/poller.plist.template +4 -2
  160. package/scripts/session/feed.mjs +237 -0
  161. package/scripts/session/feed.test.mjs +196 -0
  162. package/scripts/session/supervisor-sh.test.mjs +218 -0
  163. package/scripts/session/supervisor.mjs +328 -0
  164. package/scripts/session/supervisor.sh +141 -0
  165. package/scripts/session/supervisor.test.mjs +482 -0
  166. package/scripts/setup/configure-macos.sh +250 -55
  167. package/scripts/setup/configure-macos.test.mjs +306 -0
  168. package/scripts/setup/init-agent.sh +112 -7
  169. package/scripts/setup/init-agent.test.mjs +220 -1
  170. package/scripts/watchdog/memory-watchdog.sh +37 -1
  171. package/scripts/watchdog/memory-watchdog.test.mjs +64 -0
  172. package/scripts/setup/boot-claude-session.sh +0 -94
@@ -16,6 +16,13 @@
16
16
  * prompt under schedules/triggers/<name>.md.
17
17
  * Unknown cadences with a prompt on disk default to escalate; without
18
18
  * a prompt they go straight to dlq with a clear error.
19
+ * FRONT DOOR (design §3.4): when the seat's main session is live, an
20
+ * escalate/guarded tick is HANDED to it (state/session/handoffs/<id>.json
21
+ * + the rendered prompt) and processed as "handed-to-session" instead of
22
+ * spawning; a handoff un-acked past its deadline is re-enqueued with
23
+ * metadata.handoffTimedOut=true and then spawned on the legacy lane.
24
+ * A deferred tick (circuit, governance, concurrency, per-cadence
25
+ * in-flight guard) is re-keyed to the TAIL and the drain keeps scanning.
19
26
  * 4. Respects .emergency-stop: while present, the loop logs a heartbeat
20
27
  * but never spawns a sub-session and never processes events. Existing
21
28
  * claims remain on disk so they can be resumed once the stop is lifted.
@@ -49,7 +56,12 @@
49
56
  * logger optional fn({ ts, level, …rest }) → void for tests.
50
57
  * now injectable clock fn() → ms (tests); used when re-stamping
51
58
  * in-flight claim mtimes so the stale-claim sweep can't sweep
52
- * a live escalate (audit L6).
59
+ * a live escalate (audit L6), and for handoff deadlines.
60
+ * frontDoorState injected fn() → { frontDoor, sessionLive } (tests);
61
+ * defaults to lib/session/frontdoor#readFrontDoorState over
62
+ * config/session.yaml + state/session/heartbeat.json.
63
+ * handoffDeadlineMs how long a handed-off tick may sit un-acked before it
64
+ * is re-enqueued on the legacy lane (default 30 min).
53
65
  */
54
66
 
55
67
  import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, openSync, closeSync, statSync, unlinkSync, utimesSync } from "node:fs";
@@ -63,6 +75,7 @@ import {
63
75
  completeTick,
64
76
  failTick,
65
77
  requeueTick,
78
+ enqueueTick,
66
79
  recoverStaleClaims,
67
80
  sweepRetention,
68
81
  writeHealth,
@@ -70,6 +83,17 @@ import {
70
83
  logBusEvent,
71
84
  busDepth,
72
85
  } from "../../lib/cadence-bus.mjs";
86
+ // FRONT DOOR (design §3.4). When the seat's main session is live, an
87
+ // escalate/guarded tick is HANDED to it (a handoff file + the rendered prompt
88
+ // on disk) instead of spawning a `claude --print` sub-session; the session acks
89
+ // with `maestro session ack <tickId>`. A handoff past its deadline is expired
90
+ // and the tick re-enqueued with metadata.handoffTimedOut=true, which is never
91
+ // handed off again — so a wedged session cannot starve a cadence. Not live →
92
+ // the legacy spawn, unchanged. The decision is pure; the state reader is the
93
+ // one edge and it fails open to "not live".
94
+ import { readFrontDoorState, shouldHandOffTick } from "../../lib/session/frontdoor.mjs";
95
+ import { writeHandoff, listHandoffs, expireHandoffs, pruneHandoffs, handoffPaths } from "../../lib/session/handoffs.mjs";
96
+ import { writeFileAtomic } from "../../lib/fs-atomic.mjs";
73
97
  import { resolveClaudeBin as sharedResolveClaude, augmentedPath, daemonClaudeArgs } from "../../lib/claude-bin.mjs";
74
98
  import { getCadenceDef } from "./cadence-handlers.mjs";
75
99
  import { obligationAllowedUnderPosture } from "../../lib/plan/compile.mjs";
@@ -549,6 +573,43 @@ export function startConsumer(opts = {}) {
549
573
  const governor = opts.governor || resourceGovernor;
550
574
  const rateGuard = opts.rateGuard || rateGuardModule;
551
575
  const budgetGuard = opts.budgetGuard || budgetGuardModule;
576
+ const handoffDeadlineMs = opts.handoffDeadlineMs;
577
+ const frontDoorStateImpl = typeof opts.frontDoorState === "function"
578
+ ? opts.frontDoorState
579
+ : () => readFrontDoorState(agentRoot, { now: nowMs() });
580
+
581
+ /**
582
+ * The front-door state for THIS decision. A throwing reader means "not
583
+ * live" — the legacy lane runs, nothing is dropped.
584
+ */
585
+ function frontDoorState() {
586
+ try {
587
+ const st = frontDoorStateImpl();
588
+ return st && typeof st === "object" ? st : { frontDoor: "daemon", sessionLive: false };
589
+ } catch {
590
+ return { frontDoor: "daemon", sessionLive: false, reason: "reader-error" };
591
+ }
592
+ }
593
+
594
+ /**
595
+ * Render a cadence prompt the way realSpawnSession does ({{agent.*}} /
596
+ * {{company.*}} tokens from config/agent.json + config/company.json) and
597
+ * land it under state/session/handoffs/prompts/<tickId>.md so the session
598
+ * reads exactly what a sub-session would have been given.
599
+ */
600
+ function renderHandoffPrompt(tickId, promptPath) {
601
+ const fullPrompt = join(agentRoot, promptPath);
602
+ let body = readFileSync(fullPrompt, "utf-8");
603
+ try {
604
+ const agentCfg = JSON.parse(readFileSync(join(agentRoot, "config/agent.json"), "utf-8"));
605
+ let companyCfg = {};
606
+ try { companyCfg = JSON.parse(readFileSync(join(agentRoot, "config/company.json"), "utf-8")); } catch { /* optional */ }
607
+ body = renderTemplate(body, buildContext(agentCfg, null, companyCfg));
608
+ } catch { /* no/invalid config/agent.json — leave body verbatim */ }
609
+ const out = join(handoffPaths(agentRoot).prompts, `${tickId}.md`);
610
+ writeFileAtomic(out, body);
611
+ return out;
612
+ }
552
613
 
553
614
  /**
554
615
  * Admission gate for an escalation. Folds the shared 429 breaker + the
@@ -635,6 +696,11 @@ export function startConsumer(opts = {}) {
635
696
  dlq: 0,
636
697
  retries: 0,
637
698
  spawn_failures: 0,
699
+ // Front door (design §3.4): ticks handed to the live main session, handoffs
700
+ // that timed out and were re-enqueued, and deferrals re-keyed to the tail.
701
+ handed_off: 0,
702
+ handoff_timeouts: 0,
703
+ deferred: 0,
638
704
  last_event_id: null,
639
705
  last_decision: null,
640
706
  };
@@ -660,10 +726,94 @@ export function startConsumer(opts = {}) {
660
726
  // change recoverStaleClaims' signature (it lives in lib/cadence-bus.mjs,
661
727
  // owned by another change-set).
662
728
  const inFlightClaimIds = new Set();
729
+ // Per-cadence in-flight guard (audit F9: the same cadence spawned twice
730
+ // back-to-back). A cadence whose sub-session is still running, or whose
731
+ // handoff is still open with the session, must not start again — the second
732
+ // tick is deferred to the tail instead. Names, not ids: two ticks of one
733
+ // cadence are the duplicate this guards against.
734
+ const inFlightCadences = new Set();
663
735
 
664
736
  // Injectable clock so tests can drive the stale window deterministically.
665
737
  const nowMs = typeof opts.now === "function" ? opts.now : Date.now;
666
738
 
739
+ /** Is a cadence in flight — running here, or open as a handoff with the session? */
740
+ function cadenceInFlight(cadence) {
741
+ if (inFlightCadences.has(cadence)) return "sub-session";
742
+ try {
743
+ if (listHandoffs(agentRoot).some((h) => h.cadence === cadence)) return "handoff";
744
+ } catch { /* unreadable handoff dir → not a reason to block */ }
745
+ return null;
746
+ }
747
+
748
+ /**
749
+ * Re-queue a tick that cannot run YET (an upstream gate, never a per-event
750
+ * failure) to the TAIL under a new id (audit F8). Attempts untouched.
751
+ * `count:false` (the circuit/backoff skip) re-keys without touching the
752
+ * retry/deferral counters or last_decision — a held-back cadence was never
753
+ * attempted, and the skip is already logged and counted as skipped_*; a
754
+ * 30 s poll inside a 60 min circuit window must not read as 120 retries.
755
+ */
756
+ function deferTick(event, reason, extra = {}, { count = true } = {}) {
757
+ const r = requeueTick(agentRoot, event, { defer: true });
758
+ if (count) {
759
+ stats.retries += 1;
760
+ stats.deferred += 1;
761
+ stats.last_decision = "deferred";
762
+ log({ level: "info", stage: "escalate_deferred", reason, id: event.id, requeued_as: r && r.id, cadence: event.cadence, ...extra });
763
+ }
764
+ return { ok: false, decision: "deferred", reason };
765
+ }
766
+
767
+ // Ledger hygiene runs from the sweep at most this often.
768
+ const HANDOFF_PRUNE_EVERY_MS = 60 * 60_000;
769
+ let lastHandoffPruneAt = 0;
770
+
771
+ /**
772
+ * Expire handoffs past their deadline and re-enqueue each tick on the legacy
773
+ * lane with metadata.handoffTimedOut=true (never handed off again). The
774
+ * re-enqueue runs BEFORE the handoff is retired (expireHandoffs#onExpire):
775
+ * if the bus is unwritable the handoff stays open and the next sweep
776
+ * retries, so the cadence run is never lost. Cheap: one readdir of a small
777
+ * directory. Never throws.
778
+ */
779
+ function sweepHandoffTimeouts() {
780
+ const now = nowMs();
781
+ let out;
782
+ try {
783
+ out = expireHandoffs(agentRoot, {
784
+ now,
785
+ deadlineMs: handoffDeadlineMs,
786
+ onExpire: (h) => {
787
+ const meta = h.metadata && typeof h.metadata === "object" ? h.metadata : {};
788
+ const r = enqueueTick({
789
+ cadence: h.cadence,
790
+ source: "handoff-timeout",
791
+ agentRoot,
792
+ metadata: { ...meta, handoffTimedOut: true, handoffTickId: h.tickId, reason: `handoff to session timed out (${h.deadlineAt || "no deadline"})` },
793
+ });
794
+ // enqueueTick swallows an unwritable inbox (fallbackOnly, audit row
795
+ // only) — that is NOT back on the bus: keep the handoff open.
796
+ if (!r || !r.path) throw new Error(`tick not enqueued (${r && r.fallbackOnly ? "inbox unwritable" : "no path"})`);
797
+ stats.handoff_timeouts += 1;
798
+ log({ level: "warn", stage: "handoff_timed_out", id: h.tickId, cadence: h.cadence, requeued_as: r.id, deadline_at: h.deadlineAt || null });
799
+ return true;
800
+ },
801
+ });
802
+ } catch { return 0; }
803
+ for (const f of out.failed) {
804
+ log({ level: "error", stage: "handoff_requeue_failed", id: f.tickId, error: f.error, note: "handoff kept open; retried next sweep" });
805
+ }
806
+ if (now - lastHandoffPruneAt >= HANDOFF_PRUNE_EVERY_MS) {
807
+ lastHandoffPruneAt = now;
808
+ try {
809
+ const pr = pruneHandoffs(agentRoot, { now });
810
+ const n = pr.removed.done.length + pr.removed.prompts.length;
811
+ if (n > 0) log({ level: "info", stage: "handoffs_pruned", done: pr.removed.done.length, prompts: pr.removed.prompts.length });
812
+ } catch { /* hygiene is never a reason to skip the tick */ }
813
+ }
814
+ return out.expired.length;
815
+ }
816
+
667
817
  /**
668
818
  * Re-stamp the mtime of every in-flight claim file to the current time so
669
819
  * the next mtime-based stale sweep treats it as fresh. Best-effort: a missing
@@ -793,56 +943,22 @@ export function startConsumer(opts = {}) {
793
943
  });
794
944
  if (gate.reason === "circuit-open") stats.skipped_circuit_open += 1;
795
945
  else stats.skipped_backoff += 1;
796
- // Put the event back in inbox unchanged. Attempt accounting is
797
- // single-sourced in failTick (audit M5), so we no longer decrement here
798
- // — a held-back-by-circuit re-queue is not a failed attempt and must
799
- // not drift the count. Use the atomic requeueTick (audit M6) instead of
800
- // a bare writeFileSync so a crash can't leave a half-written inbox file.
801
- requeueTick(agentRoot, event);
946
+ // Put the event back — to the TAIL (F8). Attempt accounting is
947
+ // single-sourced in failTick (audit M5), so we never touch it here: a
948
+ // held-back-by-circuit re-queue is not a failed attempt, nor a retry.
949
+ deferTick(event, gate.reason, { retry_at: new Date(gate.retry_at).toISOString() }, { count: false });
802
950
  return { ok: false, decision: gate.reason };
803
951
  }
804
952
 
805
953
  // WS4 governance gate — beside the concurrency cap. If the host is under
806
954
  // memory/load pressure, the 429 breaker is open, or the daily budget is
807
- // exhausted (essential-only), DEFER this cadence tick: requeue it unchanged
808
- // and DO NOT call failTick (a governor/rate deferral is an upstream gate,
809
- // not a per-event failure — burning retry budget here would eventually DLQ
810
- // a perfectly good cadence just because the box was busy).
955
+ // exhausted (essential-only), DEFER this cadence tick: requeue it to the
956
+ // tail and DO NOT call failTick (a governor/rate deferral is an upstream
957
+ // gate, not a per-event failure — burning retry budget here would
958
+ // eventually DLQ a perfectly good cadence just because the box was busy).
811
959
  {
812
960
  const gov = governanceGate(event.cadence);
813
- if (!gov.admit) {
814
- log({
815
- level: "info",
816
- stage: "escalate_deferred",
817
- reason: gov.reason,
818
- id: event.id,
819
- cadence: event.cadence,
820
- });
821
- requeueTick(agentRoot, event);
822
- stats.retries += 1;
823
- stats.last_decision = "deferred";
824
- return { ok: false, decision: "deferred" };
825
- }
826
- }
827
-
828
- if (activeSubSessions >= MAX_CONCURRENT_SUB_SESSIONS) {
829
- // Re-queue and try again next tick. Single-owner cadence consumer
830
- // means this can only happen when a prior tick is still running —
831
- // queue depth is the right back-pressure signal.
832
- log({
833
- level: "info",
834
- stage: "escalate_deferred",
835
- id: event.id,
836
- cadence: event.cadence,
837
- active_subsessions: activeSubSessions,
838
- });
839
- // Re-queue unchanged — concurrent-spawn isn't a per-event failure, so
840
- // it must not touch the attempt count (single-sourced in failTick,
841
- // audit M5). Atomic requeueTick (audit M6) replaces the bare
842
- // writeFileSync that could leave a half-written inbox file.
843
- requeueTick(agentRoot, event);
844
- stats.retries += 1;
845
- return { ok: false, decision: "deferred" };
961
+ if (!gov.admit) return deferTick(event, gov.reason);
846
962
  }
847
963
 
848
964
  const def = getCadenceDef(event.cadence);
@@ -861,7 +977,61 @@ export function startConsumer(opts = {}) {
861
977
  }
862
978
  }
863
979
 
980
+ // Per-cadence in-flight guard (F9): never the same cadence twice at once —
981
+ // neither as two sub-sessions nor as a sub-session beside an open handoff.
982
+ {
983
+ const why = cadenceInFlight(event.cadence);
984
+ if (why) return deferTick(event, `cadence-in-flight:${why}`);
985
+ }
986
+
987
+ // FRONT DOOR (§3.4): a live main session takes this tick instead of a
988
+ // sub-session. Handoff = rendered prompt on disk + the ledger file; the
989
+ // tick is processed here with decision "handed-to-session". A tick whose
990
+ // previous handoff timed out (metadata.handoffTimedOut) falls through to
991
+ // the legacy spawn — shouldHandOffTick is pure and owns that rule.
992
+ {
993
+ const fd = frontDoorState();
994
+ const verdict = shouldHandOffTick({ ...fd, mode: def?.mode, metadata: event.metadata });
995
+ if (verdict.handOff) {
996
+ try {
997
+ const rendered = renderHandoffPrompt(event.id, promptPath);
998
+ const h = writeHandoff(agentRoot, {
999
+ tickId: event.id,
1000
+ cadence: event.cadence,
1001
+ mode: def?.mode || "escalate",
1002
+ promptPath: rendered,
1003
+ metadata: { ...(event.metadata || {}), sourcePrompt: promptPath },
1004
+ }, { now: nowMs(), deadlineMs: handoffDeadlineMs });
1005
+ if (!h.ok) throw new Error(h.error || "handoff write failed");
1006
+ completeTick(agentRoot, event.id, {
1007
+ decision: "handed-to-session",
1008
+ cadence: event.cadence,
1009
+ prompt: promptPath,
1010
+ handoff: h.path,
1011
+ deadline_at: h.handoff && h.handoff.deadlineAt,
1012
+ });
1013
+ stats.handed_off += 1;
1014
+ stats.last_decision = "handed-to-session";
1015
+ log({ level: "info", stage: "handed_to_session", id: event.id, cadence: event.cadence, prompt: rendered, deadline_at: h.handoff && h.handoff.deadlineAt });
1016
+ return { ok: true, decision: "handed-to-session" };
1017
+ } catch (err) {
1018
+ // A handoff we could not write is not a reason to lose the tick —
1019
+ // fall through to the legacy spawn, loudly.
1020
+ log({ level: "warn", stage: "handoff_failed_spawning_instead", id: event.id, cadence: event.cadence, error: err && err.message });
1021
+ }
1022
+ }
1023
+ }
1024
+
1025
+ if (activeSubSessions >= MAX_CONCURRENT_SUB_SESSIONS) {
1026
+ // Re-queue (to the tail) and try again next tick. Single-owner cadence
1027
+ // consumer means this can only happen when a prior tick is still
1028
+ // running — queue depth is the right back-pressure signal. Not a
1029
+ // per-event failure: the attempt count (failTick, audit M5) is untouched.
1030
+ return deferTick(event, "concurrency", { active_subsessions: activeSubSessions });
1031
+ }
1032
+
864
1033
  activeSubSessions += 1;
1034
+ inFlightCadences.add(event.cadence);
865
1035
  // Audit L6: mark this claim in-flight so the periodic stale-claim sweep
866
1036
  // (which can run concurrently with a long-running sub-session) does not
867
1037
  // treat its claimed/<id>.json as crashed and re-queue it under us.
@@ -878,6 +1048,7 @@ export function startConsumer(opts = {}) {
878
1048
  });
879
1049
  } finally {
880
1050
  activeSubSessions -= 1;
1051
+ inFlightCadences.delete(event.cadence);
881
1052
  inFlightClaimIds.delete(event.id);
882
1053
  }
883
1054
 
@@ -910,14 +1081,11 @@ export function startConsumer(opts = {}) {
910
1081
  const rec = rateGuard.recordUsageLimit(RATE_PROVIDER, cadenceUl.resetAt, { agentRoot });
911
1082
  log({ level: "warn", stage: "subsession_usage_limited", id: event.id, cadence: event.cadence, open_until: rec.openUntil, reset_at: rec.resetAt });
912
1083
  } catch { /* */ }
913
- // Same requeue-unchanged handling as a 429: a window-usage limit is a
914
- // shared, provider-side gate — not evidence THIS cadence is broken — so
915
- // requeue without failTick or a circuit trip; the shared breaker (held
916
- // until reset) gates re-escalation.
917
- requeueTick(agentRoot, event);
918
- stats.retries += 1;
919
- stats.last_decision = "deferred";
920
- return { ok: false, decision: "deferred" };
1084
+ // Same handling as a 429: a window-usage limit is a shared, provider-side
1085
+ // gate — not evidence THIS cadence is broken — so requeue (to the tail)
1086
+ // without failTick or a circuit trip; the shared breaker (held until
1087
+ // reset) gates re-escalation.
1088
+ return deferTick(event, "usage-limited");
921
1089
  }
922
1090
  if (rateGuard.classifyStderr(cadenceOut)) {
923
1091
  try {
@@ -929,12 +1097,9 @@ export function startConsumer(opts = {}) {
929
1097
  // NOT evidence that THIS cadence is broken, so we must NOT call
930
1098
  // recordSubsessionFailure here: doing so would advance the per-cadence
931
1099
  // circuit toward open and leave a perfectly-healthy cadence circuit-open
932
- // even after the shared breaker clears. Requeue unchanged (no failTick,
1100
+ // even after the shared breaker clears. Requeue to the tail (no failTick,
933
1101
  // no circuit trip); the shared breaker gates re-escalation.
934
- requeueTick(agentRoot, event);
935
- stats.retries += 1;
936
- stats.last_decision = "deferred";
937
- return { ok: false, decision: "deferred" };
1102
+ return deferTick(event, "rate-limited");
938
1103
  }
939
1104
 
940
1105
  // Failure path: log + cap retries low. The exact stderr tail comes
@@ -1040,40 +1205,49 @@ export function startConsumer(opts = {}) {
1040
1205
  // Routed through the guarded wrapper so an in-flight claim is never swept
1041
1206
  // (audit L6).
1042
1207
  recoverStaleClaimsGuarded();
1208
+ // Front door: a handed-off tick the session never acked comes back to the
1209
+ // legacy lane here, ahead of the drain, so it is claimable in this tick.
1210
+ sweepHandoffTimeouts();
1043
1211
 
1044
1212
  let processed = 0;
1045
- let escalatedThisTick = 0;
1046
- // Drain inline events as much as the consumer can in one tick; cap
1047
- // sub-session escalations at 1 per tick so a fast-failing cadence
1048
- // can't burn a whole minute's worth of retries inside a single poll.
1049
- // The next poll (DEFAULT_POLL_MS later) will pick up where we left off.
1213
+ let spawnedThisTick = 0;
1214
+ // Head-of-line (F8): a deferred tick is re-keyed to the tail, so the loop
1215
+ // KEEPS SCANNING past it to the next cadence instead of breaking. This set
1216
+ // (keyed by the tick's original id, which a re-key preserves) is how the
1217
+ // loop notices it has come back round to a tick it already deferred in
1218
+ // this very tick — it puts that one back untouched and stops, so a queue
1219
+ // holding only un-runnable ticks costs one visit per tick, not sixteen.
1220
+ const seenThisTick = new Set();
1050
1221
  while (!stopping) {
1051
1222
  const claim = claimNextTick(agentRoot);
1052
1223
  if (!claim) break;
1053
1224
  const event = claim.event;
1225
+ const key = (event.metadata && event.metadata.originalId) || event.id;
1226
+ if (seenThisTick.has(key)) {
1227
+ requeueTick(agentRoot, event);
1228
+ break;
1229
+ }
1230
+ seenThisTick.add(key);
1054
1231
  activeTick = event.id;
1055
- let didEscalate = false;
1232
+ let didSpawn = false;
1056
1233
  try {
1057
- const def = getCadenceDef(event.cadence);
1058
- const willEscalate = !def || (def.mode !== "inline" && (def.mode !== "guarded" || true));
1059
- // Roughly: if it's not a registry-inline cadence, we MAY escalate.
1060
- // We don't yet know if the guard will say inline; processEvent
1061
- // will tell us via stats. Use the escalated stats delta as the
1062
- // signal that an actual sub-session ran this iteration.
1063
- const before = stats.escalated + stats.spawn_failures + stats.skipped_circuit_open + stats.skipped_backoff;
1234
+ // Did a sub-session actually run (or fail to spawn) this iteration? A
1235
+ // deferral — circuit/backoff skip, governance, concurrency, in-flight
1236
+ // guard — is NOT a spawn and must not end the scan (that was the
1237
+ // head-of-line block). A handoff is not a spawn either: it is cheap.
1238
+ const before = stats.escalated + stats.spawn_failures;
1064
1239
  await processEvent(event);
1065
- const after = stats.escalated + stats.spawn_failures + stats.skipped_circuit_open + stats.skipped_backoff;
1066
- if (after > before) didEscalate = true;
1067
- // Silence unused var warning.
1068
- void willEscalate;
1240
+ const after = stats.escalated + stats.spawn_failures;
1241
+ if (after > before) didSpawn = true;
1069
1242
  } finally {
1070
1243
  activeTick = null;
1071
1244
  }
1072
1245
  processed += 1;
1073
- if (didEscalate) escalatedThisTick += 1;
1074
- // Hard cap: at most ONE sub-session spawn per tick. Inline ticks
1075
- // keep draining freely (they're cheap).
1076
- if (escalatedThisTick >= 1) break;
1246
+ if (didSpawn) spawnedThisTick += 1;
1247
+ // Hard cap: at most ONE sub-session spawn per tick so a fast-failing
1248
+ // cadence can't burn a whole minute's worth of retries inside a single
1249
+ // poll. Inline ticks and handoffs keep draining freely (they're cheap).
1250
+ if (spawnedThisTick >= 1) break;
1077
1251
  if (processed >= 16) break; // soft batch cap
1078
1252
  }
1079
1253
  return { processed };
@@ -1216,6 +1390,8 @@ export function startConsumer(opts = {}) {
1216
1390
  _recoverStaleClaimsGuarded: recoverStaleClaimsGuarded,
1217
1391
  _markInFlight: (id) => inFlightClaimIds.add(id),
1218
1392
  _clearInFlight: (id) => inFlightClaimIds.delete(id),
1393
+ // Front door: the open handoffs this consumer has written for the session.
1394
+ _handoffs: () => listHandoffs(agentRoot),
1219
1395
  };
1220
1396
  }
1221
1397
 
@@ -679,6 +679,33 @@ const MESSAGING_CURSOR_REL = join("state", "messaging", "inbound-cursor.json");
679
679
  * (re)install starts at "now" and replays nothing. A real, persisted cursor
680
680
  * (always > 0 in practice — see writeMessagingCursor) is returned as-is.
681
681
  */
682
+ /**
683
+ * The seat's own member cuid, as resolved by `agent-daemon.resolveSelfMemberId`
684
+ * and persisted to `config/agent.json`. This is the `actor` hq stamps on every
685
+ * event the seat emits, so it is the ONLY id the wide reader's own-echo and
686
+ * mention joins can match against.
687
+ *
688
+ * Returns "" when unresolved. Also returns "" when the field holds the SLUG
689
+ * (COHORT_AGENT_ID) rather than a cuid — a bad writer putting the slug in the
690
+ * cuid field would otherwise make this fix mask the very bug it fixes, since a
691
+ * slug read from disk is exactly as broken as a slug read from env.
692
+ *
693
+ * @param {string} agentRoot
694
+ * @returns {string} member cuid, or "" if unresolved
695
+ */
696
+ function persistedMemberId(agentRoot) {
697
+ try {
698
+ const c = JSON.parse(readFileSync(join(agentRoot, "config", "agent.json"), "utf-8"));
699
+ const mid = c && typeof c.memberId === "string" ? c.memberId.trim() : "";
700
+ if (!mid) return "";
701
+ const slug = String(process.env.COHORT_AGENT_ID || "").trim();
702
+ if (slug && mid === slug) return "";
703
+ return mid;
704
+ } catch {
705
+ return "";
706
+ }
707
+ }
708
+
682
709
  function readMessagingCursor(agentRoot) {
683
710
  try {
684
711
  const p = join(agentRoot, MESSAGING_CURSOR_REL);
@@ -813,11 +840,36 @@ async function guardMessagingInbound({ event, agentRoot, log }, opts = {}) {
813
840
  // skipped, and the agent re-ingests its own replies as fresh inbound (a
814
841
  // conversation with itself). config/org.yaml does not carry an agentId, so
815
842
  // env is the reliable source on a real machine.
843
+ //
844
+ // MUST BE THE MEMBER CUID, NOT THE SLUG. hq names the actor on every
845
+ // /v1/events row by member cuid ("cmqh0te…"); COHORT_AGENT_ID is a SLUG
846
+ // ("A028"). Every directedness join in the wide reader — own_echo, mention,
847
+ // assignee, decision proposer, doc owner — compares `me` against a
848
+ // cuid-shaped payload field, so handing it the slug does not throw: it
849
+ // silently answers "no" to every identity question. own_echo then never
850
+ // fires and the seat re-ingests its own outbound, which produces a holding
851
+ // note, which is itself re-ingested — a self-sustaining loop that burns a
852
+ // sub-session per cycle and posts into real rooms.
853
+ //
854
+ // agent-daemon.resolveSelfMemberId already resolves the cuid via whoami and
855
+ // persists it to config/agent.json; prefer that, and fall back to the slug
856
+ // (degraded, but better than no id) only until it has been resolved once.
816
857
  const agentId =
817
858
  opts.agentId ||
859
+ persistedMemberId(agentRoot) ||
818
860
  process.env.COHORT_AGENT_ID ||
819
861
  (cfg && cfg.org && cfg.org.cohort && cfg.org.cohort.agentId) ||
820
862
  undefined;
863
+ // Belt and braces on top of the id above: hand the reader BOTH namespaces so
864
+ // own-echo suppression cannot silently lapse if `agentId` resolves to the
865
+ // slug after all (persistedMemberId returns "" when no memberId is on record,
866
+ // or when it equals the slug). resolveDirected uses these ONLY to widen the
867
+ // own-echo drop — never to widen what the agent is entitled to read.
868
+ const meAliases = [
869
+ cfg && cfg.memberId,
870
+ process.env.COHORT_AGENT_ID,
871
+ cfg && cfg.org && cfg.org.cohort && cfg.org.cohort.agentId,
872
+ ].filter((v) => typeof v === "string" && v.trim());
821
873
  // Display names for prose-mention matching ("Isla, can you…" with no @).
822
874
  // The wide reader only ever uses these to match text it was ALREADY entitled
823
875
  // to read, so this cannot widen the aperture.
@@ -835,6 +887,7 @@ async function guardMessagingInbound({ event, agentRoot, log }, opts = {}) {
835
887
  const res = await pull({
836
888
  cfg,
837
889
  agentId,
890
+ meAliases,
838
891
  cursor,
839
892
  fetchImpl: opts.fetchImpl,
840
893
  myNames,
@@ -196,7 +196,7 @@ const CLAUDE_CLI_TIMEOUT_MS = 30_000;
196
196
  * it was a hardcoded example-company literal sitting between two interpolated
197
197
  * fields. This
198
198
  * is not cosmetic: the classifier's `summary` is carried verbatim into
199
- * assurance.composeAck (text a human reads) and board-mirror's board row title,
199
+ * board-mirror's board row title and the needs-attention escalation record,
200
200
  * so a fabricated employer in its frame of reference leaks into the org record.
201
201
  *
202
202
  * @param {{name?:string, role?:string, company?:string, principal?:object}} id