@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
@@ -42,6 +42,12 @@ function loadAgent() {
42
42
  return _agent;
43
43
  }
44
44
 
45
+ // Self-echo guard (25/27/30 Aug recurrences): the poll lane re-ingested this
46
+ // agent's OWN outbound sends as fresh inbound and fired holding acks at them.
47
+ // The old inline check compared `sender` against "isla-roselli" while real
48
+ // events carry "Isla Roselli" — it never matched. The match now lives in one
49
+ // tested module and every drop is audited to logs/daemon/self-echo-drops.jsonl.
50
+ import { isSelfSender, logSelfEchoDrop } from "./lib/self-echo.mjs";
45
51
  import { pollSlack } from "../poller/slack-poller.mjs";
46
52
  import { pollGmail } from "../poller/gmail-poller.mjs";
47
53
  import { pollCalendar } from "../poller/calendar-poller.mjs";
@@ -71,6 +77,7 @@ import {
71
77
  openObligations,
72
78
  escalate,
73
79
  closeObligation,
80
+ isPidAlive,
74
81
  } from "./assurance.mjs";
75
82
  import { recordPoll, recordClassification, recordSession, writeHealthDashboard } from "./health.mjs";
76
83
  import { acquireLock, releaseLock, updateLock, scanStaleLocks, acquireThreadLock, claimRequest, hasActiveClaim, sweepStaleItemClaims, sanitiseItemId } from "./session-lock.mjs";
@@ -85,7 +92,12 @@ const RUNG_MECHANISM = Object.fromEntries(RUNGS.map((r) => [r.id, r.mechanism]))
85
92
  // work and land it in the org's shared, ACL'd store so the fleet's memory
86
93
  // compounds. Fail-open + best-effort — never blocks or throws into the hot path;
87
94
  // local SQLite/interaction logs remain the fail-open cache.
88
- import { isEnabled as orgEnabled, loadOrgConfig, configFromAgent, electResponder, fetchSelfProfile } from "../../lib/org/client.mjs";
95
+ import { isEnabled as orgEnabled, loadOrgConfig, configFromAgent, electResponder, fetchSelfProfile, listMineResult as orgListMineResult } from "../../lib/org/client.mjs";
96
+ // board.mine cache (design §3.5, WP-M6): the daemon writes state/org/board-mine.json
97
+ // every 5 min; the SessionStart primer, session_status and `maestro board mine`
98
+ // read it. The when/what is pure (lib/org/board-mine-cache.mjs); the I/O is here.
99
+ import { shouldRefresh as boardMineShouldRefresh, renderRefresh as renderBoardMineRefresh, cacheTs as boardMineCacheTs, BOARD_MINE_REL } from "../../lib/org/board-mine-cache.mjs";
100
+ import { writeJsonAtomic as writeJsonAtomicFile } from "../../lib/fs-atomic.mjs";
89
101
  import { remember as orgRemember } from "../../lib/org/knowledge.mjs";
90
102
  import { sweepSessionOutcomes, resultTextFromStdout } from "./session-outcomes.mjs";
91
103
  import { recordWorkStep as orgRecordWorkStep, cohortChannelId, cohortMessageId } from "../../lib/org/work-ledger.mjs";
@@ -117,6 +129,13 @@ import { getHookBus } from "../../lib/hooks/bus.mjs";
117
129
  // classifier path below is the mechanism the ladder's `react` effect drives,
118
130
  // not a parallel universe.
119
131
  import { processOne } from "../../lib/execution/pipeline.mjs";
132
+ // FRONT DOOR (design §3.1/§3.3). When the seat's main session is live, the
133
+ // daemon leaves Cohort inbox items in place for the session to claim instead
134
+ // of spawning `claude --print`; not live → today's dispatch, unchanged. The
135
+ // gate is pure (lib/session/frontdoor.mjs); the assurance sweep reopens a
136
+ // session claim that was neither replied nor done within 20 min.
137
+ import { makeFrontDoorGate } from "../../lib/session/frontdoor.mjs";
138
+ import { sweepSessionInboxForDaemon } from "../../lib/session/inbox-claims.mjs";
120
139
  import { defaultEffects, scheduleToQueue } from "../../lib/execution/effects.mjs";
121
140
  import { loadReactObligations } from "../../lib/execution/match.mjs";
122
141
  import { policyFor } from "../../lib/execution/surface-policy.mjs";
