@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,434 @@
1
+ /**
2
+ * lib/session/inbox-claims.mjs — session claims on inbox items and the
3
+ * assurance reopen sweep (design §3.3).
4
+ *
5
+ * The daemon and the main session share ONE inbox state machine — the file
6
+ * suffixes `scripts/poller/utils.mjs` already defines:
7
+ *
8
+ * <name>.yaml new — anyone may take it
9
+ * <name>.yaml.dispatched in flight (durable admission, F1/H2)
10
+ * <name>.yaml.deferred parked
11
+ * <name>.yaml.processed terminal
12
+ *
13
+ * A SESSION claim is a `.dispatched` file carrying two top-level YAML lines
14
+ * appended below the item — `claimed_by: "session"` / `claimed_at: "<iso>"` —
15
+ * so the daemon can tell a session claim from its own (which carries none)
16
+ * and reopen it on a shorter clock: 20 minutes instead of the scanner's 2-hour
17
+ * crash-reclaim. A session deferral (`maestro inbox defer --until`) is a
18
+ * `.deferred` file carrying `deferred_until:` / `deferred_reason:`; the same
19
+ * sweep reopens it when its time passes (and `promoteDeferred` leaves it
20
+ * alone — a session deferral is not a thread-lock burst). A reply stamps
21
+ * `replied_at:` — a REPLIED claim is never reopened (that would answer the
22
+ * human twice); it is retired to `.processed` once past the retire window if
23
+ * the session never runs `inbox done`. Reopening strips the markers IN PLACE
24
+ * (same inode, so a concurrent `inbox done` rename can never be resurrected
25
+ * by a tmp+rename recreating the path) and renames back to `.yaml`, so the
26
+ * item is byte-identical to how it arrived.
27
+ *
28
+ * While the session is live the daemon's scanner is gated off, and with it
29
+ * the scanner's 2 h reclaim of the daemon's OWN `.dispatched` files (no
30
+ * marker) — items admitted just before a daemon restart would otherwise be
31
+ * stranded, invisible to `inbox list`. The sweep reopens those too, on the
32
+ * same 2 h clock, but only when told the session is live (`sessionLive`).
33
+ *
34
+ * The markers are read as COLUMN-0 keys only (the same anchoring
35
+ * `inbox-deferral.mjs` uses) so an indented look-alike inside a message body
36
+ * can never forge a claim. Clock injected; fs via node:fs on the agent root;
37
+ * every function returns a value and never throws past its boundary.
38
+ *
39
+ * @module lib/session/inbox-claims
40
+ */
41
+
42
+ import { join } from "node:path";
43
+ import { readdirSync, readFileSync, existsSync, renameSync, statSync, openSync, ftruncateSync, writeSync, closeSync } from "node:fs";
44
+ import { writeFileAtomic } from "../fs-atomic.mjs";
45
+ import { readFrontDoorState } from "./frontdoor.mjs";
46
+
47
+ /** A session claim neither replied nor done within this window is reopened. */
48
+ export const SESSION_CLAIM_STALE_MS = 20 * 60_000;
49
+ /**
50
+ * A replied claim the session never finished with `inbox done` is retired to
51
+ * `.processed` this long after the reply. The human has been answered; leaving
52
+ * it in flight forever would let the scanner re-dispatch it once the session
53
+ * dies. Same window as the scanner's own reclaim.
54
+ */
55
+ export const REPLIED_RETIRE_MS = 2 * 60 * 60_000;
56
+ /**
57
+ * A daemon-owned `.dispatched` (no session marker) older than this is an
58
+ * orphan of a daemon that restarted — mirrors the scanner's DEFAULT_RECLAIM_MS
59
+ * (scripts/poller/inbox-scan-poller.mjs), which is gated off while the
60
+ * session is live.
61
+ */
62
+ export const DAEMON_ORPHAN_RECLAIM_MS = 2 * 60 * 60_000;
63
+
64
+ const MARKER_KEYS = ["claimed_by", "claimed_at", "deferred_until", "deferred_reason", "replied_at"];
65
+ const MARKER_LINE = new RegExp(`^(?:${MARKER_KEYS.join("|")}):.*$`);
66
+
67
+ function iso(ms) { return new Date(ms).toISOString(); }
68
+ function nowOf(o) { return typeof o.now === "number" ? o.now : Date.now(); }
69
+ function q(v) { return `"${String(v).replace(/[\r\n]+/g, " ").replace(/"/g, "'")}"`; }
70
+
71
+ /**
72
+ * The marker lines to append below an item.
73
+ * @param {{claimedBy?:string, claimedAt?:string, now?:number, deferredUntil?:number|string, deferredReason?:string, repliedAt?:number|string}} o
74
+ * @returns {string} zero or more `key: "value"\n` lines
75
+ */
76
+ export function sessionMarkerLines(o = {}) {
77
+ const lines = [];
78
+ if (o.claimedBy) {
79
+ lines.push(`claimed_by: ${q(o.claimedBy)}`);
80
+ lines.push(`claimed_at: ${q(o.claimedAt || iso(nowOf(o)))}`);
81
+ }
82
+ if (o.repliedAt != null) {
83
+ lines.push(`replied_at: ${q(typeof o.repliedAt === "number" ? iso(o.repliedAt) : String(o.repliedAt))}`);
84
+ }
85
+ if (o.deferredUntil != null) {
86
+ const until = typeof o.deferredUntil === "number" ? iso(o.deferredUntil) : String(o.deferredUntil);
87
+ lines.push(`deferred_until: ${q(until)}`);
88
+ if (o.deferredReason) lines.push(`deferred_reason: ${q(o.deferredReason)}`);
89
+ }
90
+ return lines.length ? lines.join("\n") + "\n" : "";
91
+ }
92
+
93
+ function scalar(body, key) {
94
+ const m = String(body || "").match(new RegExp(`^${key}:\\s*"?([^"\\n]*)"?\\s*$`, "m"));
95
+ return m && m[1] !== "" ? m[1] : null;
96
+ }
97
+
98
+ /**
99
+ * Read the markers off an item body. Column-0 keys only.
100
+ * @param {string} body
101
+ * @returns {{claimedBy:string|null, claimedAt:string|null, deferredUntil:string|null, deferredReason:string|null, repliedAt:string|null}}
102
+ */
103
+ export function parseSessionMarkers(body) {
104
+ return {
105
+ claimedBy: scalar(body, "claimed_by"),
106
+ claimedAt: scalar(body, "claimed_at"),
107
+ deferredUntil: scalar(body, "deferred_until"),
108
+ deferredReason: scalar(body, "deferred_reason"),
109
+ repliedAt: scalar(body, "replied_at"),
110
+ };
111
+ }
112
+
113
+ /**
114
+ * The item body without any marker lines — byte-identical to how it arrived.
115
+ * @param {string} body
116
+ * @returns {string}
117
+ */
118
+ export function stripSessionMarkers(body) {
119
+ const src = String(body || "");
120
+ const lines = src.split("\n");
121
+ if (!lines.some((l) => MARKER_LINE.test(l))) return src;
122
+ return lines.filter((l) => !MARKER_LINE.test(l)).join("\n");
123
+ }
124
+
125
+ const STATE_BY_SUFFIX = [
126
+ [".yaml.processed-bundled", "bundled"],
127
+ [".yaml.processed", "processed"],
128
+ [".yaml.dispatched", "claimed"],
129
+ [".yaml.deferred", "deferred"],
130
+ [".yaml", "new"],
131
+ ];
132
+
133
+ function stateOf(name) {
134
+ for (const [suffix, state] of STATE_BY_SUFFIX) if (name.endsWith(suffix)) return state;
135
+ return null;
136
+ }
137
+
138
+ function serviceDirs(agentRoot, only) {
139
+ if (only) return [only];
140
+ try {
141
+ return readdirSync(join(agentRoot, "state", "inbox"), { withFileTypes: true })
142
+ .filter((e) => e.isDirectory() && e.name !== "attachments")
143
+ .map((e) => e.name)
144
+ .sort();
145
+ } catch { return []; /* no inbox at all */ }
146
+ }
147
+
148
+ // `writeInboxItem` names a file `<timestamp-with-:.→->-<id>.yaml`; this
149
+ // recovers the `<id>` so a lookup can be EXACT rather than a substring guess.
150
+ const FILE_ID = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}(?:-\d+)?Z?-(.+?)\.yaml(?:\.[a-z-]+)?$/;
151
+ function idOfFile(file) {
152
+ const m = file.match(FILE_ID);
153
+ return m ? m[1] : null;
154
+ }
155
+
156
+ /**
157
+ * Locate an inbox item by id (or raw_ref substring) across services.
158
+ * Prefers the live/claimed/deferred copy over a terminal one. An EXACT id
159
+ * match (the id the file was written under) always wins over a substring
160
+ * look-alike (`cohort-m1` vs `cohort-m10`); when only substring matches exist
161
+ * and they name more than one distinct item, the lookup is refused (null) —
162
+ * a truncated id must never silently pick a neighbour.
163
+ *
164
+ * @param {string} agentRoot
165
+ * @param {string} id
166
+ * @param {{service?:string}} [o]
167
+ * @returns {{service:string, file:string, path:string, state:string}|null}
168
+ */
169
+ export function findInboxFile(agentRoot, id, o = {}) {
170
+ const needle = String(id || "").trim();
171
+ if (!needle) return null;
172
+ const rank = { new: 0, claimed: 1, deferred: 2, processed: 3, bundled: 4 };
173
+ let exact = null;
174
+ let loose = null;
175
+ const looseIds = new Set();
176
+ for (const service of serviceDirs(agentRoot, o.service)) {
177
+ const dir = join(agentRoot, "state", "inbox", service);
178
+ let names;
179
+ try { names = readdirSync(dir); } catch { continue; }
180
+ for (const file of names) {
181
+ if (!file.includes(needle)) continue;
182
+ const state = stateOf(file);
183
+ if (!state) continue;
184
+ const hit = { service, file, path: join(dir, file), state };
185
+ if (idOfFile(file) === needle) {
186
+ if (!exact || rank[state] < rank[exact.state]) exact = hit;
187
+ } else {
188
+ looseIds.add(`${service}/${idOfFile(file) || file.replace(/\.yaml(\.[a-z-]+)?$/, "")}`);
189
+ if (!loose || rank[state] < rank[loose.state]) loose = hit;
190
+ }
191
+ }
192
+ }
193
+ if (exact) return exact;
194
+ return looseIds.size === 1 ? loose : null;
195
+ }
196
+
197
+ /**
198
+ * Claim an item for the session: `.yaml` → `.yaml.dispatched` (the shared
199
+ * durable-admission marker) + `claimed_by: "session"`. Only a NEW item is
200
+ * claimable — an in-flight or parked one returns `not-claimable`.
201
+ *
202
+ * @param {string} agentRoot
203
+ * @param {string} service
204
+ * @param {{id?:string, raw_ref?:string}} item
205
+ * @param {{now?:number, by?:string}} [o]
206
+ * @returns {{ok:true, path:string}|{ok:false, error:string}}
207
+ */
208
+ export function markSessionClaim(agentRoot, service, item, o = {}) {
209
+ try {
210
+ const found = findInboxFile(agentRoot, (item && (item.id || item.raw_ref)) || "", { service });
211
+ if (!found || found.state !== "new") return { ok: false, error: "not-claimable" };
212
+ // ONE file: the located item, never every substring look-alike.
213
+ const dst = `${found.path}.dispatched`;
214
+ try { renameSync(found.path, dst); }
215
+ catch (err) { return err && err.code === "ENOENT" ? { ok: false, error: "not-claimable" } : { ok: false, error: err.message }; }
216
+ const body = stripSessionMarkers(readFileSync(dst, "utf-8"));
217
+ const sep = body.endsWith("\n") || body === "" ? "" : "\n";
218
+ writeFileAtomic(dst, body + sep + sessionMarkerLines({ claimedBy: o.by || "session", now: nowOf(o) }));
219
+ return { ok: true, path: dst };
220
+ } catch (err) {
221
+ return { ok: false, error: err && err.message ? err.message : String(err) };
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Record that the session REPLIED to a claimed item (`replied_at:`), keeping
227
+ * the claim markers. Only an in-flight (`.dispatched`) item can be marked —
228
+ * a reply to a new item first needs a claim, a terminal item is finished.
229
+ *
230
+ * @param {string} agentRoot
231
+ * @param {string} service
232
+ * @param {{id?:string, raw_ref?:string}} item
233
+ * @param {{now?:number}} [o]
234
+ * @returns {{ok:true, path:string}|{ok:false, error:string}}
235
+ */
236
+ export function markSessionReplied(agentRoot, service, item, o = {}) {
237
+ try {
238
+ const found = findInboxFile(agentRoot, (item && (item.id || item.raw_ref)) || "", { service });
239
+ if (!found || found.state !== "claimed") return { ok: false, error: "not-in-flight" };
240
+ const raw = readFileSync(found.path, "utf-8");
241
+ const m = parseSessionMarkers(raw);
242
+ const body = stripSessionMarkers(raw);
243
+ const sep = body.endsWith("\n") || body === "" ? "" : "\n";
244
+ const markers = sessionMarkerLines({
245
+ claimedBy: m.claimedBy || "session",
246
+ claimedAt: m.claimedAt || undefined,
247
+ now: nowOf(o),
248
+ repliedAt: nowOf(o),
249
+ });
250
+ writeInPlace(found.path, body + sep + markers);
251
+ return { ok: true, path: found.path };
252
+ } catch (err) {
253
+ return { ok: false, error: err && err.message ? err.message : String(err) };
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Defer an item until a time: `.yaml` or `.yaml.dispatched` → `.yaml.deferred`
259
+ * with `deferred_until:` / `deferred_reason:`. The sweep reopens it when due.
260
+ *
261
+ * @param {string} agentRoot
262
+ * @param {string} service
263
+ * @param {{id?:string, raw_ref?:string}} item
264
+ * @param {{until:number|string, reason?:string, now?:number}} o
265
+ * @returns {{ok:true, path:string, until:string}|{ok:false, error:string}}
266
+ */
267
+ export function markSessionDeferral(agentRoot, service, item, o = {}) {
268
+ try {
269
+ const untilMs = typeof o.until === "number" ? o.until : Date.parse(String(o.until || ""));
270
+ if (!Number.isFinite(untilMs)) return { ok: false, error: "invalid until" };
271
+ const found = findInboxFile(agentRoot, (item && (item.id || item.raw_ref)) || "", { service });
272
+ if (!found || (found.state !== "new" && found.state !== "claimed")) return { ok: false, error: "not-deferrable" };
273
+ const base = found.file.replace(/\.dispatched$/, "");
274
+ const dst = join(agentRoot, "state", "inbox", service, `${base}.deferred`);
275
+ const body = stripSessionMarkers(readFileSync(found.path, "utf-8"));
276
+ const sep = body.endsWith("\n") || body === "" ? "" : "\n";
277
+ writeFileAtomic(found.path, body + sep + sessionMarkerLines({ deferredUntil: untilMs, deferredReason: o.reason || "" }));
278
+ renameSync(found.path, dst);
279
+ return { ok: true, path: dst, until: iso(untilMs) };
280
+ } catch (err) {
281
+ return { ok: false, error: err && err.message ? err.message : String(err) };
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Rewrite a file's contents on its EXISTING inode (open r+, truncate, write).
287
+ * Unlike tmp+rename this can never recreate a path that a concurrent rename
288
+ * (`inbox done`, `inbox reply`) has just moved away — the write either lands
289
+ * in the moved file (harmless: markers are stripped from a terminal copy) or
290
+ * fails ENOENT. The reader side is the sweep itself, so the non-atomic window
291
+ * has no other observer.
292
+ */
293
+ function writeInPlace(path, text) {
294
+ const fd = openSync(path, "r+");
295
+ try {
296
+ ftruncateSync(fd, 0);
297
+ writeSync(fd, text, 0, "utf-8");
298
+ } finally { closeSync(fd); }
299
+ }
300
+
301
+ /** Reopen a parked/claimed file: strip markers in place, rename back to `.yaml`. */
302
+ function reopen(dir, file) {
303
+ const src = join(dir, file);
304
+ const dst = join(dir, file.replace(/\.(dispatched|deferred)$/, ""));
305
+ writeInPlace(src, stripSessionMarkers(readFileSync(src, "utf-8")));
306
+ renameSync(src, dst);
307
+ return dst;
308
+ }
309
+
310
+ /** Retire a claimed file to `.processed` (terminal), markers stripped in place. */
311
+ function retire(dir, file) {
312
+ const src = join(dir, file);
313
+ const dst = join(dir, file.replace(/\.(dispatched|deferred)$/, "") + ".processed");
314
+ try { writeInPlace(src, stripSessionMarkers(readFileSync(src, "utf-8"))); } catch { /* strip is cosmetic on a terminal file */ }
315
+ renameSync(src, dst);
316
+ return dst;
317
+ }
318
+
319
+ /**
320
+ * Strip the session markers off a file in place (same inode). Exported for
321
+ * the CLI's terminal transitions. Never throws; returns whether it wrote.
322
+ * @param {string} path
323
+ * @returns {boolean}
324
+ */
325
+ export function stripMarkersInPlace(path) {
326
+ try {
327
+ const raw = readFileSync(path, "utf-8");
328
+ const clean = stripSessionMarkers(raw);
329
+ if (clean !== raw) writeInPlace(path, clean);
330
+ return true;
331
+ } catch { return false; }
332
+ }
333
+
334
+ /**
335
+ * THE ASSURANCE SWEEP. Reopens (a) a session claim neither replied nor done
336
+ * within `staleMs` (default 20 min), (b) a session deferral whose
337
+ * `deferred_until` has passed, and (c) — only when `sessionLive` — a
338
+ * daemon-owned `.dispatched` orphan (no marker) older than
339
+ * `daemonReclaimMs` (default 2 h), because the scanner that would normally
340
+ * reclaim it is gated off while the session is live. A REPLIED claim is
341
+ * never reopened; one still in flight `repliedRetireMs` (default 2 h) after
342
+ * the reply is retired to `.processed` (reason `replied-retired`). Legacy
343
+ * thread-lock `.deferred` files (no marker) are never touched —
344
+ * `promoteDeferred` owns those. A claim with an unreadable `claimed_at` falls
345
+ * back to the file mtime.
346
+ *
347
+ * @param {string} agentRoot
348
+ * @param {{now?:number, staleMs?:number, services?:string[], sessionLive?:boolean, daemonReclaimMs?:number, repliedRetireMs?:number}} [o]
349
+ * @returns {{reopened:{id:string, service:string, file:string, reason:string}[], scanned:number}}
350
+ */
351
+ export function sweepSessionInbox(agentRoot, o = {}) {
352
+ const now = nowOf(o);
353
+ const staleMs = Number.isFinite(o.staleMs) && o.staleMs > 0 ? o.staleMs : SESSION_CLAIM_STALE_MS;
354
+ const daemonReclaimMs = Number.isFinite(o.daemonReclaimMs) && o.daemonReclaimMs > 0 ? o.daemonReclaimMs : DAEMON_ORPHAN_RECLAIM_MS;
355
+ const repliedRetireMs = Number.isFinite(o.repliedRetireMs) && o.repliedRetireMs > 0 ? o.repliedRetireMs : REPLIED_RETIRE_MS;
356
+ const sessionLive = o.sessionLive === true;
357
+ const services = Array.isArray(o.services) && o.services.length ? o.services : serviceDirs(agentRoot);
358
+ const reopened = [];
359
+ let scanned = 0;
360
+ for (const service of services) {
361
+ const dir = join(agentRoot, "state", "inbox", service);
362
+ if (!existsSync(dir)) continue;
363
+ let names;
364
+ try { names = readdirSync(dir); } catch { continue; }
365
+ for (const file of names) {
366
+ const isClaim = file.endsWith(".yaml.dispatched");
367
+ const isDeferral = file.endsWith(".yaml.deferred");
368
+ if (!isClaim && !isDeferral) continue;
369
+ scanned += 1;
370
+ try {
371
+ const path = join(dir, file);
372
+ const body = readFileSync(path, "utf-8");
373
+ const m = parseSessionMarkers(body);
374
+ let reason = null;
375
+ let action = reopen;
376
+ if (isClaim && m.claimedBy && m.repliedAt) {
377
+ // Answered. Never reopened; retired once the session has had its window to finish.
378
+ let at = Date.parse(m.repliedAt);
379
+ if (!Number.isFinite(at)) { try { at = statSync(path).mtimeMs; } catch { at = now; } }
380
+ if (now - at >= repliedRetireMs) { reason = "replied-retired"; action = retire; }
381
+ } else if (isClaim && m.claimedBy) {
382
+ let at = Date.parse(m.claimedAt || "");
383
+ if (!Number.isFinite(at)) { try { at = statSync(path).mtimeMs; } catch { at = now; } }
384
+ if (now - at >= staleMs) reason = "claim-stale";
385
+ } else if (isClaim && sessionLive) {
386
+ // Daemon-owned. Its scanner is gated off while the session is live.
387
+ let age = 0;
388
+ try { age = now - statSync(path).mtimeMs; } catch { age = 0; }
389
+ if (age >= daemonReclaimMs) reason = "daemon-orphan";
390
+ } else if (isDeferral && m.deferredUntil) {
391
+ const until = Date.parse(m.deferredUntil);
392
+ if (!Number.isFinite(until) || now >= until) reason = "deferral-due";
393
+ }
394
+ if (!reason) continue;
395
+ action(dir, file);
396
+ reopened.push({ id: scalar(body, "id") || file, service, file, reason });
397
+ } catch { /* a racing rename or unreadable file — the next sweep retries */ }
398
+ }
399
+ }
400
+ return { reopened, scanned };
401
+ }
402
+
403
+ /**
404
+ * The daemon's call site for the sweep: reads the front-door state (so the
405
+ * daemon-orphan reclaim runs only while the session is live) and sweeps. A
406
+ * throwing reader means "not live" — the scanner keeps its own reclaim.
407
+ *
408
+ * @param {string} agentRoot
409
+ * @param {{now?:number, readState?:Function}} [o]
410
+ * @returns {{reopened:object[], scanned:number, sessionLive:boolean}}
411
+ */
412
+ export function sweepSessionInboxForDaemon(agentRoot, o = {}) {
413
+ const now = nowOf(o);
414
+ const readState = typeof o.readState === "function" ? o.readState : readFrontDoorState;
415
+ let sessionLive = false;
416
+ try { sessionLive = readState(agentRoot, { now }).sessionLive === true; } catch { sessionLive = false; }
417
+ return { ...sweepSessionInbox(agentRoot, { now, sessionLive }), sessionLive };
418
+ }
419
+
420
+ export default {
421
+ SESSION_CLAIM_STALE_MS,
422
+ REPLIED_RETIRE_MS,
423
+ DAEMON_ORPHAN_RECLAIM_MS,
424
+ sessionMarkerLines,
425
+ parseSessionMarkers,
426
+ stripSessionMarkers,
427
+ stripMarkersInPlace,
428
+ findInboxFile,
429
+ markSessionClaim,
430
+ markSessionDeferral,
431
+ markSessionReplied,
432
+ sweepSessionInbox,
433
+ sweepSessionInboxForDaemon,
434
+ };