@cohortapp/agent-sdk 2.11.15 → 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 (168) 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/mcp/server.test.mjs +9 -4
  35. package/lib/model-router/spawn.test.mjs +21 -0
  36. package/lib/org/board-mine-cache.mjs +99 -0
  37. package/lib/org/board-mine-cache.test.mjs +53 -0
  38. package/lib/org/board.mjs +11 -0
  39. package/lib/org/board.test.mjs +11 -1
  40. package/lib/org/client.mjs +36 -0
  41. package/lib/org/client.test.mjs +46 -0
  42. package/lib/org/inbound/directedness.mjs +18 -2
  43. package/lib/org/inbound/directedness.test.mjs +58 -0
  44. package/lib/org/inbound/index.mjs +8 -1
  45. package/lib/org/inbound/index.test.mjs +22 -0
  46. package/lib/org/mesh-directives.test.mjs +110 -0
  47. package/lib/org/mesh.mjs +61 -1
  48. package/lib/org/protocol.checksum +1 -1
  49. package/lib/org/protocol.mjs +52 -0
  50. package/lib/org/protocol.test.mjs +12 -1
  51. package/lib/org/registry.mjs +3 -2
  52. package/lib/org/tool-surface.mjs +120 -0
  53. package/lib/org/tool-surface.test.mjs +118 -5
  54. package/lib/security/external-content.mjs +1 -1
  55. package/lib/security/external-content.test.mjs +17 -0
  56. package/lib/session/config.mjs +137 -0
  57. package/lib/session/config.test.mjs +92 -0
  58. package/lib/session/feed-core.mjs +229 -0
  59. package/lib/session/feed-core.test.mjs +198 -0
  60. package/lib/session/first-run.mjs +126 -0
  61. package/lib/session/first-run.test.mjs +121 -0
  62. package/lib/session/frontdoor.mjs +266 -0
  63. package/lib/session/frontdoor.test.mjs +205 -0
  64. package/lib/session/handoffs.mjs +295 -0
  65. package/lib/session/handoffs.test.mjs +183 -0
  66. package/lib/session/identity.mjs +220 -0
  67. package/lib/session/identity.test.mjs +180 -0
  68. package/lib/session/inbox-claims.mjs +434 -0
  69. package/lib/session/inbox-claims.test.mjs +286 -0
  70. package/lib/session/launch-args.mjs +161 -0
  71. package/lib/session/launch-args.test.mjs +157 -0
  72. package/lib/session/liveness.mjs +174 -0
  73. package/lib/session/liveness.test.mjs +100 -0
  74. package/lib/session/status-summary.mjs +172 -0
  75. package/lib/session/status-summary.test.mjs +118 -0
  76. package/lib/session-permissions.mjs +39 -3
  77. package/lib/session-permissions.test.mjs +20 -0
  78. package/lib/setup/claude-probe.mjs +161 -24
  79. package/lib/setup/claude-probe.test.mjs +187 -0
  80. package/lib/setup/sections/learning.mjs +2 -1
  81. package/lib/setup/sections/model.mjs +104 -24
  82. package/lib/setup/sections/model.test.mjs +240 -0
  83. package/lib/setup/sections/org.mjs +27 -2
  84. package/lib/setup/sections/org.test.mjs +35 -2
  85. package/lib/setup/sections/verify.mjs +5 -0
  86. package/lib/setup/state.mjs +30 -10
  87. package/lib/setup/state.test.mjs +24 -1
  88. package/lib/singleton.js +11 -3
  89. package/lib/singleton.test.mjs +16 -0
  90. package/lib/subagents/lock.mjs +1 -1
  91. package/lib/telemetry/collect.mjs +270 -6
  92. package/lib/telemetry/collect.test.mjs +196 -1
  93. package/lib/upgrade/global-refresh.mjs +108 -0
  94. package/lib/upgrade/global-refresh.test.mjs +65 -0
  95. package/lib/upgrade/launchd-reconcile.mjs +327 -0
  96. package/lib/upgrade/launchd-reconcile.test.mjs +272 -0
  97. package/lib/upgrade/post-steps.mjs +151 -0
  98. package/lib/upgrade/post-steps.test.mjs +200 -0
  99. package/lib/upgrade/verify.mjs +215 -0
  100. package/lib/upgrade/verify.test.mjs +164 -0
  101. package/lib/voice/outbound.mjs +3 -2
  102. package/lib/voice/post-call-brief.mjs +2 -1
  103. package/lib/voice/session-rotation.mjs +6 -1
  104. package/lib/voice/session-rotation.test.mjs +114 -0
  105. package/package.json +3 -3
  106. package/plugins/maestro-skills/plugin.json +21 -1
  107. package/plugins/maestro-skills/skills/board-work.md +63 -0
  108. package/plugins/maestro-skills/skills/inbound-triage.md +80 -0
  109. package/plugins/maestro-skills/skills/main-session.md +102 -0
  110. package/plugins/maestro-skills/skills/peer-sessions.md +65 -0
  111. package/plugins/maestro-skills/skills/persona-discipline.md +75 -0
  112. package/scaffold/CLAUDE.md +24 -0
  113. package/scripts/ci/check-durable-write-seam.mjs +147 -0
  114. package/scripts/ci/check-durable-write-seam.test.mjs +90 -0
  115. package/scripts/ci/check.mjs +3 -0
  116. package/scripts/collective/hook-runner.mjs +39 -4
  117. package/scripts/collective/hook-runner.test.mjs +85 -2
  118. package/scripts/daemon/agent-daemon-board-mine.test.mjs +96 -0
  119. package/scripts/daemon/agent-daemon-frontdoor.test.mjs +60 -0
  120. package/scripts/daemon/agent-daemon.mjs +141 -10
  121. package/scripts/daemon/agent-daemon.test.mjs +73 -0
  122. package/scripts/daemon/assurance-e2e.test.mjs +141 -6
  123. package/scripts/daemon/assurance.mjs +461 -37
  124. package/scripts/daemon/assurance.test.mjs +408 -43
  125. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +334 -0
  126. package/scripts/daemon/cadence-consumer.mjs +254 -78
  127. package/scripts/daemon/cadence-handlers.mjs +53 -0
  128. package/scripts/daemon/classifier.mjs +1 -1
  129. package/scripts/daemon/dispatcher-resume.test.mjs +166 -0
  130. package/scripts/daemon/dispatcher.mjs +127 -19
  131. package/scripts/daemon/health.mjs +12 -1
  132. package/scripts/daemon/inbox-deferral-session.test.mjs +49 -0
  133. package/scripts/daemon/inbox-deferral.mjs +6 -0
  134. package/scripts/daemon/lib/self-echo.mjs +201 -0
  135. package/scripts/daemon/lib/self-echo.test.mjs +153 -0
  136. package/scripts/daemon/maestro-daemon.mjs +3 -0
  137. package/scripts/daemon/responder.mjs +51 -40
  138. package/scripts/daemon/sdk-version.mjs +51 -0
  139. package/scripts/daemon/sdk-version.test.mjs +31 -0
  140. package/scripts/hooks/pre-send-audit.sh +97 -4
  141. package/scripts/hooks/pre-send-audit.test.mjs +140 -1
  142. package/scripts/local-triggers/autoupdate.sh +243 -19
  143. package/scripts/local-triggers/autoupdate.test.mjs +488 -0
  144. package/scripts/local-triggers/generate-plists.sh +24 -1
  145. package/scripts/local-triggers/generate-plists.test.mjs +49 -11
  146. package/scripts/org/send-orgmail.first-contact.test.mjs +102 -0
  147. package/scripts/org/send-orgmail.mjs +27 -3
  148. package/scripts/poller/inbox-privilege-injection.test.mjs +167 -0
  149. package/scripts/poller/slack-poller.mjs +13 -1
  150. package/scripts/poller/utils.mjs +46 -1
  151. package/scripts/poller-launchd/install.sh +19 -11
  152. package/scripts/poller-launchd/install.test.mjs +243 -0
  153. package/scripts/poller-launchd/launchd-poller-wrapper.sh +92 -0
  154. package/scripts/poller-launchd/migrate.sh +66 -0
  155. package/scripts/poller-launchd/poller.plist.template +4 -2
  156. package/scripts/session/feed.mjs +237 -0
  157. package/scripts/session/feed.test.mjs +196 -0
  158. package/scripts/session/supervisor-sh.test.mjs +218 -0
  159. package/scripts/session/supervisor.mjs +328 -0
  160. package/scripts/session/supervisor.sh +141 -0
  161. package/scripts/session/supervisor.test.mjs +482 -0
  162. package/scripts/setup/configure-macos.sh +250 -55
  163. package/scripts/setup/configure-macos.test.mjs +306 -0
  164. package/scripts/setup/init-agent.sh +112 -7
  165. package/scripts/setup/init-agent.test.mjs +220 -1
  166. package/scripts/watchdog/memory-watchdog.sh +37 -1
  167. package/scripts/watchdog/memory-watchdog.test.mjs +64 -0
  168. package/scripts/setup/boot-claude-session.sh +0 -94