@@ -227,8 +246,13 @@ async function poll() {
227
246
  * cohort path can run on its own fast interval (pollCohortFast) without waiting
228
247
  * on the 60s full poll. Returns the count of new items processed. Never throws.
229
248
  */
249
+ const _frontDoorGate = makeFrontDoorGate({ agentRoot: AGENT_REPO_DIR });
230
250
  async function pollService(svc) {
231
251
  try {
252
+ // Front door: a live main session owns this service's inbox — leave it.
253
+ const fd = _frontDoorGate.check(svc.name);
254
+ if (fd.changed) console.log(`[daemon] front door for ${svc.name}: ${fd.dispatch ? "daemon dispatches" : "main session owns intake"} (${fd.reason})`);
255
+ if (!fd.dispatch) return 0;
232
256
  const result = await svc.fn();
233
257
  // Dedup by raw_ref first: the same Slack message arrives via multiple paths
234
258
  // (channel scan, thread scan, events-server JSON) with different item IDs.
@@ -236,12 +260,18 @@ async function pollService(svc) {
236
260
  // once regardless of path.
237
261
  const seenRefs = new Set();
238
262
  const newItems = result.items.filter((item) => {
239
- // Skip the agent's own messages — defensive check in case intake filters miss them
240
- const sender = (item.sender || "").toLowerCase();
241
- const me = loadAgent();
242
- const myFirst = (me.firstName || "").toLowerCase();
243
- const myFull = (me.fullName || "").toLowerCase().replace(/\s+/g, "-");
244
- if (myFirst && (sender === myFirst || sender === myFull)) return false;
263
+ // SELF-ECHO DROP (ingestion guard #1). The agent's own outbound, echoed
264
+ // back by a lane, is not inbound. Dropped LOUDLY (audit log) and the inbox
265
+ // file is retired — without the rename the same echo was re-scanned and
266
+ // re-considered every tick forever. The old check here compared `sender`
267
+ // against `fullName` with spaces folded to dashes, which never matched the
268
+ // real event shape ("Isla Roselli") — the exact reason the 25/27/30 Aug
269
+ // self-reply loops got past it.
270
+ if (isSelfSender(item)) {
271
+ logSelfEchoDrop(item, { stage: "poll", service: svc.name });
272
+ markProcessed(item, svc.name);
273
+ return false;
274
+ }
245
275
 
246
276
  const lockKey = item.raw_ref || item.id || `${svc.name}-${Date.now()}`;
247
277
  if (seenRefs.has(lockKey)) return false;
@@ -278,7 +308,7 @@ async function pollService(svc) {
278
308
  // against the 60s poll's own cohort scan (kept as a safety net).
279
309
  let _cohortPoller = null;
280
310
  let _cohortFastRunning = false;
281
- async function pollCohortFast() {
311
+ export async function pollCohortFast() {
282
312
  if (_cohortFastRunning) return 0;
283
313
  if (!_existsSync(join(AGENT_REPO_DIR, "config/org.yaml"))) return 0;
284
314
  if (!_cohortPoller) _cohortPoller = makeInboxScanPoller("cohort", { agentRoot: AGENT_REPO_DIR });
@@ -339,6 +369,18 @@ async function processItemTraced(item, service, itemId, trace_id, deps = {}) {
339
369
  // regression-prone line in the file.
340
370
  enrichItem(item, service);
341
371
 
372
+ // ── SELF-ECHO DROP (guard #2, the choke point) ───────────────────────────
373
+ // Every entry path — the 60s poll, the fast cohort scan, crash recovery —
374
+ // crosses this line, so an echo that slipped past a lane's own filter is
375
+ // still stopped before it can be classified, elected on, acked or answered.
376
+ // Retiring the file is part of the drop: an un-retired echo is re-delivered
377
+ // next poll and re-dropped forever, which is noise pretending to be safety.
378
+ if (isSelfSender(item)) {
379
+ logSelfEchoDrop(item, { stage: "process", service });
380
+ markProcessed(item, service);
381
+ return { ok: true, path: "filtered", reason: "self_echo" };
382
+ }
383
+
342
384
  // ── THE EXECUTION LADDER, at the front of the chain ──────────────────────
343
385
  // Returns `{ handled }`. `handled:true` means the ladder took responsibility
344
386
  // for this item — it ignored it (journalled), queued it, delegated it,
@@ -899,6 +941,20 @@ export async function answerItem(item, service, itemId, trace_id, deps = {}, rou
899
941
  const ackVerdict = _claudeAvailable()
900
942
  ? shouldAcknowledge({ willSpawnSession: true, item, source: "inbox" })
901
943
  : { ack: false, reason: "claude-unavailable" };
944
+ // ── DISPATCH GATE: emitter-class inbound spawns NOTHING ─────────────────
945
+ // `emitterClass: true` means the gate read the inbound and recognised output
946
+ // this seat's own machinery class produces — a peer agent's ack-shaped
947
+ // one-liner, or another daemon's narrator line (progress/failure/terminal).
948
+ // Suppressing only the ack while still spawning would keep the expensive
949
+ // half of the 25/27/30 Aug loop: a 15-45 minute session burned answering a
950
+ // reflex. The gate already audited the refusal
951
+ // (logs/daemon/dispatch-gate-drops.jsonl); retire the item and stop here.
952
+ if (!ackVerdict.ack && ackVerdict.emitterClass) {
953
+ console.log(`[daemon] dispatch gate: no ack, no session for ${itemId} (${ackVerdict.reason})`);
954
+ counters.bump("dispatch.gate_refused", { service, reason: ackVerdict.reason });
955
+ markProcessed(item, service);
956
+ return { ok: true, path: "filtered", reason: ackVerdict.reason };
957
+ }
902
958
  try {
903
959
  // The DEBT is opened whether or not we can speak. Those are two different
904
960
  // questions and conflating them is what made a whole class of ask
@@ -1712,6 +1768,32 @@ export function _resetDaemonOrgCfgCache() {
1712
1768
  _cachedDaemonOrgCfg = undefined;
1713
1769
  }
1714
1770
 
1771
+ /**
1772
+ * Refresh state/org/board-mine.json from hq `board.mine` when the cache is
1773
+ * 5 min old (or absent). Fail-open end to end: an un-enrolled seat writes
1774
+ * nothing; a FAILED read (hq unreachable) keeps the previous items and marks
1775
+ * the document `stale: true` with `itemsAt` (lib/org/board-mine-cache.mjs
1776
+ * renderRefresh) — one bad tick must not blank the primer, session_status
1777
+ * and `maestro board mine` for five minutes. `listMineImpl` may return the
1778
+ * `{ok, items}` shape of listMineResult or a bare array (= ok).
1779
+ * @param {{now?:number, listMineImpl?:Function, force?:boolean}} [deps]
1780
+ * @returns {Promise<{refreshed:boolean, reason:string, count?:number}>}
1781
+ */
1782
+ export async function refreshBoardMineCache(deps = {}) {
1783
+ const now = Number.isFinite(deps.now) ? deps.now : Date.now();
1784
+ const path = join(AGENT_REPO_DIR, BOARD_MINE_REL);
1785
+ let cur = null;
1786
+ try { cur = JSON.parse(readFileSync(path, "utf8")); } catch { cur = null; /* absent or corrupt → refresh */ }
1787
+ if (!deps.force && !boardMineShouldRefresh(boardMineCacheTs(cur), now)) return { refreshed: false, reason: "fresh" };
1788
+ const cfg = daemonOrgCfg();
1789
+ if (!orgEnabled(cfg)) return { refreshed: false, reason: "org-disabled" };
1790
+ const conn = configFromAgent(cfg) || {};
1791
+ const result = await (deps.listMineImpl || orgListMineResult)({ base: conn.base, token: conn.token, orgId: conn.orgId });
1792
+ const doc = renderBoardMineRefresh(result, cur, now);
1793
+ writeJsonAtomicFile(path, doc);
1794
+ return { refreshed: true, reason: doc.stale ? "kept-previous" : "written", count: doc.items.length };
1795
+ }
1796
+
1715
1797
  /** Longest completion note we will put on a board comment. */
1716
1798
  const COMPLETION_NOTE_CHARS = 1200;
1717
1799
 
@@ -2179,6 +2261,38 @@ async function sweepBacklog() {
2179
2261
  // Main
2180
2262
  // ---------------------------------------------------------------------------
2181
2263
 
2264
+ /**
2265
+ * Daemon singleton ADVISORY — a pidfile check, deliberately not a lock.
2266
+ *
2267
+ * Two live daemons on one AGENT_DIR (the operator's manual run next to the
2268
+ * launchd one) both poll, both sweep, and both narrate; the per-obligation
2269
+ * ownership guard in assurance.sweepObligations bounds the damage, but the
2270
+ * condition itself deserves a LOUD line the moment it starts. This only logs
2271
+ * and counts — it never refuses to start and never exits — because launchd's
2272
+ * KeepAlive restart may race the old process's teardown, and a pidfile that
2273
+ * can veto startup under KeepAlive is a daemon that stays down. A recycled pid
2274
+ * can false-positive; that costs one loud log line, nothing more.
2275
+ */
2276
+ function advisePidfileSingleton() {
2277
+ const pidPath = join(AGENT_REPO_DIR, "state", "daemon.pid");
2278
+ try {
2279
+ let prior = null;
2280
+ try { prior = parseInt(readFileSync(pidPath, "utf-8"), 10); } catch { /* no pidfile yet */ }
2281
+ if (Number.isFinite(prior) && prior > 0 && prior !== process.pid && isPidAlive(prior)) {
2282
+ console.error(
2283
+ `[daemon] ⚠ ANOTHER DAEMON MAY BE LIVE: pid ${prior} (from ${pidPath}) is running and also holds ${AGENT_REPO_DIR}. ` +
2284
+ `Two daemons on one AGENT_DIR double-poll and race the obligation ledger — stop one unless this is deliberate.`,
2285
+ );
2286
+ counters.bump("daemon.singleton_advisory", { prior_pid: prior });
2287
+ }
2288
+ mkdirSync(join(AGENT_REPO_DIR, "state"), { recursive: true });
2289
+ writeFileSync(pidPath, `${process.pid}\n`);
2290
+ } catch (err) {
2291
+ // Advisory only — an unwritable pidfile must never stop the daemon booting.
2292
+ console.warn(`[daemon] pidfile advisory unavailable: ${err.message}`);
2293
+ }
2294
+ }
2295
+
2182
2296
  async function main() {
2183
2297
  console.log("╔══════════════════════════════════════════════════════════╗");
2184
2298
  console.log(`║ ${(loadAgent().firstName || "Agent").padEnd(8)} Daemon — Reactive Event Processor ║`);
@@ -2205,6 +2319,11 @@ async function main() {
2205
2319
  // No emergency stop — continue
2206
2320
  }
2207
2321
 
2322
+ // Two-daemon advisory: log loudly if another live daemon already holds this
2323
+ // AGENT_DIR (pidfile check; never blocks startup — see the function's note on
2324
+ // launchd KeepAlive semantics), then record this process as the holder.
2325
+ advisePidfileSingleton();
2326
+
2208
2327
  // Clear orphaned state from prior daemon instances
2209
2328
  const staleCleared = scanStaleLocks();
2210
2329
  if (staleCleared > 0) {
@@ -2242,6 +2361,9 @@ async function main() {
2242
2361
 
2243
2362
  // Initial poll immediately
2244
2363
  await poll();
2364
+ // First board.mine cache write, so the primer has something before the
2365
+ // first health tick (fail-open; an un-enrolled seat writes nothing).
2366
+ refreshBoardMineCache().catch((err) => console.error("[daemon] board-mine cache error:", err.message));
2245
2367
 
2246
2368
  // Poll loop
2247
2369
  setInterval(async () => {
@@ -2285,6 +2407,12 @@ async function main() {
2285
2407
  if (claimsSwept > 0) {
2286
2408
  console.log(`[daemon] Swept ${claimsSwept} stale item claims`);
2287
2409
  }
2410
+ // Front door: reopen session claims older than 20 min, due deferrals and
2411
+ // (while the session is live) the daemon's own orphaned .dispatched files.
2412
+ const reopened = sweepSessionInboxForDaemon(AGENT_REPO_DIR).reopened;
2413
+ if (reopened.length > 0) console.log(`[daemon] reopened ${reopened.length} session-claimed inbox item(s): ${reopened.map((r) => `${r.id} (${r.reason})`).join(", ")}`);
2414
+ // board.mine cache — the pure gate says "no" for four of every five ticks.
2415
+ refreshBoardMineCache().catch((err) => console.error("[daemon] board-mine cache error:", err.message));
2288
2416
  } catch (err) {
2289
2417
  console.error("[daemon] Health write error:", err.message);
2290
2418
  }
@@ -2300,18 +2428,21 @@ async function main() {
2300
2428
  }, HEALTH_INTERVAL);
2301
2429
 
2302
2430
  // Graceful shutdown — clean up active.json so next startup doesn't see stale sessions
2431
+ // Exit honours a pre-set process.exitCode: the org-mesh "restart" directive
2432
+ // sets 1 before signalling so launchd's KeepAlive {SuccessfulExit:false}
2433
+ // relaunches the daemon; a plain SIGTERM still exits 0 (stays down).
2303
2434
  process.on("SIGTERM", () => {
2304
2435
  console.log("[daemon] SIGTERM received, shutting down gracefully");
2305
2436
  resetActiveSessions();
2306
2437
  writeHealthDashboard();
2307
- process.exit(0);
2438
+ process.exit(process.exitCode || 0);
2308
2439
  });
2309
2440
 
2310
2441
  process.on("SIGINT", () => {
2311
2442
  console.log("[daemon] SIGINT received, shutting down");
2312
2443
  resetActiveSessions();
2313
2444
  writeHealthDashboard();
2314
- process.exit(0);
2445
+ process.exit(process.exitCode || 0);
2315
2446
  });
2316
2447
 
2317
2448
  console.log("[daemon] Running. Ctrl+C to stop.");
@@ -903,3 +903,76 @@ test("fastPathClassify: a short directed question skips the classifier; work-req
903
903
  assert.ok(daemon.fastPathClassify({ content: "what OS?" }, true));
904
904
  assert.equal(daemon.fastPathClassify({ content: "what OS?" }, true, { disableFastPath: true }), null);
905
905
  });
906
+
907
+ // ===========================================================================
908
+ // SELF-ECHO — the agent's own outbound, re-ingested by a poll lane, must be
909
+ // dropped BEFORE it can be classified, elected on, acked or answered. Recurred
910
+ // live 2026-08-25 / 08-27 / 08-30: the old filter normalised the agent's full
911
+ // name to "isla-roselli" while real events carry "Isla Roselli", so the agent
912
+ // said "Looking now." to itself (and, in shared DMs, to another agent's echo).
913
+ // ===========================================================================
914
+
915
+ test("SELF-ECHO: an echoed own message is dropped at the processItem choke point — file retired, drop audited", async () => {
916
+ resetState();
917
+ const selfEcho = await import("./lib/self-echo.mjs");
918
+ const { writeFileSync: wf } = await import("node:fs");
919
+ mkdirSync(join(AGENT_DIR, "config"), { recursive: true });
920
+ wf(join(AGENT_DIR, "config", "agent.json"), JSON.stringify({
921
+ firstName: "Isla", fullName: "Isla Roselli", memberId: "cmqh0tcml004ihhl6zev6ocb5",
922
+ }));
923
+ selfEcho._resetSelfIdentity();
924
+ try {
925
+ // The REAL echo shape: display name verbatim, spaces and all.
926
+ const item = { id: "MSG-SELF", raw_ref: "slack:DSELF:1", channel: "dm/candace", channel_id: "DSELF0001", sender: "Isla Roselli", content: "Both answers — and the second one settles it." };
927
+ const name = writeLiveItem(item);
928
+
929
+ let pipelineTouched = null;
930
+ const res = await daemon.processItem(item, "slack", {
931
+ classify: async () => { pipelineTouched = "classify"; return { priority: "high", action: "respond", model: "opus", summary: "x", category: "action_required", directed_at_agent: true }; },
932
+ isQuickReply: () => { pipelineTouched = "isQuickReply"; return false; },
933
+ sendQuickResponse: async () => { pipelineTouched = "quick_reply"; return { sent: true }; },
934
+ sendHoldingMessage: async () => { pipelineTouched = "holding_ack"; return { sent: true, holdingText: "x" }; },
935
+ dispatch: () => { pipelineTouched = "dispatch"; },
936
+ claudeAvailable: () => true,
937
+ });
938
+
939
+ assert.equal(res && res.path, "filtered", "the echo is filtered, not answered");
940
+ assert.equal(res && res.reason, "self_echo");
941
+ assert.equal(pipelineTouched, null, "no classify, no ack, no reply, no session — the echo never enters the pipeline");
942
+ assert.deepEqual(inboxFiles(), [`${name}.processed`], "the echo's inbox file is RETIRED — an un-retired echo would be re-scanned and re-dropped every tick forever");
943
+ const auditPath = join(AGENT_DIR, "logs", "daemon", "self-echo-drops.jsonl");
944
+ assert.ok(existsSync(auditPath), "every drop leaves an audit row");
945
+ assert.match(readFileSync(auditPath, "utf-8"), /slack:DSELF:1/, "the audit row names the dropped event");
946
+ } finally {
947
+ rmSync(join(AGENT_DIR, "config", "agent.json"), { force: true });
948
+ selfEcho._resetSelfIdentity();
949
+ }
950
+ });
951
+
952
+ test("SELF-ECHO: a genuine colleague's message is untouched by the guard", async () => {
953
+ resetState();
954
+ const selfEcho = await import("./lib/self-echo.mjs");
955
+ const { writeFileSync: wf } = await import("node:fs");
956
+ mkdirSync(join(AGENT_DIR, "config"), { recursive: true });
957
+ wf(join(AGENT_DIR, "config", "agent.json"), JSON.stringify({
958
+ firstName: "Isla", fullName: "Isla Roselli", memberId: "cmqh0tcml004ihhl6zev6ocb5",
959
+ }));
960
+ selfEcho._resetSelfIdentity();
961
+ try {
962
+ const item = { id: "MSG-COLLEAGUE", raw_ref: "slack:DCOLL:1", channel: "dm/jordan", channel_id: "DCOLL0001", is_dm: true, sender: "Jordan Blake", content: "please draft the board memo" };
963
+ writeLiveItem(item);
964
+ let dispatched = false;
965
+ const res = await daemon.answerItem(item, "slack", "slack:DCOLL:1", "trace-coll", {
966
+ classify: async () => ({ priority: "critical", action: "respond", model: "opus", summary: "draft board memo", category: "action_required", directed_at_agent: true }),
967
+ isQuickReply: () => false,
968
+ sendHoldingMessage: async () => ({ sent: true, holdingText: "Starting on the board memo now." }),
969
+ dispatch: (_p, _it, _cr, _s, opts) => { dispatched = true; opts.onClose({ ok: true, code: 0 }); },
970
+ claudeAvailable: () => true,
971
+ });
972
+ assert.equal(dispatched, true, "a real colleague's ask still dispatches");
973
+ assert.equal(res.path, "session");
974
+ } finally {
975
+ rmSync(join(AGENT_DIR, "config", "agent.json"), { force: true });
976
+ selfEcho._resetSelfIdentity();
977
+ }
978
+ });
@@ -47,6 +47,17 @@ const AGENT_DIR = mkdtempSync(join(tmpdir(), "maestro-assurance-e2e-"));
47
47
  process.env.AGENT_DIR = AGENT_DIR;
48
48
  process.env.AGENT_ROOT = AGENT_DIR;
49
49
  process.env.MAESTRO_DAEMON_NO_AUTOSTART = "1";
50
+ // HERMETICITY. `receipts.recordOutbound` attributes a send to an obligation and
51
+ // a session from AMBIENT ENV when the caller does not name them — the seam that
52
+ // lets a CLI send lane inside a spawned session attribute itself. Run this file
53
+ // from inside a live maestro session and those variables are inherited, so the
54
+ // receipt SCENARIO 2 writes to prove "the session answered" is stamped with a
55
+ // DIFFERENT debt's key and reads back as `attributed-elsewhere`. The scenario
56
+ // then fails on a real machine and passes on a bare CI runner, which is the
57
+ // wrong way round for a test to be environment-sensitive.
58
+ delete process.env.MAESTRO_OBLIGATION_KEY;
59
+ delete process.env.MAESTRO_SESSION_ID;
60
+ delete process.env.AGENT_SESSION_ID;
50
61
 
51
62
  let daemon, assurance, receipts;
52
63
 
@@ -103,10 +114,16 @@ function transport(virtualClock = () => null) {
103
114
  };
104
115
  }
105
116
 
106
- /** The daemon's ack seam, shaped exactly like responder.sendHoldingMessage. */
107
- function ackSeam() {
117
+ /** The daemon's ack seam, shaped exactly like responder.sendHoldingMessage:
118
+ * the line is GENERATED (here through the REAL generateAck with an injected
119
+ * runner — no spawn in tests), and a failed generation means silence, never
120
+ * a canned fallback. */
121
+ function ackSeam(runImpl) {
108
122
  return async (item, classResult) => {
109
- const text = assurance.composeAck(item, classResult);
123
+ const text = await assurance.generateAck(item, classResult, {
124
+ runImpl: runImpl || (async () => "Taking the git fixes now — push coming shortly."),
125
+ });
126
+ if (text == null) return { sent: false, holdingText: null, error: "ack_generation_failed", genFailed: true };
110
127
  heard("ack", text);
111
128
  return { sent: true, holdingText: text, via: "fake" };
112
129
  };
@@ -136,6 +153,24 @@ function makeItem(id, content) {
136
153
  return item;
137
154
  }
138
155
 
156
+ /**
157
+ * Every scenario injects both seams that would otherwise spawn: `dispatch` (no
158
+ * child process) and `sendHoldingMessage` (the ack line comes from an injected
159
+ * runner). No scenario in this file ever needs the real `claude` binary.
160
+ *
161
+ * The daemon's ack path is nevertheless gated on `claudeAvailable()` — a
162
+ * NECESSARY production fail-safe (never post "on it" when the CLI that would
163
+ * answer cannot start). Left to the real probe, that gate reads a fact about
164
+ * the MACHINE, not about the scenario: these tests pass on a developer box with
165
+ * the CLI installed and suppress every acknowledgement on a CI runner without
166
+ * it. That is precisely how SCENARIOS 2, 3 and 6 came to be red on main and
167
+ * green everywhere they were written. The premise is declared here instead.
168
+ *
169
+ * The gate's false branch is not untested by this — see "the ack is suppressed
170
+ * when the CLI is unavailable" at the end of this file.
171
+ */
172
+ const CLI_PRESENT = () => true;
173
+
139
174
  const COMPLEX = {
140
175
  priority: "critical", action: "respond", model: "opus", category: "action_required",
141
176
  directed_at_agent: true, summary: "Fix the issues you just noted and push the fixes to git",
@@ -152,6 +187,7 @@ test("SCENARIO 1 — a fast ask gets a direct answer, with no pointless holding
152
187
 
153
188
  let holdingCalls = 0;
154
189
  await daemon.processItem(item, "cohort", {
190
+ claudeAvailable: CLI_PRESENT,
155
191
  classify: async () => ({ priority: "normal", action: "respond", model: "sonnet", category: "question", directed_at_agent: true, summary: "board call time" }),
156
192
  isQuickReply: () => true,
157
193
  sendQuickResponse: async (it) => {
@@ -181,6 +217,7 @@ test("SCENARIO 2 — a slow ask is acknowledged at once, updated while it runs,
181
217
 
182
218
  let onCloseHook = null;
183
219
  await daemon.processItem(item, "cohort", {
220
+ claudeAvailable: CLI_PRESENT,
184
221
  classify: async () => COMPLEX,
185
222
  isQuickReply: () => false,
186
223
  sendHoldingMessage: ackSeam(),
@@ -189,9 +226,10 @@ test("SCENARIO 2 — a slow ask is acknowledged at once, updated while it runs,
189
226
 
190
227
  const ack = transcript.find((m) => m.kind === "ack");
191
228
  assert.ok(ack, "the acknowledgement must go out");
192
- assert.ok(ack.ms < 1000, `acknowledged in ${ack.ms}ms — the old path budgeted 60,000ms and missed 2 times in 3`);
193
- assert.match(ack.text, /on it|looking now|digging in/i);
194
- assert.ok(ack.text.length <= 40, "the ack is one short human line, not a templated paragraph");
229
+ assert.ok(ack.ms < 1000, `acknowledged in ${ack.ms}ms`);
230
+ assert.match(ack.text, /git fixes/i, "the ack is bespoke to the actual ask, not a rotation pick");
231
+ assert.doesNotMatch(ack.text, /^(on it|looking now)[.!—]?\s*$/i, "never the retired canned lines");
232
+ assert.ok(ack.text.length <= 120, "the ack is one short human line, not a templated paragraph");
195
233
 
196
234
  const key = assurance.openObligations()[0].key;
197
235
  note("obligation opened and acknowledged; session running");
@@ -230,6 +268,7 @@ test("SCENARIO 3 — a timing-out session tells the human what happened and that
230
268
 
231
269
  let onCloseHook = null;
232
270
  await daemon.processItem(item, "cohort", {
271
+ claudeAvailable: CLI_PRESENT,
233
272
  classify: async () => ({ ...COMPLEX, summary: "Reconcile July spend against forecast" }),
234
273
  isQuickReply: () => false,
235
274
  sendHoldingMessage: ackSeam(),
@@ -265,6 +304,7 @@ test("SCENARIO 4 — a crashing session tells the human it failed, stops, and es
265
304
 
266
305
  let onCloseHook = null;
267
306
  await daemon.processItem(item, "cohort", {
307
+ claudeAvailable: CLI_PRESENT,
268
308
  classify: async () => ({ ...COMPLEX, summary: "Draft the Q3 investor update" }),
269
309
  isQuickReply: () => false,
270
310
  sendHoldingMessage: ackSeam(),
@@ -299,6 +339,7 @@ test("SCENARIO 5 — a session that exits 0 without replying no longer passes as
299
339
 
300
340
  let onCloseHook = null;
301
341
  await daemon.processItem(item, "cohort", {
342
+ claudeAvailable: CLI_PRESENT,
302
343
  classify: async () => ({ ...COMPLEX, summary: "Check whether the migration landed cleanly" }),
303
344
  isQuickReply: () => false,
304
345
  sendHoldingMessage: ackSeam(),
@@ -333,6 +374,7 @@ test("SCENARIO 6 — a debt orphaned by a daemon restart is spoken to on the nex
333
374
  const item = makeItem("ORPHAN-1", ask);
334
375
 
335
376
  await daemon.processItem(item, "cohort", {
377
+ claudeAvailable: CLI_PRESENT,
336
378
  classify: async () => ({ ...COMPLEX, summary: "Fix DNS on the staging domain" }),
337
379
  isQuickReply: () => false,
338
380
  sendHoldingMessage: ackSeam(),
@@ -357,6 +399,65 @@ test("SCENARIO 6 — a debt orphaned by a daemon restart is spoken to on the nex
357
399
  note("previously: the typing dot stopped and that was the last the human ever heard", 30_000);
358
400
  });
359
401
 
402
+ // ===========================================================================
403
+ // SCENARIO 6b — A PEER AGENT'S ACK ARRIVES AS INBOUND. The dispatch gate reads
404
+ // it, and NEITHER an ack goes out NOR a session spawns — the machinery half of
405
+ // the 25/27/30 Aug loop, closed at the gate.
406
+ // ===========================================================================
407
+
408
+ test("SCENARIO 6b — an ack-shaped one-liner from a peer agent seat spawns nothing and acks nothing", async () => {
409
+ const echoAck = "On it — taking a look now.";
410
+ beginScenario("SCENARIO 6b — PEER AGENT ACK (emitter-class inbound)", echoAck);
411
+ const item = {
412
+ id: "PEER-ACK-1",
413
+ raw_ref: "cohort:dm-a016-a039:PEER-ACK-1",
414
+ message_id: "PEER-ACK-1",
415
+ service: "cohort",
416
+ channel: "dm-a016-a039", // an agent↔agent DM — the structural seat signal
417
+ channel_id: "CDM-PEER-ACK-1",
418
+ is_dm: true,
419
+ sender: "Piotr Novak",
420
+ sender_id: "A039",
421
+ content: echoAck,
422
+ };
423
+ writeFileSync(join(AGENT_DIR, "state", "inbox", "cohort", "PEER-ACK-1.yaml"), "id: PEER-ACK-1\n");
424
+
425
+ let holdingCalls = 0;
426
+ let dispatched = 0;
427
+ await daemon.processItem(item, "cohort", {
428
+ claudeAvailable: CLI_PRESENT,
429
+ classify: async () => ({ ...COMPLEX, summary: "peer says it's on it" }),
430
+ isQuickReply: () => false,
431
+ sendHoldingMessage: async () => { holdingCalls++; return { sent: true, holdingText: "x" }; },
432
+ dispatch: () => { dispatched++; },
433
+ });
434
+
435
+ assert.equal(holdingCalls, 0, "no acknowledgement at machinery");
436
+ assert.equal(dispatched, 0, "and no session burned answering a reflex");
437
+ assert.equal(transcript.length, 0, "the peer seat hears nothing");
438
+ assert.ok(!assurance.openObligations().some((o) => o.key === item.raw_ref), "no debt opened for emitter-class inbound");
439
+ // The refusal is on the audit trail — same log family as the self-echo drops.
440
+ const { readFileSync } = await import("node:fs");
441
+ const drops = readFileSync(join(AGENT_DIR, "logs", "daemon", "dispatch-gate-drops.jsonl"), "utf-8")
442
+ .split("\n").filter(Boolean).map((l) => JSON.parse(l));
443
+ const drop = drops.find((d) => d.raw_ref === item.raw_ref);
444
+ assert.ok(drop, "the gate refusal is audited");
445
+ assert.equal(drop.reason, "peer-ack-shaped");
446
+ note("gate refusal audited to logs/daemon/dispatch-gate-drops.jsonl; item retired");
447
+
448
+ // The CONTRAST: the same shape from a human sender still gets the full path.
449
+ const humanItem = makeItem("HUMAN-TERSE-1", "On it — will do.");
450
+ let humanDispatched = 0;
451
+ await daemon.processItem(humanItem, "cohort", {
452
+ claudeAvailable: CLI_PRESENT,
453
+ classify: async () => COMPLEX,
454
+ isQuickReply: () => false,
455
+ sendHoldingMessage: ackSeam(async () => "Got it — I'll pick this up now."),
456
+ dispatch: () => { humanDispatched++; },
457
+ });
458
+ assert.equal(humanDispatched, 1, "a human's terse line is still worked on");
459
+ });
460
+
360
461
  // ===========================================================================
361
462
  // SCENARIO 7 — TWO ASKS, ONE ROOM. The owner sends a second thing before the
362
463
  // first is done. One session answers; the other exits 0 in silence.
@@ -420,3 +521,37 @@ test("SCENARIO 7 — a second ask in the same DM is not discharged by the first
420
521
  assert.match(rescue.text, /race between the poller's lock release/, "and B's actual work reaches the human");
421
522
  note("previously: B closed as `answered` on A's receipt — zero messages, ask gone");
422
523
  });
524
+
525
+ // ===========================================================================
526
+ // THE GATE'S OTHER SIDE — the fail-safe every scenario above suppresses.
527
+ // ===========================================================================
528
+
529
+ /**
530
+ * Mutation proof for `CLI_PRESENT`. Every scenario above declares the CLI
531
+ * present so that the ack path is exercised rather than skipped; this asserts
532
+ * the branch they are stepping over still behaves. Flip `CLI_PRESENT` to
533
+ * `() => false` and the scenarios go red; delete the `claudeAvailable` seam
534
+ * from THIS test and it goes red on any box with the CLI installed.
535
+ */
536
+ test("the ack is suppressed when the CLI is unavailable — but the debt is still opened", async () => {
537
+ const ask = "rebuild the index and tell me when it's done";
538
+ beginScenario("CLI UNAVAILABLE — silence with a tracked debt, never a promise", ask);
539
+ const item = makeItem("NO-CLI-1", ask);
540
+
541
+ let dispatched = 0;
542
+ await daemon.processItem(item, "cohort", {
543
+ claudeAvailable: () => false,
544
+ classify: async () => COMPLEX,
545
+ isQuickReply: () => false,
546
+ sendHoldingMessage: ackSeam(),
547
+ dispatch: () => { dispatched++; },
548
+ });
549
+
550
+ assert.equal(transcript.filter((m) => m.kind === "ack").length, 0,
551
+ "never promise a reply the CLI that would write it cannot start");
552
+ const rec = assurance.openObligations().find((o) => o.key === item.raw_ref);
553
+ assert.ok(rec, "the debt is opened anyway — silence is compensated by the sweep, not forgotten");
554
+ assert.equal(rec.state, "open");
555
+ assert.equal(dispatched, 1, "the work is still attempted; only the promise is withheld");
556
+ note("no ack, obligation open: the assurance sweep owns the compensation");
557
+ });