@cohortapp/agent-sdk 2.11.15 → 2.13.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 (280) hide show
  1. package/.env.example +37 -22
  2. package/README.md +2 -0
  3. package/bin/maestro.mjs +117 -39
  4. package/bin/maestro.test.mjs +175 -5
  5. package/docs/guides/front-door-session.md +313 -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/design.mjs +185 -0
  17. package/lib/cli/design.test.mjs +270 -0
  18. package/lib/cli/doctor-checks.mjs +441 -0
  19. package/lib/cli/doctor-checks.test.mjs +336 -0
  20. package/lib/cli/global-setup-extras.mjs +454 -0
  21. package/lib/cli/global-setup-extras.test.mjs +462 -0
  22. package/lib/cli/inbox.mjs +304 -0
  23. package/lib/cli/inbox.test.mjs +230 -0
  24. package/lib/cli/session-ack.mjs +63 -0
  25. package/lib/cli/session-ack.test.mjs +63 -0
  26. package/lib/cli/session.mjs +760 -0
  27. package/lib/cli/session.test.mjs +613 -0
  28. package/lib/collective/global-config.mjs +209 -6
  29. package/lib/collective/global-config.test.mjs +145 -0
  30. package/lib/collective/global-skills.mjs +145 -0
  31. package/lib/collective/global-skills.test.mjs +126 -0
  32. package/lib/collective/presence.mjs +4 -3
  33. package/lib/collective/vendor-skills.mjs +305 -0
  34. package/lib/collective/vendor-skills.test.mjs +306 -0
  35. package/lib/comms/send-gate.mjs +115 -0
  36. package/lib/comms/send-gate.test.mjs +113 -0
  37. package/lib/design/design-md.mjs +793 -0
  38. package/lib/design/design-md.test.mjs +318 -0
  39. package/lib/design/fixtures/DESIGN.golden.md +238 -0
  40. package/lib/design/fixtures/PRODUCT.golden.md +67 -0
  41. package/lib/design/fixtures/foundation.json +133 -0
  42. package/lib/design/refresh-gate.mjs +154 -0
  43. package/lib/design/refresh-gate.test.mjs +144 -0
  44. package/lib/design/write.mjs +275 -0
  45. package/lib/design/write.test.mjs +241 -0
  46. package/lib/feature-init.mjs +2 -2
  47. package/lib/mcp/server.test.mjs +9 -4
  48. package/lib/model-router/spawn.test.mjs +21 -0
  49. package/lib/org/board-mine-cache.mjs +99 -0
  50. package/lib/org/board-mine-cache.test.mjs +53 -0
  51. package/lib/org/board.mjs +11 -0
  52. package/lib/org/board.test.mjs +11 -1
  53. package/lib/org/client.mjs +36 -0
  54. package/lib/org/client.test.mjs +46 -0
  55. package/lib/org/inbound/directedness.mjs +18 -2
  56. package/lib/org/inbound/directedness.test.mjs +58 -0
  57. package/lib/org/inbound/index.mjs +8 -1
  58. package/lib/org/inbound/index.test.mjs +22 -0
  59. package/lib/org/mesh-directives.test.mjs +110 -0
  60. package/lib/org/mesh.mjs +61 -1
  61. package/lib/org/protocol.checksum +1 -1
  62. package/lib/org/protocol.mjs +52 -0
  63. package/lib/org/protocol.test.mjs +12 -1
  64. package/lib/org/registry.mjs +3 -2
  65. package/lib/org/tool-surface.mjs +120 -0
  66. package/lib/org/tool-surface.test.mjs +118 -5
  67. package/lib/prompts/parallelism.mjs +79 -0
  68. package/lib/prompts/parallelism.test.mjs +177 -0
  69. package/lib/security/external-content.mjs +1 -1
  70. package/lib/security/external-content.test.mjs +17 -0
  71. package/lib/session/config.mjs +137 -0
  72. package/lib/session/config.test.mjs +92 -0
  73. package/lib/session/feed-core.mjs +229 -0
  74. package/lib/session/feed-core.test.mjs +198 -0
  75. package/lib/session/first-run.mjs +126 -0
  76. package/lib/session/first-run.test.mjs +121 -0
  77. package/lib/session/frontdoor.mjs +266 -0
  78. package/lib/session/frontdoor.test.mjs +205 -0
  79. package/lib/session/handoffs.mjs +295 -0
  80. package/lib/session/handoffs.test.mjs +183 -0
  81. package/lib/session/identity.mjs +220 -0
  82. package/lib/session/identity.test.mjs +180 -0
  83. package/lib/session/inbox-claims.mjs +434 -0
  84. package/lib/session/inbox-claims.test.mjs +286 -0
  85. package/lib/session/launch-args.mjs +161 -0
  86. package/lib/session/launch-args.test.mjs +157 -0
  87. package/lib/session/liveness.mjs +174 -0
  88. package/lib/session/liveness.test.mjs +100 -0
  89. package/lib/session/status-summary.mjs +172 -0
  90. package/lib/session/status-summary.test.mjs +118 -0
  91. package/lib/session-permissions.mjs +39 -3
  92. package/lib/session-permissions.test.mjs +20 -0
  93. package/lib/setup/claude-probe.mjs +161 -24
  94. package/lib/setup/claude-probe.test.mjs +187 -0
  95. package/lib/setup/sections/learning.mjs +2 -1
  96. package/lib/setup/sections/model.mjs +104 -24
  97. package/lib/setup/sections/model.test.mjs +240 -0
  98. package/lib/setup/sections/org.mjs +27 -2
  99. package/lib/setup/sections/org.test.mjs +35 -2
  100. package/lib/setup/sections/verify.mjs +5 -0
  101. package/lib/setup/state.mjs +30 -10
  102. package/lib/setup/state.test.mjs +24 -1
  103. package/lib/singleton.js +11 -3
  104. package/lib/singleton.test.mjs +16 -0
  105. package/lib/subagents/lock.mjs +1 -1
  106. package/lib/telemetry/collect.mjs +270 -6
  107. package/lib/telemetry/collect.test.mjs +196 -1
  108. package/lib/upgrade/global-refresh.mjs +108 -0
  109. package/lib/upgrade/global-refresh.test.mjs +65 -0
  110. package/lib/upgrade/launchd-reconcile.mjs +327 -0
  111. package/lib/upgrade/launchd-reconcile.test.mjs +272 -0
  112. package/lib/upgrade/post-steps.mjs +151 -0
  113. package/lib/upgrade/post-steps.test.mjs +200 -0
  114. package/lib/upgrade/verify.mjs +215 -0
  115. package/lib/upgrade/verify.test.mjs +164 -0
  116. package/lib/voice/outbound.mjs +3 -2
  117. package/lib/voice/post-call-brief.mjs +2 -1
  118. package/lib/voice/session-rotation.mjs +6 -1
  119. package/lib/voice/session-rotation.test.mjs +114 -0
  120. package/package.json +3 -3
  121. package/plugins/maestro-skills/plugin.json +25 -1
  122. package/plugins/maestro-skills/skills/board-work.md +63 -0
  123. package/plugins/maestro-skills/skills/cohort-design.md +153 -0
  124. package/plugins/maestro-skills/skills/inbound-triage.md +80 -0
  125. package/plugins/maestro-skills/skills/main-session.md +102 -0
  126. package/plugins/maestro-skills/skills/peer-sessions.md +65 -0
  127. package/plugins/maestro-skills/skills/persona-discipline.md +75 -0
  128. package/plugins/maestro-skills/vendor/emilkowalski/LICENSE +21 -0
  129. package/plugins/maestro-skills/vendor/emilkowalski/UPSTREAM.json +70 -0
  130. package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/RECIPES.md +324 -0
  131. package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/SKILL.md +199 -0
  132. package/plugins/maestro-skills/vendor/emilkowalski/skills/animation-vocabulary/SKILL.md +173 -0
  133. package/plugins/maestro-skills/vendor/emilkowalski/skills/apple-design/SKILL.md +282 -0
  134. package/plugins/maestro-skills/vendor/emilkowalski/skills/emil-design-eng/SKILL.md +674 -0
  135. package/plugins/maestro-skills/vendor/emilkowalski/skills/find-animation-opportunities/SKILL.md +132 -0
  136. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/AUDIT.md +115 -0
  137. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/PLAN-TEMPLATE.md +73 -0
  138. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/SKILL.md +101 -0
  139. package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/PICKER.md +197 -0
  140. package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/SKILL.md +90 -0
  141. package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/SKILL.md +112 -0
  142. package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/STANDARDS.md +187 -0
  143. package/plugins/maestro-skills/vendor/impeccable/LICENSE +191 -0
  144. package/plugins/maestro-skills/vendor/impeccable/NOTICE.md +11 -0
  145. package/plugins/maestro-skills/vendor/impeccable/SKILL.md +86 -0
  146. package/plugins/maestro-skills/vendor/impeccable/UPSTREAM.json +201 -0
  147. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-asset-producer.md +42 -0
  148. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-documenter.md +29 -0
  149. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-finish-reviewer.md +43 -0
  150. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-manual-edit-applier.md +97 -0
  151. package/plugins/maestro-skills/vendor/impeccable/reference/adapt.md +312 -0
  152. package/plugins/maestro-skills/vendor/impeccable/reference/adapt.native.md +58 -0
  153. package/plugins/maestro-skills/vendor/impeccable/reference/android.md +46 -0
  154. package/plugins/maestro-skills/vendor/impeccable/reference/animate.md +89 -0
  155. package/plugins/maestro-skills/vendor/impeccable/reference/audit.md +136 -0
  156. package/plugins/maestro-skills/vendor/impeccable/reference/audit.native.md +139 -0
  157. package/plugins/maestro-skills/vendor/impeccable/reference/bolder.md +33 -0
  158. package/plugins/maestro-skills/vendor/impeccable/reference/clarify.md +94 -0
  159. package/plugins/maestro-skills/vendor/impeccable/reference/colorize.md +86 -0
  160. package/plugins/maestro-skills/vendor/impeccable/reference/craft-floor.md +44 -0
  161. package/plugins/maestro-skills/vendor/impeccable/reference/craft.md +5 -0
  162. package/plugins/maestro-skills/vendor/impeccable/reference/critique.md +806 -0
  163. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/asset-producer.md +37 -0
  164. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/documenter.md +24 -0
  165. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/finish-reviewer.md +38 -0
  166. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/manual-edit-applier.md +92 -0
  167. package/plugins/maestro-skills/vendor/impeccable/reference/delight.md +70 -0
  168. package/plugins/maestro-skills/vendor/impeccable/reference/distill.md +111 -0
  169. package/plugins/maestro-skills/vendor/impeccable/reference/doctor.md +54 -0
  170. package/plugins/maestro-skills/vendor/impeccable/reference/document.md +416 -0
  171. package/plugins/maestro-skills/vendor/impeccable/reference/extract.md +69 -0
  172. package/plugins/maestro-skills/vendor/impeccable/reference/harden.md +336 -0
  173. package/plugins/maestro-skills/vendor/impeccable/reference/hooks.md +111 -0
  174. package/plugins/maestro-skills/vendor/impeccable/reference/init.md +131 -0
  175. package/plugins/maestro-skills/vendor/impeccable/reference/ios.md +51 -0
  176. package/plugins/maestro-skills/vendor/impeccable/reference/layout.md +84 -0
  177. package/plugins/maestro-skills/vendor/impeccable/reference/live-setup.md +104 -0
  178. package/plugins/maestro-skills/vendor/impeccable/reference/live.md +325 -0
  179. package/plugins/maestro-skills/vendor/impeccable/reference/new-work.md +147 -0
  180. package/plugins/maestro-skills/vendor/impeccable/reference/onboard.md +234 -0
  181. package/plugins/maestro-skills/vendor/impeccable/reference/operate.md +61 -0
  182. package/plugins/maestro-skills/vendor/impeccable/reference/optimize.md +258 -0
  183. package/plugins/maestro-skills/vendor/impeccable/reference/overdrive.md +127 -0
  184. package/plugins/maestro-skills/vendor/impeccable/reference/polish.md +105 -0
  185. package/plugins/maestro-skills/vendor/impeccable/reference/quieter.md +99 -0
  186. package/plugins/maestro-skills/vendor/impeccable/reference/routing.md +24 -0
  187. package/plugins/maestro-skills/vendor/impeccable/reference/shape.md +59 -0
  188. package/plugins/maestro-skills/vendor/impeccable/reference/typeset.md +80 -0
  189. package/plugins/maestro-skills/vendor/impeccable/reference/visualize.md +46 -0
  190. package/plugins/maestro-skills/vendor/taste-skill/LICENSE +21 -0
  191. package/plugins/maestro-skills/vendor/taste-skill/UPSTREAM.json +37 -0
  192. package/plugins/maestro-skills/vendor/taste-skill/skills/minimalist-skill/SKILL.md +85 -0
  193. package/plugins/maestro-skills/vendor/taste-skill/skills/redesign-skill/SKILL.md +178 -0
  194. package/plugins/maestro-skills/vendor/taste-skill/skills/soft-skill/SKILL.md +98 -0
  195. package/plugins/maestro-skills/vendor/taste-skill/skills/taste-skill/SKILL.md +1206 -0
  196. package/plugins/maestro-skills/vendor/unlazy/LICENSE +21 -0
  197. package/plugins/maestro-skills/vendor/unlazy/SECURITY.md +72 -0
  198. package/plugins/maestro-skills/vendor/unlazy/SKILL.md +104 -0
  199. package/plugins/maestro-skills/vendor/unlazy/UPSTREAM.json +94 -0
  200. package/plugins/maestro-skills/vendor/unlazy/references/dispatch.md +82 -0
  201. package/plugins/maestro-skills/vendor/unlazy/references/gates.md +149 -0
  202. package/plugins/maestro-skills/vendor/unlazy/references/method.md +49 -0
  203. package/plugins/maestro-skills/vendor/unlazy/references/orchestration.md +107 -0
  204. package/plugins/maestro-skills/vendor/unlazy/references/parallel.md +133 -0
  205. package/plugins/maestro-skills/vendor/unlazy/references/token-economy.md +48 -0
  206. package/plugins/maestro-skills/vendor/unlazy/scripts/dispatch-check.mjs +139 -0
  207. package/plugins/maestro-skills/vendor/unlazy/scripts/gate-check.mjs +960 -0
  208. package/plugins/maestro-skills/vendor/unlazy/scripts/gate-lint.mjs +245 -0
  209. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/check-supervisor.mjs +46 -0
  210. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/dispatch.mjs +293 -0
  211. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/gates.mjs +953 -0
  212. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/process-tree.mjs +161 -0
  213. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/regex-worker.mjs +9 -0
  214. package/plugins/maestro-skills/vendor/unlazy/templates/PLAN.md +116 -0
  215. package/plugins/maestro-skills/vendor/unlazy/templates/gates-leaf.md +51 -0
  216. package/plugins/maestro-skills/vendor/unlazy/templates/gates-node.md +51 -0
  217. package/scaffold/CLAUDE.md +24 -0
  218. package/scripts/ci/check-durable-write-seam.mjs +147 -0
  219. package/scripts/ci/check-durable-write-seam.test.mjs +90 -0
  220. package/scripts/ci/check-skill-packs.mjs +388 -0
  221. package/scripts/ci/check-skill-packs.test.mjs +495 -0
  222. package/scripts/ci/check.mjs +6 -0
  223. package/scripts/collective/hook-runner.mjs +39 -4
  224. package/scripts/collective/hook-runner.test.mjs +85 -2
  225. package/scripts/daemon/agent-daemon-board-mine.test.mjs +96 -0
  226. package/scripts/daemon/agent-daemon-design.test.mjs +238 -0
  227. package/scripts/daemon/agent-daemon-frontdoor.test.mjs +60 -0
  228. package/scripts/daemon/agent-daemon.mjs +249 -10
  229. package/scripts/daemon/agent-daemon.test.mjs +73 -0
  230. package/scripts/daemon/assurance-e2e.test.mjs +141 -6
  231. package/scripts/daemon/assurance.mjs +461 -37
  232. package/scripts/daemon/assurance.test.mjs +408 -43
  233. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +393 -0
  234. package/scripts/daemon/cadence-consumer.mjs +289 -89
  235. package/scripts/daemon/cadence-handlers.mjs +53 -0
  236. package/scripts/daemon/classifier.mjs +1 -1
  237. package/scripts/daemon/dispatcher-resume.test.mjs +166 -0
  238. package/scripts/daemon/dispatcher.mjs +127 -19
  239. package/scripts/daemon/health.mjs +12 -1
  240. package/scripts/daemon/inbox-deferral-session.test.mjs +49 -0
  241. package/scripts/daemon/inbox-deferral.mjs +6 -0
  242. package/scripts/daemon/lib/self-echo.mjs +201 -0
  243. package/scripts/daemon/lib/self-echo.test.mjs +153 -0
  244. package/scripts/daemon/maestro-daemon.mjs +3 -0
  245. package/scripts/daemon/prompt-builder.mjs +19 -3
  246. package/scripts/daemon/responder.mjs +51 -40
  247. package/scripts/daemon/sdk-version.mjs +51 -0
  248. package/scripts/daemon/sdk-version.test.mjs +31 -0
  249. package/scripts/hooks/pre-send-audit.sh +97 -4
  250. package/scripts/hooks/pre-send-audit.test.mjs +140 -1
  251. package/scripts/local-triggers/autoupdate.sh +243 -19
  252. package/scripts/local-triggers/autoupdate.test.mjs +518 -0
  253. package/scripts/local-triggers/generate-plists.sh +24 -1
  254. package/scripts/local-triggers/generate-plists.test.mjs +49 -11
  255. package/scripts/org/send-orgmail.first-contact.test.mjs +102 -0
  256. package/scripts/org/send-orgmail.mjs +27 -3
  257. package/scripts/poller/inbox-privilege-injection.test.mjs +167 -0
  258. package/scripts/poller/slack-poller.mjs +13 -1
  259. package/scripts/poller/utils.mjs +46 -1
  260. package/scripts/poller-launchd/install.sh +19 -11
  261. package/scripts/poller-launchd/install.test.mjs +243 -0
  262. package/scripts/poller-launchd/launchd-poller-wrapper.sh +92 -0
  263. package/scripts/poller-launchd/migrate.sh +66 -0
  264. package/scripts/poller-launchd/poller.plist.template +4 -2
  265. package/scripts/session/feed.mjs +237 -0
  266. package/scripts/session/feed.test.mjs +196 -0
  267. package/scripts/session/supervisor-sh.test.mjs +218 -0
  268. package/scripts/session/supervisor.mjs +328 -0
  269. package/scripts/session/supervisor.sh +141 -0
  270. package/scripts/session/supervisor.test.mjs +482 -0
  271. package/scripts/setup/configure-macos.sh +250 -55
  272. package/scripts/setup/configure-macos.test.mjs +306 -0
  273. package/scripts/setup/init-agent.sh +112 -7
  274. package/scripts/setup/init-agent.test.mjs +220 -1
  275. package/scripts/vendor/skill-packs.mjs +354 -0
  276. package/scripts/vendor/sync-skill-packs.mjs +242 -0
  277. package/scripts/vendor/sync-skill-packs.test.mjs +103 -0
  278. package/scripts/watchdog/memory-watchdog.sh +37 -1
  279. package/scripts/watchdog/memory-watchdog.test.mjs +64 -0
  280. package/scripts/setup/boot-claude-session.sh +0 -94