@@ -20,6 +20,7 @@
20
20
  * - check-no-build-artifacts : no tracked .pyc/symlinks reach the tarball
21
21
  * - check-docs-accuracy : README links resolve; counts/agent-refs WARN
22
22
  * - check-subagent-frontmatter: every agents/*.md passes the registry validator
23
+ * - check-durable-write-seam : durable JSON writes under lib/ go through fs-atomic
23
24
  *
24
25
  * Usage: `node scripts/ci/check.mjs`
25
26
  * exit 0 → all checks passed; exit 1 → one or more failed.
@@ -38,6 +39,7 @@ import { run as runNoResidualIdentity } from "./check-no-residual-identity.mjs";
38
39
  import { run as runNoBuildArtifacts } from "./check-no-build-artifacts.mjs";
39
40
  import { run as runDocsAccuracy } from "./check-docs-accuracy.mjs";
40
41
  import { run as runSubagentFrontmatter } from "./check-subagent-frontmatter.mjs";
42
+ import { run as runDurableWriteSeam } from "./check-durable-write-seam.mjs";
41
43
 
42
44
  /**
43
45
  * The ordered list of guards this aggregator runs.
@@ -54,6 +56,7 @@ export const CHECKS = [
54
56
  { name: "check-no-build-artifacts", run: runNoBuildArtifacts },
55
57
  { name: "check-docs-accuracy", run: runDocsAccuracy },
56
58
  { name: "check-subagent-frontmatter", run: runSubagentFrontmatter },
59
+ { name: "check-durable-write-seam", run: runDurableWriteSeam },
57
60
  ];
58
61
 
59
62
  /**
@@ -76,8 +76,10 @@ async function main() {
76
76
  const repo = basename(cwd || "");
77
77
 
78
78
  if (event === "prime") {
79
- try { presence.register(SELF_ROOT, { sessionId, cwd, repo, kind: classifyKind(input) }); } catch { /* */ }
80
- if (denied) return; // never inject agent memory into a ring-fenced/client session
79
+ const kind = classifyKind(input);
80
+ try { presence.register(SELF_ROOT, { sessionId, cwd, repo, kind }); } catch { /* */ }
81
+ if (denied) return; // never inject agent memory (or the persona) into a ring-fenced/client session
82
+ const parts = [];
81
83
  try {
82
84
  const recall = await import("../../lib/collective/recall.mjs");
83
85
  const digest = recall.buildDigest(SELF_ROOT, {
@@ -86,8 +88,25 @@ async function main() {
86
88
  halfLifeDays: config.recencyHalfLifeDays,
87
89
  staleSec: config.presence && config.presence.staleSec,
88
90
  });
89
- emitContext(digest);
90
- } catch { /* */ }
91
+ if (digest) parts.push(digest);
92
+ } catch { /* the digest is best-effort; the primer below still runs */ }
93
+ // Front-door session (2026-09): the identity block + a one-line session
94
+ // status (main session liveness, peers, open handoffs, board-mine count
95
+ // from state/org/board-mine.json when the daemon has cached it). Any
96
+ // session on the machine — a home-dir `claude`, a peer, the main session
97
+ // itself — learns who it is from this. Silent when the repo has no
98
+ // identity yet (config/agent.json absent or unconfigured), and skipped
99
+ // for the daemon's own `claude --print` children (MAESTRO_DAEMON=1): their
100
+ // prompt-builder preamble already carries the identity, so the block
101
+ // would be ~400 duplicate tokens on every dispatch.
102
+ if (kind !== "daemon") {
103
+ try {
104
+ const primer = await buildFrontDoorPrimer(SELF_ROOT);
105
+ if (primer) parts.push(primer);
106
+ } catch { /* an unreadable state dir must never break a session start */ }
107
+ }
108
+ // ONE frame: Claude Code reads a single JSON object from a hook's stdout.
109
+ emitContext(parts.join("\n\n"));
91
110
  return;
