@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,220 @@
1
+ /**
2
+ * lib/session/identity.mjs — WHO the main session is, and how it comes back.
3
+ *
4
+ * The main session's id is STABLE across relaunches so the agent keeps its
5
+ * transcript: `state/session/main-session.json` holds
6
+ *
7
+ * { sessionId, createdAt, resumes, lastLaunchAt?, rotations: [iso…], rotatedFrom? }
8
+ *
9
+ * The first launch of an id runs `claude --session-id <id>`; every later
10
+ * launch runs `claude --resume <id>`. If a resume dies non-zero inside
11
+ * {@link FAILURE_WINDOW_MS} the transcript is presumed missing/corrupt and the
12
+ * supervisor ROTATES to a fresh id — at most {@link MAX_ROTATIONS_PER_HOUR}
13
+ * times per hour, after which it backs off {@link BACKOFF_MS} and lets launchd
14
+ * relaunch it. Every decision here is a pure function of the record, the
15
+ * observed exit and an injected clock; the supervisor does the I/O.
16
+ *
17
+ * Names: the mux session is `maestro-<first>` and the Claude session is
18
+ * `<first>-main`, where `<first>` is {@link agentFirstName} — the SAME
19
+ * derivation `scripts/local-triggers/generate-plists.sh` uses for the launchd
20
+ * labels (lower-cased `firstName` from `config/agent.json`, else an inline
21
+ * literal in `config/agent.ts`, else the directory name minus `-ai`), so the
22
+ * plist, the lock, the mux and the session agree. `identity.test.mjs` runs the
23
+ * generator's block verbatim against this function to pin that.
24
+ *
25
+ * @module lib/session/identity
26
+ */
27
+
28
+ "use strict";
29
+
30
+ import { readFileSync as fsReadFileSync } from "node:fs";
31
+ import { join, basename } from "node:path";
32
+ import { randomUUID } from "node:crypto";
33
+ import { writeJsonAtomic as fsWriteJsonAtomic } from "../fs-atomic.mjs";
34
+ import { sessionPaths } from "./config.mjs";
35
+
36
+ /** A resume that dies inside this window is treated as a broken transcript. */
37
+ export const FAILURE_WINDOW_MS = 20_000;
38
+ /** Rotations allowed in any rolling hour before the supervisor backs off. */
39
+ export const MAX_ROTATIONS_PER_HOUR = 3;
40
+ /** How long the supervisor sleeps once the rotation budget is spent. */
41
+ export const BACKOFF_MS = 10 * 60_000;
42
+ const HOUR_MS = 3_600_000;
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
+ function iso(ms) { return new Date(ms).toISOString(); }
47
+ function nowMs(now) { return typeof now === "function" ? Number(now()) : (now == null ? Date.now() : Number(now)); }
48
+
49
+ /**
50
+ * Pure: parse the on-disk record. Returns null for anything that is not a
51
+ * record carrying a valid session id, so a corrupt file simply means "start
52
+ * fresh" rather than a crash.
53
+ * @param {string|null|undefined} text
54
+ */
55
+ export function parseMainSession(text) {
56
+ if (typeof text !== "string" || !text.trim()) return null;
57
+ let raw;
58
+ try { raw = JSON.parse(text); } catch { return null; }
59
+ if (!raw || typeof raw !== "object" || !UUID_RE.test(String(raw.sessionId || ""))) return null;
60
+ const rec = {
61
+ sessionId: String(raw.sessionId).toLowerCase(),
62
+ createdAt: typeof raw.createdAt === "string" ? raw.createdAt : "",
63
+ resumes: Number.isInteger(raw.resumes) && raw.resumes >= 0 ? raw.resumes : 0,
64
+ rotations: Array.isArray(raw.rotations) ? raw.rotations.filter((r) => typeof r === "string" && Number.isFinite(Date.parse(r))) : [],
65
+ };
66
+ if (typeof raw.lastLaunchAt === "string") rec.lastLaunchAt = raw.lastLaunchAt;
67
+ if (typeof raw.rotatedFrom === "string") rec.rotatedFrom = raw.rotatedFrom;
68
+ return rec;
69
+ }
70
+
71
+ /**
72
+ * Pure: mint a brand-new main-session record.
73
+ * @param {{now?:number|Function, uuid?:Function}} [opts]
74
+ */
75
+ export function newMainSession(opts = {}) {
76
+ const uuid = opts.uuid || randomUUID;
77
+ return { sessionId: uuid(), createdAt: iso(nowMs(opts.now)), resumes: 0, rotations: [] };
78
+ }
79
+
80
+ /**
81
+ * Read `state/session/main-session.json`. Fail-open: null when absent/corrupt.
82
+ * @param {string} agentRoot
83
+ * @param {{readFileSync?:Function}} [deps]
84
+ */
85
+ export function loadMainSession(agentRoot, deps = {}) {
86
+ const readFileSync = deps.readFileSync || fsReadFileSync;
87
+ try {
88
+ return parseMainSession(readFileSync(sessionPaths(agentRoot).mainSessionFile, "utf8"));
89
+ } catch {
90
+ // Absent on first boot, or unreadable — either way the caller starts fresh.
91
+ return null;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Persist the record atomically. Returns a result frame rather than throwing.
97
+ * @param {string} agentRoot
98
+ * @param {object} record
99
+ * @param {{writeJsonAtomic?:Function}} [deps]
100
+ * @returns {{ok:true}|{ok:false, error:string}}
101
+ */
102
+ export function saveMainSession(agentRoot, record, deps = {}) {
103
+ const writeJsonAtomic = deps.writeJsonAtomic || fsWriteJsonAtomic;
104
+ try {
105
+ writeJsonAtomic(sessionPaths(agentRoot).mainSessionFile, record);
106
+ return { ok: true };
107
+ } catch (err) {
108
+ return { ok: false, error: err && err.message ? err.message : String(err) };
109
+ }
110
+ }
111
+
112
+ /**
113
+ * The agent's first name as generate-plists.sh derives it, so the launchd
114
+ * label, the lock, the mux session and the Claude session name agree:
115
+ *
116
+ * 1. `config/agent.json` `firstName`, lower-cased (NOT slugged — the
117
+ * generator only lower-cases, and one derivation must win);
118
+ * 2. else `config/agent.ts` with an INLINE quoted literal
119
+ * (`firstName: 'Priya'`; a type-only `firstName: string;` is not a value);
120
+ * 3. else, or when the value is "unconfigured", the agent directory's
121
+ * basename minus a `-ai` suffix (`~/ravi-ai` → `ravi`).
122
+ *
123
+ * "agent" only when even the directory yields nothing. Never throws.
124
+ * @param {string} agentRoot
125
+ * @param {{readFileSync?:Function}} [deps]
126
+ */
127
+ export function agentFirstName(agentRoot, deps = {}) {
128
+ const readFileSync = deps.readFileSync || fsReadFileSync;
129
+ let first = "";
130
+ try {
131
+ const agent = JSON.parse(readFileSync(join(agentRoot, "config", "agent.json"), "utf8"));
132
+ const v = agent && agent.firstName;
133
+ first = v == null || v === false ? "" : String(v).trim().toLowerCase();
134
+ } catch {
135
+ first = ""; // absent or malformed → next source
136
+ }
137
+ if (!first) {
138
+ try {
139
+ const ts = readFileSync(join(agentRoot, "config", "agent.ts"), "utf8");
140
+ const line = ts.split("\n").find((l) => /firstName:\s*['"]/.test(l));
141
+ const m = line && line.match(/firstName:\s*['"]([^'"]*)['"]/);
142
+ first = m ? m[1].trim().toLowerCase() : "";
143
+ } catch {
144
+ first = "";
145
+ }
146
+ }
147
+ if (!first || first === "unconfigured") first = basename(agentRoot).replace(/-ai$/, ""); // as-is: the generator does not lower-case this one
148
+ return first || "agent";
149
+ }
150
+
151
+ /** `maestro-<first>` — the screen/tmux session name. */
152
+ export function muxSessionName(first) { return `maestro-${first}`; }
153
+ /** `<first>-main` — the Claude session `--name`. */
154
+ export function mainSessionName(first) { return `${first}-main`; }
155
+
156
+ /**
157
+ * Pure: which flag this launch needs. A never-launched id (or `resume:false`)
158
+ * starts with `--session-id`; an id that has run before is `--resume`d.
159
+ * @param {{resumes:number}} record
160
+ * @param {{resume?:boolean}} [cfg]
161
+ * @returns {"session-id"|"resume"}
162
+ */
163
+ export function launchMode(record, cfg = {}) {
164
+ if (cfg.resume === false) return "session-id";
165
+ return record && record.resumes > 0 ? "resume" : "session-id";
166
+ }
167
+
168
+ /**
169
+ * Pure: the record after a launch has been issued.
170
+ * @param {object} record @param {{now?:number|Function}} [opts]
171
+ */
172
+ export function recordLaunch(record, opts = {}) {
173
+ return { ...record, resumes: (record.resumes || 0) + 1, lastLaunchAt: iso(nowMs(opts.now)) };
174
+ }
175
+
176
+ /**
177
+ * Pure: what the supervisor does after the mux session ends.
178
+ *
179
+ * relaunch — the session ended (cleanly, or after living past the window):
180
+ * exit 75 and let launchd bring it back with the same id.
181
+ * rotate — a resume died inside the failure window: mint a fresh id first.
182
+ * backoff — the rotation budget for this hour is spent: sleep, then exit 75.
183
+ *
184
+ * An UNKNOWN exit code (the exit file was not written) inside the window is
185
+ * treated as a failure: the cost of a wrong rotation is a fresh transcript,
186
+ * the cost of a wrong relaunch is a seat stuck in a 30-second crash loop.
187
+ *
188
+ * @param {{record:object, mode:"session-id"|"resume", exitCode:number|null, startedAt:number, endedAt:number, now?:number|Function, failureWindowMs?:number, maxRotationsPerHour?:number}} a
189
+ * @returns {{action:"relaunch"}|{action:"rotate"}|{action:"backoff", sleepMs:number}}
190
+ */
191
+ export function rotationDecision(a) {
192
+ const windowMs = a.failureWindowMs ?? FAILURE_WINDOW_MS;
193
+ const max = a.maxRotationsPerHour ?? MAX_ROTATIONS_PER_HOUR;
194
+ const durationMs = Number(a.endedAt) - Number(a.startedAt);
195
+ const failed = a.exitCode === null || a.exitCode === undefined || a.exitCode !== 0;
196
+ if (a.mode !== "resume" || !failed || !(durationMs < windowMs)) return { action: "relaunch" };
197
+ const t = nowMs(a.now);
198
+ const recent = (a.record && a.record.rotations || []).filter((r) => t - Date.parse(r) < HOUR_MS);
199
+ if (recent.length >= max) return { action: "backoff", sleepMs: BACKOFF_MS };
200
+ return { action: "rotate" };
201
+ }
202
+
203
+ /**
204
+ * Pure: the record after a rotation — fresh id, zero resumes, this rotation
205
+ * stamped, and the rotation history pruned to the last hour.
206
+ * @param {object} record @param {{now?:number|Function, uuid?:Function}} [opts]
207
+ */
208
+ export function rotateMainSession(record, opts = {}) {
209
+ const t = nowMs(opts.now);
210
+ const fresh = newMainSession({ now: t, uuid: opts.uuid });
211
+ const kept = (record.rotations || []).filter((r) => t - Date.parse(r) < HOUR_MS);
212
+ return { ...fresh, rotations: [...kept, iso(t)], rotatedFrom: record.sessionId };
213
+ }
214
+
215
+ export default {
216
+ FAILURE_WINDOW_MS, MAX_ROTATIONS_PER_HOUR, BACKOFF_MS,
217
+ parseMainSession, newMainSession, loadMainSession, saveMainSession,
218
+ agentFirstName, muxSessionName, mainSessionName,
219
+ launchMode, recordLaunch, rotationDecision, rotateMainSession,
220
+ };
@@ -0,0 +1,180 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { join, resolve, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { spawnSync } from "node:child_process";
8
+
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+
11
+ import {
12
+ parseMainSession,
13
+ newMainSession,
14
+ loadMainSession,
15
+ saveMainSession,
16
+ agentFirstName,
17
+ muxSessionName,
18
+ mainSessionName,
19
+ launchMode,
20
+ recordLaunch,
21
+ rotationDecision,
22
+ rotateMainSession,
23
+ FAILURE_WINDOW_MS,
24
+ MAX_ROTATIONS_PER_HOUR,
25
+ BACKOFF_MS,
26
+ } from "./identity.mjs";
27
+
28
+ const T0 = Date.parse("2026-09-08T10:00:00Z");
29
+ const UUID = "11111111-2222-4333-8444-555555555555";
30
+
31
+ test("parseMainSession: valid record round-trips; junk → null", () => {
32
+ const rec = parseMainSession(JSON.stringify({ sessionId: UUID, createdAt: "2026-09-08T09:00:00Z", resumes: 2 }));
33
+ assert.deepEqual(rec, { sessionId: UUID, createdAt: "2026-09-08T09:00:00Z", resumes: 2, rotations: [] });
34
+ assert.equal(parseMainSession("{not json"), null);
35
+ assert.equal(parseMainSession(JSON.stringify({ sessionId: "not-a-uuid" })), null);
36
+ assert.equal(parseMainSession(JSON.stringify({ resumes: 1 })), null);
37
+ assert.equal(parseMainSession(null), null);
38
+ });
39
+
40
+ test("newMainSession: uses the injected uuid + clock, starts at zero resumes", () => {
41
+ const rec = newMainSession({ now: T0, uuid: () => UUID });
42
+ assert.deepEqual(rec, { sessionId: UUID, createdAt: "2026-09-08T10:00:00.000Z", resumes: 0, rotations: [] });
43
+ });
44
+
45
+ test("loadMainSession / saveMainSession go through injected fs and the atomic writer", () => {
46
+ const disk = {};
47
+ const deps = {
48
+ readFileSync: (p) => { if (!(p in disk)) { const e = new Error("ENOENT"); e.code = "ENOENT"; throw e; } return disk[p]; },
49
+ writeJsonAtomic: (p, obj) => { disk[p] = JSON.stringify(obj); },
50
+ };
51
+ assert.equal(loadMainSession("/agent", deps), null);
52
+ const rec = newMainSession({ now: T0, uuid: () => UUID });
53
+ const saved = saveMainSession("/agent", rec, deps);
54
+ assert.deepEqual(saved, { ok: true });
55
+ assert.ok(disk[join("/agent", "state", "session", "main-session.json")]);
56
+ assert.deepEqual(loadMainSession("/agent", deps), rec);
57
+ });
58
+
59
+ test("saveMainSession: a failing writer is a returned error, not a throw", () => {
60
+ const res = saveMainSession("/agent", newMainSession({ now: T0, uuid: () => UUID }), {
61
+ writeJsonAtomic: () => { throw new Error("EROFS"); },
62
+ });
63
+ assert.equal(res.ok, false);
64
+ assert.match(res.error, /EROFS/);
65
+ });
66
+
67
+ test("agentFirstName: lower-cased firstName from config/agent.json; agent.ts, then the dir name minus -ai, as generate-plists.sh does", () => {
68
+ const fs = (files) => ({ readFileSync: (p) => { if (!(p in files)) { const e = new Error(`ENOENT ${p}`); e.code = "ENOENT"; throw e; } return files[p]; } });
69
+ const J = join("/Users/o/ravi-ai", "config", "agent.json");
70
+ const T = join("/Users/o/ravi-ai", "config", "agent.ts");
71
+ assert.equal(agentFirstName("/Users/o/ravi-ai", fs({ [J]: '{"firstName":"Olivia"}' })), "olivia");
72
+ // NOT slugged: the plist generator lower-cases only, and the label, the
73
+ // lock, the mux and the session name must agree on one derivation.
74
+ assert.equal(agentFirstName("/Users/o/ravi-ai", fs({ [J]: '{"firstName":"Jean Luc"}' })), "jean luc");
75
+ assert.equal(agentFirstName("/Users/o/ravi-ai", fs({ [J]: '{"firstName":"José"}' })), "josé");
76
+ // Fallbacks in the generator's order: agent.ts inline literal, then the directory.
77
+ assert.equal(agentFirstName("/Users/o/ravi-ai", fs({ [T]: "export const AGENT = { firstName: 'Priya', lastName: 'T' };\n" })), "priya");
78
+ assert.equal(agentFirstName("/Users/o/ravi-ai", fs({ [T]: "interface A { firstName: string; }\n" })), "ravi", "a type-only firstName is not a value");
79
+ assert.equal(agentFirstName("/Users/o/ravi-ai", fs({ [J]: '{"firstName":"UNCONFIGURED"}' })), "ravi");
80
+ assert.equal(agentFirstName("/Users/o/ravi-ai", fs({ [J]: "{" })), "ravi");
81
+ assert.equal(agentFirstName("/Users/o/ravi-ai", fs({})), "ravi");
82
+ assert.equal(agentFirstName("/Users/o/sam", fs({})), "sam");
83
+ // Blank everywhere (no usable dir name either) → "agent".
84
+ assert.equal(agentFirstName("/", fs({ [join("/", "config", "agent.json")]: '{"firstName":" "}' })), "agent");
85
+ });
86
+
87
+ test("agentFirstName agrees with generate-plists.sh's derivation, run verbatim from the script (cross-pin)", () => {
88
+ const script = readFileSync(resolve(__dirname, "..", "..", "scripts", "local-triggers", "generate-plists.sh"), "utf8");
89
+ const start = script.indexOf('AGENT_JSON="$AGENT_DIR/config/agent.json"');
90
+ const end = script.indexOf("AGENT_UPPER=");
91
+ assert.ok(start > 0 && end > start, "generate-plists.sh derivation block markers moved — update this cross-pin AND agentFirstName together");
92
+ const block = script.slice(start, end);
93
+ const shellFirst = (dir) => {
94
+ const r = spawnSync("/bin/bash", ["-c", `AGENT_DIR=${JSON.stringify(dir)}\n${block}\nprintf '%s' "$AGENT_FIRST"`], { encoding: "utf8", env: { PATH: "/usr/bin:/bin", LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8" } });
95
+ assert.equal(r.status, 0, r.stderr);
96
+ return r.stdout;
97
+ };
98
+ const cases = [
99
+ ["ravi-ai", { json: { firstName: "Olivia" } }],
100
+ ["ravi-ai", { json: { firstName: "Jean Luc" } }],
101
+ ["ravi-ai", { json: { firstName: "José" } }],
102
+ ["ravi-ai", { json: { firstName: "UNCONFIGURED" } }],
103
+ ["ravi-ai", { ts: "export const AGENT = { firstName: 'Priya', lastName: 'T' };\n" }],
104
+ ["ravi-ai", { ts: "interface A { firstName: string; }\n" }],
105
+ ["sam-ai", {}],
106
+ ["kai", { json: {} }],
107
+ ["Ravi-ai", {}], // the generator does NOT lower-case the directory fallback
108
+ ];
109
+ for (const [dirName, fx] of cases) {
110
+ const base = mkdtempSync(join(tmpdir(), "maestro-first-"));
111
+ const dir = join(base, dirName);
112
+ mkdirSync(join(dir, "config"), { recursive: true });
113
+ if (fx.json) writeFileSync(join(dir, "config", "agent.json"), JSON.stringify(fx.json));
114
+ if (fx.ts) writeFileSync(join(dir, "config", "agent.ts"), fx.ts);
115
+ try {
116
+ assert.equal(agentFirstName(dir), shellFirst(dir), `${dirName} ${JSON.stringify(fx)}`);
117
+ } finally {
118
+ rmSync(base, { recursive: true, force: true });
119
+ }
120
+ }
121
+ });
122
+
123
+ test("muxSessionName / mainSessionName", () => {
124
+ assert.equal(muxSessionName("olivia"), "maestro-olivia");
125
+ assert.equal(mainSessionName("olivia"), "olivia-main");
126
+ });
127
+
128
+ test("launchMode: a never-launched id starts fresh; a launched id resumes; resume:false always starts fresh", () => {
129
+ const fresh = newMainSession({ now: T0, uuid: () => UUID });
130
+ assert.equal(launchMode(fresh, { resume: true }), "session-id");
131
+ const launched = recordLaunch(fresh, { now: T0 });
132
+ assert.equal(launched.resumes, 1);
133
+ assert.equal(launched.lastLaunchAt, "2026-09-08T10:00:00.000Z");
134
+ assert.equal(launchMode(launched, { resume: true }), "resume");
135
+ assert.equal(launchMode(launched, { resume: false }), "session-id");
136
+ // recordLaunch is pure — the input is untouched.
137
+ assert.equal(fresh.resumes, 0);
138
+ });
139
+
140
+ test("rotationDecision: non-zero exit inside the failure window on a resume → rotate", () => {
141
+ const rec = { sessionId: UUID, createdAt: "x", resumes: 2, rotations: [] };
142
+ const d = rotationDecision({ record: rec, mode: "resume", exitCode: 1, startedAt: T0, endedAt: T0 + 5_000, now: T0 + 5_000 });
143
+ assert.equal(d.action, "rotate");
144
+ });
145
+
146
+ test("rotationDecision: a clean end, a long-lived session, or a fresh-id launch → relaunch (exit 75)", () => {
147
+ const rec = { sessionId: UUID, createdAt: "x", resumes: 2, rotations: [] };
148
+ const base = { record: rec, startedAt: T0, now: T0 + 5_000 };
149
+ assert.equal(rotationDecision({ ...base, mode: "resume", exitCode: 0, endedAt: T0 + 5_000 }).action, "relaunch");
150
+ assert.equal(rotationDecision({ ...base, mode: "resume", exitCode: 1, endedAt: T0 + FAILURE_WINDOW_MS + 1, now: T0 + FAILURE_WINDOW_MS + 1 }).action, "relaunch");
151
+ assert.equal(rotationDecision({ ...base, mode: "session-id", exitCode: 1, endedAt: T0 + 5_000 }).action, "relaunch");
152
+ });
153
+
154
+ test("rotationDecision: an unknown exit code inside the window counts as a failure (fail-safe toward rotation)", () => {
155
+ const rec = { sessionId: UUID, createdAt: "x", resumes: 1, rotations: [] };
156
+ const d = rotationDecision({ record: rec, mode: "resume", exitCode: null, startedAt: T0, endedAt: T0 + 1_000, now: T0 + 1_000 });
157
+ assert.equal(d.action, "rotate");
158
+ });
159
+
160
+ test("rotationDecision: more than MAX_ROTATIONS_PER_HOUR in the last hour → backoff", () => {
161
+ const recent = Array.from({ length: MAX_ROTATIONS_PER_HOUR }, (_, i) => new Date(T0 - (i + 1) * 60_000).toISOString());
162
+ const rec = { sessionId: UUID, createdAt: "x", resumes: 1, rotations: recent };
163
+ const d = rotationDecision({ record: rec, mode: "resume", exitCode: 1, startedAt: T0, endedAt: T0 + 1_000, now: T0 + 1_000 });
164
+ assert.equal(d.action, "backoff");
165
+ assert.equal(d.sleepMs, BACKOFF_MS);
166
+ // Rotations older than an hour no longer count.
167
+ const old = recent.map((iso) => new Date(Date.parse(iso) - 3_600_000).toISOString());
168
+ const d2 = rotationDecision({ record: { ...rec, rotations: old }, mode: "resume", exitCode: 1, startedAt: T0, endedAt: T0 + 1_000, now: T0 + 1_000 });
169
+ assert.equal(d2.action, "rotate");
170
+ });
171
+
172
+ test("rotateMainSession: fresh id, zero resumes, rotation stamped and pruned to the last hour", () => {
173
+ const rec = { sessionId: UUID, createdAt: "x", resumes: 4, rotations: [new Date(T0 - 2 * 3_600_000).toISOString()] };
174
+ const next = rotateMainSession(rec, { now: T0, uuid: () => "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" });
175
+ assert.equal(next.sessionId, "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee");
176
+ assert.equal(next.resumes, 0);
177
+ assert.deepEqual(next.rotations, ["2026-09-08T10:00:00.000Z"]);
178
+ assert.equal(next.rotatedFrom, UUID);
179
+ assert.equal(rec.resumes, 4);
180
+ });