@@ -0,0 +1,174 @@
1
+ /**
2
+ * lib/session/liveness.mjs — is the main session alive? Is the daemon?
3
+ *
4
+ * The daemon must never ASSUME a main session is there: liveness is MEASURED.
5
+ * The feed (scripts/session/feed.mjs) writes `state/session/heartbeat.json`
6
+ * every {@link HEARTBEAT_INTERVAL_MS}; anything that needs to know whether the
7
+ * front door is open calls {@link isSessionLive} and gets a boolean derived
8
+ * from that file's age against an injected clock. A beat older than
9
+ * {@link STALE_MS} (six missed beats) means the session is gone and the daemon
10
+ * falls back to legacy `--print` dispatch, so nothing is dropped either way.
11
+ *
12
+ * Heartbeat shape (frozen contract, read by the daemon and the doctor):
13
+ *
14
+ * { pid, ppid, sessionId, name, ts, feedVersion }
15
+ *
16
+ * The daemon's own liveness (for the feed's `daemon-stale` directive) is the
17
+ * mtime of `state/dashboards/daemon-health.yaml`, rescued by a live
18
+ * `state/daemon.pid` — both read by the caller and handed to the pure
19
+ * {@link isDaemonStale}.
20
+ *
21
+ * All decisions are pure over injected inputs; the two I/O helpers
22
+ * ({@link writeHeartbeat}, {@link sessionLiveness}) take injectable fs and never
23
+ * throw.
24
+ *
25
+ * @module lib/session/liveness
26
+ */
27
+
28
+ "use strict";
29
+
30
+ import { readFileSync as fsReadFileSync } from "node:fs";
31
+ import { writeJsonAtomic as fsWriteJsonAtomic } from "../fs-atomic.mjs";
32
+ import { sessionPaths } from "./config.mjs";
33
+
34
+ /** Bumped when the feed's line vocabulary changes. Carried on every beat. */
35
+ export const FEED_VERSION = "1";
36
+ /** How often the feed beats. */
37
+ export const HEARTBEAT_INTERVAL_MS = 15_000;
38
+ /** A beat older than this means the session is not live (six missed beats). */
39
+ export const STALE_MS = 90_000;
40
+ /** A daemon health file older than this (with no live pid) is a stale daemon. */
41
+ export const DAEMON_STALE_MS = 5 * 60_000;
42
+
43
+ function nowMs(now) { return typeof now === "function" ? Number(now()) : (now == null ? Date.now() : Number(now)); }
44
+
45
+ /**
46
+ * Pure: the heartbeat record.
47
+ * @param {{pid:number, ppid:number, sessionId:string|null, name:string, now?:number|Function, feedVersion?:string}} a
48
+ */
49
+ export function buildHeartbeat(a) {
50
+ return {
51
+ pid: a.pid,
52
+ ppid: a.ppid,
53
+ sessionId: a.sessionId ?? null,
54
+ name: a.name,
55
+ ts: new Date(nowMs(a.now)).toISOString(),
56
+ feedVersion: a.feedVersion || FEED_VERSION,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Pure: parse a heartbeat file body. Null unless it carries a parseable `ts`.
62
+ * @param {string|null|undefined} text
63
+ */
64
+ export function parseHeartbeat(text) {
65
+ if (typeof text !== "string" || !text.trim()) return null;
66
+ let raw;
67
+ try { raw = JSON.parse(text); } catch { return null; }
68
+ if (!raw || typeof raw !== "object" || typeof raw.ts !== "string" || !Number.isFinite(Date.parse(raw.ts))) return null;
69
+ return {
70
+ pid: Number.isInteger(raw.pid) ? raw.pid : null,
71
+ ppid: Number.isInteger(raw.ppid) ? raw.ppid : null,
72
+ sessionId: typeof raw.sessionId === "string" ? raw.sessionId : null,
73
+ name: typeof raw.name === "string" ? raw.name : "",
74
+ ts: raw.ts,
75
+ feedVersion: typeof raw.feedVersion === "string" ? raw.feedVersion : "",
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Pure: is this heartbeat live at `now`?
81
+ *
82
+ * Liveness is a WINDOW around `now`, not a one-sided age: a beat older than
83
+ * `staleMs` is stale, and a beat further in the FUTURE than `staleMs` is
84
+ * clock skew — the clock was stepped back after the beat was written (NTP,
85
+ * a manual correction), so the beat says nothing about whether the feed is
86
+ * running now. Both read as not-live, because the daemon's fallback for
87
+ * not-live is legacy dispatch (fail-open): a wrong "live" withholds every
88
+ * inbound item for the size of the step, a wrong "not live" costs one
89
+ * double-handled item at most. Small skew (under `staleMs`) is tolerated so
90
+ * a resume/suspend jitter does not evict a real session. An optional
91
+ * `pidAlive(pid)` probe lets a caller refuse a fresh beat whose writer is
92
+ * known to be dead.
93
+ *
94
+ * @param {ReturnType<typeof parseHeartbeat>} hb
95
+ * @param {{now?:number|Function, staleMs?:number, pidAlive?:(pid:number)=>boolean}} [opts]
96
+ * @returns {{live:boolean, reason:"fresh"|"stale"|"skew"|"missing"|"pid-dead", ageMs:number|null}}
97
+ */
98
+ export function heartbeatLiveness(hb, opts = {}) {
99
+ if (!hb) return { live: false, reason: "missing", ageMs: null };
100
+ const staleMs = opts.staleMs ?? STALE_MS;
101
+ const ageMs = nowMs(opts.now) - Date.parse(hb.ts);
102
+ if (ageMs >= staleMs) return { live: false, reason: "stale", ageMs };
103
+ if (ageMs <= -staleMs) return { live: false, reason: "skew", ageMs };
104
+ if (typeof opts.pidAlive === "function" && Number.isInteger(hb.pid) && hb.pid > 0) {
105
+ let alive = true;
106
+ try { alive = !!opts.pidAlive(hb.pid); } catch { alive = true; /* probe failure must not evict a fresh beat */ }
107
+ if (!alive) return { live: false, reason: "pid-dead", ageMs };
108
+ }
109
+ return { live: true, reason: "fresh", ageMs };
110
+ }
111
+
112
+ /**
113
+ * Write the heartbeat atomically (tmp + rename). Result frame, never throws.
114
+ * @param {string} agentRoot @param {object} hb @param {{writeJsonAtomic?:Function}} [deps]
115
+ * @returns {{ok:true}|{ok:false, error:string}}
116
+ */
117
+ export function writeHeartbeat(agentRoot, hb, deps = {}) {
118
+ const writeJsonAtomic = deps.writeJsonAtomic || fsWriteJsonAtomic;
119
+ try {
120
+ writeJsonAtomic(sessionPaths(agentRoot).heartbeatFile, hb);
121
+ return { ok: true };
122
+ } catch (err) {
123
+ return { ok: false, error: err && err.message ? err.message : String(err) };
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Read the heartbeat file and judge it. Fail-open: any read error is
129
+ * "missing" (not live), because a daemon that cannot see a beat must dispatch.
130
+ *
131
+ * @param {string} agentRoot
132
+ * @param {{now?:number|Function, staleMs?:number, pidAlive?:Function, readFileSync?:Function}} [opts]
133
+ * @returns {{live:boolean, reason:string, ageMs:number|null, heartbeat:object|null}}
134
+ */
135
+ export function sessionLiveness(agentRoot, opts = {}) {
136
+ const readFileSync = opts.readFileSync || fsReadFileSync;
137
+ let hb = null;
138
+ try {
139
+ hb = parseHeartbeat(readFileSync(sessionPaths(agentRoot).heartbeatFile, "utf8"));
140
+ } catch {
141
+ // No heartbeat file: no session has ever run here, or it is gone. Not live.
142
+ hb = null;
143
+ }
144
+ return { ...heartbeatLiveness(hb, opts), heartbeat: hb };
145
+ }
146
+
147
+ /**
148
+ * The boolean the daemon asks for: is the main session live right now?
149
+ * @param {string} agentRoot
150
+ * @param {{now?:number|Function, staleMs?:number, pidAlive?:Function, readFileSync?:Function}} [opts]
151
+ */
152
+ export function isSessionLive(agentRoot, opts = {}) {
153
+ return sessionLiveness(agentRoot, opts).live;
154
+ }
155
+
156
+ /**
157
+ * Pure: is the daemon stale? Fresh health-file mtime → no. A live daemon pid
158
+ * → no (a daemon can be up while its dashboard writer is behind). Otherwise,
159
+ * an absent or old health file is stale.
160
+ *
161
+ * @param {{healthMtimeMs:number|null, pidAlive:boolean|null, now?:number|Function, staleMs?:number}} a
162
+ */
163
+ export function isDaemonStale(a) {
164
+ if (a.pidAlive === true) return false;
165
+ const staleMs = a.staleMs ?? DAEMON_STALE_MS;
166
+ if (!Number.isFinite(a.healthMtimeMs)) return true;
167
+ return nowMs(a.now) - a.healthMtimeMs >= staleMs;
168
+ }
169
+
170
+ export default {
171
+ FEED_VERSION, HEARTBEAT_INTERVAL_MS, STALE_MS, DAEMON_STALE_MS,
172
+ buildHeartbeat, parseHeartbeat, heartbeatLiveness, writeHeartbeat,
173
+ sessionLiveness, isSessionLive, isDaemonStale,
174
+ };
@@ -0,0 +1,100 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { join } from "node:path";
4
+
5
+ import {
6
+ FEED_VERSION,
7
+ HEARTBEAT_INTERVAL_MS,
8
+ STALE_MS,
9
+ DAEMON_STALE_MS,
10
+ buildHeartbeat,
11
+ parseHeartbeat,
12
+ heartbeatLiveness,
13
+ writeHeartbeat,
14
+ sessionLiveness,
15
+ isSessionLive,
16
+ isDaemonStale,
17
+ } from "./liveness.mjs";
18
+
19
+ const T0 = Date.parse("2026-09-08T10:00:00Z");
20
+ const UUID = "11111111-2222-4333-8444-555555555555";
21
+
22
+ test("constants: 15 s beat, 90 s stale, 5 min daemon stale", () => {
23
+ assert.equal(HEARTBEAT_INTERVAL_MS, 15_000);
24
+ assert.equal(STALE_MS, 90_000);
25
+ assert.equal(DAEMON_STALE_MS, 5 * 60_000);
26
+ assert.equal(typeof FEED_VERSION, "string");
27
+ });
28
+
29
+ test("buildHeartbeat: exactly the documented fields, ts from the injected clock", () => {
30
+ const hb = buildHeartbeat({ pid: 10, ppid: 9, sessionId: UUID, name: "olivia-main", now: T0 });
31
+ assert.deepEqual(hb, { pid: 10, ppid: 9, sessionId: UUID, name: "olivia-main", ts: "2026-09-08T10:00:00.000Z", feedVersion: FEED_VERSION });
32
+ });
33
+
34
+ test("parseHeartbeat: round-trip; junk → null", () => {
35
+ const hb = buildHeartbeat({ pid: 10, ppid: 9, sessionId: UUID, name: "n", now: T0 });
36
+ assert.deepEqual(parseHeartbeat(JSON.stringify(hb)), hb);
37
+ assert.equal(parseHeartbeat("{"), null);
38
+ assert.equal(parseHeartbeat(JSON.stringify({ pid: 1 })), null); // no ts
39
+ assert.equal(parseHeartbeat(JSON.stringify({ ts: "yesterday" })), null);
40
+ assert.equal(parseHeartbeat(undefined), null);
41
+ });
42
+
43
+ test("heartbeatLiveness: fresh → live; older than staleMs → stale; slightly future-dated beats are live", () => {
44
+ const hb = buildHeartbeat({ pid: 1, ppid: 0, sessionId: UUID, name: "n", now: T0 });
45
+ assert.deepEqual(heartbeatLiveness(hb, { now: T0 + 30_000 }), { live: true, reason: "fresh", ageMs: 30_000 });
46
+ assert.deepEqual(heartbeatLiveness(hb, { now: T0 + 90_000 }), { live: false, reason: "stale", ageMs: 90_000 });
47
+ assert.equal(heartbeatLiveness(hb, { now: T0 + 89_999 }).live, true);
48
+ assert.equal(heartbeatLiveness(hb, { now: T0 + 10_000, staleMs: 5_000 }).live, false);
49
+ assert.equal(heartbeatLiveness(hb, { now: T0 - 5_000 }).live, true);
50
+ assert.deepEqual(heartbeatLiveness(null, { now: T0 }), { live: false, reason: "missing", ageMs: null });
51
+ });
52
+
53
+ test("heartbeatLiveness: a beat further in the FUTURE than staleMs is clock skew, not liveness (fail-open to dispatch)", () => {
54
+ // The feed beat at T0, the session died, and NTP stepped the clock back
55
+ // 20 min: without a bound the dead session would read as live for 20 min
56
+ // and the daemon would withhold dispatch the whole time.
57
+ const hb = buildHeartbeat({ pid: 1, ppid: 0, sessionId: UUID, name: "n", now: T0 });
58
+ assert.deepEqual(heartbeatLiveness(hb, { now: T0 - 20 * 60_000 }), { live: false, reason: "skew", ageMs: -20 * 60_000 });
59
+ assert.deepEqual(heartbeatLiveness(hb, { now: T0 - 90_000 }), { live: false, reason: "skew", ageMs: -90_000 });
60
+ assert.equal(heartbeatLiveness(hb, { now: T0 - 89_999 }).live, true);
61
+ assert.equal(heartbeatLiveness(hb, { now: T0 - 6_000, staleMs: 5_000 }).reason, "skew");
62
+ });
63
+
64
+ test("heartbeatLiveness: a beat whose pid is known-dead is not live even when fresh", () => {
65
+ const hb = buildHeartbeat({ pid: 4242, ppid: 0, sessionId: UUID, name: "n", now: T0 });
66
+ const r = heartbeatLiveness(hb, { now: T0 + 1_000, pidAlive: () => false });
67
+ assert.deepEqual(r, { live: false, reason: "pid-dead", ageMs: 1_000 });
68
+ // pid liveness is optional: without a probe, freshness decides.
69
+ assert.equal(heartbeatLiveness(hb, { now: T0 + 1_000 }).live, true);
70
+ });
71
+
72
+ test("writeHeartbeat: atomic write to state/session/heartbeat.json; failure is a value", () => {
73
+ const disk = {};
74
+ const hb = buildHeartbeat({ pid: 1, ppid: 0, sessionId: UUID, name: "n", now: T0 });
75
+ assert.deepEqual(writeHeartbeat("/agent", hb, { writeJsonAtomic: (p, o) => { disk[p] = o; } }), { ok: true });
76
+ assert.deepEqual(disk[join("/agent", "state", "session", "heartbeat.json")], hb);
77
+ const bad = writeHeartbeat("/agent", hb, { writeJsonAtomic: () => { throw new Error("ENOSPC"); } });
78
+ assert.equal(bad.ok, false);
79
+ assert.match(bad.error, /ENOSPC/);
80
+ });
81
+
82
+ test("sessionLiveness / isSessionLive read the heartbeat file through injected fs", () => {
83
+ const hb = buildHeartbeat({ pid: process.pid, ppid: 0, sessionId: UUID, name: "n", now: T0 });
84
+ const deps = { readFileSync: (p) => { assert.equal(p, join("/agent", "state", "session", "heartbeat.json")); return JSON.stringify(hb); } };
85
+ const r = sessionLiveness("/agent", { now: T0 + 1_000, ...deps });
86
+ assert.equal(r.live, true);
87
+ assert.equal(r.heartbeat.sessionId, UUID);
88
+ assert.equal(isSessionLive("/agent", { now: T0 + 1_000, ...deps }), true);
89
+ assert.equal(isSessionLive("/agent", { now: T0 + 100_000, ...deps }), false);
90
+ assert.equal(isSessionLive("/agent", { now: T0, readFileSync: () => { throw new Error("ENOENT"); } }), false);
91
+ assert.deepEqual(sessionLiveness("/agent", { now: T0, readFileSync: () => { throw new Error("ENOENT"); } }), { live: false, reason: "missing", ageMs: null, heartbeat: null });
92
+ });
93
+
94
+ test("isDaemonStale: fresh health file → not stale; old health + dead pid → stale; a live pid rescues an old file", () => {
95
+ assert.equal(isDaemonStale({ healthMtimeMs: T0 - 60_000, pidAlive: false, now: T0 }), false);
96
+ assert.equal(isDaemonStale({ healthMtimeMs: T0 - 6 * 60_000, pidAlive: false, now: T0 }), true);
97
+ assert.equal(isDaemonStale({ healthMtimeMs: T0 - 6 * 60_000, pidAlive: true, now: T0 }), false);
98
+ assert.equal(isDaemonStale({ healthMtimeMs: null, pidAlive: null, now: T0 }), true);
99
+ assert.equal(isDaemonStale({ healthMtimeMs: null, pidAlive: true, now: T0 }), false);
100
+ });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * lib/session/status-summary.mjs — the pure session-status core.
3
+ *
4
+ * ONE derivation of "is the main session live, who are its peers, what is
5
+ * waiting for it" shared by three consumers: the cohort-mcp `session_status`
6
+ * tool, the SessionStart `prime` hook (which injects a one-line status into
7
+ * every new session on the machine), and `maestro board`. The feed
8
+ * (scripts/session/feed.mjs) writes `state/session/heartbeat.json` every 15 s;
9
+ * `maestro session spawn` keeps `state/session/peers.json`; the cadence
10
+ * consumer drops `state/session/handoffs/<tickId>.json`; the daemon caches
11
+ * `board.mine` to `state/org/board-mine.json` (`{ts, items}`) every 5 min via
12
+ * lib/org/board-mine-cache.mjs. This module only
13
+ * READS those — the writers own their formats and this tolerates every
14
+ * partial/absent/corrupt shape by reporting "absent", never by throwing.
15
+ *
16
+ * `summarizeSessionStatus` is pure over injected snapshots + `now`;
17
+ * `readSessionStatus` is the thin disk reader around it.
18
+ *
19
+ * @module lib/session/status-summary
20
+ */
21
+
22
+ "use strict";
23
+
24
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
25
+ import { join } from "node:path";
26
+ import { buildIdentityClaudeMd, IDENTITY_BEGIN, IDENTITY_END } from "../collective/global-config.mjs";
27
+
28
+ /** A heartbeat older than this is a dead or wedged session (feed beats every 15 s). */
29
+ export const DEFAULT_STALE_MS = 90_000;
30
+
31
+ function toMs(v) {
32
+ if (typeof v === "number" && Number.isFinite(v)) return v;
33
+ if (typeof v === "string") {
34
+ const n = Date.parse(v);
35
+ if (Number.isFinite(n)) return n;
36
+ const asNum = Number(v);
37
+ if (Number.isFinite(asNum) && v.trim() !== "") return asNum;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ function itemsOf(boardMine) {
43
+ if (Array.isArray(boardMine)) return boardMine;
44
+ if (boardMine && typeof boardMine === "object" && Array.isArray(boardMine.items)) return boardMine.items;
45
+ return [];
46
+ }
47
+
48
+ function peersOf(peers) {
49
+ if (Array.isArray(peers)) return peers.filter((p) => p && typeof p === "object");
50
+ if (peers && typeof peers === "object" && Array.isArray(peers.peers)) return peers.peers.filter((p) => p && typeof p === "object");
51
+ return [];
52
+ }
53
+
54
+ /**
55
+ * Derive the session status from snapshots. Pure.
56
+ *
57
+ * @param {object} o
58
+ * @param {object|null} [o.heartbeat] - state/session/heartbeat.json
59
+ * @param {object[]|object|null} [o.peers] - state/session/peers.json (array, or {peers:[]})
60
+ * @param {string[]|null} [o.handoffs] - names of open handoff files
61
+ * @param {object|object[]|null} [o.boardMine] - state/org/board-mine.json ({ts, items}; older {items, fetchedAt}) or a bare array
62
+ * @param {number} [o.now]
63
+ * @param {number} [o.staleMs]
64
+ */
65
+ export function summarizeSessionStatus(o = {}) {
66
+ const now = Number.isFinite(o.now) ? o.now : Date.now();
67
+ const staleMs = Number.isFinite(o.staleMs) && o.staleMs > 0 ? o.staleMs : DEFAULT_STALE_MS;
68
+ const hb = o.heartbeat && typeof o.heartbeat === "object" ? o.heartbeat : null;
69
+ const ts = hb ? toMs(hb.ts) : null;
70
+ let state = "absent";
71
+ let ageMs = null;
72
+ if (ts !== null) {
73
+ ageMs = Math.max(0, now - ts);
74
+ state = ageMs <= staleMs ? "live" : "stale";
75
+ }
76
+ const peers = peersOf(o.peers);
77
+ const handoffs = Array.isArray(o.handoffs) ? o.handoffs.filter((h) => typeof h === "string") : [];
78
+ const items = itemsOf(o.boardMine);
79
+ // The daemon writes {ts, items} (lib/org/board-mine-cache.mjs); fetchedAt is the earlier spelling.
80
+ const bm = o.boardMine && typeof o.boardMine === "object" && !Array.isArray(o.boardMine) ? o.boardMine : null;
81
+ const fetchedAt = bm && typeof bm.fetchedAt === "string" ? bm.fetchedAt : bm && typeof bm.ts === "string" ? bm.ts : null;
82
+ return {
83
+ live: state === "live",
84
+ state,
85
+ name: hb && typeof hb.name === "string" ? hb.name : null,
86
+ sessionId: hb && typeof hb.sessionId === "string" ? hb.sessionId : null,
87
+ pid: hb && Number.isFinite(hb.pid) ? hb.pid : null,
88
+ heartbeatAt: ts === null ? null : new Date(ts).toISOString(),
89
+ ageMs,
90
+ staleMs,
91
+ peers: peers.map((p) => ({
92
+ name: typeof p.name === "string" ? p.name : null,
93
+ purpose: typeof p.purpose === "string" ? p.purpose : null,
94
+ startedAt: typeof p.startedAt === "string" ? p.startedAt : null,
95
+ })),
96
+ peerCount: peers.length,
97
+ handoffsOpen: handoffs.length,
98
+ handoffs,
99
+ boardMineCount: items.length,
100
+ boardMineFetchedAt: fetchedAt,
101
+ };
102
+ }
103
+
104
+ function plural(n, word) { return `${n} ${word}${n === 1 ? "" : "s"}`; }
105
+
106
+ /** One line for a primer: "main session alex-main live (beat 5s ago) · 1 peer · 1 open handoff · 2 board items". */
107
+ export function statusLine(s) {
108
+ const st = s && typeof s === "object" ? s : summarizeSessionStatus({});
109
+ const who = st.name ? `main session ${st.name}` : "main session";
110
+ let head;
111
+ if (st.state === "live") head = `${who} live (beat ${Math.round((st.ageMs || 0) / 1000)}s ago)`;
112
+ else if (st.state === "stale") head = `${who} stale (last beat ${Math.round((st.ageMs || 0) / 1000)}s ago)`;
113
+ else head = `${who} not running (no heartbeat)`;
114
+ return [
115
+ head,
116
+ plural(st.peerCount || 0, "peer"),
117
+ plural(st.handoffsOpen || 0, "open handoff"),
118
+ plural(st.boardMineCount || 0, "board item") + (st.boardMineFetchedAt ? " (cached)" : ""),
119
+ ].join(" · ");
120
+ }
121
+
122
+ function readJson(p) {
123
+ try {
124
+ if (!existsSync(p)) return null;
125
+ return JSON.parse(readFileSync(p, "utf8"));
126
+ } catch {
127
+ return null; // absent/corrupt state is "not running", never a crash in a hook
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Read the four state files under an agent root and summarise. Fail-open:
133
+ * an unreadable file is the same as an absent one.
134
+ * @param {string} agentRoot
135
+ * @param {{now?:number, staleMs?:number}} [o]
136
+ */
137
+ export function readSessionStatus(agentRoot, o = {}) {
138
+ const root = String(agentRoot || "");
139
+ const sess = join(root, "state", "session");
140
+ let handoffs = [];
141
+ try {
142
+ const dir = join(sess, "handoffs");
143
+ if (existsSync(dir)) handoffs = readdirSync(dir).filter((f) => f.endsWith(".json"));
144
+ } catch {
145
+ handoffs = []; // a vanished directory mid-read is an empty queue
146
+ }
147
+ return summarizeSessionStatus({
148
+ heartbeat: readJson(join(sess, "heartbeat.json")),
149
+ peers: readJson(join(sess, "peers.json")),
150
+ handoffs,
151
+ boardMine: readJson(join(root, "state", "org", "board-mine.json")),
152
+ now: o.now,
153
+ staleMs: o.staleMs,
154
+ });
155
+ }
156
+
157
+ /**
158
+ * The SessionStart primer text: the identity block (delimited, so a reader can
159
+ * tell it from the memory digest) + the one-line status. Empty when the
160
+ * agent has no identity yet (a bare/unconfigured repo says nothing).
161
+ * @param {{agentJson:object, agentRoot:string, status:object}} o
162
+ * @returns {string}
163
+ */
164
+ export function buildPrimerContext(o = {}) {
165
+ const a = o.agentJson && typeof o.agentJson === "object" ? o.agentJson : null;
166
+ if (!a || !(a.fullName || a.firstName)) return "";
167
+ const block = buildIdentityClaudeMd(a, { agentRoot: o.agentRoot });
168
+ const status = o.status && typeof o.status === "object" ? o.status : summarizeSessionStatus({});
169
+ return [IDENTITY_BEGIN, block, IDENTITY_END, "", `Session status: ${statusLine(status)}.`].join("\n");
170
+ }
171
+
172
+ export default { summarizeSessionStatus, readSessionStatus, statusLine, buildPrimerContext, DEFAULT_STALE_MS };
@@ -0,0 +1,118 @@
1
+ /**
2
+ * status-summary.test.mjs — the pure session-status core the cohort-mcp
3
+ * `session_status` tool, the SessionStart primer and `maestro board` share.
4
+ * Run: node --test lib/session/status-summary.test.mjs
5
+ */
6
+ "use strict";
7
+
8
+ import { test } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ import {
15
+ summarizeSessionStatus, readSessionStatus, statusLine, buildPrimerContext, DEFAULT_STALE_MS,
16
+ } from "./status-summary.mjs";
17
+
18
+ const NOW = Date.parse("2026-09-08T12:00:00Z");
19
+
20
+ test("summarizeSessionStatus: live when the heartbeat is fresh, stale past staleMs, absent when missing", () => {
21
+ const fresh = summarizeSessionStatus({
22
+ heartbeat: { pid: 1, sessionId: "abc", name: "alex-main", ts: new Date(NOW - 10_000).toISOString() },
23
+ now: NOW,
24
+ });
25
+ assert.equal(fresh.live, true);
26
+ assert.equal(fresh.state, "live");
27
+ assert.equal(fresh.name, "alex-main");
28
+ assert.equal(fresh.ageMs, 10_000);
29
+ const stale = summarizeSessionStatus({ heartbeat: { ts: NOW - DEFAULT_STALE_MS - 1 }, now: NOW });
30
+ assert.equal(stale.live, false);
31
+ assert.equal(stale.state, "stale");
32
+ const none = summarizeSessionStatus({ heartbeat: null, now: NOW });
33
+ assert.equal(none.live, false);
34
+ assert.equal(none.state, "absent");
35
+ const garbage = summarizeSessionStatus({ heartbeat: { ts: "not a date" }, now: NOW });
36
+ assert.equal(garbage.state, "absent", "unparseable ts is absent, never a throw");
37
+ });
38
+
39
+ test("summarizeSessionStatus: counts peers, open handoffs and board-mine items; tolerates garbage", () => {
40
+ const s = summarizeSessionStatus({
41
+ heartbeat: { ts: NOW },
42
+ peers: [{ name: "alex-research" }, { name: "alex-deck" }],
43
+ handoffs: ["t1.json", "t2.json", "t3.json"],
44
+ boardMine: { items: [{ itemId: "a" }, { itemId: "b" }], fetchedAt: new Date(NOW - 60_000).toISOString() },
45
+ now: NOW,
46
+ });
47
+ assert.equal(s.peers.length, 2);
48
+ assert.equal(s.peerCount, 2);
49
+ assert.equal(s.handoffsOpen, 3);
50
+ assert.equal(s.boardMineCount, 2);
51
+ assert.equal(s.boardMineFetchedAt, new Date(NOW - 60_000).toISOString());
52
+ const g = summarizeSessionStatus({ peers: "x", handoffs: null, boardMine: [1, 2, 3], now: NOW });
53
+ assert.equal(g.peerCount, 0);
54
+ assert.equal(g.handoffsOpen, 0);
55
+ assert.equal(g.boardMineCount, 3, "a bare array is accepted as the item list");
56
+ const g2 = summarizeSessionStatus({ boardMine: { nope: true }, now: NOW });
57
+ assert.equal(g2.boardMineCount, 0);
58
+ assert.equal(g2.boardMineFetchedAt, null);
59
+ });
60
+
61
+ test("statusLine: one line a primer can inject", () => {
62
+ const s = summarizeSessionStatus({
63
+ heartbeat: { ts: NOW - 5_000, name: "alex-main" },
64
+ peers: [{ name: "alex-deck" }],
65
+ handoffs: ["a"],
66
+ boardMine: { items: [{}, {}] },
67
+ now: NOW,
68
+ });
69
+ const line = statusLine(s);
70
+ assert.equal(line.split("\n").length, 1);
71
+ assert.match(line, /alex-main/);
72
+ assert.match(line, /live/);
73
+ assert.match(line, /1 peer/);
74
+ assert.match(line, /1 open handoff/);
75
+ assert.match(line, /2 board item/);
76
+ const absent = statusLine(summarizeSessionStatus({ now: NOW }));
77
+ assert.match(absent, /not running|absent/i);
78
+ });
79
+
80
+ test("readSessionStatus: reads state/session/{heartbeat,peers}.json, handoffs/*.json and state/org/board-mine.json; fail-open", () => {
81
+ const root = mkdtempSync(join(tmpdir(), "status-summary-"));
82
+ try {
83
+ assert.equal(readSessionStatus(root, { now: NOW }).state, "absent", "empty root → absent, no throw");
84
+ mkdirSync(join(root, "state", "session", "handoffs", "done"), { recursive: true });
85
+ mkdirSync(join(root, "state", "org"), { recursive: true });
86
+ writeFileSync(join(root, "state", "session", "heartbeat.json"), JSON.stringify({ pid: 9, name: "alex-main", ts: NOW - 1000 }));
87
+ writeFileSync(join(root, "state", "session", "peers.json"), JSON.stringify([{ name: "alex-deck", muxName: "m" }]));
88
+ writeFileSync(join(root, "state", "session", "handoffs", "t1.json"), "{}");
89
+ writeFileSync(join(root, "state", "session", "handoffs", "t2.json"), "{}");
90
+ writeFileSync(join(root, "state", "session", "handoffs", "notes.txt"), "x");
91
+ writeFileSync(join(root, "state", "session", "handoffs", "done", "t0.json"), "{}");
92
+ writeFileSync(join(root, "state", "org", "board-mine.json"), JSON.stringify({ items: [{ itemId: "i1" }], fetchedAt: "2026-09-08T11:59:00.000Z" }));
93
+ const s = readSessionStatus(root, { now: NOW });
94
+ assert.equal(s.live, true);
95
+ assert.equal(s.name, "alex-main");
96
+ assert.equal(s.peerCount, 1);
97
+ assert.equal(s.handoffsOpen, 2, "only *.json in handoffs/, not done/ or other files");
98
+ assert.equal(s.boardMineCount, 1);
99
+ // peers.json may also be an object with a `peers` array.
100
+ writeFileSync(join(root, "state", "session", "peers.json"), JSON.stringify({ peers: [{ name: "a" }, { name: "b" }] }));
101
+ assert.equal(readSessionStatus(root, { now: NOW }).peerCount, 2);
102
+ // Corrupt files are absent, not fatal.
103
+ writeFileSync(join(root, "state", "session", "heartbeat.json"), "{not json");
104
+ assert.equal(readSessionStatus(root, { now: NOW }).state, "absent");
105
+ } finally { rmSync(root, { recursive: true, force: true }); }
106
+ });
107
+
108
+ test("buildPrimerContext: identity block + status line + counts; empty when there is no agent identity", () => {
109
+ const status = summarizeSessionStatus({ heartbeat: { ts: NOW, name: "alex-main" }, handoffs: ["a", "b"], boardMine: { items: [{}] }, now: NOW });
110
+ const ctx = buildPrimerContext({ agentJson: { fullName: "Alex Rivera", firstName: "Alex" }, agentRoot: "/r/alex-ai", status });
111
+ assert.match(ctx, /maestro:identity/);
112
+ assert.match(ctx, /Alex Rivera/);
113
+ assert.match(ctx, /alex-main/);
114
+ assert.match(ctx, /2 open handoffs/);
115
+ assert.match(ctx, /1 board item/);
116
+ assert.equal(buildPrimerContext({ agentJson: {}, agentRoot: "/r", status }), "", "no identity → nothing to say");
117
+ assert.equal(buildPrimerContext({ agentJson: null, agentRoot: "/r", status }), "");
118
+ });
@@ -20,7 +20,9 @@
20
20
  * (`--strict-mcp-config` / `--bare`) exactly as before, so `--strict-mcp-config`
21
21
  * is preserved in BOTH modes.
22
22
  *
23
- * Caller sites (today): scripts/daemon/cadence-consumer.mjs#realSpawnSession.
23
+ * Caller sites (today): scripts/daemon/cadence-consumer.mjs#realSpawnSession,
24
+ * classifier/responder/assurance (`source`), and the front-door supervisor
25
+ * (`source: "main-session"`, see {@link MAIN_SESSION_SOURCE}).
24
26
  */
25
27
 
26
28
  import { getCadenceDef } from "../scripts/daemon/cadence-handlers.mjs";
@@ -64,6 +66,29 @@ function resolveAllowlist(cadence) {
64
66
  return [...CONSERVATIVE_DEFAULT_ALLOWLIST];
65
67
  }
66
68
 
69
+ /**
70
+ * The `source` the front-door supervisor passes. The main session is an
71
+ * INTERACTIVE `claude` inside a multiplexer with nobody attached: any tool
72
+ * outside its allowlist raises a y/n prompt that no one can answer, and the
73
+ * session wedges while its feed keeps beating (so the daemon believes the
74
+ * front door is open and withholds dispatch). The conservative `--print`
75
+ * allowlist therefore never applies to it — in scoped mode it gets either
76
+ * the operator's explicit main-session allowlist (`config/session.yaml`
77
+ * `allowedTools`) or, absent one, bypass.
78
+ */
79
+ export const MAIN_SESSION_SOURCE = "main-session";
80
+
81
+ /**
82
+ * Pure: a caller-supplied allowlist, cleaned. Non-array / empty → null.
83
+ * @param {unknown} list
84
+ * @returns {string[]|null}
85
+ */
86
+ function explicitAllowlist(list) {
87
+ if (!Array.isArray(list)) return null;
88
+ const cleaned = list.filter((t) => typeof t === "string" && t.trim()).map((t) => t.trim());
89
+ return cleaned.length > 0 ? cleaned : null;
90
+ }
91
+
67
92
  /**
68
93
  * Build the `claude` CLI permission args for a spawned sub-session.
69
94
  *
@@ -83,12 +108,19 @@ function resolveAllowlist(cadence) {
83
108
  * via `.env` and have a long-lived daemon pick it up on the next spawn without
84
109
  * a code change.
85
110
  *
111
+ * - "1" with `source: "main-session"` — the interactive front door. Uses
112
+ * `allowedTools` when the caller supplies a non-empty list, else bypass;
113
+ * the conservative `--print` default is never applied (it would hang an
114
+ * unattended interactive session on its first permission prompt).
115
+ *
86
116
  * @param {Object} [opts]
87
117
  * @param {string} [opts.cadence] cadence name; used to look up a per-cadence allowlist in scoped mode.
88
118
  * @param {string} [opts.mode] cadence mode ("inline"|"guarded"|"escalate"); accepted for forward-compat (future per-mode tightening), currently unused.
89
- * @returns {string[]} permission CLI args to splice into the `claude --print` argv.
119
+ * @param {string} [opts.source] who is spawning ("classifier", "responder", "ack", "main-session", …); only {@link MAIN_SESSION_SOURCE} changes the result.
120
+ * @param {string[]} [opts.allowedTools] main-session only: an explicit allowlist from config/session.yaml.
121
+ * @returns {string[]} permission CLI args to splice into the `claude` argv.
90
122
  */
91
- export function sessionPermissionArgs({ cadence, mode } = {}) {
123
+ export function sessionPermissionArgs({ cadence, mode, source, allowedTools } = {}) {
92
124
  // `mode` is reserved for future per-mode tightening; reference it so linters
93
125
  // don't flag it as unused while keeping the signature stable for callers.
94
126
  void mode;
@@ -96,6 +128,10 @@ export function sessionPermissionArgs({ cadence, mode } = {}) {
96
128
  // Default / back-compat: UNCHANGED behaviour.
97
129
  return ["--dangerously-skip-permissions"];
98
130
  }
131
+ if (source === MAIN_SESSION_SOURCE) {
132
+ const explicit = explicitAllowlist(allowedTools);
133
+ return explicit ? ["--allowedTools", explicit.join(",")] : ["--dangerously-skip-permissions"];
134
+ }
99
135
  const allow = resolveAllowlist(cadence);
100
136
  return ["--allowedTools", allow.join(",")];
101
137
  }
@@ -98,3 +98,23 @@ test("conservative default allowlist is read-only / search-only (no write, shell
98
98
  // And it must be non-empty so scoped mode never emits an empty allowlist.
99
99
  assert.ok(CONSERVATIVE_DEFAULT_ALLOWLIST.length > 0);
100
100
  });
101
+
102
+ test("main session: scoped mode never hands the interactive front door the --print allowlist (it would hang on the first prompt)", () => {
103
+ withScopedEnv("1", () => {
104
+ // No main-session allowlist configured → bypass, because nobody is
105
+ // attached to answer a y/n prompt inside the multiplexer.
106
+ assert.deepEqual(sessionPermissionArgs({ source: "main-session" }), ["--dangerously-skip-permissions"]);
107
+ assert.deepEqual(sessionPermissionArgs({ source: "main-session", allowedTools: [] }), ["--dangerously-skip-permissions"]);
108
+ assert.deepEqual(sessionPermissionArgs({ source: "main-session", allowedTools: "Read" }), ["--dangerously-skip-permissions"]);
109
+ // An operator-defined main-session allowlist IS honoured.
110
+ assert.deepEqual(
111
+ sessionPermissionArgs({ source: "main-session", allowedTools: ["Read", " Monitor ", "", 3, "mcp__cohort__*"] }),
112
+ ["--allowedTools", "Read,Monitor,mcp__cohort__*"],
113
+ );
114
+ // Other sources are untouched by the new option.
115
+ assert.deepEqual(sessionPermissionArgs({ source: "responder", allowedTools: ["Bash"] }), ["--allowedTools", CONSERVATIVE_DEFAULT_ALLOWLIST.join(",")]);
116
+ });
117
+ withScopedEnv(undefined, () => {
118
+ assert.deepEqual(sessionPermissionArgs({ source: "main-session", allowedTools: ["Read"] }), ["--dangerously-skip-permissions"]);
119
+ });
120
+ });