92
111
  }
93
112
 
@@ -116,6 +135,22 @@ async function main() {
116
135
  }
117
136
  }
118
137
 
138
+ /** The identity block + status line for `prime`. "" when there is nothing to say. */
139
+ function buildFrontDoorPrimer(agentRoot) {
140
+ let agentJson = null;
141
+ try {
142
+ const p = join(agentRoot, "config", "agent.json");
143
+ if (existsSync(p)) agentJson = JSON.parse(readFileSync(p, "utf8"));
144
+ } catch {
145
+ agentJson = null; // an unconfigured/corrupt agent.json is "no identity yet"
146
+ }
147
+ if (!agentJson || !(agentJson.fullName || agentJson.firstName)) return "";
148
+ // Dynamic import so a hook on a repo that predates lib/session stays silent
149
+ // instead of failing the session start.
150
+ return import("../../lib/session/status-summary.mjs").then((mod) =>
151
+ mod.buildPrimerContext({ agentJson, agentRoot, status: mod.readSessionStatus(agentRoot, { now: Date.now() }) }));
152
+ }
153
+
119
154
  function classifyKind(input) {
120
155
  const src = String(input.source || "").toLowerCase();
121
156
  if (src.includes("daemon") || process.env.MAESTRO_DAEMON === "1") return "daemon";
@@ -24,11 +24,14 @@ function tmpRoot() {
24
24
  mkdirSync(join(root, "config"), { recursive: true });
25
25
  return root;
26
26
  }
27
- function run(event, input, root) {
27
+ function run(event, input, root, extraEnv = {}) {
28
+ const env = { ...process.env, MAESTRO_COLLECTIVE_ROOT: root };
29
+ delete env.MAESTRO_DAEMON; // never inherit a daemon marker from the runner's own environment
30
+ Object.assign(env, extraEnv);
28
31
  return spawnSync(process.execPath, [RUNNER, event], {
29
32
  input: JSON.stringify(input),
30
33
  encoding: "utf8",
31
- env: { ...process.env, MAESTRO_COLLECTIVE_ROOT: root },
34
+ env,
32
35
  });
33
36
  }
34
37
 
@@ -88,3 +91,83 @@ test("disabled config → fully silent no-op", () => {
88
91
  assert.equal(r.stdout.trim(), "");
89
92
  } finally { rmSync(root, { recursive: true, force: true }); }
90
93
  });
94
+
95
+ // ── WP-M4: the front-door primer (identity block + session status) ─────────
96
+
97
+ test("prime: injects the identity block + one-line session status + handoff/board counts when the repo has an identity", () => {
98
+ const root = tmpRoot();
99
+ try {
100
+ writeFileSync(join(root, "config", "agent.json"), JSON.stringify({
101
+ firstName: "Alex", fullName: "Alex Rivera", title: "Chief of Staff", company: "Acme",
102
+ principal: { fullName: "Jordan Lee", title: "CEO" }, repoSlug: "alex-ai",
103
+ }));
104
+ mkdirSync(join(root, "state", "session", "handoffs"), { recursive: true });
105
+ mkdirSync(join(root, "state", "org"), { recursive: true });
106
+ writeFileSync(join(root, "state", "session", "heartbeat.json"), JSON.stringify({ pid: 1, name: "alex-main", ts: Date.now() }));
107
+ writeFileSync(join(root, "state", "session", "handoffs", "t1.json"), "{}");
108
+ writeFileSync(join(root, "state", "session", "handoffs", "t2.json"), "{}");
109
+ writeFileSync(join(root, "state", "org", "board-mine.json"), JSON.stringify({ items: [{ itemId: "i1" }], fetchedAt: new Date().toISOString() }));
110
+ const r = run("prime", { session_id: "me", cwd: "/x/maestro" }, root);
111
+ assert.equal(r.status, 0);
112
+ const payload = JSON.parse(r.stdout);
113
+ const ctx = payload.hookSpecificOutput.additionalContext;
114
+ assert.match(ctx, /maestro:identity/);
115
+ assert.match(ctx, /Alex Rivera/);
116
+ assert.match(ctx, /Chief of Staff/);
117
+ assert.match(ctx, /`alex-main`/);
118
+ assert.match(ctx, /Session status: main session alex-main live/);
119
+ assert.match(ctx, /2 open handoffs/);
120
+ assert.match(ctx, /1 board item/);
121
+ } finally { rmSync(root, { recursive: true, force: true }); }
122
+ });
123
+
124
+ test("prime: identity primer and memory digest are emitted together in ONE hook frame", () => {
125
+ const root = tmpRoot();
126
+ try {
127
+ writeFileSync(join(root, "config", "agent.json"), JSON.stringify({ firstName: "Alex", fullName: "Alex Rivera" }));
128
+ register(root, { sessionId: "other", cwd: "/x/openclaw", intent: "research" });
129
+ appendCards(root, [{ kind: "decision", title: "Shipped billing", salience: 0.9, ts: new Date().toISOString() }], { sessionId: "prev" });
130
+ const r = run("prime", { session_id: "me", cwd: "/x/maestro" }, root);
131
+ assert.equal(r.status, 0);
132
+ const frames = r.stdout.trim().split("\n").filter(Boolean);
133
+ assert.equal(frames.length, 1, "exactly one JSON frame on stdout");
134
+ const ctx = JSON.parse(frames[0]).hookSpecificOutput.additionalContext;
135
+ assert.match(ctx, /maestro:collective-digest/);
136
+ assert.match(ctx, /Shipped billing/);
137
+ assert.match(ctx, /maestro:identity/);
138
+ assert.match(ctx, /Session status: main session not running/);
139
+ } finally { rmSync(root, { recursive: true, force: true }); }
140
+ });
141
+
142
+ test("prime: a denylisted cwd gets neither memory nor identity", () => {
143
+ const root = tmpRoot();
144
+ try {
145
+ writeFileSync(join(root, "config", "collective.yaml"), "repoDenylist: [client-secret]\n");
146
+ writeFileSync(join(root, "config", "agent.json"), JSON.stringify({ firstName: "Alex", fullName: "Alex Rivera" }));
147
+ const r = run("prime", { session_id: "me", cwd: "/work/client-secret" }, root);
148
+ assert.equal(r.status, 0);
149
+ assert.equal(r.stdout.trim(), "");
150
+ } finally { rmSync(root, { recursive: true, force: true }); }
151
+ });
152
+
153
+ test("prime: the daemon's own `claude --print` children (MAESTRO_DAEMON=1) get the memory digest but NOT the identity primer", () => {
154
+ const root = tmpRoot();
155
+ try {
156
+ writeFileSync(join(root, "config", "agent.json"), JSON.stringify({ firstName: "Alex", fullName: "Alex Rivera" }));
157
+ // No digest material → a daemon child is fully silent.
158
+ const quiet = run("prime", { session_id: "d1", cwd: "/x/maestro" }, root, { MAESTRO_DAEMON: "1" });
159
+ assert.equal(quiet.status, 0);
160
+ assert.equal(quiet.stdout.trim(), "", "no identity block for a daemon child");
161
+ // With digest material → the digest still flows, the identity block still does not.
162
+ register(root, { sessionId: "other", cwd: "/x/openclaw", intent: "research" });
163
+ appendCards(root, [{ kind: "decision", title: "Shipped billing", salience: 0.9, ts: new Date().toISOString() }], { sessionId: "prev" });
164
+ const r = run("prime", { session_id: "d2", cwd: "/x/maestro" }, root, { MAESTRO_DAEMON: "1" });
165
+ assert.equal(r.status, 0);
166
+ const ctx = JSON.parse(r.stdout).hookSpecificOutput.additionalContext;
167
+ assert.match(ctx, /Shipped billing/);
168
+ assert.ok(!/maestro:identity/.test(ctx), "identity primer is skipped for daemon children");
169
+ // An interactive session on the same repo still gets it.
170
+ const i = run("prime", { session_id: "i1", cwd: "/x/maestro" }, root);
171
+ assert.match(JSON.parse(i.stdout).hookSpecificOutput.additionalContext, /maestro:identity/);
172
+ } finally { rmSync(root, { recursive: true, force: true }); }
173
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * agent-daemon-board-mine.test.mjs — the daemon caches hq `board.mine` to
3
+ * state/org/board-mine.json every 5 minutes (design §3.5, WP-M6).
4
+ *
5
+ * Drives the REAL refreshBoardMineCache with an injected listMine and a fixed
6
+ * clock under a tmp AGENT_DIR. Same `imapflow` loader stub as the other
7
+ * daemon tests.
8
+ */
9
+
10
+ import { test, before } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { register } from "node:module";
13
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { tmpdir } from "node:os";
16
+
17
+ const loaderSrc = `
18
+ export async function resolve(specifier, context, next) {
19
+ if (specifier === "imapflow") return { url: "imapflow-stub:main", shortCircuit: true };
20
+ return next(specifier, context);
21
+ }
22
+ export async function load(url, context, next) {
23
+ if (url === "imapflow-stub:main") return { format: "module", shortCircuit: true, source: "export class ImapFlow {}" };
24
+ return next(url, context);
25
+ }
26
+ `;
27
+ register(`data:text/javascript,${encodeURIComponent(loaderSrc)}`);
28
+
29
+ const AGENT_DIR = mkdtempSync(join(tmpdir(), "maestro-daemon-board-mine-"));
30
+ process.env.AGENT_DIR = AGENT_DIR;
31
+ process.env.MAESTRO_DAEMON_NO_AUTOSTART = "1";
32
+
33
+ const NOW = Date.parse("2026-09-08T12:00:00Z");
34
+ const CACHE = join(AGENT_DIR, "state", "org", "board-mine.json");
35
+
36
+ let daemon;
37
+ before(async () => {
38
+ mkdirSync(join(AGENT_DIR, "config"), { recursive: true });
39
+ mkdirSync(join(AGENT_DIR, "state", "sessions", "locks"), { recursive: true });
40
+ writeFileSync(join(AGENT_DIR, "config", "org.yaml"), "org:\n cohort:\n enabled: true\n base: https://os.example.test\n token: nlk_test_token\n orgId: org_test\n");
41
+ writeFileSync(join(AGENT_DIR, "config", "agent.json"), JSON.stringify({ firstName: "Nova", lastName: "Reyes" }));
42
+ const url = new URL("./agent-daemon.mjs", import.meta.url);
43
+ daemon = await import(`${url.href}?boardmine=${Math.random()}`);
44
+ });
45
+
46
+ test("first call writes state/org/board-mine.json as {ts, items} from listMine; a call inside 5 min is a no-op; one at 5 min refreshes", async () => {
47
+ const calls = [];
48
+ const listMineImpl = async (o) => { calls.push(o); return [{ itemId: "i1", title: "Do X", col: "todo" }, null, "junk"]; };
49
+ const first = await daemon.refreshBoardMineCache({ now: NOW, listMineImpl });
50
+ assert.deepEqual(first, { refreshed: true, reason: "written", count: 1 });
51
+ assert.ok(existsSync(CACHE));
52
+ const doc = JSON.parse(readFileSync(CACHE, "utf8"));
53
+ assert.deepEqual(doc, { ts: "2026-09-08T12:00:00.000Z", items: [{ itemId: "i1", title: "Do X", col: "todo" }] });
54
+ assert.equal(calls.length, 1);
55
+ assert.equal(calls[0].base, "https://os.example.test", "the daemon's org.yaml connection is what listMine gets");
56
+ assert.equal(calls[0].orgId, "org_test");
57
+
58
+ const soon = await daemon.refreshBoardMineCache({ now: NOW + 4 * 60_000, listMineImpl });
59
+ assert.deepEqual(soon, { refreshed: false, reason: "fresh" });
60
+ assert.equal(calls.length, 1, "no hq call inside the interval");
61
+
62
+ const later = await daemon.refreshBoardMineCache({ now: NOW + 5 * 60_000, listMineImpl: async () => ({ ok: true, items: [] }) });
63
+ assert.deepEqual(later, { refreshed: true, reason: "written", count: 0 });
64
+ assert.deepEqual(JSON.parse(readFileSync(CACHE, "utf8")), { ts: "2026-09-08T12:05:00.000Z", items: [] }, "an empty board is a real answer");
65
+ });
66
+
67
+ test("hq unreachable for one tick: the last known good items are KEPT and the document is marked stale with itemsAt; the next good read clears it", async () => {
68
+ await daemon.refreshBoardMineCache({ now: NOW + 10 * 60_000, listMineImpl: async () => [{ itemId: "keep-me" }] });
69
+ const failed = await daemon.refreshBoardMineCache({ now: NOW + 15 * 60_000, listMineImpl: async () => ({ ok: false, items: [], error: "http 503" }) });
70
+ assert.deepEqual(failed, { refreshed: true, reason: "kept-previous", count: 1 });
71
+ const doc = JSON.parse(readFileSync(CACHE, "utf8"));
72
+ assert.deepEqual(doc, { ts: "2026-09-08T12:15:00.000Z", items: [{ itemId: "keep-me" }], stale: true, itemsAt: "2026-09-08T12:10:00.000Z", error: "http 503" });
73
+ const ok = await daemon.refreshBoardMineCache({ now: NOW + 20 * 60_000, listMineImpl: async () => ({ ok: true, items: [{ itemId: "fresh" }] }) });
74
+ assert.deepEqual(ok, { refreshed: true, reason: "written", count: 1 });
75
+ assert.deepEqual(JSON.parse(readFileSync(CACHE, "utf8")), { ts: "2026-09-08T12:20:00.000Z", items: [{ itemId: "fresh" }] });
76
+ });
77
+
78
+ test("a corrupt cache is refreshed; force bypasses the interval; the tick-loop call never throws when listMine rejects", async () => {
79
+ writeFileSync(CACHE, "{not json");
80
+ const r = await daemon.refreshBoardMineCache({ now: NOW + 30 * 60_000, listMineImpl: async () => [{ itemId: "z" }] });
81
+ assert.equal(r.refreshed, true);
82
+ const forced = await daemon.refreshBoardMineCache({ now: NOW + 30 * 60_000 + 1, force: true, listMineImpl: async () => [{ itemId: "y" }] });
83
+ assert.equal(forced.refreshed, true);
84
+ assert.deepEqual(JSON.parse(readFileSync(CACHE, "utf8")).items, [{ itemId: "y" }]);
85
+ await assert.rejects(daemon.refreshBoardMineCache({ now: NOW + 40 * 60_000, listMineImpl: async () => { throw new Error("boom"); } }), /boom/);
86
+ assert.deepEqual(JSON.parse(readFileSync(CACHE, "utf8")).items, [{ itemId: "y" }], "a failed refresh leaves the previous cache in place");
87
+ });
88
+
89
+ test("an un-enrolled seat (org disabled) writes nothing", async () => {
90
+ writeFileSync(join(AGENT_DIR, "config", "org.yaml"), "org:\n cohort:\n enabled: false\n");
91
+ daemon._resetDaemonOrgCfgCache();
92
+ rmSync(CACHE, { force: true });
93
+ const r = await daemon.refreshBoardMineCache({ now: NOW + 60 * 60_000, listMineImpl: async () => [{ itemId: "no" }] });
94
+ assert.deepEqual(r, { refreshed: false, reason: "org-disabled" });
95
+ assert.ok(!existsSync(CACHE));
96
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * agent-daemon-frontdoor.test.mjs — the daemon steps back from the Cohort
3
+ * inbox while the main session is live (design §3.1).
4
+ *
5
+ * Drives the REAL fast cohort scan (pollCohortFast → pollService) with a live
6
+ * heartbeat planted under the tmp AGENT_DIR and a fresh inbox item: the item
7
+ * must be left exactly as it arrived (no lock, no rename, no classification).
8
+ * The not-live branch is the pre-existing dispatch path, which would reach the
9
+ * Haiku classifier — it is covered by the pure gate tests instead.
10
+ *
11
+ * Same `imapflow` loader stub as agent-daemon.test.mjs.
12
+ */
13
+
14
+ import { test, before } from "node:test";
15
+ import assert from "node:assert/strict";
16
+ import { register } from "node:module";
17
+ import { mkdtempSync, mkdirSync, writeFileSync, readdirSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { tmpdir } from "node:os";
20
+
21
+ const loaderSrc = `
22
+ export async function resolve(specifier, context, next) {
23
+ if (specifier === "imapflow") return { url: "imapflow-stub:main", shortCircuit: true };
24
+ return next(specifier, context);
25
+ }
26
+ export async function load(url, context, next) {
27
+ if (url === "imapflow-stub:main") return { format: "module", shortCircuit: true, source: "export class ImapFlow {}" };
28
+ return next(url, context);
29
+ }
30
+ `;
31
+ register(`data:text/javascript,${encodeURIComponent(loaderSrc)}`);
32
+
33
+ const AGENT_DIR = mkdtempSync(join(tmpdir(), "maestro-daemon-frontdoor-"));
34
+ process.env.AGENT_DIR = AGENT_DIR;
35
+ process.env.MAESTRO_DAEMON_NO_AUTOSTART = "1";
36
+
37
+ let daemon;
38
+ before(async () => {
39
+ mkdirSync(join(AGENT_DIR, "config"), { recursive: true });
40
+ mkdirSync(join(AGENT_DIR, "state", "inbox", "cohort"), { recursive: true });
41
+ mkdirSync(join(AGENT_DIR, "state", "session"), { recursive: true });
42
+ mkdirSync(join(AGENT_DIR, "state", "sessions", "locks"), { recursive: true });
43
+ writeFileSync(join(AGENT_DIR, "config", "org.yaml"), "org:\n cohort:\n enabled: true\n");
44
+ writeFileSync(join(AGENT_DIR, "config", "agent.json"), JSON.stringify({ firstName: "Nova", lastName: "Reyes" }));
45
+ const url = new URL("./agent-daemon.mjs", import.meta.url);
46
+ daemon = await import(`${url.href}?frontdoor=${Math.random()}`);
47
+ });
48
+
49
+ test("live main session: pollCohortFast leaves a fresh cohort item untouched for the session", async () => {
50
+ writeFileSync(join(AGENT_DIR, "state", "session", "heartbeat.json"), JSON.stringify({ pid: process.pid, ts: new Date().toISOString() }));
51
+ const name = "2026-09-08T10-00-00-000Z-cohort-fd1.yaml";
52
+ writeFileSync(join(AGENT_DIR, "state", "inbox", "cohort", name), [
53
+ 'id: "cohort-fd1"', 'service: "cohort"', 'channel: "dm/olivia"', 'channel_id: "ch1"', 'channel_kind: "DM"',
54
+ 'sender: "Olivia Chen"', 'timestamp: "2026-09-08T10:00:00.000Z"', 'subject: ""', "content: |", " hello", 'raw_ref: "cohort-fd1"', "",
55
+ ].join("\n"));
56
+ const n = await daemon.pollCohortFast();
57
+ assert.equal(n, 0, "nothing dispatched by the daemon");
58
+ assert.deepEqual(readdirSync(join(AGENT_DIR, "state", "inbox", "cohort")), [name], "the item is still a fresh .yaml");
59
+ assert.deepEqual(readdirSync(join(AGENT_DIR, "state", "sessions", "locks")), [], "no per-item lock taken");
60
+ });
@@ -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.");