@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,760 @@
1
+ /**
2
+ * lib/cli/session.mjs — backs `maestro session <sub>` (front-door session,
3
+ * design §3.1 / §3.6 / §6 WP-M3).
4
+ *
5
+ * WHY A LIBRARY AND NOT A `case` BLOCK IN bin/maestro.mjs. Same reason as
6
+ * lib/subagents/cli.mjs: the dispatcher cannot be imported without executing,
7
+ * so a verb surface that reads state and drives launchctl/tmux/screen would be
8
+ * untestable there. `run()` returns `{ok, code, lines, data?}` and only PRINTS
9
+ * when asked; every process boundary (execs, TTY, uid, HOME, clock, pid
10
+ * liveness, the claude binary) is injected via `deps`, so the tests never
11
+ * touch launchd or a multiplexer. bin/maestro.mjs keeps a one-line `case`.
12
+ *
13
+ * Commands:
14
+ *
15
+ * status [--brief|--json] lock holder · heartbeat age · main session id ·
16
+ * mux session · plist installed/loaded · peers ·
17
+ * open handoffs · restart/upgrade flags
18
+ * attach print the exact `tmux attach` / `screen -r` line;
19
+ * exec it when stdin+stdout are a TTY
20
+ * start bootstrap the plist if not loaded, then kickstart
21
+ * stop bootout the label from the gui domain
22
+ * restart [--force] touch state/session/restart-requested, then
23
+ * kickstart (the session exits itself at an idle
24
+ * moment and the supervisor relaunches on the new
25
+ * code); --force = kickstart -k
26
+ * spawn --name <slug> [--cwd <dir>] "<prompt>"
27
+ * detached mux session running the CLI as
28
+ * <first>-<slug> with lib/session-permissions args;
29
+ * registers state/session/peers.json; prints the name
30
+ * peers [--json] list peers, pruning dead ones from the registry
31
+ * handoffs [--json] open cadence handoffs (state/session/handoffs/*.json)
32
+ * ack <tickId> [--result p] delegates to lib/cli/session-ack.mjs (WP-M2) when
33
+ * present; a clear failure when it is not
34
+ *
35
+ * State it reads (all written by the supervisor / feed / daemon, never here
36
+ * except peers.json and the restart marker):
37
+ * state/locks/process/session.pid lib/singleton.js acquireLock("session")
38
+ * state/session/heartbeat.json {pid, ppid, sessionId, name, ts, feedVersion}
39
+ * state/session/main-session.json {sessionId, createdAt, resumes}
40
+ * state/session/peers.json {peers:[{name, muxName, startedAt, purpose, cwd}]}
41
+ * (read-modify-written ONLY through updatePeers(),
42
+ * under the O_EXCL sidecar peers.json.lock)
43
+ * config/session.yaml mux + allowedTools via lib/session/config.mjs
44
+ * (WP-M1) — one source of truth with the supervisor
45
+ * state/session/handoffs/<tick>.json {tickId, cadence, mode, promptPath, enqueuedAt, deadlineAt}
46
+ * state/session/restart-requested marker
47
+ * state/session/upgrade-notice.json {from, to, at} (autoupdate.sh) — reported only
48
+ * while `to` differs from the running SDK version
49
+ *
50
+ * Everything is fail-open: unreadable state reads as absent. `now` is a
51
+ * parameter wherever it changes the answer. Node builtins only. ESM.
52
+ *
53
+ * @module lib/cli/session
54
+ */
55
+
56
+ "use strict";
57
+
58
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
59
+ import { basename, dirname, join } from "node:path";
60
+ import { homedir } from "node:os";
61
+ import { spawnSync } from "node:child_process";
62
+ import { parseArgs } from "node:util";
63
+ import { pathToFileURL } from "node:url";
64
+ import { writeJsonAtomic } from "../fs-atomic.mjs";
65
+ import { resolveAgentRoot } from "../agent-root.mjs";
66
+ import { resolveClaudeBin } from "../claude-bin.mjs";
67
+ import { sessionPermissionArgs } from "../session-permissions.mjs";
68
+ import { withParallelism } from "../prompts/parallelism.mjs";
69
+
70
+ /** The SDK's own package.json version — what "installed" means for an upgrade notice. */
71
+ function ownVersion() {
72
+ try { return JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version || null; } catch { return null; }
73
+ }
74
+
75
+ /** Heartbeat older than this is "not live" (design §3.2: 90 s). */
76
+ export const STALE_MS = 90_000;
77
+
78
+ /** A peer slug: lower-case, digits, hyphens; short. */
79
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,40}$/;
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Names and paths (pure)
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /**
86
+ * The agent's first name, lower-cased. The chain is EXACTLY generate-plists.sh's,
87
+ * so the CLI and the plist can never disagree on the label:
88
+ * 1. config/agent.json `firstName` (SOT)
89
+ * 2. config/agent.ts inline `firstName: 'X'` (legacy seats; a type-only
90
+ * `firstName: string;` must not match)
91
+ * 3. directory basename minus a `-ai` suffix
92
+ * @param {string} agentRoot
93
+ * @returns {string}
94
+ */
95
+ export function resolveFirstName(agentRoot) {
96
+ try {
97
+ const cfg = JSON.parse(readFileSync(join(agentRoot, "config", "agent.json"), "utf8"));
98
+ const f = typeof cfg.firstName === "string" ? cfg.firstName.trim().toLowerCase() : "";
99
+ if (f && f !== "unconfigured") return f;
100
+ } catch {
101
+ // no/invalid agent.json — fall through (fail-open)
102
+ }
103
+ try {
104
+ const ts = readFileSync(join(agentRoot, "config", "agent.ts"), "utf8");
105
+ const m = ts.match(/firstName:\s*['"]([^'"]+)['"]/);
106
+ const f = m ? m[1].trim().toLowerCase() : "";
107
+ if (f && f !== "unconfigured") return f;
108
+ } catch {
109
+ // no agent.ts — fall through
110
+ }
111
+ return basename(agentRoot).toLowerCase().replace(/-ai$/, "");
112
+ }
113
+
114
+ /** @param {string} first @returns {string} launchd label */
115
+ export function sessionLabel(first) { return `ai.maestro.${first}-session`; }
116
+
117
+ /** @param {string} first @returns {string} the main session's mux name */
118
+ export function muxSessionName(first) { return `maestro-${first}`; }
119
+
120
+ /** @param {string} first @param {string} slug @returns {string} a peer's mux name */
121
+ export function peerMuxName(first, slug) { return `maestro-${first}-${slug}`; }
122
+
123
+ /**
124
+ * Every state path the CLI touches, in one place.
125
+ * @param {string} agentRoot
126
+ */
127
+ export function sessionPaths(agentRoot) {
128
+ const dir = join(agentRoot, "state", "session");
129
+ return {
130
+ dir,
131
+ heartbeat: join(dir, "heartbeat.json"),
132
+ mainSession: join(dir, "main-session.json"),
133
+ peers: join(dir, "peers.json"),
134
+ handoffsDir: join(dir, "handoffs"),
135
+ handoffsDone: join(dir, "handoffs", "done"),
136
+ restartRequested: join(dir, "restart-requested"),
137
+ upgradeNotice: join(dir, "upgrade-notice.json"),
138
+ lock: join(agentRoot, "state", "locks", "process", "session.pid"),
139
+ };
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Pure decisions
144
+ // ---------------------------------------------------------------------------
145
+
146
+ /** ms or ISO → epoch ms, else null. */
147
+ function toMs(v) {
148
+ if (typeof v === "number" && Number.isFinite(v)) return v;
149
+ if (typeof v === "string" && v) { const t = Date.parse(v); return Number.isFinite(t) ? t : null; }
150
+ return null;
151
+ }
152
+
153
+ /**
154
+ * Age of a heartbeat record at `now`, or null when there is none.
155
+ * @param {{ts?: number|string}|null} hb
156
+ * @param {number} now epoch ms
157
+ * @returns {number|null}
158
+ */
159
+ export function heartbeatAgeMs(hb, now) {
160
+ if (!hb || typeof hb !== "object") return null;
161
+ const ts = toMs(hb.ts);
162
+ if (ts === null) return null;
163
+ return Math.max(0, now - ts);
164
+ }
165
+
166
+ /**
167
+ * Is the front-door session live at `now`? Liveness is MEASURED (a fresh
168
+ * heartbeat), never assumed from a lock or a plist.
169
+ * @param {{ts?: number|string}|null} hb
170
+ * @param {number} now
171
+ * @param {{staleMs?: number}} [o]
172
+ */
173
+ export function isLive(hb, now, o = {}) {
174
+ const age = heartbeatAgeMs(hb, now);
175
+ return age !== null && age < (o.staleMs ?? STALE_MS);
176
+ }
177
+
178
+ /**
179
+ * The exact attach command for the main session.
180
+ * @param {string} first
181
+ * @param {"tmux"|"screen"} kind
182
+ * @returns {string[]} argv
183
+ */
184
+ export function attachCommand(first, kind) {
185
+ const name = muxSessionName(first);
186
+ return kind === "tmux" ? ["tmux", "attach", "-t", name] : ["screen", "-r", name];
187
+ }
188
+
189
+ /**
190
+ * Permission args for an INTERACTIVE peer session. Same rule as the front-door
191
+ * supervisor's main-session posture (lib/session-permissions.mjs, WP-M1): the
192
+ * `MAESTRO_SCOPED_PERMISSIONS=1` switch is honoured, but a peer runs inside a
193
+ * multiplexer with nobody attached, so the conservative read-only `--print`
194
+ * allowlist would only wedge it on its first prompt. Scoped mode therefore
195
+ * uses the operator's explicit allowlist (config/session.yaml `allowedTools`)
196
+ * or bypass; unscoped is byte-for-byte the pre-H1 posture.
197
+ *
198
+ * `sessionPermissionArgs` is still the source of the unscoped default so the
199
+ * two lanes cannot drift on it.
200
+ * @param {{env?:object, allowedTools?:string[]}} [o]
201
+ * @returns {string[]}
202
+ */
203
+ export function peerPermissionArgs({ env = process.env, allowedTools } = {}) {
204
+ if (!env || env.MAESTRO_SCOPED_PERMISSIONS !== "1") {
205
+ const prev = process.env.MAESTRO_SCOPED_PERMISSIONS;
206
+ // Ask the shared module for the unscoped default without mutating the
207
+ // caller's env for longer than the call.
208
+ if (prev !== undefined) delete process.env.MAESTRO_SCOPED_PERMISSIONS;
209
+ try { return sessionPermissionArgs({}); } finally { if (prev !== undefined) process.env.MAESTRO_SCOPED_PERMISSIONS = prev; }
210
+ }
211
+ const explicit = Array.isArray(allowedTools) ? allowedTools.filter((t) => typeof t === "string" && t.trim()).map((t) => t.trim()) : [];
212
+ return explicit.length ? ["--allowedTools", explicit.join(",")] : ["--dangerously-skip-permissions"];
213
+ }
214
+
215
+ /**
216
+ * CLI args for a spawned peer: `--name <first>-<slug>`, the interactive
217
+ * permission posture ({@link peerPermissionArgs}), then the prompt. The binary
218
+ * itself is prepended by the caller.
219
+ *
220
+ * THE PROMPT IS LED BY THE PARALLELISM DIRECTIVE (WP-M7 mechanic 5). A peer is
221
+ * spawned precisely when there is more work than one session can hold, so it is
222
+ * the one lane where "dispatch the independent tasks together" pays every time
223
+ * — and the directive carries the file-scope rule that makes that safe on a
224
+ * shared checkout. `withParallelism` is idempotent, so a caller that already
225
+ * led its prompt with it (a daemon prompt handed on to `session spawn`) does
226
+ * not get it twice; the peers registry still records the OPERATOR's prompt as
227
+ * the peer's purpose, not the directive.
228
+ * @param {{first:string, slug:string, prompt:string, env?:object, allowedTools?:string[]}} o
229
+ * @returns {string[]}
230
+ */
231
+ export function buildSpawnArgs({ first, slug, prompt, env, allowedTools }) {
232
+ return ["--name", `${first}-${slug}`, ...peerPermissionArgs({ env, allowedTools }), withParallelism(prompt)];
233
+ }
234
+
235
+ /**
236
+ * Split a peer registry into live and dead, given a mux-presence probe.
237
+ * @param {Array<{muxName:string}>} peers
238
+ * @param {(muxName:string)=>boolean} present
239
+ */
240
+ export function prunePeers(peers, present) {
241
+ const live = [], dead = [];
242
+ for (const p of Array.isArray(peers) ? peers : []) {
243
+ if (p && typeof p.muxName === "string" && present(p.muxName)) live.push(p); else dead.push(p);
244
+ }
245
+ return { live, dead };
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // Effects (all behind `deps`)
250
+ // ---------------------------------------------------------------------------
251
+
252
+ /** Real process boundaries. Tests replace every one of these. */
253
+ function defaultDeps() {
254
+ return {
255
+ /** execFile-style, no shell. ENOENT → status 127. */
256
+ exec(cmd, args = [], o = {}) {
257
+ const r = spawnSync(cmd, args, { encoding: "utf8", cwd: o.cwd, env: o.env, stdio: ["ignore", "pipe", "pipe"] });
258
+ if (r.error) return { status: 127, stdout: "", stderr: r.error.message };
259
+ return { status: r.status ?? 1, stdout: r.stdout || "", stderr: r.stderr || "" };
260
+ },
261
+ /** Foreground, inheriting the terminal (attach). */
262
+ execInherit(cmd, args = []) {
263
+ const r = spawnSync(cmd, args, { stdio: "inherit" });
264
+ if (r.error) return { status: 127, stdout: "", stderr: r.error.message };
265
+ return { status: r.status ?? 1, stdout: "", stderr: "" };
266
+ },
267
+ now: () => Date.now(),
268
+ uid: () => (typeof process.getuid === "function" ? process.getuid() : 501),
269
+ home: homedir(),
270
+ isTTY: Boolean(process.stdin.isTTY && process.stdout.isTTY),
271
+ isAlive(pid) { try { process.kill(pid, 0); return true; } catch { return false; } },
272
+ claudeBin: null, // resolved lazily via resolveClaudeBin()
273
+ env: process.env,
274
+ /** config/session.yaml via lib/session/config.mjs (WP-M1); {} when that module is absent (fail-open). */
275
+ async loadSessionConfig(agentRoot) {
276
+ try {
277
+ const m = await import("../session/config.mjs");
278
+ return await m.loadSessionConfig(agentRoot);
279
+ } catch { return {}; }
280
+ },
281
+ /** The version the running SDK reports — an upgrade notice for it is already applied. */
282
+ installedVersion: ownVersion(),
283
+ };
284
+ }
285
+
286
+ function readJson(p) {
287
+ try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } // absent/corrupt → null (fail-open)
288
+ }
289
+
290
+ /** peers.json accepts a bare array or {peers:[…]}; returns the array. */
291
+ function readPeers(paths) {
292
+ const j = readJson(paths.peers);
293
+ if (Array.isArray(j)) return j;
294
+ if (j && Array.isArray(j.peers)) return j.peers;
295
+ return [];
296
+ }
297
+
298
+ function writePeers(paths, peers) {
299
+ writeJsonAtomic(paths.peers, { peers });
300
+ }
301
+
302
+ /** Sync sleep without a busy loop. */
303
+ function sleepMs(ms) {
304
+ try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* best effort */ }
305
+ }
306
+
307
+ /** How long an untouched peers.json.lock is trusted before it is broken. */
308
+ const PEERS_LOCK_STALE_MS = 30_000;
309
+
310
+ /**
311
+ * Read-modify-write peers.json under an O_EXCL sidecar lock, so two `spawn`s
312
+ * (or a `spawn` racing a pruning `status`) from separate processes cannot drop
313
+ * a registration. The lock is `<peers.json>.lock` holding the pid; a lock
314
+ * older than 30 s is treated as abandoned and broken. Fail-open on the
315
+ * registry read (absent/corrupt → []), never on the lock: a caller that cannot
316
+ * get the lock within `waitMs` is told so and leaves the file alone.
317
+ *
318
+ * @param {ReturnType<typeof sessionPaths>} paths
319
+ * @param {(peers:object[])=>object[]} mutate pure: current list → new list
320
+ * @param {{now?:()=>number, waitMs?:number, lockMtimeMs?:(lock:string)=>number|null}} [o]
321
+ * @returns {{ok:true, peers:object[]}|{ok:false, error:string}}
322
+ */
323
+ export function updatePeers(paths, mutate, o = {}) {
324
+ const now = o.now || (() => Date.now());
325
+ const waitMs = o.waitMs ?? 3000;
326
+ const lock = `${paths.peers}.lock`;
327
+ const mtime = o.lockMtimeMs || ((p) => { try { return statSync(p).mtimeMs; } catch { return null; } });
328
+ try { mkdirSync(dirname(paths.peers), { recursive: true }); } catch { /* reported below by the open */ }
329
+ const deadline = now() + waitMs;
330
+ let fd = null;
331
+ for (;;) {
332
+ try { fd = openSync(lock, "wx"); break; } catch (e) {
333
+ if (!e || e.code !== "EEXIST") return { ok: false, error: `peers lock: ${e && e.message ? e.message : e}` };
334
+ const m = mtime(lock);
335
+ if (m !== null && now() - m > PEERS_LOCK_STALE_MS) { try { unlinkSync(lock); } catch { /* raced; loop */ } continue; }
336
+ if (now() >= deadline) return { ok: false, error: `peers lock held by another process (${lock})` };
337
+ sleepMs(15);
338
+ }
339
+ }
340
+ try {
341
+ try { writeFileSync(fd, `${process.pid}\n`); } catch { /* the lock's existence is what matters */ }
342
+ const peers = mutate(readPeers(paths));
343
+ writePeers(paths, peers);
344
+ return { ok: true, peers };
345
+ } catch (e) {
346
+ return { ok: false, error: e && e.message ? e.message : String(e) };
347
+ } finally {
348
+ try { closeSync(fd); } catch { /* already closed */ }
349
+ try { unlinkSync(lock); } catch { /* already gone */ }
350
+ }
351
+ }
352
+
353
+ /** Is a mux session with this name present? tmux first, then screen -ls. */
354
+ function muxPresence(d, name) {
355
+ const t = d.exec("tmux", ["has-session", "-t", name]);
356
+ if (t.status === 0) return { present: true, kind: "tmux" };
357
+ const s = d.exec("screen", ["-ls"]);
358
+ if (s.status === 0 || /Socket/.test(s.stdout)) {
359
+ const re = new RegExp(`\\.${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\s|$)`, "m");
360
+ if (re.test(s.stdout)) return { present: true, kind: "screen" };
361
+ }
362
+ return { present: false, kind: null };
363
+ }
364
+
365
+ /**
366
+ * Which multiplexer a new peer should use. ONE source of truth with the
367
+ * supervisor (WP-M1): config/session.yaml `mux:` first; `MAESTRO_SESSION_MUX`
368
+ * only when the config is absent or `auto`; then tmux-if-installed, else
369
+ * screen — so `attach`/`peers` never span two multiplexers.
370
+ * @param {object} d deps
371
+ * @param {{mux?:string}} cfg loaded session config (may be {})
372
+ */
373
+ function chooseMux(d, cfg = {}) {
374
+ const fromCfg = cfg && (cfg.mux === "screen" || cfg.mux === "tmux") ? cfg.mux : null;
375
+ const pref = fromCfg || (d.env && d.env.MAESTRO_SESSION_MUX) || "auto";
376
+ if (pref === "screen") return "screen";
377
+ if (pref === "tmux") return "tmux";
378
+ return d.exec("tmux", ["-V"]).status === 0 ? "tmux" : "screen";
379
+ }
380
+
381
+ /** Session config, fail-open to {} (a broken loader must not stop a spawn). */
382
+ async function loadCfg(ctx) {
383
+ try { const c = await ctx.d.loadSessionConfig(ctx.agentRoot); return c && typeof c === "object" ? c : {}; } catch { return {}; }
384
+ }
385
+
386
+ function lockState(paths, d) {
387
+ let raw = null;
388
+ try { raw = readFileSync(paths.lock, "utf8"); } catch { return { held: false, pid: null }; }
389
+ const pid = parseInt((raw.split("\n")[0] || "").trim(), 10) || 0;
390
+ if (!pid) return { held: false, pid: null };
391
+ // Best-effort liveness (pid only). The identity-aware check lives in
392
+ // lib/singleton.js and runs at acquire time; a status read must not evict.
393
+ return { held: d.isAlive(pid), pid };
394
+ }
395
+
396
+ function listHandoffs(paths, now) {
397
+ let names = [];
398
+ try { names = readdirSync(paths.handoffsDir); } catch { return []; }
399
+ const out = [];
400
+ for (const n of names) {
401
+ if (!n.endsWith(".json")) continue;
402
+ const p = join(paths.handoffsDir, n);
403
+ try { if (!statSync(p).isFile()) continue; } catch { continue; }
404
+ const h = readJson(p);
405
+ if (!h || typeof h !== "object") continue; // corrupt handoff: skipped, never fatal
406
+ const enq = toMs(h.enqueuedAt);
407
+ const dl = toMs(h.deadlineAt);
408
+ out.push({
409
+ tickId: h.tickId || n.replace(/\.json$/, ""),
410
+ cadence: h.cadence || null,
411
+ mode: h.mode || null,
412
+ promptPath: h.promptPath || null,
413
+ enqueuedAt: h.enqueuedAt ?? null,
414
+ deadlineAt: h.deadlineAt ?? null,
415
+ ageMs: enq === null ? null : Math.max(0, now - enq),
416
+ overdue: dl !== null && now > dl,
417
+ path: p,
418
+ });
419
+ }
420
+ return out.sort((a, b) => (toMs(a.enqueuedAt) ?? 0) - (toMs(b.enqueuedAt) ?? 0));
421
+ }
422
+
423
+ /** Everything `status` reports, as data. */
424
+ function snapshot(ctx) {
425
+ const { agentRoot, first, d, paths } = ctx;
426
+ const now = d.now();
427
+ const label = sessionLabel(first);
428
+ const hb = readJson(paths.heartbeat);
429
+ const ageMs = heartbeatAgeMs(hb, now);
430
+ const live = isLive(hb, now);
431
+ const mux = muxPresence(d, muxSessionName(first));
432
+ const plistPath = join(d.home, "Library", "LaunchAgents", `${label}.plist`);
433
+ const plistInstalled = existsSync(plistPath);
434
+ const loaded = d.exec("launchctl", ["list", label]).status === 0;
435
+ const { live: peers, dead } = prunePeers(readPeers(paths), (n) => muxPresence(d, n).present);
436
+ if (dead.length) {
437
+ // Prune under the registry lock, dropping only what THIS read saw dead —
438
+ // a peer registered in between survives. A status read never fails on it.
439
+ const gone = new Set(dead.map((p) => p && p.name));
440
+ updatePeers(paths, (cur) => cur.filter((p) => !(p && gone.has(p.name))), { now: d.now, waitMs: 500 });
441
+ }
442
+ const handoffs = listHandoffs(paths, now);
443
+ const main = readJson(paths.mainSession);
444
+ const notice = readJson(paths.upgradeNotice);
445
+ const installed = typeof d.installedVersion === "string" && d.installedVersion ? d.installedVersion : null;
446
+ // An upgrade notice whose target is what is already running was applied by
447
+ // a session restart; it is not pending and must not be surfaced (it stays
448
+ // on disk — the feed keys it by mtime — until the next release overwrites it).
449
+ const upgradeNotice = notice && typeof notice === "object" && !(installed && notice.to === installed) ? notice : null;
450
+ return {
451
+ first,
452
+ name: `${first}-main`,
453
+ label,
454
+ agentRoot,
455
+ live,
456
+ lock: lockState(paths, d),
457
+ heartbeat: {
458
+ live, ageMs,
459
+ ts: hb && hb.ts != null ? hb.ts : null,
460
+ pid: hb && hb.pid != null ? hb.pid : null,
461
+ sessionId: hb && hb.sessionId ? hb.sessionId : null,
462
+ feedVersion: hb && hb.feedVersion != null ? hb.feedVersion : null,
463
+ },
464
+ mainSession: main && typeof main === "object"
465
+ ? { sessionId: main.sessionId || null, createdAt: main.createdAt || null, resumes: main.resumes ?? 0 }
466
+ : null,
467
+ mux: { present: mux.present, kind: mux.kind, name: muxSessionName(first) },
468
+ plist: { installed: plistInstalled, loaded, path: plistPath },
469
+ peers,
470
+ handoffs: { open: handoffs.length, overdue: handoffs.filter((h) => h.overdue).length },
471
+ restartRequested: existsSync(paths.restartRequested),
472
+ upgradeNotice,
473
+ sdkVersion: installed,
474
+ ts: new Date(now).toISOString(),
475
+ };
476
+ }
477
+
478
+ const fmtAge = (ms) => {
479
+ if (ms === null || ms === undefined) return "none";
480
+ const s = Math.round(ms / 1000);
481
+ if (s < 90) return `${s}s`;
482
+ const m = Math.round(s / 60);
483
+ if (m < 90) return `${m}m`;
484
+ return `${Math.round(m / 60)}h`;
485
+ };
486
+
487
+ function briefLine(s) {
488
+ const bits = [];
489
+ bits.push(s.heartbeat.ageMs === null ? "no heartbeat" : `hb ${fmtAge(s.heartbeat.ageMs)}`);
490
+ bits.push(s.lock.held ? `lock pid ${s.lock.pid}` : "no lock");
491
+ bits.push(s.mux.present ? `${s.mux.kind} ${s.mux.name}` : "no mux session");
492
+ bits.push(s.plist.installed ? (s.plist.loaded ? "plist loaded" : "plist not loaded") : "plist not installed");
493
+ bits.push(`${s.peers.length} peer${s.peers.length === 1 ? "" : "s"}`);
494
+ bits.push(`${s.handoffs.open} handoff${s.handoffs.open === 1 ? "" : "s"}${s.handoffs.overdue ? ` (${s.handoffs.overdue} overdue)` : ""}`);
495
+ if (s.restartRequested) bits.push("restart requested");
496
+ if (s.upgradeNotice && s.upgradeNotice.to) bits.push(`upgrade → ${s.upgradeNotice.to}`);
497
+ return `${s.name}: ${s.live ? "live" : "not live"} (${bits.join(", ")})`;
498
+ }
499
+
500
+ function humanStatus(s) {
501
+ const L = [];
502
+ L.push(`Front-door session: ${s.name} (${s.label}) — ${s.live ? "LIVE" : "NOT LIVE"}`);
503
+ L.push(` supervisor lock: ${s.lock.held ? `held by pid ${s.lock.pid}` : "not held"}`);
504
+ L.push(` heartbeat: ${s.heartbeat.ageMs === null ? "none" : `${fmtAge(s.heartbeat.ageMs)} ago${s.live ? "" : " (stale)"}`}${s.heartbeat.sessionId ? ` · session ${s.heartbeat.sessionId}` : ""}`);
505
+ L.push(` main session: ${s.mainSession ? `${s.mainSession.sessionId || "?"} (resumes: ${s.mainSession.resumes})` : "none recorded"}`);
506
+ L.push(` mux: ${s.mux.present ? `${s.mux.kind} ${s.mux.name}` : `none (${s.mux.name})`}`);
507
+ L.push(` launchd: ${s.plist.installed ? (s.plist.loaded ? "installed, loaded" : "installed, NOT loaded") : "not installed"}`);
508
+ L.push(` peers: ${s.peers.length ? s.peers.map((p) => p.name).join(", ") : "none"}`);
509
+ L.push(` handoffs open: ${s.handoffs.open}${s.handoffs.overdue ? ` (${s.handoffs.overdue} overdue)` : ""}`);
510
+ const flags = [];
511
+ if (s.restartRequested) flags.push("restart requested");
512
+ if (s.upgradeNotice && s.upgradeNotice.to) flags.push(`upgrade available ${s.upgradeNotice.from || "?"} → ${s.upgradeNotice.to}`);
513
+ if (flags.length) L.push(` flags: ${flags.join("; ")}`);
514
+ if (!s.plist.installed) L.push("Next: npm run init-agent (generates + loads the session plist), then: maestro session start");
515
+ else if (!s.live) L.push("Next: maestro session start (then: maestro session attach)");
516
+ else L.push("Attach: maestro session attach");
517
+ return L;
518
+ }
519
+
520
+ /** Ensure the label is loaded (bootstrap the plist if needed). */
521
+ function ensureLoaded(ctx, say) {
522
+ const { d, first } = ctx;
523
+ const label = sessionLabel(first);
524
+ const target = `gui/${d.uid()}`;
525
+ if (d.exec("launchctl", ["list", label]).status === 0) return { ok: true, target, wasLoaded: true };
526
+ const plistPath = join(d.home, "Library", "LaunchAgents", `${label}.plist`);
527
+ if (!existsSync(plistPath)) {
528
+ say(`session: ${label} is not installed (${plistPath}).`);
529
+ say(" Fix: npm run init-agent — or: bash scripts/local-triggers/generate-plists.sh, then copy + launchctl load the session plist.");
530
+ return { ok: false, target, wasLoaded: false };
531
+ }
532
+ const b = d.exec("launchctl", ["bootstrap", target, plistPath]);
533
+ if (b.status !== 0) say(`session: launchctl bootstrap returned ${b.status}${b.stderr ? `: ${b.stderr.trim()}` : ""} — continuing to kickstart`);
534
+ else say(`session: bootstrapped ${label}`);
535
+ return { ok: true, target, wasLoaded: false };
536
+ }
537
+
538
+ // ---------------------------------------------------------------------------
539
+ // run()
540
+ // ---------------------------------------------------------------------------
541
+
542
+ export function usage() {
543
+ return `maestro session <command>
544
+
545
+ status [--brief|--json] lock holder, heartbeat age, mux session, plist, peers, handoffs
546
+ attach print the attach command (tmux attach / screen -r); exec it on a TTY
547
+ start load the launchd job if needed, then kickstart it
548
+ stop launchctl bootout the session job
549
+ restart [--force] ask the session to restart itself at an idle moment (--force: kickstart -k)
550
+ spawn --name <slug> [--cwd <dir>] "<prompt>"
551
+ run a peer as <first>-<slug> in its own detached mux session; prints the name
552
+ peers [--json] list peers (dead ones are pruned from state/session/peers.json)
553
+ handoffs [--json] list open cadence handoffs
554
+ ack <tickId> [--result <p>] mark a handoff done (moves it to handoffs/done/)`;
555
+ }
556
+
557
+ const FLAGS = {
558
+ json: { type: "boolean", default: false },
559
+ brief: { type: "boolean", default: false },
560
+ force: { type: "boolean", default: false },
561
+ name: { type: "string" },
562
+ cwd: { type: "string" },
563
+ result: { type: "string" },
564
+ };
565
+
566
+ /**
567
+ * @param {string[]} argv
568
+ * @param {{agentRoot?:string, print?:boolean, deps?:object, ackModule?:string}} [opts]
569
+ * @returns {Promise<{ok:boolean, code:number, lines:string[], data?:any}>}
570
+ */
571
+ export async function run(argv = [], opts = {}) {
572
+ const agentRoot = opts.agentRoot || resolveAgentRoot();
573
+ const d = { ...defaultDeps(), ...(opts.deps || {}) };
574
+ const lines = [];
575
+ const say = (s) => { lines.push(s); if (opts.print !== false) console.log(s); };
576
+ const fail = (msg, code = 1) => { say(msg); return { ok: false, code, lines }; };
577
+
578
+ let parsed;
579
+ try {
580
+ parsed = parseArgs({ args: argv, options: FLAGS, allowPositionals: true, strict: false });
581
+ } catch (e) {
582
+ return fail(`session: ${e && e.message ? e.message : e}`, 2);
583
+ }
584
+ const [sub, ...rest] = parsed.positionals;
585
+ const flags = parsed.values;
586
+ const first = resolveFirstName(agentRoot);
587
+ const paths = sessionPaths(agentRoot);
588
+ const ctx = { agentRoot, first, d, paths };
589
+
590
+ switch (sub) {
591
+ case undefined:
592
+ case "help":
593
+ case "--help":
594
+ say(usage());
595
+ return { ok: true, code: 0, lines };
596
+
597
+ case "status": {
598
+ const s = snapshot(ctx);
599
+ if (flags.json) say(JSON.stringify(s, null, 2));
600
+ else if (flags.brief) say(briefLine(s));
601
+ else for (const l of humanStatus(s)) say(l);
602
+ return { ok: true, code: 0, lines, data: s };
603
+ }
604
+
605
+ case "attach": {
606
+ const mux = muxPresence(d, muxSessionName(first));
607
+ if (!mux.present) {
608
+ return fail(`session: no ${muxSessionName(first)} multiplexer session is running. Start it with: maestro session start`);
609
+ }
610
+ const cmd = attachCommand(first, mux.kind);
611
+ say(`Attach: ${cmd.join(" ")}`);
612
+ if (!d.isTTY) { say("(not a TTY — run that command from a terminal)"); return { ok: true, code: 0, lines, data: { command: cmd } }; }
613
+ const r = d.execInherit(cmd[0], cmd.slice(1));
614
+ return { ok: r.status === 0, code: r.status, lines, data: { command: cmd } };
615
+ }
616
+
617
+ case "start": {
618
+ const l = ensureLoaded(ctx, say);
619
+ if (!l.ok) return { ok: false, code: 1, lines };
620
+ const label = sessionLabel(first);
621
+ const k = d.exec("launchctl", ["kickstart", `${l.target}/${label}`]);
622
+ if (k.status !== 0) return fail(`session: launchctl kickstart ${label} returned ${k.status}${k.stderr ? `: ${k.stderr.trim()}` : ""}`);
623
+ say(`session: started ${label} (launchd keeps it alive; attach with: maestro session attach)`);
624
+ return { ok: true, code: 0, lines };
625
+ }
626
+
627
+ case "stop": {
628
+ const label = sessionLabel(first);
629
+ const r = d.exec("launchctl", ["bootout", `gui/${d.uid()}/${label}`]);
630
+ if (r.status !== 0 && !/not find|No such|not loaded|3: /i.test(r.stderr || "")) {
631
+ return fail(`session: launchctl bootout ${label} returned ${r.status}${r.stderr ? `: ${r.stderr.trim()}` : ""}`);
632
+ }
633
+ say(r.status === 0 ? `session: stopped ${label} (launchd will not relaunch it until: maestro session start)` : `session: ${label} was not loaded`);
634
+ return { ok: true, code: 0, lines };
635
+ }
636
+
637
+ case "restart": {
638
+ // The marker is what the session's feed turns into a `restart` directive;
639
+ // the session finishes its turn, exits, and the supervisor relaunches on
640
+ // whatever code is on disk now. It is written ONLY once the job is known
641
+ // to be loaded: a job that is not running has nothing to restart, and a
642
+ // marker left behind would fire spuriously on its next first boot.
643
+ // kickstart without -k only starts a job that is not running; --force
644
+ // kills and restarts immediately.
645
+ const l = ensureLoaded(ctx, say);
646
+ if (!l.ok) return { ok: false, code: 1, lines };
647
+ const label = sessionLabel(first);
648
+ if (l.wasLoaded) {
649
+ try {
650
+ mkdirSync(paths.dir, { recursive: true });
651
+ writeFileSync(paths.restartRequested, `${new Date(d.now()).toISOString()}\n`);
652
+ } catch (e) {
653
+ return fail(`session: could not write ${paths.restartRequested}: ${e && e.message ? e.message : e}`);
654
+ }
655
+ }
656
+ const args = flags.force ? ["kickstart", "-k", `${l.target}/${label}`] : ["kickstart", `${l.target}/${label}`];
657
+ const k = d.exec("launchctl", args);
658
+ if (k.status !== 0) return fail(`session: launchctl ${args.join(" ")} returned ${k.status}${k.stderr ? `: ${k.stderr.trim()}` : ""}`);
659
+ if (!l.wasLoaded) say(`session: ${label} was not running — started it (no restart marker written)`);
660
+ else say(flags.force ? `session: ${label} killed and restarted` : `session: restart requested — ${label} will relaunch at its next idle moment (use --force to restart now)`);
661
+ return { ok: true, code: 0, lines };
662
+ }
663
+
664
+ case "spawn": {
665
+ const slug = typeof flags.name === "string" ? flags.name.trim() : "";
666
+ if (!slug) return fail("session spawn: --name <slug> is required", 2);
667
+ if (!SLUG_RE.test(slug)) return fail(`session spawn: invalid --name "${slug}" (use lower-case letters, digits and hyphens)`, 2);
668
+ const prompt = rest.join(" ").trim();
669
+ if (!prompt) return fail("session spawn: a prompt is required: maestro session spawn --name <slug> \"<prompt>\"", 2);
670
+ const cwd = flags.cwd ? String(flags.cwd) : agentRoot;
671
+ if (!existsSync(cwd)) return fail(`session spawn: --cwd ${cwd} does not exist`, 2);
672
+ const name = `${first}-${slug}`;
673
+ const muxName = peerMuxName(first, slug);
674
+
675
+ const registered = readPeers(paths).find((p) => p && p.name === name);
676
+ if (registered && muxPresence(d, registered.muxName || muxName).present) {
677
+ return fail(`session spawn: ${name} is already running (${registered.muxName || muxName}); pick another --name or wait for it to finish`);
678
+ }
679
+
680
+ const cfg = await loadCfg(ctx);
681
+ const kind = chooseMux(d, cfg);
682
+ const bin = d.claudeBin || resolveClaudeBin();
683
+ const args = buildSpawnArgs({ first, slug, prompt, env: d.env, allowedTools: cfg.allowedTools });
684
+ const r = kind === "tmux"
685
+ ? d.exec("tmux", ["new-session", "-d", "-s", muxName, "-c", cwd, "--", bin, ...args], { cwd, env: d.env })
686
+ : d.exec("screen", ["-dmS", muxName, bin, ...args], { cwd, env: d.env });
687
+ if (r.status !== 0) return fail(`session spawn: ${kind} returned ${r.status}${r.stderr ? `: ${r.stderr.trim()}` : ""}`);
688
+
689
+ const startedAt = new Date(d.now()).toISOString();
690
+ const reg = updatePeers(paths, (cur) => [
691
+ ...cur.filter((p) => p && p.name !== name),
692
+ { name, muxName, startedAt, purpose: prompt.slice(0, 200), cwd, mux: kind },
693
+ ], { now: d.now });
694
+ if (!reg.ok) say(`session spawn: warning — peers.json not updated: ${reg.error}`);
695
+ const attach = kind === "tmux" ? `tmux attach -t ${muxName}` : `screen -r ${muxName}`;
696
+ say(`session: spawned ${name} in ${kind} session ${muxName} (attach: ${attach})`);
697
+ say(name);
698
+ return { ok: true, code: 0, lines, data: { name, muxName, kind, startedAt } };
699
+ }
700
+
701
+ case "peers": {
702
+ const { live, dead } = prunePeers(readPeers(paths), (n) => muxPresence(d, n).present);
703
+ if (dead.length) {
704
+ const gone = new Set(dead.map((p) => p && p.name));
705
+ updatePeers(paths, (cur) => cur.filter((p) => !(p && gone.has(p.name))), { now: d.now, waitMs: 500 }); // listing never fails on a registry write
706
+ }
707
+ if (flags.json) { say(JSON.stringify(live, null, 2)); return { ok: true, code: 0, lines, data: live }; }
708
+ if (!live.length) say(`session: no peers running${dead.length ? ` (${dead.length} finished, pruned)` : ""}`);
709
+ else {
710
+ say(`Peers (${live.length})${dead.length ? ` — ${dead.length} finished, pruned` : ""}:`);
711
+ for (const p of live) say(` ${String(p.name).padEnd(28)} ${p.mux || "mux"} ${p.muxName} since ${p.startedAt || "?"}${p.purpose ? ` — ${p.purpose}` : ""}`);
712
+ }
713
+ return { ok: true, code: 0, lines, data: live };
714
+ }
715
+
716
+ case "handoffs": {
717
+ const list = listHandoffs(paths, d.now());
718
+ if (flags.json) { say(JSON.stringify(list, null, 2)); return { ok: true, code: 0, lines, data: list }; }
719
+ if (!list.length) say("session: no open handoffs");
720
+ else {
721
+ say(`Open handoffs (${list.length}):`);
722
+ for (const h of list) say(` ${String(h.tickId).padEnd(24)} ${String(h.cadence || "?").padEnd(26)} ${String(h.mode || "").padEnd(9)} age ${fmtAge(h.ageMs)}${h.overdue ? " OVERDUE" : ""}`);
723
+ say("Ack one with: maestro session ack <tickId> [--result <path>]");
724
+ }
725
+ return { ok: true, code: 0, lines, data: list };
726
+ }
727
+
728
+ case "ack": {
729
+ const tickId = rest[0];
730
+ if (!tickId) return fail("session ack: <tickId> is required", 2);
731
+ const spec = opts.ackModule || "./session-ack.mjs";
732
+ const url = spec.startsWith("/") ? pathToFileURL(spec).href : new URL(spec, import.meta.url).href;
733
+ let mod;
734
+ try {
735
+ mod = await import(url);
736
+ } catch (e) {
737
+ if (e && (e.code === "ERR_MODULE_NOT_FOUND" || /Cannot find module/.test(String(e.message)))) {
738
+ return fail("session ack: not available in this install — lib/cli/session-ack.mjs is missing (it ships with the daemon-integration package; upgrade the SDK).");
739
+ }
740
+ return fail(`session ack: could not load session-ack: ${e && e.message ? e.message : e}`);
741
+ }
742
+ if (typeof mod.ackHandoff !== "function") return fail("session ack: lib/cli/session-ack.mjs does not export ackHandoff()");
743
+ let r;
744
+ try {
745
+ r = await mod.ackHandoff({ agentRoot, tickId, resultPath: flags.result ? String(flags.result) : null, now: d.now() });
746
+ } catch (e) {
747
+ return fail(`session ack: ${e && e.message ? e.message : e}`);
748
+ }
749
+ if (!r || r.ok === false) return fail(`session ack: ${(r && r.error) || "failed"}`);
750
+ // WP-M2's ackHandoff returns {ok, path, already}; older shapes used movedTo.
751
+ const dest = r.path || r.movedTo || "";
752
+ if (r.already) say(`session: ${tickId} was already acked${dest ? ` (${dest})` : ""}`);
753
+ else say(`session: acked ${tickId}${dest ? ` → ${dest}` : ""}`);
754
+ return { ok: true, code: 0, lines, data: r };
755
+ }
756
+
757
+ default:
758
+ return fail(`session: unknown command "${sub}"\n\n${usage()}`, 2);
759
+ }
760
+ }