@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,286 @@
1
+ /**
2
+ * inbox-claims.test.mjs — session claims on inbox items + the assurance
3
+ * reopen sweep (design §3.3).
4
+ *
5
+ * `maestro inbox claim <id>` parks the item at `.yaml.dispatched` (the same
6
+ * durable-admission marker the daemon uses) and stamps `claimed_by: session`
7
+ * into the YAML. A claim neither replied nor done within 20 min is reopened
8
+ * (`.dispatched` → `.yaml`) by the daemon sweep; a `--until` deferral is
9
+ * reopened once its time passes. Clock injected; real fs on a tmp root.
10
+ */
11
+
12
+ import { test } from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, utimesSync, statSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { tmpdir } from "node:os";
17
+
18
+ import {
19
+ SESSION_CLAIM_STALE_MS,
20
+ sessionMarkerLines,
21
+ parseSessionMarkers,
22
+ stripSessionMarkers,
23
+ findInboxFile,
24
+ markSessionClaim,
25
+ markSessionDeferral,
26
+ markSessionReplied,
27
+ sweepSessionInbox,
28
+ sweepSessionInboxForDaemon,
29
+ DAEMON_ORPHAN_RECLAIM_MS,
30
+ REPLIED_RETIRE_MS,
31
+ } from "./inbox-claims.mjs";
32
+
33
+ const T0 = Date.parse("2026-09-08T10:00:00.000Z");
34
+ const ITEM = `id: "cohort-abc123"
35
+ service: "cohort"
36
+ channel: "dm/olivia"
37
+ channel_id: "ch_1"
38
+ sender: "Olivia Chen"
39
+ timestamp: "2026-09-08T09:59:00.000Z"
40
+ subject: ""
41
+ content: |
42
+ can you send me the deck?
43
+ raw_ref: "cohort-abc123"
44
+ `;
45
+
46
+ function root() {
47
+ const dir = mkdtempSync(join(tmpdir(), "maestro-inbox-claims-"));
48
+ mkdirSync(join(dir, "state", "inbox", "cohort"), { recursive: true });
49
+ return dir;
50
+ }
51
+ function rm(dir) { try { rmSync(dir, { recursive: true, force: true }); } catch { /* */ } }
52
+ function plant(dir, name = "2026-09-08T09-59-00-000Z-cohort-abc123.yaml", body = ITEM) {
53
+ const p = join(dir, "state", "inbox", "cohort", name);
54
+ writeFileSync(p, body);
55
+ return p;
56
+ }
57
+ function ls(dir) { return readdirSync(join(dir, "state", "inbox", "cohort")).sort(); }
58
+
59
+ test("markers: lines round-trip through parse and strip without touching the item", () => {
60
+ const lines = sessionMarkerLines({ claimedBy: "session", now: T0 });
61
+ const body = ITEM + lines;
62
+ assert.deepEqual(parseSessionMarkers(body), {
63
+ claimedBy: "session", claimedAt: new Date(T0).toISOString(), deferredUntil: null, deferredReason: null, repliedAt: null,
64
+ });
65
+ assert.equal(stripSessionMarkers(body), ITEM);
66
+ // Markers must be TOP-LEVEL keys: an indented look-alike inside a body block is ignored.
67
+ const forged = ITEM.replace("can you send me the deck?", "claimed_by: \"session\"\n x");
68
+ assert.equal(parseSessionMarkers(forged).claimedBy, null);
69
+ assert.equal(stripSessionMarkers(forged), forged);
70
+ assert.deepEqual(parseSessionMarkers(""), { claimedBy: null, claimedAt: null, deferredUntil: null, deferredReason: null, repliedAt: null });
71
+ });
72
+
73
+ test("findInboxFile: locates an item by id in any state, reports the state; null when absent", () => {
74
+ const dir = root();
75
+ try {
76
+ plant(dir);
77
+ const f = findInboxFile(dir, "cohort-abc123");
78
+ assert.equal(f.service, "cohort");
79
+ assert.equal(f.state, "new");
80
+ assert.ok(f.path.endsWith(".yaml"));
81
+ assert.equal(findInboxFile(dir, "nope"), null);
82
+ // A specific service narrows the search; a wrong one finds nothing.
83
+ assert.equal(findInboxFile(dir, "cohort-abc123", { service: "slack" }), null);
84
+ plant(dir, "2026-09-08T09-59-00-000Z-cohort-abc123.yaml.dispatched");
85
+ rmSync(f.path);
86
+ assert.equal(findInboxFile(dir, "cohort-abc123").state, "claimed");
87
+ } finally { rm(dir); }
88
+ });
89
+
90
+ test("markSessionClaim: renames to .dispatched and stamps claimed_by/claimed_at; not claimable twice", () => {
91
+ const dir = root();
92
+ try {
93
+ plant(dir);
94
+ const r = markSessionClaim(dir, "cohort", { id: "cohort-abc123" }, { now: T0 });
95
+ assert.equal(r.ok, true);
96
+ assert.deepEqual(ls(dir), ["2026-09-08T09-59-00-000Z-cohort-abc123.yaml.dispatched"]);
97
+ const body = readFileSync(r.path, "utf-8");
98
+ const m = parseSessionMarkers(body);
99
+ assert.equal(m.claimedBy, "session");
100
+ assert.equal(m.claimedAt, new Date(T0).toISOString());
101
+ assert.ok(body.startsWith(ITEM), "the item body is untouched above the markers");
102
+ const again = markSessionClaim(dir, "cohort", { id: "cohort-abc123" }, { now: T0 + 1 });
103
+ assert.equal(again.ok, false);
104
+ assert.equal(again.error, "not-claimable");
105
+ } finally { rm(dir); }
106
+ });
107
+
108
+ test("markSessionDeferral: parks the item as .deferred with deferred_until/reason (from new or claimed)", () => {
109
+ const dir = root();
110
+ try {
111
+ plant(dir);
112
+ markSessionClaim(dir, "cohort", { id: "cohort-abc123" }, { now: T0 });
113
+ const r = markSessionDeferral(dir, "cohort", { id: "cohort-abc123" }, { until: T0 + 30 * 60_000, reason: "waiting on finance", now: T0 });
114
+ assert.equal(r.ok, true);
115
+ assert.deepEqual(ls(dir), ["2026-09-08T09-59-00-000Z-cohort-abc123.yaml.deferred"]);
116
+ const m = parseSessionMarkers(readFileSync(r.path, "utf-8"));
117
+ assert.equal(m.deferredUntil, new Date(T0 + 30 * 60_000).toISOString());
118
+ assert.equal(m.deferredReason, "waiting on finance");
119
+ // The claim marker is replaced, not stacked.
120
+ assert.equal(m.claimedBy, null);
121
+ assert.equal(markSessionDeferral(dir, "cohort", { id: "ghost" }, { until: T0, now: T0 }).ok, false);
122
+ } finally { rm(dir); }
123
+ });
124
+
125
+ test("sweepSessionInbox: a session claim older than 20 min is reopened to .yaml with markers stripped; fresh stays", () => {
126
+ const dir = root();
127
+ try {
128
+ plant(dir);
129
+ markSessionClaim(dir, "cohort", { id: "cohort-abc123" }, { now: T0 });
130
+ // A daemon-dispatched (no marker) .dispatched file is NOT the sweep's to reopen.
131
+ plant(dir, "2026-09-08T09-58-00-000Z-cohort-daemon1.yaml.dispatched", ITEM.replace(/cohort-abc123/g, "cohort-daemon1"));
132
+
133
+ const fresh = sweepSessionInbox(dir, { now: T0 + SESSION_CLAIM_STALE_MS - 1 });
134
+ assert.deepEqual(fresh.reopened, []);
135
+ assert.equal(fresh.scanned >= 2, true);
136
+
137
+ const r = sweepSessionInbox(dir, { now: T0 + SESSION_CLAIM_STALE_MS + 1 });
138
+ assert.deepEqual(r.reopened.map((x) => [x.id, x.service, x.reason]), [["cohort-abc123", "cohort", "claim-stale"]]);
139
+ assert.deepEqual(ls(dir), [
140
+ "2026-09-08T09-58-00-000Z-cohort-daemon1.yaml.dispatched",
141
+ "2026-09-08T09-59-00-000Z-cohort-abc123.yaml",
142
+ ]);
143
+ assert.equal(readFileSync(join(dir, "state/inbox/cohort/2026-09-08T09-59-00-000Z-cohort-abc123.yaml"), "utf-8"), ITEM);
144
+ } finally { rm(dir); }
145
+ });
146
+
147
+ test("sweepSessionInbox: a deferral whose time has passed is reopened; a future one stays; unmarked .deferred untouched", () => {
148
+ const dir = root();
149
+ try {
150
+ plant(dir);
151
+ markSessionDeferral(dir, "cohort", { id: "cohort-abc123" }, { until: T0 + 10_000, reason: "later", now: T0 });
152
+ // Legacy daemon deferral (thread-lock park) carries no marker — promoteDeferred owns it.
153
+ plant(dir, "2026-09-08T09-58-00-000Z-cohort-legacy.yaml.deferred", ITEM.replace(/cohort-abc123/g, "cohort-legacy"));
154
+ assert.deepEqual(sweepSessionInbox(dir, { now: T0 + 9_999 }).reopened, []);
155
+ const r = sweepSessionInbox(dir, { now: T0 + 10_000 });
156
+ assert.deepEqual(r.reopened.map((x) => [x.id, x.reason]), [["cohort-abc123", "deferral-due"]]);
157
+ assert.deepEqual(ls(dir), [
158
+ "2026-09-08T09-58-00-000Z-cohort-legacy.yaml.deferred",
159
+ "2026-09-08T09-59-00-000Z-cohort-abc123.yaml",
160
+ ]);
161
+ } finally { rm(dir); }
162
+ });
163
+
164
+ test("sweepSessionInbox: a claim with an unparsable claimed_at falls back to the file mtime; missing inbox dir → nothing", () => {
165
+ const dir = root();
166
+ try {
167
+ const p = plant(dir, "2026-09-08T09-59-00-000Z-cohort-abc123.yaml.dispatched", ITEM + 'claimed_by: "session"\nclaimed_at: "garbage"\n');
168
+ const old = new Date(T0 - SESSION_CLAIM_STALE_MS - 1000);
169
+ utimesSync(p, old, old);
170
+ const r = sweepSessionInbox(dir, { now: T0 });
171
+ assert.deepEqual(r.reopened.map((x) => x.id), ["cohort-abc123"]);
172
+ assert.deepEqual(sweepSessionInbox(join(dir, "nope"), { now: T0 }), { reopened: [], scanned: 0 });
173
+ } finally { rm(dir); }
174
+ });
175
+
176
+ // ── review fixes ─────────────────────────────────────────────────────────────
177
+
178
+ test("findInboxFile: an exact id wins over substring look-alikes; a truncated id that matches several is refused", () => {
179
+ const dir = root();
180
+ try {
181
+ for (const id of ["cohort-m1", "cohort-m10", "cohort-m11"]) {
182
+ plant(dir, `2026-09-08T09-59-00-000Z-${id}.yaml`, ITEM.replace(/cohort-abc123/g, id));
183
+ }
184
+ assert.ok(findInboxFile(dir, "cohort-m1").file.endsWith("-cohort-m1.yaml"));
185
+ assert.ok(findInboxFile(dir, "cohort-m10").file.endsWith("-cohort-m10.yaml"));
186
+ // "m1" is a substring of all three and the exact id of none → ambiguous, not a guess.
187
+ assert.equal(findInboxFile(dir, "m1"), null);
188
+ // A unique substring (a raw_ref fragment) still resolves.
189
+ assert.ok(findInboxFile(dir, "m11").file.endsWith("-cohort-m11.yaml"));
190
+ } finally { rm(dir); }
191
+ });
192
+
193
+ test("markSessionClaim: renames ONLY the claimed item — look-alike ids stay live", () => {
194
+ const dir = root();
195
+ try {
196
+ for (const id of ["cohort-m1", "cohort-m10", "cohort-m11"]) {
197
+ plant(dir, `2026-09-08T09-59-00-000Z-${id}.yaml`, ITEM.replace(/cohort-abc123/g, id));
198
+ }
199
+ const r = markSessionClaim(dir, "cohort", { id: "cohort-m1" }, { now: T0 });
200
+ assert.equal(r.ok, true);
201
+ assert.deepEqual(ls(dir), [
202
+ "2026-09-08T09-59-00-000Z-cohort-m1.yaml.dispatched",
203
+ "2026-09-08T09-59-00-000Z-cohort-m10.yaml",
204
+ "2026-09-08T09-59-00-000Z-cohort-m11.yaml",
205
+ ]);
206
+ } finally { rm(dir); }
207
+ });
208
+
209
+ test("sweepSessionInbox: reopen never recreates the path — the reopened file is the SAME inode, and a vanished claim is not resurrected", () => {
210
+ const dir = root();
211
+ try {
212
+ plant(dir);
213
+ const claim = markSessionClaim(dir, "cohort", { id: "cohort-abc123" }, { now: T0 });
214
+ const ino = statSync(claim.path).ino;
215
+ const r = sweepSessionInbox(dir, { now: T0 + SESSION_CLAIM_STALE_MS + 1 });
216
+ assert.equal(r.reopened.length, 1);
217
+ const live = join(dir, "state/inbox/cohort/2026-09-08T09-59-00-000Z-cohort-abc123.yaml");
218
+ assert.equal(statSync(live).ino, ino, "markers are stripped in place, not via tmp+rename over the claim path");
219
+ assert.equal(readFileSync(live, "utf-8"), ITEM);
220
+ // A claim that was finished (renamed away) between the readdir and the reopen is left alone.
221
+ const p2 = plant(dir, "2026-09-08T09-58-00-000Z-cohort-gone.yaml.dispatched", ITEM.replace(/cohort-abc123/g, "cohort-gone") + sessionMarkerLines({ claimedBy: "session", now: T0 }));
222
+ rmSync(p2);
223
+ const r2 = sweepSessionInbox(dir, { now: T0 + SESSION_CLAIM_STALE_MS + 1 });
224
+ assert.equal(r2.reopened.length, 0);
225
+ assert.equal(existsSync(join(dir, "state/inbox/cohort/2026-09-08T09-58-00-000Z-cohort-gone.yaml")), false);
226
+ } finally { rm(dir); }
227
+ });
228
+
229
+ test("sweepSessionInbox: a claim that was REPLIED is not reopened at 20 min; it is retired to .processed once past the retire window", () => {
230
+ const dir = root();
231
+ try {
232
+ plant(dir);
233
+ markSessionClaim(dir, "cohort", { id: "cohort-abc123" }, { now: T0 });
234
+ const rep = markSessionReplied(dir, "cohort", { id: "cohort-abc123" }, { now: T0 + 60_000 });
235
+ assert.equal(rep.ok, true);
236
+ const m = parseSessionMarkers(readFileSync(rep.path, "utf-8"));
237
+ assert.equal(m.claimedBy, "session", "the claim marker survives the reply stamp");
238
+ assert.equal(m.repliedAt, new Date(T0 + 60_000).toISOString());
239
+
240
+ const at20 = sweepSessionInbox(dir, { now: T0 + SESSION_CLAIM_STALE_MS + 1 });
241
+ assert.deepEqual(at20.reopened, [], "a replied item must never be re-answered");
242
+ assert.deepEqual(ls(dir), ["2026-09-08T09-59-00-000Z-cohort-abc123.yaml.dispatched"]);
243
+
244
+ const late = sweepSessionInbox(dir, { now: T0 + 60_000 + REPLIED_RETIRE_MS + 1 });
245
+ assert.deepEqual(late.reopened.map((x) => [x.id, x.reason]), [["cohort-abc123", "replied-retired"]]);
246
+ assert.deepEqual(ls(dir), ["2026-09-08T09-59-00-000Z-cohort-abc123.yaml.processed"]);
247
+ // Replying to an item that is not in flight (new / processed) is refused, never a throw.
248
+ assert.equal(markSessionReplied(dir, "cohort", { id: "cohort-abc123" }, { now: T0 }).ok, false);
249
+ } finally { rm(dir); }
250
+ });
251
+
252
+ test("sweepSessionInbox: a daemon-owned .dispatched orphan is reopened for a LIVE session once past the daemon reclaim window; never while the session is not live", () => {
253
+ const dir = root();
254
+ try {
255
+ const p = plant(dir, "2026-09-08T09-58-00-000Z-cohort-daemon1.yaml.dispatched", ITEM.replace(/cohort-abc123/g, "cohort-daemon1"));
256
+ const old = new Date(T0 - DAEMON_ORPHAN_RECLAIM_MS - 1000);
257
+ utimesSync(p, old, old);
258
+ // Not live → the scanner's own reclaim owns it; the sweep does nothing.
259
+ assert.deepEqual(sweepSessionInbox(dir, { now: T0 }).reopened, []);
260
+ assert.deepEqual(sweepSessionInbox(dir, { now: T0, sessionLive: false }).reopened, []);
261
+ // Live but the file is still inside the window → in flight, untouched.
262
+ const fresh = plant(dir, "2026-09-08T09-58-30-000Z-cohort-daemon2.yaml.dispatched", ITEM.replace(/cohort-abc123/g, "cohort-daemon2"));
263
+ utimesSync(fresh, new Date(T0 - 1000), new Date(T0 - 1000));
264
+ const r = sweepSessionInbox(dir, { now: T0, sessionLive: true });
265
+ assert.deepEqual(r.reopened.map((x) => [x.id, x.reason]), [["cohort-daemon1", "daemon-orphan"]]);
266
+ assert.deepEqual(ls(dir), [
267
+ "2026-09-08T09-58-00-000Z-cohort-daemon1.yaml",
268
+ "2026-09-08T09-58-30-000Z-cohort-daemon2.yaml.dispatched",
269
+ ]);
270
+ } finally { rm(dir); }
271
+ });
272
+
273
+ test("sweepSessionInboxForDaemon: reads liveness through the front-door reader; a throwing reader means not live (scanner keeps its reclaim)", () => {
274
+ const dir = root();
275
+ try {
276
+ const p = plant(dir, "2026-09-08T09-58-00-000Z-cohort-daemon1.yaml.dispatched", ITEM.replace(/cohort-abc123/g, "cohort-daemon1"));
277
+ const old = new Date(T0 - DAEMON_ORPHAN_RECLAIM_MS - 1000);
278
+ utimesSync(p, old, old);
279
+ const dead = sweepSessionInboxForDaemon(dir, { now: T0, readState: () => { throw new Error("boom"); } });
280
+ assert.equal(dead.sessionLive, false);
281
+ assert.deepEqual(dead.reopened, []);
282
+ const live = sweepSessionInboxForDaemon(dir, { now: T0, readState: () => ({ frontDoor: "session", sessionLive: true }) });
283
+ assert.equal(live.sessionLive, true);
284
+ assert.deepEqual(live.reopened.map((x) => x.reason), ["daemon-orphan"]);
285
+ } finally { rm(dir); }
286
+ });
@@ -0,0 +1,161 @@
1
+ /**
2
+ * lib/session/launch-args.mjs — the exact argv the supervisor runs. Pure.
3
+ *
4
+ * Three things are decided here and nowhere else:
5
+ *
6
+ * 1. The `claude` argv for the main session — `--name <first>-main`, the
7
+ * stable id (`--session-id` on the first launch of an id, `--resume`
8
+ * after), the permission posture the caller resolved via
9
+ * `lib/session-permissions.mjs`, and the bootstrap prompt.
10
+ * 2. Which multiplexer runs it. macOS ships `/usr/bin/screen`; `tmux` is
11
+ * preferred when installed (`mux: auto`). Both run in the FOREGROUND of
12
+ * the supervisor so the supervisor lives exactly as long as the session:
13
+ * `screen -D -m` blocks until the session ends; tmux `new-session -d`
14
+ * returns at once, so the supervisor blocks on a `has-session` probe
15
+ * (`=name` is tmux's exact-match form) until the session is gone.
16
+ * 3. How the session's exit code reaches the supervisor. Neither mux
17
+ * propagates the child's status, so `claude` is wrapped in
18
+ * {@link SESSION_EXIT_SCRIPT}: a `/bin/sh -c` whose script takes the exit
19
+ * file and the command as POSITIONAL parameters — nothing user-controlled
20
+ * is ever interpolated into shell text.
21
+ *
22
+ * No I/O. `detectMux` is the one probe and takes `existsSync` injected.
23
+ *
24
+ * @module lib/session/launch-args
25
+ */
26
+
27
+ "use strict";
28
+
29
+ import { mainSessionName } from "./identity.mjs";
30
+
31
+ /** The prompt every (re)launch of the main session starts with. */
32
+ export const BOOTSTRAP_PROMPT =
33
+ "You are resuming as the main session for this seat. Load the maestro main-session skill " +
34
+ "(~/.claude/skills/maestro-main-session/SKILL.md or /maestro:main-session) and follow it.";
35
+
36
+ /**
37
+ * `sh -c` body that runs `"$@"` and records its exit code. Positional only:
38
+ * `$0` is a label, `$1` the exit file, the rest the command. The exit file's
39
+ * parent must already exist (the supervisor creates `state/session/`).
40
+ */
41
+ export const SESSION_EXIT_SCRIPT =
42
+ 'exit_file="$1"; shift; "$@"; rc=$?; printf \'%s\\n\' "$rc" > "$exit_file" 2>/dev/null; exit "$rc"';
43
+
44
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
45
+
46
+ /** Where the multiplexers live on a Mac (nvm-free, launchd-safe, in order). */
47
+ export const MUX_CANDIDATES = Object.freeze({
48
+ tmux: Object.freeze(["/opt/homebrew/bin/tmux", "/usr/local/bin/tmux", "/usr/bin/tmux"]),
49
+ screen: Object.freeze(["/usr/bin/screen", "/opt/homebrew/bin/screen", "/usr/local/bin/screen"]),
50
+ });
51
+
52
+ /**
53
+ * Pure: the `claude` argv (without the binary).
54
+ * @param {{first:string, sessionId:string, mode:"session-id"|"resume", permissionArgs?:string[], extraArgs?:string[], prompt?:string}} a
55
+ * @returns {string[]}
56
+ */
57
+ export function buildClaudeArgs(a) {
58
+ if (!UUID_RE.test(String(a.sessionId || ""))) throw new Error(`buildClaudeArgs: session id must be a uuid, got ${JSON.stringify(a.sessionId)}`);
59
+ if (a.mode !== "session-id" && a.mode !== "resume") throw new Error(`buildClaudeArgs: unknown mode ${JSON.stringify(a.mode)}`);
60
+ return [
61
+ "--name", mainSessionName(a.first),
62
+ a.mode === "resume" ? "--resume" : "--session-id", a.sessionId,
63
+ ...(a.permissionArgs || []),
64
+ ...(a.extraArgs || []),
65
+ a.prompt || BOOTSTRAP_PROMPT,
66
+ ];
67
+ }
68
+
69
+ /**
70
+ * Which multiplexers are installed, by fixed path. A throwing probe reads as
71
+ * absent — the supervisor then reports "no multiplexer" rather than crashing.
72
+ * @param {{existsSync:(p:string)=>boolean}} deps
73
+ * @returns {{tmux:string|null, screen:string|null}}
74
+ */
75
+ export function detectMux(deps) {
76
+ const probe = (p) => { try { return !!deps.existsSync(p); } catch { return false; } };
77
+ const first = (list) => list.find(probe) || null;
78
+ return { tmux: first(MUX_CANDIDATES.tmux), screen: first(MUX_CANDIDATES.screen) };
79
+ }
80
+
81
+ /**
82
+ * Pure: pick the multiplexer. `auto` prefers tmux; an explicit preference that
83
+ * is not installed falls back to whatever is, because a seat with a wrong
84
+ * `mux:` line should still come up.
85
+ * @param {"auto"|"screen"|"tmux"} pref
86
+ * @param {{tmux:string|null, screen:string|null}} found
87
+ * @returns {{kind:"tmux"|"screen", bin:string}|null}
88
+ */
89
+ export function chooseMux(pref, found) {
90
+ const order = pref === "screen" ? ["screen", "tmux"] : ["tmux", "screen"];
91
+ for (const kind of order) if (found[kind]) return { kind, bin: found[kind] };
92
+ return null;
93
+ }
94
+
95
+ /**
96
+ * Pure: the commands the supervisor runs for one session lifetime.
97
+ *
98
+ * start the command that launches the session (blocking for screen).
99
+ * waitProbe tmux only — exits 0 while the session exists; the supervisor
100
+ * polls it to block. null for screen.
101
+ * exists the probe used to detect an orphaned session before launch.
102
+ * wipe screen only — `screen -wipe` removes DEAD sockets (a SIGKILLed
103
+ * screen leaves `name (Dead ???)` behind, which `-ls` still lists).
104
+ * null for tmux, whose server owns its sockets.
105
+ * stop ends the session on request (SIGTERM to the supervisor):
106
+ * `screen -S name -X quit` / `tmux kill-session -t =name`.
107
+ *
108
+ * @param {{mux:{kind:string, bin:string}, muxName:string, cwd:string, claudeBin:string, claudeArgs:string[], exitFile:string}} a
109
+ */
110
+ export function buildMuxCommands(a) {
111
+ const wrapped = ["/bin/sh", "-c", SESSION_EXIT_SCRIPT, "maestro-session", a.exitFile, a.claudeBin, ...a.claudeArgs];
112
+ if (a.mux.kind === "screen") {
113
+ return {
114
+ blocking: true,
115
+ start: { file: a.mux.bin, args: ["-D", "-m", "-S", a.muxName, ...wrapped] },
116
+ waitProbe: null,
117
+ exists: { file: a.mux.bin, args: ["-ls"] },
118
+ wipe: { file: a.mux.bin, args: ["-wipe"] },
119
+ stop: { file: a.mux.bin, args: ["-S", a.muxName, "-X", "quit"] },
120
+ };
121
+ }
122
+ if (a.mux.kind === "tmux") {
123
+ const probe = { file: a.mux.bin, args: ["has-session", "-t", `=${a.muxName}`] };
124
+ return {
125
+ blocking: false,
126
+ start: { file: a.mux.bin, args: ["new-session", "-d", "-s", a.muxName, "-c", a.cwd, ...wrapped] },
127
+ waitProbe: probe,
128
+ exists: probe,
129
+ wipe: null,
130
+ stop: { file: a.mux.bin, args: ["kill-session", "-t", `=${a.muxName}`] },
131
+ };
132
+ }
133
+ throw new Error(`buildMuxCommands: unknown mux kind ${JSON.stringify(a.mux && a.mux.kind)}`);
134
+ }
135
+
136
+ /**
137
+ * Pure: does `screen -ls` output list a LIVE session named exactly `name`?
138
+ * Lines look like `\t48213.maestro-olivia\t(Detached)`. A `(Dead ???)` line
139
+ * is a socket whose screen process was killed (SIGKILL from a watchdog, an
140
+ * OOM, launchd's process-group kill): nothing is running behind it, so it
141
+ * must not be adopted — adopting it would block until someone ran
142
+ * `screen -wipe` by hand.
143
+ * @param {string|null|undefined} out @param {string} name
144
+ */
145
+ export function parseScreenList(out, name) {
146
+ if (typeof out !== "string") return false;
147
+ const re = new RegExp(`^\\s*\\d+\\.${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\s|$)`);
148
+ return out.split(/\r?\n/).some((line) => re.test(line) && !/\(Dead\b/.test(line));
149
+ }
150
+
151
+ /** Pure: the recorded exit code, or null when unreadable. */
152
+ export function parseExitFile(text) {
153
+ if (typeof text !== "string") return null;
154
+ const m = text.trim().match(/^\d{1,3}$/);
155
+ return m ? Number(m[0]) : null;
156
+ }
157
+
158
+ export default {
159
+ BOOTSTRAP_PROMPT, SESSION_EXIT_SCRIPT, MUX_CANDIDATES,
160
+ buildClaudeArgs, detectMux, chooseMux, buildMuxCommands, parseScreenList, parseExitFile,
161
+ };
@@ -0,0 +1,157 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import {
5
+ BOOTSTRAP_PROMPT,
6
+ SESSION_EXIT_SCRIPT,
7
+ buildClaudeArgs,
8
+ detectMux,
9
+ chooseMux,
10
+ buildMuxCommands,
11
+ parseScreenList,
12
+ parseExitFile,
13
+ } from "./launch-args.mjs";
14
+
15
+ const UUID = "11111111-2222-4333-8444-555555555555";
16
+
17
+ test("BOOTSTRAP_PROMPT is the exact resume prompt from the design", () => {
18
+ assert.equal(
19
+ BOOTSTRAP_PROMPT,
20
+ "You are resuming as the main session for this seat. Load the maestro main-session skill (~/.claude/skills/maestro-main-session/SKILL.md or /maestro:main-session) and follow it.",
21
+ );
22
+ });
23
+
24
+ test("buildClaudeArgs: fresh id → --session-id; relaunch → --resume; permissions spliced; prompt last", () => {
25
+ const fresh = buildClaudeArgs({ first: "olivia", sessionId: UUID, mode: "session-id", permissionArgs: ["--dangerously-skip-permissions"] });
26
+ assert.deepEqual(fresh, ["--name", "olivia-main", "--session-id", UUID, "--dangerously-skip-permissions", BOOTSTRAP_PROMPT]);
27
+ const resume = buildClaudeArgs({ first: "olivia", sessionId: UUID, mode: "resume", permissionArgs: ["--allowedTools", "Read"], extraArgs: ["--model", "opus"] });
28
+ assert.deepEqual(resume, ["--name", "olivia-main", "--resume", UUID, "--allowedTools", "Read", "--model", "opus", BOOTSTRAP_PROMPT]);
29
+ });
30
+
31
+ test("buildClaudeArgs: a non-uuid session id or unknown mode is a defect", () => {
32
+ assert.throws(() => buildClaudeArgs({ first: "o", sessionId: "nope", mode: "resume" }), /session id/);
33
+ assert.throws(() => buildClaudeArgs({ first: "o", sessionId: UUID, mode: "attach" }), /mode/);
34
+ });
35
+
36
+ test("detectMux: probes the fixed candidate paths through injected existsSync", () => {
37
+ const found = detectMux({ existsSync: (p) => p === "/opt/homebrew/bin/tmux" || p === "/usr/bin/screen" });
38
+ assert.deepEqual(found, { tmux: "/opt/homebrew/bin/tmux", screen: "/usr/bin/screen" });
39
+ assert.deepEqual(detectMux({ existsSync: () => false }), { tmux: null, screen: null });
40
+ // A throwing probe is treated as absent.
41
+ assert.deepEqual(detectMux({ existsSync: () => { throw new Error("boom"); } }), { tmux: null, screen: null });
42
+ });
43
+
44
+ test("chooseMux: auto prefers tmux, falls back to screen, null when neither; explicit choice must exist", () => {
45
+ const both = { tmux: "/opt/homebrew/bin/tmux", screen: "/usr/bin/screen" };
46
+ assert.deepEqual(chooseMux("auto", both), { kind: "tmux", bin: "/opt/homebrew/bin/tmux" });
47
+ assert.deepEqual(chooseMux("auto", { tmux: null, screen: "/usr/bin/screen" }), { kind: "screen", bin: "/usr/bin/screen" });
48
+ assert.equal(chooseMux("auto", { tmux: null, screen: null }), null);
49
+ assert.deepEqual(chooseMux("screen", both), { kind: "screen", bin: "/usr/bin/screen" });
50
+ assert.deepEqual(chooseMux("tmux", both), { kind: "tmux", bin: "/opt/homebrew/bin/tmux" });
51
+ // An explicit mux that is not installed falls back rather than failing the seat.
52
+ assert.deepEqual(chooseMux("tmux", { tmux: null, screen: "/usr/bin/screen" }), { kind: "screen", bin: "/usr/bin/screen" });
53
+ });
54
+
55
+ test("SESSION_EXIT_SCRIPT records the wrapped command's exit code without interpolating any argument", () => {
56
+ assert.match(SESSION_EXIT_SCRIPT, /"\$@"/);
57
+ assert.match(SESSION_EXIT_SCRIPT, /exit "\$rc"/);
58
+ assert.doesNotMatch(SESSION_EXIT_SCRIPT, /\$\{?[A-Z_]+\}?/); // no env expansion — everything is positional
59
+ });
60
+
61
+ test("buildMuxCommands (screen): one FOREGROUND command, -D -m -S <name>, claude wrapped by the exit-recording sh", () => {
62
+ const c = buildMuxCommands({
63
+ mux: { kind: "screen", bin: "/usr/bin/screen" },
64
+ muxName: "maestro-olivia",
65
+ cwd: "/agent",
66
+ claudeBin: "/Users/o/.local/bin/claude",
67
+ claudeArgs: ["--name", "olivia-main", "--resume", UUID, "prompt with spaces"],
68
+ exitFile: "/agent/state/session/last-exit",
69
+ });
70
+ assert.equal(c.blocking, true);
71
+ assert.equal(c.waitProbe, null);
72
+ assert.equal(c.start.file, "/usr/bin/screen");
73
+ assert.deepEqual(c.start.args, [
74
+ "-D", "-m", "-S", "maestro-olivia",
75
+ "/bin/sh", "-c", SESSION_EXIT_SCRIPT, "maestro-session", "/agent/state/session/last-exit",
76
+ "/Users/o/.local/bin/claude", "--name", "olivia-main", "--resume", UUID, "prompt with spaces",
77
+ ]);
78
+ assert.deepEqual(c.exists, { file: "/usr/bin/screen", args: ["-ls"] });
79
+ });
80
+
81
+ test("buildMuxCommands (tmux): detached new-session, then a has-session probe the supervisor blocks on", () => {
82
+ const c = buildMuxCommands({
83
+ mux: { kind: "tmux", bin: "/opt/homebrew/bin/tmux" },
84
+ muxName: "maestro-olivia",
85
+ cwd: "/agent",
86
+ claudeBin: "claude",
87
+ claudeArgs: ["--name", "olivia-main"],
88
+ exitFile: "/agent/state/session/last-exit",
89
+ });
90
+ assert.equal(c.blocking, false);
91
+ assert.equal(c.start.file, "/opt/homebrew/bin/tmux");
92
+ assert.deepEqual(c.start.args, [
93
+ "new-session", "-d", "-s", "maestro-olivia", "-c", "/agent",
94
+ "/bin/sh", "-c", SESSION_EXIT_SCRIPT, "maestro-session", "/agent/state/session/last-exit",
95
+ "claude", "--name", "olivia-main",
96
+ ]);
97
+ assert.deepEqual(c.waitProbe, { file: "/opt/homebrew/bin/tmux", args: ["has-session", "-t", "=maestro-olivia"] });
98
+ assert.deepEqual(c.exists, c.waitProbe);
99
+ });
100
+
101
+ test("buildMuxCommands: unknown mux kind is a defect", () => {
102
+ assert.throws(() => buildMuxCommands({ mux: { kind: "zellij", bin: "z" }, muxName: "m", cwd: "/", claudeBin: "c", claudeArgs: [], exitFile: "/e" }), /mux/);
103
+ });
104
+
105
+ test("parseScreenList: finds an exact session name in `screen -ls` output, not a prefix", () => {
106
+ const out = [
107
+ "There are screens on:",
108
+ "\t48213.maestro-olivia\t(Detached)",
109
+ "\t48214.maestro-olivia-old\t(Attached)",
110
+ "2 Sockets in /var/folders/xx/.screen.",
111
+ "",
112
+ ].join("\n");
113
+ assert.equal(parseScreenList(out, "maestro-olivia"), true);
114
+ assert.equal(parseScreenList(out, "maestro-oliv"), false);
115
+ assert.equal(parseScreenList("No Sockets found in /var/folders/xx/.screen.\n", "maestro-olivia"), false);
116
+ assert.equal(parseScreenList(null, "maestro-olivia"), false);
117
+ });
118
+
119
+ test("parseScreenList: a `(Dead ???)` socket is NOT a live session (a SIGKILLed screen leaves one behind)", () => {
120
+ // Verbatim shape of `screen -ls` after the screen process was kill -9'd.
121
+ const dead = [
122
+ "There is a screen on:",
123
+ "\t82332.maestro-olivia\t(Dead ???)",
124
+ "Remove dead screens with 'screen -wipe'.",
125
+ "1 Socket in /var/folders/xx/.screen.",
126
+ "",
127
+ ].join("\n");
128
+ assert.equal(parseScreenList(dead, "maestro-olivia"), false);
129
+ // A dead socket next to a live one with the same name: the live one counts.
130
+ const mixed = [
131
+ "There are screens on:",
132
+ "\t82332.maestro-olivia\t(Dead ???)",
133
+ "\t90001.maestro-olivia\t(Detached)",
134
+ "2 Sockets in /var/folders/xx/.screen.",
135
+ "",
136
+ ].join("\n");
137
+ assert.equal(parseScreenList(mixed, "maestro-olivia"), true);
138
+ });
139
+
140
+ test("buildMuxCommands: screen has a `wipe` (dead-socket cleanup) and both muxes a `stop`; tmux has no wipe", () => {
141
+ const base = { muxName: "maestro-olivia", cwd: "/agent", claudeBin: "claude", claudeArgs: [], exitFile: "/e" };
142
+ const s = buildMuxCommands({ ...base, mux: { kind: "screen", bin: "/usr/bin/screen" } });
143
+ assert.deepEqual(s.wipe, { file: "/usr/bin/screen", args: ["-wipe"] });
144
+ assert.deepEqual(s.stop, { file: "/usr/bin/screen", args: ["-S", "maestro-olivia", "-X", "quit"] });
145
+ const t = buildMuxCommands({ ...base, mux: { kind: "tmux", bin: "/opt/homebrew/bin/tmux" } });
146
+ assert.equal(t.wipe, null);
147
+ assert.deepEqual(t.stop, { file: "/opt/homebrew/bin/tmux", args: ["kill-session", "-t", "=maestro-olivia"] });
148
+ });
149
+
150
+ test("parseExitFile: integer or null", () => {
151
+ assert.equal(parseExitFile("0\n"), 0);
152
+ assert.equal(parseExitFile(" 1 "), 1);
153
+ assert.equal(parseExitFile("137"), 137);
154
+ assert.equal(parseExitFile(""), null);
155
+ assert.equal(parseExitFile("abc"), null);
156
+ assert.equal(parseExitFile(null), null);
157
+ });