@cohortapp/agent-sdk 2.16.0 → 2.18.4

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 (529) hide show
  1. package/.claude/settings.json +18 -0
  2. package/.env.example +23 -7
  3. package/README.md +1 -0
  4. package/bin/maestro.mjs +62 -0
  5. package/docs/guides/billing-console-keys.md +60 -0
  6. package/docs/guides/front-door-session.md +54 -9
  7. package/docs/guides/mac-mini.md +20 -25
  8. package/docs/guides/poller-daemon-setup.md +4 -1
  9. package/docs/guides/setup-wizard.md +1 -1
  10. package/docs/runbooks/fleet-rollout.md +156 -0
  11. package/docs/runbooks/mac-mini-bootstrap.md +12 -14
  12. package/lib/action-executor.js +19 -3
  13. package/lib/budget-guard.mjs +279 -3
  14. package/lib/channels/base-adapter.mjs +3 -1
  15. package/lib/channels/contract.mjs +2 -1
  16. package/lib/channels/inbox-item.mjs +8 -0
  17. package/lib/claude-bin.mjs +5 -6
  18. package/lib/cli/doctor-checks.mjs +141 -10
  19. package/lib/cli/global-setup-extras.mjs +5 -1
  20. package/lib/cli/inbox.mjs +100 -15
  21. package/lib/cli/seat-auth.mjs +463 -0
  22. package/lib/cli/session.mjs +80 -12
  23. package/lib/collective/capture.mjs +8 -6
  24. package/lib/collective/global-config.mjs +63 -1
  25. package/lib/collective/presence.mjs +142 -5
  26. package/lib/comms/send-gate.mjs +559 -1
  27. package/lib/context/budget.mjs +327 -0
  28. package/lib/context/history-scope.mjs +138 -0
  29. package/lib/diagnostics/alerts.mjs +49 -0
  30. package/lib/diagnostics/cadence-output-freshness.mjs +288 -0
  31. package/lib/engine/agents/definitions.mjs +343 -0
  32. package/lib/engine/agents/persist.mjs +275 -0
  33. package/lib/engine/agents/runtime.mjs +748 -0
  34. package/lib/engine/agents/usage.mjs +95 -0
  35. package/lib/engine/auth-status.mjs +139 -0
  36. package/lib/engine/budget.mjs +194 -0
  37. package/lib/engine/cli.mjs +1204 -0
  38. package/lib/engine/commands/index.mjs +269 -0
  39. package/lib/engine/context/budget.mjs +219 -0
  40. package/lib/engine/context/cache.mjs +125 -0
  41. package/lib/engine/context/child-env.mjs +215 -0
  42. package/lib/engine/context/compaction.mjs +342 -0
  43. package/lib/engine/context/images.mjs +90 -0
  44. package/lib/engine/context/instructions.mjs +327 -0
  45. package/lib/engine/context/lazy-instructions.mjs +169 -0
  46. package/lib/engine/context/manager.mjs +182 -0
  47. package/lib/engine/context/real-path.mjs +91 -0
  48. package/lib/engine/context/secret-values.mjs +163 -0
  49. package/lib/engine/context/settings.mjs +274 -0
  50. package/lib/engine/context/stream-input.mjs +159 -0
  51. package/lib/engine/guard.mjs +152 -0
  52. package/lib/engine/hooks.mjs +713 -0
  53. package/lib/engine/loop.mjs +560 -0
  54. package/lib/engine/mcp/client.mjs +254 -0
  55. package/lib/engine/mcp/config.mjs +301 -0
  56. package/lib/engine/mcp/http.mjs +201 -0
  57. package/lib/engine/mcp/index.mjs +146 -0
  58. package/lib/engine/mcp/jsonrpc.mjs +147 -0
  59. package/lib/engine/mcp/naming.mjs +66 -0
  60. package/lib/engine/mcp/resources.mjs +89 -0
  61. package/lib/engine/mcp/results.mjs +133 -0
  62. package/lib/engine/mcp/stdio.mjs +137 -0
  63. package/lib/engine/mcp/supervisor.mjs +116 -0
  64. package/lib/engine/messages.mjs +104 -0
  65. package/lib/engine/output/json.mjs +164 -0
  66. package/lib/engine/output/stream-json.mjs +266 -0
  67. package/lib/engine/permissions.mjs +845 -0
  68. package/lib/engine/process-identity.mjs +164 -0
  69. package/lib/engine/process-tree.mjs +551 -0
  70. package/lib/engine/prompt.mjs +60 -0
  71. package/lib/engine/session/store.mjs +299 -0
  72. package/lib/engine/session-runtime/args.mjs +97 -0
  73. package/lib/engine/session-runtime/host.mjs +143 -0
  74. package/lib/engine/session-runtime/inbox.mjs +122 -0
  75. package/lib/engine/session-runtime/notifications.mjs +129 -0
  76. package/lib/engine/session-runtime/registry.mjs +328 -0
  77. package/lib/engine/session-runtime/runner.mjs +344 -0
  78. package/lib/engine/session-runtime/socket.mjs +212 -0
  79. package/lib/engine/session-runtime/wakeup.mjs +115 -0
  80. package/lib/engine/skills/index.mjs +321 -0
  81. package/lib/engine/tools/bash-background.mjs +533 -0
  82. package/lib/engine/tools/bash.mjs +216 -0
  83. package/lib/engine/tools/edit.mjs +97 -0
  84. package/lib/engine/tools/glob.mjs +81 -0
  85. package/lib/engine/tools/grep.mjs +224 -0
  86. package/lib/engine/tools/index.mjs +84 -0
  87. package/lib/engine/tools/list-agents.mjs +32 -0
  88. package/lib/engine/tools/ls.mjs +127 -0
  89. package/lib/engine/tools/monitor.mjs +82 -0
  90. package/lib/engine/tools/notebook-edit.mjs +218 -0
  91. package/lib/engine/tools/read.mjs +103 -0
  92. package/lib/engine/tools/schedule-wakeup.mjs +45 -0
  93. package/lib/engine/tools/schema.mjs +144 -0
  94. package/lib/engine/tools/send-message.mjs +77 -0
  95. package/lib/engine/tools/session.mjs +70 -0
  96. package/lib/engine/tools/todo.mjs +144 -0
  97. package/lib/engine/tools/toolsearch.mjs +217 -0
  98. package/lib/engine/tools/walk.mjs +193 -0
  99. package/lib/engine/tools/web-switch.mjs +31 -0
  100. package/lib/engine/tools/webfetch-html.mjs +387 -0
  101. package/lib/engine/tools/webfetch-net.mjs +340 -0
  102. package/lib/engine/tools/webfetch.mjs +198 -0
  103. package/lib/engine/tools/websearch.mjs +91 -0
  104. package/lib/engine/tools/workflow.mjs +95 -0
  105. package/lib/engine/tools/write.mjs +76 -0
  106. package/lib/engine/tui/line-editor.mjs +137 -0
  107. package/lib/engine/tui/render.mjs +86 -0
  108. package/lib/engine/tui/tui.mjs +274 -0
  109. package/lib/engine/wire/anthropic-messages.mjs +263 -0
  110. package/lib/engine/wire/effort.mjs +36 -0
  111. package/lib/engine/wire/errors.mjs +496 -0
  112. package/lib/engine/wire/http.mjs +441 -0
  113. package/lib/engine/wire/index.mjs +76 -0
  114. package/lib/engine/wire/openai-chat.mjs +332 -0
  115. package/lib/engine/wire/prompt-cache.mjs +79 -0
  116. package/lib/engine/wire/search.mjs +140 -0
  117. package/lib/engine/wire/sse.mjs +114 -0
  118. package/lib/engine/wire/stall.mjs +349 -0
  119. package/lib/engine/wire/token-provider.mjs +175 -0
  120. package/lib/engine/wire/usage.mjs +192 -0
  121. package/lib/engine/workflow/host.mjs +524 -0
  122. package/lib/engine/workflow/journal.mjs +188 -0
  123. package/lib/engine/workflow/json-schema.mjs +171 -0
  124. package/lib/engine/workflow/meta.mjs +329 -0
  125. package/lib/engine/workflow/notifications.mjs +52 -0
  126. package/lib/engine/workflow/runtime.mjs +447 -0
  127. package/lib/engine/workflow/sandbox.mjs +534 -0
  128. package/lib/engine/workflow/worker.mjs +141 -0
  129. package/lib/engine/workflow/worktree.mjs +74 -0
  130. package/lib/execution/disposition.mjs +1 -1
  131. package/lib/execution/intake.mjs +10 -0
  132. package/lib/execution/surface-policy.mjs +15 -0
  133. package/lib/learning/curator.mjs +8 -6
  134. package/lib/learning/reflect.mjs +8 -6
  135. package/lib/model-router/catalog/cohort.yaml +137 -0
  136. package/lib/model-router/catalog.mjs +118 -1
  137. package/lib/model-router/economics.mjs +9 -0
  138. package/lib/model-router/failover.mjs +67 -16
  139. package/lib/model-router/llm-task.mjs +39 -3
  140. package/lib/model-router/resolve.mjs +95 -3
  141. package/lib/model-router/spawn.mjs +46 -47
  142. package/lib/model-router/taxonomy.mjs +126 -4
  143. package/lib/org/cost-sync.mjs +141 -11
  144. package/lib/org/inbound/broadcast.mjs +289 -0
  145. package/lib/org/inbound/collective.mjs +375 -0
  146. package/lib/org/inbound/directedness.mjs +96 -8
  147. package/lib/org/inbound/facts.mjs +82 -4
  148. package/lib/org/inbound/hydrate.mjs +555 -51
  149. package/lib/org/inbound/project.mjs +22 -0
  150. package/lib/org/inbound/surfaces.mjs +14 -0
  151. package/lib/org/llm-token.mjs +879 -0
  152. package/lib/org/mesh.mjs +61 -0
  153. package/lib/org/messaging.mjs +3 -1
  154. package/lib/org/protocol.checksum +1 -1
  155. package/lib/org/protocol.mjs +15 -0
  156. package/lib/org/quota.mjs +520 -0
  157. package/lib/org/tool-surface.mjs +104 -16
  158. package/lib/org/ui-parity.mjs +16 -1
  159. package/lib/org/work-ledger.mjs +37 -6
  160. package/lib/rate-guard.mjs +114 -1
  161. package/lib/resource-governor.mjs +41 -6
  162. package/lib/runtime/adapter.mjs +823 -0
  163. package/lib/runtime/child-env.mjs +191 -0
  164. package/lib/runtime/legacy-shell-guard.mjs +97 -0
  165. package/lib/runtime/seat-engine.mjs +162 -0
  166. package/lib/session/ask-ledger.mjs +271 -0
  167. package/lib/session/current-work.mjs +676 -0
  168. package/lib/session/feed-core.mjs +40 -3
  169. package/lib/session/launch-args.mjs +56 -4
  170. package/lib/session/status-summary.mjs +26 -9
  171. package/lib/session/upgrade-notice.mjs +42 -0
  172. package/lib/setup/claude-probe.mjs +117 -13
  173. package/lib/setup/enrich.mjs +13 -10
  174. package/lib/setup/sections/model.mjs +39 -13
  175. package/lib/telemetry/collect.mjs +208 -9
  176. package/lib/upgrade/ignored-drift.mjs +105 -0
  177. package/lib/voice/post-call-brief.mjs +30 -17
  178. package/package.json +15 -3
  179. package/plugins/maestro-skills/skills/board-work.md +5 -0
  180. package/plugins/maestro-skills/skills/inbound-triage.md +56 -15
  181. package/plugins/maestro-skills/skills/main-session.md +18 -7
  182. package/scripts/ci/check-tarball-fidelity.mjs +126 -2
  183. package/scripts/ci/run-tests.mjs +47 -19
  184. package/scripts/cohort-llm/api-key-helper.mjs +92 -0
  185. package/scripts/collective/hook-runner.mjs +29 -2
  186. package/scripts/continuous-monitor.sh +13 -0
  187. package/scripts/cost/track-claude-usage.mjs +15 -0
  188. package/scripts/daemon/agent-daemon.mjs +408 -20
  189. package/scripts/daemon/assurance.mjs +48 -12
  190. package/scripts/daemon/cadence-consumer.mjs +218 -68
  191. package/scripts/daemon/cadence-handlers.mjs +73 -4
  192. package/scripts/daemon/classifier.mjs +75 -26
  193. package/scripts/daemon/context-compiler.mjs +104 -59
  194. package/scripts/daemon/deliver.mjs +30 -1
  195. package/scripts/daemon/dispatcher.mjs +804 -157
  196. package/scripts/daemon/health.mjs +14 -1
  197. package/scripts/daemon/lib/session-router.mjs +310 -42
  198. package/scripts/daemon/maestro-daemon.mjs +11 -0
  199. package/scripts/daemon/prompt-builder.mjs +121 -12
  200. package/scripts/daemon/responder.mjs +315 -146
  201. package/scripts/daemon/sdk-version.mjs +98 -16
  202. package/scripts/eval/probe-gateway.mjs +635 -0
  203. package/scripts/eval/replay/extract.mjs +270 -0
  204. package/scripts/eval/replay/grade.mjs +260 -0
  205. package/scripts/eval/replay/lib/config.mjs +50 -0
  206. package/scripts/eval/replay/lib/effects.mjs +65 -0
  207. package/scripts/eval/replay/lib/fixture.mjs +188 -0
  208. package/scripts/eval/replay/lib/judge.mjs +72 -0
  209. package/scripts/eval/replay/lib/redact.mjs +136 -0
  210. package/scripts/eval/replay/lib/sandbox.mjs +170 -0
  211. package/scripts/eval/replay/lib/schema-check.mjs +63 -0
  212. package/scripts/eval/replay/lib/transcript.mjs +76 -0
  213. package/scripts/eval/replay/mcp-replay-stub.mjs +101 -0
  214. package/scripts/eval/replay/report.mjs +185 -0
  215. package/scripts/eval/replay/run.mjs +404 -0
  216. package/scripts/fleet/rollout.mjs +1094 -0
  217. package/scripts/hooks/pre-send-audit.sh +36 -245
  218. package/scripts/hooks/pre-write-yaml-validate.mjs +275 -0
  219. package/scripts/hooks/validate-state-yaml.sh +190 -0
  220. package/scripts/huddle/huddle-llm.mjs +361 -0
  221. package/scripts/huddle/huddle-server.mjs +46 -121
  222. package/scripts/local-triggers/autoupdate.sh +448 -78
  223. package/scripts/local-triggers/run-trigger.sh +13 -0
  224. package/scripts/maintenance/pin-integrity.mjs +364 -0
  225. package/scripts/poll-slack-events.sh +41 -9
  226. package/scripts/poller/slack-socket-mode.mjs +28 -3
  227. package/scripts/session/supervisor.mjs +80 -13
  228. package/scripts/spawn-session.sh +13 -0
  229. package/bin/maestro.test.mjs +0 -1574
  230. package/lib/action-executor.test.mjs +0 -871
  231. package/lib/archetype.test.mjs +0 -132
  232. package/lib/assurance/plan-note.test.mjs +0 -234
  233. package/lib/assurance/room-budget.test.mjs +0 -486
  234. package/lib/assurance/tier.test.mjs +0 -174
  235. package/lib/autonomy.test.mjs +0 -66
  236. package/lib/backlog.test.mjs +0 -302
  237. package/lib/backup/policy.test.mjs +0 -305
  238. package/lib/budget-escalate.test.mjs +0 -232
  239. package/lib/budget-guard.envelope.test.mjs +0 -476
  240. package/lib/budget-guard.test.mjs +0 -427
  241. package/lib/cadence-bus-requeue.test.mjs +0 -83
  242. package/lib/cadence-bus-schedule.test.mjs +0 -194
  243. package/lib/cadence-bus.test.mjs +0 -720
  244. package/lib/cadences.test.mjs +0 -230
  245. package/lib/capability/inventory.test.mjs +0 -232
  246. package/lib/capability.test.mjs +0 -78
  247. package/lib/channels/base-adapter.test.mjs +0 -590
  248. package/lib/channels/channels.test.mjs +0 -371
  249. package/lib/channels/contract.test.mjs +0 -162
  250. package/lib/channels/inbox-item.test.mjs +0 -368
  251. package/lib/channels/orgmail/adapter.test.mjs +0 -448
  252. package/lib/channels/pairing.test.mjs +0 -270
  253. package/lib/channels/repeat-suppressor.test.mjs +0 -134
  254. package/lib/channels/slack-adapter.test.mjs +0 -212
  255. package/lib/channels/telegram-adapter.test.mjs +0 -306
  256. package/lib/channels/voice/adapter.test.mjs +0 -278
  257. package/lib/channels/whatsapp/adapter-baileys.test.mjs +0 -359
  258. package/lib/channels/whatsapp/baileys-typing.test.mjs +0 -154
  259. package/lib/charter.test.mjs +0 -89
  260. package/lib/claude-bin.test.mjs +0 -131
  261. package/lib/cli/board.test.mjs +0 -227
  262. package/lib/cli/design.test.mjs +0 -270
  263. package/lib/cli/doctor-checks.test.mjs +0 -336
  264. package/lib/cli/global-setup-extras.test.mjs +0 -462
  265. package/lib/cli/inbox.test.mjs +0 -230
  266. package/lib/cli/session-ack.test.mjs +0 -63
  267. package/lib/cli/session.test.mjs +0 -613
  268. package/lib/collective/capture.test.mjs +0 -121
  269. package/lib/collective/cards.test.mjs +0 -114
  270. package/lib/collective/config.test.mjs +0 -123
  271. package/lib/collective/global-config.test.mjs +0 -220
  272. package/lib/collective/global-skills.test.mjs +0 -126
  273. package/lib/collective/presence.test.mjs +0 -95
  274. package/lib/collective/recall.test.mjs +0 -116
  275. package/lib/collective/vendor-skills.test.mjs +0 -306
  276. package/lib/comms/send-gate.test.mjs +0 -770
  277. package/lib/comms.test.mjs +0 -41
  278. package/lib/cost/ledger-row.test.mjs +0 -183
  279. package/lib/design/design-md.test.mjs +0 -318
  280. package/lib/design/fixtures/DESIGN.golden.md +0 -238
  281. package/lib/design/fixtures/PRODUCT.golden.md +0 -67
  282. package/lib/design/fixtures/foundation.json +0 -133
  283. package/lib/design/refresh-gate.test.mjs +0 -144
  284. package/lib/design/write.test.mjs +0 -241
  285. package/lib/diagnostics/alerts.test.mjs +0 -318
  286. package/lib/diagnostics/backup-freshness.test.mjs +0 -185
  287. package/lib/diagnostics/counters.test.mjs +0 -206
  288. package/lib/diagnostics/events.test.mjs +0 -290
  289. package/lib/diagnostics/otel.test.mjs +0 -196
  290. package/lib/diagnostics/trace.test.mjs +0 -251
  291. package/lib/env-compat.test.mjs +0 -104
  292. package/lib/execution/disposition.test.mjs +0 -553
  293. package/lib/execution/drive.test.mjs +0 -270
  294. package/lib/execution/effects.test.mjs +0 -344
  295. package/lib/execution/intake.test.mjs +0 -389
  296. package/lib/execution/journal.test.mjs +0 -261
  297. package/lib/execution/match.test.mjs +0 -235
  298. package/lib/execution/pipeline.test.mjs +0 -392
  299. package/lib/execution/route.test.mjs +0 -186
  300. package/lib/execution/surface-policy.test.mjs +0 -162
  301. package/lib/fs-atomic.test.mjs +0 -72
  302. package/lib/fs-ownership.test.mjs +0 -158
  303. package/lib/goals/admission.test.mjs +0 -164
  304. package/lib/goals/classify.test.mjs +0 -167
  305. package/lib/goals/collaborate.test.mjs +0 -336
  306. package/lib/goals/gaps.test.mjs +0 -284
  307. package/lib/goals/loop.test.mjs +0 -845
  308. package/lib/hooks/bus.test.mjs +0 -387
  309. package/lib/identity/persona.test.mjs +0 -142
  310. package/lib/kpi-sensors.test.mjs +0 -278
  311. package/lib/kpi.test.mjs +0 -244
  312. package/lib/learning/config.test.mjs +0 -75
  313. package/lib/learning/counters.test.mjs +0 -69
  314. package/lib/learning/curator-consolidate.test.mjs +0 -238
  315. package/lib/learning/curator.test.mjs +0 -106
  316. package/lib/learning/reflect.test.mjs +0 -0
  317. package/lib/learning/session-index.test.mjs +0 -125
  318. package/lib/learning/skill-writer.test.mjs +0 -210
  319. package/lib/mandate/audit.test.mjs +0 -195
  320. package/lib/mandate/contract.test.mjs +0 -185
  321. package/lib/mandate/derive.test.mjs +0 -274
  322. package/lib/mandate/model.test.mjs +0 -164
  323. package/lib/mandate/refresh.test.mjs +0 -389
  324. package/lib/mcp/server.test.mjs +0 -426
  325. package/lib/model-router/auth-profiles.test.mjs +0 -580
  326. package/lib/model-router/catalog.test.mjs +0 -385
  327. package/lib/model-router/economics.test.mjs +0 -438
  328. package/lib/model-router/failover.test.mjs +0 -439
  329. package/lib/model-router/health.test.mjs +0 -338
  330. package/lib/model-router/integration-coverage.test.mjs +0 -831
  331. package/lib/model-router/integration.test.mjs +0 -564
  332. package/lib/model-router/ledger.test.mjs +0 -415
  333. package/lib/model-router/llm-task.test.mjs +0 -392
  334. package/lib/model-router/org-credentials.test.mjs +0 -265
  335. package/lib/model-router/pricing-refresh.test.mjs +0 -286
  336. package/lib/model-router/reconcile.test.mjs +0 -316
  337. package/lib/model-router/repair.test.mjs +0 -180
  338. package/lib/model-router/spawn.test.mjs +0 -446
  339. package/lib/model-router/taxonomy.test.mjs +0 -410
  340. package/lib/model-router.test.mjs +0 -1207
  341. package/lib/org/activity.test.mjs +0 -134
  342. package/lib/org/approvals.test.mjs +0 -216
  343. package/lib/org/awareness.test.mjs +0 -159
  344. package/lib/org/board-mine-cache.test.mjs +0 -53
  345. package/lib/org/board.test.mjs +0 -187
  346. package/lib/org/bootstrap-context.test.mjs +0 -153
  347. package/lib/org/client.test.mjs +0 -1206
  348. package/lib/org/cohort-client.test.mjs +0 -126
  349. package/lib/org/cost-sync.test.mjs +0 -153
  350. package/lib/org/doctor.test.mjs +0 -346
  351. package/lib/org/engagement-ledger.test.mjs +0 -112
  352. package/lib/org/engagement.test.mjs +0 -739
  353. package/lib/org/handoff.test.mjs +0 -269
  354. package/lib/org/inbound/directedness.test.mjs +0 -668
  355. package/lib/org/inbound/facts.test.mjs +0 -471
  356. package/lib/org/inbound/hydrate.test.mjs +0 -453
  357. package/lib/org/inbound/index.test.mjs +0 -429
  358. package/lib/org/inbound/project.test.mjs +0 -287
  359. package/lib/org/integration-tools.test.mjs +0 -160
  360. package/lib/org/keys.test.mjs +0 -92
  361. package/lib/org/knowledge.test.mjs +0 -326
  362. package/lib/org/leases.test.mjs +0 -235
  363. package/lib/org/mesh-directives.test.mjs +0 -110
  364. package/lib/org/mesh-integration.test.mjs +0 -127
  365. package/lib/org/mesh.test.mjs +0 -400
  366. package/lib/org/messaging.test.mjs +0 -471
  367. package/lib/org/param-contract.test.mjs +0 -477
  368. package/lib/org/policy.test.mjs +0 -237
  369. package/lib/org/protocol.checksum.test.mjs +0 -90
  370. package/lib/org/protocol.test.mjs +0 -323
  371. package/lib/org/push.test.mjs +0 -792
  372. package/lib/org/registry.test.mjs +0 -100
  373. package/lib/org/resource-tools.test.mjs +0 -361
  374. package/lib/org/tool-access.test.mjs +0 -144
  375. package/lib/org/tool-surface-integration.test.mjs +0 -120
  376. package/lib/org/tool-surface.test.mjs +0 -1268
  377. package/lib/org/typing.test.mjs +0 -291
  378. package/lib/org/ui-parity.test.mjs +0 -560
  379. package/lib/org/verify.test.mjs +0 -194
  380. package/lib/org/work-ledger.test.mjs +0 -273
  381. package/lib/plan/adoption-e2e.test.mjs +0 -366
  382. package/lib/plan/budget-enforcement.test.mjs +0 -400
  383. package/lib/plan/compile.test.mjs +0 -382
  384. package/lib/plan/emit.test.mjs +0 -269
  385. package/lib/plan/explain.test.mjs +0 -188
  386. package/lib/prompts/parallelism.test.mjs +0 -177
  387. package/lib/rag/rag.test.mjs +0 -505
  388. package/lib/rate-guard.test.mjs +0 -272
  389. package/lib/reactive-gate.test.mjs +0 -57
  390. package/lib/render.test.mjs +0 -68
  391. package/lib/resource-governor.test.mjs +0 -488
  392. package/lib/scheduling/dynamic-jobs.test.mjs +0 -344
  393. package/lib/scheduling/jitter.test.mjs +0 -140
  394. package/lib/secrets/broker.test.mjs +0 -280
  395. package/lib/secrets/providers.test.mjs +0 -274
  396. package/lib/security/audit-engine.test.mjs +0 -424
  397. package/lib/security/coerce-args.test.mjs +0 -281
  398. package/lib/security/dangerous-tools.test.mjs +0 -68
  399. package/lib/security/external-content.test.mjs +0 -84
  400. package/lib/security/redact.test.mjs +0 -441
  401. package/lib/security/secret-equal.test.mjs +0 -55
  402. package/lib/session/config.test.mjs +0 -92
  403. package/lib/session/feed-core.test.mjs +0 -198
  404. package/lib/session/first-run.test.mjs +0 -121
  405. package/lib/session/frontdoor.test.mjs +0 -205
  406. package/lib/session/handoffs.test.mjs +0 -183
  407. package/lib/session/identity.test.mjs +0 -180
  408. package/lib/session/inbox-claims.test.mjs +0 -286
  409. package/lib/session/launch-args.test.mjs +0 -157
  410. package/lib/session/liveness.test.mjs +0 -100
  411. package/lib/session/status-summary.test.mjs +0 -118
  412. package/lib/session-permissions.test.mjs +0 -120
  413. package/lib/setup/claude-probe.test.mjs +0 -187
  414. package/lib/setup/completeness.test.mjs +0 -110
  415. package/lib/setup/context-pack.test.mjs +0 -89
  416. package/lib/setup/enrich.test.mjs +0 -115
  417. package/lib/setup/enroll-from-cohort.test.mjs +0 -300
  418. package/lib/setup/integration.test.mjs +0 -162
  419. package/lib/setup/io.test.mjs +0 -77
  420. package/lib/setup/runner.test.mjs +0 -132
  421. package/lib/setup/sections/identity.test.mjs +0 -234
  422. package/lib/setup/sections/inventory.test.mjs +0 -198
  423. package/lib/setup/sections/learning.test.mjs +0 -81
  424. package/lib/setup/sections/mandate.test.mjs +0 -388
  425. package/lib/setup/sections/messaging.test.mjs +0 -127
  426. package/lib/setup/sections/model.test.mjs +0 -240
  427. package/lib/setup/sections/org.test.mjs +0 -346
  428. package/lib/setup/sections/orgmail.test.mjs +0 -118
  429. package/lib/setup/sections/recovery.test.mjs +0 -98
  430. package/lib/setup/sections/subagents.test.mjs +0 -429
  431. package/lib/setup/sections/verify.test.mjs +0 -175
  432. package/lib/setup/sot.test.mjs +0 -81
  433. package/lib/setup/state.test.mjs +0 -115
  434. package/lib/singleton.test.mjs +0 -151
  435. package/lib/subagents/cli.test.mjs +0 -389
  436. package/lib/subagents/client.test.mjs +0 -309
  437. package/lib/subagents/gap.test.mjs +0 -234
  438. package/lib/subagents/lock.test.mjs +0 -248
  439. package/lib/subagents/manifest.test.mjs +0 -175
  440. package/lib/subagents/refs.test.mjs +0 -204
  441. package/lib/subagents/resolve.test.mjs +0 -422
  442. package/lib/subagents/schema.test.mjs +0 -328
  443. package/lib/telemetry/alerts.test.mjs +0 -109
  444. package/lib/telemetry/collect.test.mjs +0 -1274
  445. package/lib/tool-definitions-integration.test.mjs +0 -83
  446. package/lib/tool-definitions.test.mjs +0 -437
  447. package/lib/upgrade/global-refresh.test.mjs +0 -65
  448. package/lib/upgrade/launchd-reconcile.test.mjs +0 -272
  449. package/lib/upgrade/post-steps.test.mjs +0 -200
  450. package/lib/upgrade/verify.test.mjs +0 -164
  451. package/lib/util/fetch-timeout.test.mjs +0 -202
  452. package/lib/util/reconnect.test.mjs +0 -369
  453. package/lib/util/unhandled.test.mjs +0 -216
  454. package/lib/voice/outbound.test.mjs +0 -69
  455. package/lib/voice/session-rotation.test.mjs +0 -114
  456. package/lib/voice/stt.test.mjs +0 -226
  457. package/lib/voice/voice.test.mjs +0 -990
  458. package/scripts/cadence/enqueue-cadence-tick.test.mjs +0 -187
  459. package/scripts/ci/check-docs-accuracy.test.mjs +0 -409
  460. package/scripts/ci/check-durable-write-seam.test.mjs +0 -90
  461. package/scripts/ci/check-no-build-artifacts.test.mjs +0 -71
  462. package/scripts/ci/check-no-residual-identity.test.mjs +0 -202
  463. package/scripts/ci/check-skill-packs.test.mjs +0 -495
  464. package/scripts/ci/check-subagent-frontmatter.test.mjs +0 -124
  465. package/scripts/ci/check.test.mjs +0 -194
  466. package/scripts/ci/conformance-org-api.test.mjs +0 -425
  467. package/scripts/cloud-relay/voice/relay-identity.test.mjs +0 -96
  468. package/scripts/collective/hook-runner.test.mjs +0 -173
  469. package/scripts/cost/fleet-digest.test.mjs +0 -207
  470. package/scripts/cost/track-claude-usage-pricing.test.mjs +0 -183
  471. package/scripts/cost/track-claude-usage.test.mjs +0 -148
  472. package/scripts/daemon/agent-daemon-board-mine.test.mjs +0 -96
  473. package/scripts/daemon/agent-daemon-design.test.mjs +0 -238
  474. package/scripts/daemon/agent-daemon-frontdoor.test.mjs +0 -60
  475. package/scripts/daemon/agent-daemon.test.mjs +0 -995
  476. package/scripts/daemon/assurance-e2e.test.mjs +0 -613
  477. package/scripts/daemon/assurance.test.mjs +0 -1791
  478. package/scripts/daemon/board-mirror.test.mjs +0 -165
  479. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +0 -393
  480. package/scripts/daemon/cadence-consumer-governance.test.mjs +0 -276
  481. package/scripts/daemon/cadence-consumer.test.mjs +0 -776
  482. package/scripts/daemon/cadence-handlers.test.mjs +0 -837
  483. package/scripts/daemon/classifier-identity.test.mjs +0 -137
  484. package/scripts/daemon/classifier.test.mjs +0 -266
  485. package/scripts/daemon/classify-kind.test.mjs +0 -40
  486. package/scripts/daemon/context-compiler.test.mjs +0 -300
  487. package/scripts/daemon/deliver.test.mjs +0 -564
  488. package/scripts/daemon/dispatcher-cooldown.test.mjs +0 -122
  489. package/scripts/daemon/dispatcher-governance.test.mjs +0 -1013
  490. package/scripts/daemon/dispatcher-resume.test.mjs +0 -166
  491. package/scripts/daemon/execution-ladder.test.mjs +0 -470
  492. package/scripts/daemon/goal-steward-cadence.test.mjs +0 -312
  493. package/scripts/daemon/inbox-deferral-session.test.mjs +0 -49
  494. package/scripts/daemon/inbox-deferral.test.mjs +0 -336
  495. package/scripts/daemon/inbox-wake.test.mjs +0 -199
  496. package/scripts/daemon/integration.test.mjs +0 -149
  497. package/scripts/daemon/lib/self-echo.test.mjs +0 -153
  498. package/scripts/daemon/lib/session-router.test.mjs +0 -295
  499. package/scripts/daemon/prompt-builder-preamble.test.mjs +0 -210
  500. package/scripts/daemon/prompt-builder.test.mjs +0 -344
  501. package/scripts/daemon/responder-cost.test.mjs +0 -68
  502. package/scripts/daemon/responder-history.test.mjs +0 -185
  503. package/scripts/daemon/sdk-version.test.mjs +0 -31
  504. package/scripts/daemon/session-lock.test.mjs +0 -252
  505. package/scripts/daemon/session-outcomes.test.mjs +0 -533
  506. package/scripts/daemon/typing-registry.test.mjs +0 -102
  507. package/scripts/hooks/pre-send-audit.test.mjs +0 -354
  508. package/scripts/huddle/huddle-prompt.test.mjs +0 -176
  509. package/scripts/local-triggers/autoupdate.test.mjs +0 -518
  510. package/scripts/local-triggers/generate-plists.test.mjs +0 -456
  511. package/scripts/media-generation/brand-clause.test.mjs +0 -135
  512. package/scripts/org/send-orgmail.first-contact.test.mjs +0 -102
  513. package/scripts/poller/inbox-privilege-injection.test.mjs +0 -167
  514. package/scripts/poller/inbox-scan-poller.test.mjs +0 -295
  515. package/scripts/poller/lib/cloud-relay-dedup.test.mjs +0 -133
  516. package/scripts/poller/slack-socket-mode.test.mjs +0 -805
  517. package/scripts/poller-launchd/install.test.mjs +0 -243
  518. package/scripts/restore-from-backup.test.mjs +0 -181
  519. package/scripts/session/feed.test.mjs +0 -196
  520. package/scripts/session/supervisor-sh.test.mjs +0 -218
  521. package/scripts/session/supervisor.test.mjs +0 -482
  522. package/scripts/setup/configure-macos.test.mjs +0 -306
  523. package/scripts/setup/gen-subagent-manifest.test.mjs +0 -124
  524. package/scripts/setup/generate-agent-package-json.test.mjs +0 -143
  525. package/scripts/setup/generate-capability.test.mjs +0 -134
  526. package/scripts/setup/init-agent.test.mjs +0 -370
  527. package/scripts/setup/init-skill-marketplace.test.mjs +0 -193
  528. package/scripts/vendor/sync-skill-packs.test.mjs +0 -103
  529. package/scripts/watchdog/memory-watchdog.test.mjs +0 -64
@@ -1,1274 +0,0 @@
1
- /**
2
- * collect.test.mjs — the SHARED STATUS SNAPSHOT collector (collectStatus).
3
- *
4
- * Hermetic: injects node:os, a child-process count, an execFile fake (so no real
5
- * powermetrics/df fork), and log text — asserts the snapshot shape, the
6
- * state/activity/session derivation, machine stats, sub-agent count, and
7
- * claudeAuth detection. Also unit-tests the pure parsers.
8
- *
9
- * Run: node --test lib/telemetry/collect.test.mjs
10
- */
11
- "use strict";
12
-
13
- import { test } from "node:test";
14
- import assert from "node:assert/strict";
15
- import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "node:fs";
16
- import { tmpdir } from "node:os";
17
- import { join } from "node:path";
18
-
19
- import {
20
- collectStatus,
21
- resolveActivity,
22
- detectClaudeAuth,
23
- countSubAgents,
24
- parsePowermetrics,
25
- parseDf,
26
- collectMachine,
27
- collectPowermetrics,
28
- collectThermal,
29
- parseThermalProbe,
30
- parseThermalPressure,
31
- powermetricsSamplers,
32
- parseSystemProfilerHardware,
33
- ramLabel,
34
- collectSpend24h,
35
- collectInventory,
36
- sessionLiveness,
37
- SESSION_STALE_MS,
38
- _internals,
39
- } from "./collect.mjs";
40
-
41
- const NOW = Date.parse("2026-06-12T12:00:00Z");
42
-
43
- /** A node:os fake with controllable load/mem/cpus. */
44
- function fakeOs(opts = {}) {
45
- return {
46
- loadavg: () => opts.loadavg || [1.5, 1.2, 1.0],
47
- totalmem: () => opts.totalmem ?? 16 * 1024 * 1024 * 1024,
48
- freemem: () => opts.freemem ?? 8 * 1024 * 1024 * 1024,
49
- cpus: () => new Array(opts.cpus ?? 8).fill({ model: "x" }),
50
- platform: () => opts.platform || "darwin",
51
- };
52
- }
53
-
54
- /** An execFile fake that returns canned stdout per binary. Never forks. */
55
- function fakeExecFile(map = {}) {
56
- return (cmd, args, optsOrCb, maybeCb) => {
57
- const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
58
- const out = map[cmd] ?? "";
59
- queueMicrotask(() => cb(null, out, ""));
60
- return { on() {} };
61
- };
62
- }
63
-
64
- // ---------------------------------------------------------------------------
65
-
66
- test("collectStatus builds a well-formed snapshot (mocked os/powermetrics/df)", async () => {
67
- const status = await collectStatus({
68
- agentRoot: "/nonexistent-root", // no logs → claudeAuth unknown
69
- now: NOW,
70
- os: fakeOs({ loadavg: [2, 1, 1], totalmem: 16e9, freemem: 4e9, cpus: 8 }),
71
- execFile: fakeExecFile({
72
- "/usr/bin/powermetrics": "CPU die temperature: 61.4 C\nCPU idle residency: 78.0 %\n",
73
- "/bin/df": "Filesystem 1024-blocks Used Available Capacity Mounted\n/dev/disk1 1000 600 400 60% /\n",
74
- }),
75
- subAgentsRunning: 2,
76
- });
77
-
78
- // shape
79
- assert.equal(typeof status.state, "string");
80
- assert.equal(typeof status.activity, "string");
81
- assert.equal(typeof status.subAgentsRunning, "number");
82
- assert.ok(Array.isArray(status.alerts));
83
- assert.ok(Array.isArray(status.machine.loadavg));
84
- assert.equal(status.machine.loadavg.length, 3);
85
- assert.match(status.ts, /^2026-06-12T12:00:00/);
86
-
87
- // machine
88
- assert.equal(status.machine.memUsedPct, 75); // 1 - 4/16 = 0.75
89
- assert.equal(status.machine.tempC, 61.4);
90
- assert.equal(status.machine.diskUsedPct, 60);
91
- assert.equal(status.machine.cpuPct, 22); // 100 - 78 idle
92
-
93
- // sub-agents + idle→busy promotion
94
- assert.equal(status.subAgentsRunning, 2);
95
- assert.equal(status.state, "busy"); // idle but children running → busy
96
- assert.equal(status.claudeAuth, "unknown");
97
- });
98
-
99
- test("collectStatus: relogin in logs → state error + claudeAuth relogin_required + critical alert", async () => {
100
- const status = await collectStatus({
101
- agentRoot: "/x",
102
- now: NOW,
103
- os: fakeOs(),
104
- execFile: fakeExecFile({ "/usr/bin/powermetrics": "", "/bin/df": "" }),
105
- subAgentsRunning: 0,
106
- logText: "ERROR authentication_error: please run /login\n",
107
- });
108
- assert.equal(status.claudeAuth, "relogin_required");
109
- assert.equal(status.state, "error");
110
- const relogin = status.alerts.find((a) => a.id === "claude_relogin_required");
111
- assert.ok(relogin, "a critical re-login alert is attached");
112
- assert.equal(relogin.severity, "critical");
113
- });
114
-
115
- test("resolveActivity: a running work session → busy + session{taskClass,model}", () => {
116
- const sessions = [
117
- { sessionId: "s1", kind: "interactive", intent: "session: inbox", model: "sonnet", lastBeat: "2026-06-12T11:59:50Z" },
118
- { sessionId: "d1", kind: "daemon", intent: "daemon", lastBeat: "2026-06-12T11:59:55Z" },
119
- ];
120
- const act = resolveActivity({ sessions, now: NOW });
121
- assert.equal(act.state, "busy");
122
- assert.equal(act.session.id, "s1");
123
- assert.equal(act.session.taskClass, "inbox");
124
- assert.equal(act.session.model, "sonnet");
125
- assert.match(act.activity, /inbox/);
126
- });
127
-
128
- test("resolveActivity: only a daemon present → idle, no session", () => {
129
- const act = resolveActivity({ sessions: [{ sessionId: "d1", kind: "daemon", intent: "daemon", lastBeat: "2026-06-12T11:59:55Z" }], now: NOW });
130
- assert.equal(act.state, "idle");
131
- assert.equal(act.session, null);
132
- assert.equal(act.activity, "idle");
133
- });
134
-
135
- test("resolveActivity: nothing running → idle", () => {
136
- const act = resolveActivity({ sessions: [], now: NOW });
137
- assert.equal(act.state, "idle");
138
- assert.equal(act.activity, "idle");
139
- assert.equal(act.session, null);
140
- });
141
-
142
- test("countSubAgents: explicit override wins; injected liveClaudeStats used otherwise", () => {
143
- assert.equal(countSubAgents({ subAgentsRunning: 3 }), 3);
144
- assert.equal(countSubAgents({ liveClaudeStats: () => ({ count: 4, rssMB: 100 }) }), 4);
145
- assert.equal(countSubAgents({ liveClaudeStats: () => { throw new Error("boom"); } }), 0);
146
- });
147
-
148
- test("detectClaudeAuth: logText override paths", () => {
149
- assert.equal(detectClaudeAuth({ logText: "all good, session complete" }), "ok");
150
- assert.equal(detectClaudeAuth({ logText: "Invalid API key provided" }), "relogin_required");
151
- assert.equal(detectClaudeAuth({ logText: "" }), "unknown");
152
- });
153
-
154
- test("detectClaudeAuth: scans recent log files under agentRoot", () => {
155
- const root = mkdtempSync(join(tmpdir(), "tel-auth-"));
156
- try {
157
- mkdirSync(join(root, "logs", "sessions"), { recursive: true });
158
- writeFileSync(join(root, "logs", "sessions", "2026-06-12-sessions.jsonl"),
159
- '{"msg":"oauth token has expired, please run claude login"}\n');
160
- assert.equal(detectClaudeAuth({ agentRoot: root, now: NOW }), "relogin_required");
161
-
162
- // A clean recent log → ok.
163
- const root2 = mkdtempSync(join(tmpdir(), "tel-auth2-"));
164
- mkdirSync(join(root2, "logs", "sessions"), { recursive: true });
165
- writeFileSync(join(root2, "logs", "sessions", "2026-06-12-sessions.jsonl"), '{"msg":"ok"}\n');
166
- assert.equal(detectClaudeAuth({ agentRoot: root2, now: NOW }), "ok");
167
- rmSync(root2, { recursive: true, force: true });
168
-
169
- // No logs at all → unknown.
170
- const root3 = mkdtempSync(join(tmpdir(), "tel-auth3-"));
171
- assert.equal(detectClaudeAuth({ agentRoot: root3, now: NOW }), "unknown");
172
- rmSync(root3, { recursive: true, force: true });
173
- } finally {
174
- rmSync(root, { recursive: true, force: true });
175
- }
176
- });
177
-
178
- test("collectMachine: derives memUsedPct + loadavg without powermetrics/df", async () => {
179
- // memUsedPct now prefers macOS's memory_pressure, so "no external tools" has
180
- // to include that one too — otherwise this test reads the REAL machine and
181
- // asserts against the fake os, which is how it started failing.
182
- const noTools = (cmd, args, opts, cb) => { cb(new Error("ENOENT")); return { on() {} }; };
183
- const m = await collectMachine({ os: fakeOs({ totalmem: 10e9, freemem: 2e9, loadavg: [4, 2, 1], cpus: 4 }), execFile: noTools, powermetrics: false, disk: false });
184
- assert.equal(m.memUsedPct, 80);
185
- assert.deepEqual(m.loadavg, [4, 2, 1]);
186
- assert.equal(m.cpuPct, 100); // load 4 / 4 cpus = 100%
187
- assert.equal(m.tempC, undefined);
188
- assert.equal(m.diskUsedPct, undefined);
189
- });
190
-
191
- test("parsePowermetrics + parseDf are tolerant pure parsers", () => {
192
- const pm = parsePowermetrics("GPU die temperature: 45 C\nCPU die temperature: 72.5 C\nCPU idle residency: 90.0 %\n");
193
- assert.equal(pm.tempC, 72.5);
194
- assert.equal(pm.cpuPct, 10);
195
- assert.deepEqual(parsePowermetrics("nonsense"), {});
196
-
197
- assert.equal(parseDf("Filesystem blocks Used Avail Capacity Mounted\n/dev/x 100 88 12 88% /\n"), 88);
198
- assert.equal(parseDf(""), undefined);
199
- });
200
-
201
- test("collectStatus never throws on a broken execFile / os", async () => {
202
- const status = await collectStatus({
203
- agentRoot: "/x",
204
- now: NOW,
205
- os: { loadavg: () => { throw new Error("x"); }, totalmem: () => { throw new Error("x"); }, freemem: () => 0, cpus: () => { throw new Error("x"); }, platform: () => "darwin" },
206
- execFile: () => { throw new Error("exec boom"); },
207
- subAgentsRunning: 0,
208
- });
209
- assert.ok(status && typeof status === "object");
210
- assert.deepEqual(status.machine.loadavg, [0, 0, 0]);
211
- assert.equal(status.machine.memUsedPct, 0);
212
- });
213
-
214
- test("execFileSafe resolves '' on a throwing exec impl (no reject)", async () => {
215
- const out = await _internals.execFileSafe(() => { throw new Error("nope"); }, "x", [], 50);
216
- assert.equal(out, "");
217
- });
218
-
219
- // ---------------------------------------------------------------------------
220
- // The Fleet telemetry vocabulary — the fields the hq Fleet row reads by name.
221
- //
222
- // These four (device / ram / uptimeDays / spend24h) were READ by
223
- // hq/src/components/fleet/machine.ts#buildFleetMachine and emitted by NOTHING,
224
- // while four others (loadavg / memUsedPct / cpuPct / diskUsedPct) were emitted
225
- // here and read by nothing. The intersection in production was EMPTY, so the
226
- // Fleet view em-dashed every hardware column on the one machine that was
227
- // genuinely alive. These tests pin the producer half of that contract.
228
- // ---------------------------------------------------------------------------
229
-
230
- const SP_HARDWARE = [
231
- "Hardware:",
232
- " Hardware Overview:",
233
- " Model Name: Mac mini",
234
- " Model Identifier: Mac16,11",
235
- " Chip: Apple M4 Pro",
236
- " Total Number of Cores: 12 (8 performance and 4 efficiency)",
237
- " Memory: 24 GB",
238
- "",
239
- ].join("\n");
240
-
241
- test("collectMachine emits the INVENTORY the Fleet row reads: device, ram, cores", async () => {
242
- const m = await collectMachine({
243
- os: fakeOs({ totalmem: 24 * 1024 ** 3, freemem: 2e9, cpus: 12 }),
244
- execFile: fakeExecFile({ "/usr/sbin/system_profiler": SP_HARDWARE }),
245
- powermetrics: false,
246
- disk: false,
247
- spend: false,
248
- });
249
- assert.equal(m.device, "Mac mini M4 Pro");
250
- assert.equal(m.ram, "24 GB");
251
- assert.equal(m.cores, 12);
252
- });
253
-
254
- test("collectMachine falls back to node:os inventory when system_profiler yields nothing", async () => {
255
- const m = await collectMachine({
256
- os: {
257
- loadavg: () => [1, 1, 1],
258
- totalmem: () => 24 * 1024 ** 3,
259
- freemem: () => 12 * 1024 ** 3,
260
- cpus: () => new Array(12).fill({ model: "Apple M4 Pro" }),
261
- platform: () => "darwin",
262
- uptime: () => 0,
263
- },
264
- execFile: fakeExecFile({}), // system_profiler returns ""
265
- powermetrics: false,
266
- disk: false,
267
- spend: false,
268
- });
269
- // Never nothing: node:os alone still proves the chip, the RAM and the cores.
270
- assert.equal(m.device, "Apple M4 Pro");
271
- assert.equal(m.ram, "24 GB");
272
- assert.equal(m.cores, 12);
273
- });
274
-
275
- test("collectMachine emits uptimeDays", async () => {
276
- const osImpl = fakeOs({});
277
- osImpl.uptime = () => 5756923; // ~66.6 days
278
- const m = await collectMachine({
279
- os: osImpl, powermetrics: false, disk: false, inventory: false, spend: false,
280
- });
281
- assert.equal(m.uptimeDays, 66.6);
282
- });
283
-
284
- test("ramLabel renders binary GB the way the spec sheet does", () => {
285
- assert.equal(ramLabel(24 * 1024 ** 3), "24 GB");
286
- assert.equal(ramLabel(192 * 1024 ** 3), "192 GB");
287
- assert.equal(ramLabel(8 * 1024 ** 3), "8 GB");
288
- assert.equal(ramLabel(0), "");
289
- assert.equal(ramLabel("nonsense"), "");
290
- });
291
-
292
- test("parseSystemProfilerHardware is a tolerant pure parser", () => {
293
- assert.deepEqual(parseSystemProfilerHardware(SP_HARDWARE), {
294
- device: "Mac mini M4 Pro",
295
- ram: "24 GB",
296
- cores: 12,
297
- });
298
- // Partial output degrades field-by-field rather than throwing.
299
- assert.deepEqual(parseSystemProfilerHardware(" Memory: 16 GB\n"), { ram: "16 GB" });
300
- assert.deepEqual(parseSystemProfilerHardware(""), {});
301
- assert.deepEqual(parseSystemProfilerHardware(null), {});
302
- });
303
-
304
- // ---------------------------------------------------------------------------
305
- // tempC — the sampler bug, and the rule that a failure is never silent
306
- // ---------------------------------------------------------------------------
307
-
308
- test("powermetricsSamplers uses the arm64 spelling — 'smc' is Intel-only", () => {
309
- // The shipped constant was "smc,cpu_power" unconditionally. On Apple Silicon
310
- // powermetrics rejects that argument list outright and exits non-zero BEFORE
311
- // the root check, so the probe was unreachable on every machine in the fleet.
312
- assert.equal(powermetricsSamplers("arm64"), "thermal,cpu_power");
313
- assert.equal(powermetricsSamplers("x64"), "smc,cpu_power");
314
- });
315
-
316
- test("collectPowermetrics passes the arch-correct sampler to the binary", async () => {
317
- const seen = [];
318
- const spy = (cmd, args, optsOrCb, maybeCb) => {
319
- const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
320
- seen.push({ cmd, args });
321
- queueMicrotask(() => cb(null, "CPU die temperature: 54.2 C\n", ""));
322
- return { on() {} };
323
- };
324
- const pm = await collectPowermetrics({ execFile: spy, platform: "darwin", arch: "arm64" });
325
- assert.equal(pm.tempC, 54.2);
326
- assert.equal(pm.source, "powermetrics");
327
- const samplerArg = seen[0].args[seen[0].args.indexOf("--samplers") + 1];
328
- assert.equal(samplerArg, "thermal,cpu_power");
329
- assert.ok(!samplerArg.includes("smc"), "must not send the Intel-only sampler to arm64");
330
- });
331
-
332
- test("collectPowermetrics ALWAYS says why it has no answer (fail-open, never silent)", async () => {
333
- // The unprivileged reality on every seat: the probe yields nothing.
334
- const pm = await collectPowermetrics({
335
- execFile: fakeExecFile({}), platform: "darwin", arch: "arm64", sudoPowermetrics: false,
336
- });
337
- assert.equal(pm.tempC, undefined);
338
- assert.match(pm.detail, /requires root/);
339
- // It no longer tells the operator to edit sudoers to get a temperature. That
340
- // advice was futile: powermetrics has no die temperature on Apple Silicon at
341
- // ANY privilege level, so following it to the letter still yielded nothing.
342
- assert.ok(!/TELEMETRY_SUDO_POWERMETRICS/.test(pm.detail));
343
- assert.ok(!/sudoers/.test(pm.detail));
344
-
345
- // A non-darwin host is a DIFFERENT absence and says so.
346
- const linux = await collectPowermetrics({ platform: "linux" });
347
- assert.match(linux.detail, /platform linux/);
348
- });
349
-
350
- test("collectMachine surfaces a thermal failure as a NOTE, never as the value", async () => {
351
- const m = await collectMachine({
352
- os: fakeOs({}),
353
- execFile: fakeExecFile({}),
354
- platform: "darwin",
355
- arch: "arm64",
356
- sudoPowermetrics: false,
357
- disk: false,
358
- inventory: false,
359
- spend: false,
360
- });
361
- assert.equal(m.tempC, undefined);
362
- assert.equal(m.tempSource, undefined);
363
- assert.equal(m.thermalState, undefined);
364
- assert.equal(typeof m.tempDetail, "string");
365
- // The note must not blame the operator's sudoers file for a limitation of the
366
- // hardware — that is the message this whole rewrite exists to delete.
367
- assert.ok(!/sudoers/.test(m.tempDetail));
368
- // And the cpuPct it DOES have is labelled as the estimate it is.
369
- assert.equal(m.cpuSource, "loadavg");
370
- });
371
-
372
- test("collectPowermetrics tries the opt-in sudo path only when enabled", async () => {
373
- const seen = [];
374
- const spy = (cmd, args, optsOrCb, maybeCb) => {
375
- const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
376
- seen.push(cmd);
377
- // Only the sudo invocation yields.
378
- const out = cmd === "/usr/bin/sudo" ? "CPU die temperature: 61.0 C\n" : "";
379
- queueMicrotask(() => cb(null, out, ""));
380
- return { on() {} };
381
- };
382
- const off = await collectPowermetrics({
383
- execFile: spy, platform: "darwin", arch: "arm64", sudoPowermetrics: false,
384
- });
385
- assert.equal(off.tempC, undefined, "must not shell out to sudo unless asked");
386
- assert.ok(!seen.includes("/usr/bin/sudo"));
387
-
388
- seen.length = 0;
389
- const on = await collectPowermetrics({
390
- execFile: spy, platform: "darwin", arch: "arm64", sudoPowermetrics: true,
391
- });
392
- assert.equal(on.tempC, 61);
393
- assert.equal(on.source, "sudo-powermetrics");
394
- assert.ok(seen.includes("/usr/bin/sudo"));
395
- // `-n` so a machine with no NOPASSWD rule fails instantly instead of hanging
396
- // the beat on a password prompt nobody will ever see.
397
- const sudoCall = seen.indexOf("/usr/bin/sudo");
398
- assert.ok(sudoCall >= 0);
399
- });
400
-
401
- // ---------------------------------------------------------------------------
402
- // Thermal — real °C without root, and a coarse state word that never fails.
403
- //
404
- // Every test here stubs the exec seam. NOTHING in this file forks `swift`,
405
- // `powermetrics` or `pmset`; the probe's real output is pinned as fixture text.
406
- // ---------------------------------------------------------------------------
407
-
408
- /** Verbatim stdout of `swift lib/telemetry/thermal-probe.swift` on a Mac16,11. */
409
- const PROBE_OK = "thermalState=nominal\ntempC=60.76\ntempSensors=42\n";
410
-
411
- /** Verbatim `sudo powermetrics --samplers thermal` — note: NO temperature line. */
412
- const PM_THERMAL = [
413
- "Machine model: Mac16,11",
414
- "",
415
- "**** Thermal pressure ****",
416
- "",
417
- "Current pressure level: Nominal",
418
- "",
419
- ].join("\n");
420
-
421
- /** Route stdout by binary, so a test can say which rung of the ladder answers. */
422
- const SWIFT = "/usr/bin/swift";
423
- const SUDO = "/usr/bin/sudo";
424
- const PMSET = "/usr/bin/pmset";
425
-
426
- test("collectThermal reads a REAL °C unprivileged, via the IOHID probe", async () => {
427
- // The headline fix. powermetrics cannot produce a die temperature on Apple
428
- // Silicon even as root; IOHIDEventSystem hands it over to any uid.
429
- const seen = [];
430
- const spy = (cmd, args, optsOrCb, maybeCb) => {
431
- const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
432
- seen.push(cmd);
433
- queueMicrotask(() => cb(null, cmd === SWIFT ? PROBE_OK : "", ""));
434
- return { on() {} };
435
- };
436
- const th = await collectThermal({ execFile: spy, platform: "darwin", arch: "arm64" });
437
- assert.equal(th.tempC, 60.76);
438
- assert.equal(th.tempSource, "iohid");
439
- assert.equal(th.thermalState, "nominal");
440
- assert.equal(th.thermalSource, "processinfo");
441
- // No root, no sudo, on the happy path.
442
- assert.ok(!seen.includes(SUDO), "the °C path must never need sudo");
443
- // The detail is an EXPLANATION riding alongside a real reading, not a
444
- // substitute for one — the drawer prints the number and demotes this.
445
- assert.equal(typeof th.detail, "string");
446
- assert.match(th.detail, /IOHIDEventSystem/);
447
- });
448
-
449
- test("collectThermal falls back to the coarse STATE when there is no °C", async () => {
450
- // The probe ran but the sensors gave nothing: state alone is still a useful,
451
- // honest answer, and the field semantics say so rather than faking a number.
452
- const spy = (cmd, args, optsOrCb, maybeCb) => {
453
- const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
454
- queueMicrotask(() => cb(null, cmd === SWIFT ? "thermalState=serious\n" : "", ""));
455
- return { on() {} };
456
- };
457
- const th = await collectThermal({ execFile: spy, platform: "darwin", arch: "arm64" });
458
- assert.equal(th.tempC, undefined);
459
- assert.equal(th.thermalState, "serious");
460
- assert.equal(th.thermalSource, "processinfo");
461
- assert.match(th.detail, /no numeric °C/);
462
- });
463
-
464
- test("collectThermal uses sudo powermetrics for PRESSURE only, and only when opted in", async () => {
465
- const seen = [];
466
- const spy = (cmd, args, optsOrCb, maybeCb) => {
467
- const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
468
- seen.push(cmd);
469
- // swift is absent on this host; sudo powermetrics answers.
470
- queueMicrotask(() => cb(null, cmd === SUDO ? PM_THERMAL : "", ""));
471
- return { on() {} };
472
- };
473
- const off = await collectThermal({
474
- execFile: spy, platform: "darwin", arch: "arm64", sudoPowermetrics: false,
475
- });
476
- assert.ok(!seen.includes(SUDO), "must not shell out to sudo unless asked");
477
- assert.equal(off.thermalState, undefined);
478
-
479
- seen.length = 0;
480
- const on = await collectThermal({
481
- execFile: spy, platform: "darwin", arch: "arm64", sudoPowermetrics: true,
482
- });
483
- assert.ok(seen.includes(SUDO));
484
- assert.equal(on.thermalState, "nominal");
485
- assert.equal(on.thermalSource, "sudo-powermetrics");
486
- // Root buys a pressure LEVEL and nothing more. If this ever starts asserting a
487
- // tempC from powermetrics on arm64, the fixture is lying.
488
- assert.equal(on.tempC, undefined);
489
- assert.match(on.detail, /pressure/i);
490
- });
491
-
492
- test("collectThermal will NOT dress 'no thermal events on record' up as a live reading", async () => {
493
- // `pmset -g therm` on a healthy Mac reports history, not temperature. Calling
494
- // that "nominal" would be inventing a measurement nobody took.
495
- const spy = (cmd, args, optsOrCb, maybeCb) => {
496
- const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
497
- const out = cmd === PMSET ? "Note: No thermal warning level has been recorded\n" : "";
498
- queueMicrotask(() => cb(null, out, ""));
499
- return { on() {} };
500
- };
501
- const th = await collectThermal({ execFile: spy, platform: "darwin", arch: "arm64" });
502
- assert.equal(th.thermalState, undefined);
503
- assert.equal(th.tempC, undefined);
504
- assert.match(th.detail, /xcode-select --install/);
505
-
506
- // But a REAL throttle event — a speed limit below 100 — is a live signal.
507
- const throttling = (cmd, args, optsOrCb, maybeCb) => {
508
- const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
509
- const out = cmd === PMSET ? "CPU_Speed_Limit \t= 60\n" : "";
510
- queueMicrotask(() => cb(null, out, ""));
511
- return { on() {} };
512
- };
513
- const hot = await collectThermal({ execFile: throttling, platform: "darwin", arch: "arm64" });
514
- assert.equal(hot.thermalState, "serious");
515
- assert.equal(hot.thermalSource, "pmset");
516
- });
517
-
518
- test("parseThermalProbe rejects the sensor that lies (-9201.14 °C)", () => {
519
- // `PMU tdev2` returns garbage on real hardware. The probe filters it, and the
520
- // collector band-checks AGAIN — trusting a subprocess's arithmetic is how an
521
- // absurd number reaches a dashboard.
522
- assert.deepEqual(parseThermalProbe("thermalState=nominal\ntempC=-9201.14\n"), {
523
- thermalState: "nominal",
524
- });
525
- assert.equal(parseThermalProbe("tempC=900\n").tempC, undefined);
526
- assert.equal(parseThermalProbe("tempC=61.2\n").tempC, 61.2);
527
- // Unknown keys are ignored so the probe can grow a field without breaking us.
528
- assert.equal(parseThermalProbe("tempC=61.2\nfuture=x\n").tempC, 61.2);
529
- assert.deepEqual(parseThermalProbe(""), {});
530
- assert.deepEqual(parseThermalProbe(null), {});
531
- // Only the four real levels are accepted.
532
- assert.equal(parseThermalProbe("thermalState=toasty\n").thermalState, undefined);
533
- });
534
-
535
- test("parseThermalPressure reads the only thermal fact powermetrics has on arm64", () => {
536
- assert.equal(parseThermalPressure(PM_THERMAL), "nominal");
537
- assert.equal(parseThermalPressure("Current pressure level: Critical"), "critical");
538
- assert.equal(parseThermalPressure("no such block"), undefined);
539
- assert.equal(parseThermalPressure(""), undefined);
540
- });
541
-
542
- test("parsePowermetrics computes CPU% from the PER-CLUSTER arm64 shape", () => {
543
- // The old regex wanted a bare `CPU idle residency:` line. Apple Silicon never
544
- // prints one — only per-core and per-cluster lines — so on every seat in the
545
- // fleet this silently fell through and then blamed the operator's sudoers.
546
- const arm = [
547
- "**** Processor usage ****",
548
- "E-Cluster idle residency: 78.34%",
549
- "CPU 0 idle residency: 90.00%",
550
- "CPU 1 idle residency: 80.00%",
551
- "CPU 2 idle residency: 70.00%",
552
- "CPU 3 idle residency: 60.00%",
553
- ].join("\n");
554
- const parsed = parsePowermetrics(arm);
555
- assert.equal(parsed.cpuPct, 25); // 100 - mean(90,80,70,60)
556
- // The Intel/total shape still wins outright where it exists.
557
- assert.equal(parsePowermetrics("CPU Average idle residency: 78.0 %").cpuPct, 22);
558
- });
559
-
560
- test("collectMachine ships tempC + thermalState + the note TOGETHER", async () => {
561
- // The old collector made the reading and the explanation MUTUALLY EXCLUSIVE
562
- // (`else if`), which left hq with nothing to put in the value slot — so it
563
- // printed a 118-character sudoers sentence in a column of "62°C" and "41%".
564
- const spy = (cmd, args, optsOrCb, maybeCb) => {
565
- const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
566
- queueMicrotask(() => cb(null, cmd === SWIFT ? PROBE_OK : "", ""));
567
- return { on() {} };
568
- };
569
- const m = await collectMachine({
570
- os: fakeOs({}),
571
- execFile: spy,
572
- platform: "darwin",
573
- arch: "arm64",
574
- sudoPowermetrics: false,
575
- disk: false,
576
- inventory: false,
577
- spend: false,
578
- });
579
- assert.equal(m.tempC, 60.8); // round1
580
- assert.equal(m.tempSource, "iohid");
581
- assert.equal(m.thermalState, "nominal");
582
- assert.equal(m.thermalSource, "processinfo");
583
- assert.equal(typeof m.tempDetail, "string");
584
- });
585
-
586
- // ---------------------------------------------------------------------------
587
- // spend24h
588
- // ---------------------------------------------------------------------------
589
-
590
- test("collectSpend24h sums BILLABLE rows in a true rolling 24h window", () => {
591
- const root = mkdtempSync(join(tmpdir(), "spend-"));
592
- try {
593
- const dir = join(root, "state", "cost-tracking");
594
- mkdirSync(dir, { recursive: true });
595
- const now = Date.parse("2026-08-13T06:00:00Z");
596
- const row = (ts, cost) => JSON.stringify({
597
- ts, model: "claude-opus-4-5", input_tokens: 10, output_tokens: 5, total_cost_usd: cost,
598
- });
599
- // Yesterday's file: one row inside the window, one outside it.
600
- writeFileSync(join(dir, "2026-08-12.jsonl"), [
601
- row("2026-08-12T05:00:00Z", 1.5), // 25h ago — OUTSIDE
602
- row("2026-08-12T07:00:00Z", 2.25), // 23h ago — inside
603
- ].join("\n") + "\n");
604
- writeFileSync(join(dir, "2026-08-13.jsonl"), [
605
- row("2026-08-13T01:00:00Z", 0.75),
606
- // A non-LLM attribution row: $0 is a FACT (no model ran), not an absence.
607
- JSON.stringify({ ts: "2026-08-13T02:00:00Z", model: null, task_class: "messaging.send", total_cost_usd: null, estimated_usd: 0 }),
608
- ].join("\n") + "\n");
609
-
610
- assert.equal(collectSpend24h({ agentRoot: root, now }), 3);
611
- } finally {
612
- rmSync(root, { recursive: true, force: true });
613
- }
614
- });
615
-
616
- test("collectSpend24h: no ledger is UNDEFINED, not a confident zero", () => {
617
- assert.equal(collectSpend24h({ agentRoot: "/nonexistent-root-xyz" }), undefined);
618
- assert.equal(collectSpend24h({}), undefined);
619
- });
620
-
621
- test("collectSpend24h does not count an UNMEASURED row as free", () => {
622
- const root = mkdtempSync(join(tmpdir(), "spend2-"));
623
- try {
624
- const dir = join(root, "state", "cost-tracking");
625
- mkdirSync(dir, { recursive: true });
626
- const now = Date.parse("2026-08-13T06:00:00Z");
627
- writeFileSync(join(dir, "2026-08-13.jsonl"), [
628
- // measurement:"unknown" → billableUsd().usd === null. Skipped, not zeroed.
629
- JSON.stringify({ ts: "2026-08-13T01:00:00Z", model: "claude-opus-4-5", measurement: "unknown", input_tokens: null, output_tokens: null, total_cost_usd: null }),
630
- JSON.stringify({ ts: "2026-08-13T02:00:00Z", model: "claude-opus-4-5", input_tokens: 1, output_tokens: 1, total_cost_usd: 4.5 }),
631
- "{ not json",
632
- ].join("\n") + "\n");
633
- assert.equal(collectSpend24h({ agentRoot: root, now }), 4.5);
634
- } finally {
635
- rmSync(root, { recursive: true, force: true });
636
- }
637
- });
638
-
639
- test("collectStatus ships the FULL machine vocabulary the Fleet row reads", async () => {
640
- const root = mkdtempSync(join(tmpdir(), "vocab-"));
641
- try {
642
- const osImpl = fakeOs({ totalmem: 24 * 1024 ** 3, freemem: 1e9, loadavg: [1.26, 1.2, 1.08], cpus: 12 });
643
- osImpl.uptime = () => 5756923;
644
- const status = await collectStatus({
645
- agentRoot: root,
646
- now: Date.parse("2026-08-13T06:00:00Z"),
647
- os: osImpl,
648
- arch: "arm64",
649
- sudoPowermetrics: false,
650
- execFile: fakeExecFile({
651
- "/usr/sbin/system_profiler": SP_HARDWARE,
652
- "/bin/df": "Filesystem 1024-blocks Used Available Capacity Mounted\n/dev/disk1 100 47 53 47% /\n",
653
- }),
654
- subAgentsRunning: 0,
655
- });
656
- const m = status.machine;
657
- // The five keys hq reads that this collector used to emit NONE of.
658
- assert.equal(m.device, "Mac mini M4 Pro");
659
- assert.equal(m.ram, "24 GB");
660
- assert.equal(m.uptimeDays, 66.6);
661
- assert.equal(typeof m.tempDetail, "string"); // tempC needs root; the REASON ships
662
- // The four it emitted that hq used to read NONE of.
663
- assert.deepEqual(m.loadavg, [1.26, 1.2, 1.08]);
664
- assert.ok(m.memUsedPct > 90);
665
- assert.equal(typeof m.cpuPct, "number");
666
- assert.equal(m.diskUsedPct, 47);
667
- assert.equal(m.cpuSource, "loadavg");
668
- } finally {
669
- rmSync(root, { recursive: true, force: true });
670
- }
671
- });
672
-
673
- // ---------------------------------------------------------------------------
674
- // memUsedPct — pressure, not occupancy
675
- //
676
- // This was `1 - os.freemem()/os.totalmem()`, which is not a pressure measure on
677
- // macOS: os.freemem() counts only entirely-free pages, and macOS keeps nearly
678
- // all RAM occupied with file cache, purgeable and compressed pages that are
679
- // reclaimable on demand. Measured on a healthy idle Mac mini M4 Pro at one
680
- // instant: os.freemem() → 99.2% "used", memory_pressure → 68% free. The seat
681
- // showed a standing CRITICAL while the watchdog, reading memory_pressure,
682
- // correctly did nothing.
683
- // ---------------------------------------------------------------------------
684
-
685
- test("memUsedPct comes from memory_pressure, not from free/total", async () => {
686
- const seen = [];
687
- const execFile = (cmd, args, opts, cb) => {
688
- seen.push(cmd);
689
- if (cmd === "/usr/bin/memory_pressure") {
690
- cb(null, "System-wide memory free percentage: 68%\n");
691
- } else {
692
- cb(new Error("not stubbed"));
693
- }
694
- return { on() {} };
695
- };
696
- // An os whose free/total ratio would say 99% used — the old, wrong answer.
697
- const osImpl = {
698
- loadavg: () => [1, 1, 1],
699
- totalmem: () => 24 * 1024 ** 3,
700
- freemem: () => 0.2 * 1024 ** 3,
701
- cpus: () => [],
702
- uptime: () => 3600,
703
- };
704
-
705
- const m = await collectMachine({ execFile, os: osImpl, platform: "darwin" });
706
-
707
- assert.equal(m.memUsedPct, 32, "100 - 68 free, i.e. what the watchdog thresholds on");
708
- assert.notEqual(m.memUsedPct, 99, "the free/total ratio must not be the answer on darwin");
709
- assert.ok(seen.includes("/usr/bin/memory_pressure"), "memory_pressure is consulted");
710
- });
711
-
712
- test("memUsedPct falls back to free/total when memory_pressure yields nothing", async () => {
713
- // Linux, or a macOS where the binary is missing/erroring. There the ratio
714
- // does mean what it says, so a fallback is right — silence is not.
715
- const execFile = (cmd, args, opts, cb) => { cb(new Error("ENOENT")); return { on() {} }; };
716
- const osImpl = {
717
- loadavg: () => [0, 0, 0],
718
- totalmem: () => 1000,
719
- freemem: () => 250,
720
- cpus: () => [],
721
- uptime: () => 1,
722
- };
723
-
724
- const m = await collectMachine({ execFile, os: osImpl, platform: "darwin" });
725
- assert.equal(m.memUsedPct, 75, "1 - 250/1000");
726
- });
727
-
728
- test("memUsedPct ignores an out-of-range memory_pressure reading", async () => {
729
- const execFile = (cmd, args, opts, cb) => {
730
- cb(null, "System-wide memory free percentage: 8000%\n");
731
- return { on() {} };
732
- };
733
- const osImpl = {
734
- loadavg: () => [0, 0, 0],
735
- totalmem: () => 1000,
736
- freemem: () => 400,
737
- cpus: () => [],
738
- uptime: () => 1,
739
- };
740
-
741
- const m = await collectMachine({ execFile, os: osImpl, platform: "darwin" });
742
- assert.equal(m.memUsedPct, 60, "a nonsense percentage falls back rather than propagating");
743
- });
744
-
745
- // ---------------------------------------------------------------------------
746
- // Fleet identity + front door (§3.8): hostname / tailnetIp / sdkVersion ride
747
- // the inventory; machine.frontDoor + machine.sessionLive come off the main
748
- // session's heartbeat file. This is how a fleet-wide upgrade is verified.
749
- //
750
- // WHY `machine` and not `session`: hq's presence.beat validator is
751
- // `session: z.object({id,taskClass,model}).strict()` — an unknown key there
752
- // rejects the WHOLE beat (BAD_REQUEST before any write) and the seat drops out
753
- // of the mesh. `machine` is `z.record(z.unknown())` by design, so a new field
754
- // lands the moment the daemon emits it. `session` stays `null` when no work
755
- // session runs — consumers read null as "no work session", and that contract
756
- // is pinned here.
757
- // ---------------------------------------------------------------------------
758
-
759
- test("collectInventory emits hostname, tailnetIp (tailscale ip -4) and sdkVersion", async () => {
760
- const osImpl = fakeOs({ cpus: 12, totalmem: 24 * 1024 ** 3 });
761
- osImpl.hostname = () => "ethans-mac-mini.local";
762
- const inv = await collectInventory({
763
- os: osImpl,
764
- execFile: fakeExecFile({ "/usr/sbin/system_profiler": SP_HARDWARE, tailscale: "100.95.116.68\n" }),
765
- sdkVersion: "2.12.0",
766
- });
767
- assert.equal(inv.hostname, "ethans-mac-mini.local");
768
- assert.equal(inv.tailnetIp, "100.95.116.68");
769
- assert.equal(inv.sdkVersion, "2.12.0");
770
- assert.equal(inv.device, "Mac mini M4 Pro");
771
- });
772
-
773
- test("collectInventory falls back to the Tailscale.app binary and is fail-open without any", async () => {
774
- const osImpl = fakeOs({});
775
- osImpl.hostname = () => "h";
776
- const appBin = "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
777
- const viaApp = await collectInventory({
778
- os: osImpl, sdkVersion: "x",
779
- execFile: fakeExecFile({ [appBin]: "100.68.98.127\nfd7a::1\n" }),
780
- inventoryCache: false,
781
- });
782
- assert.equal(viaApp.tailnetIp, "100.68.98.127");
783
- const none = await collectInventory({ os: osImpl, sdkVersion: "x", execFile: fakeExecFile({}), inventoryCache: false });
784
- assert.equal(none.tailnetIp, undefined, "no tailscale → the field drops out, nothing throws");
785
- assert.equal(none.hostname, "h");
786
- });
787
-
788
- test("sdkVersion is read from the installed @cohortapp/agent-sdk package.json, not from the cache", async () => {
789
- const root = mkdtempSync(join(tmpdir(), "inv-sdk-"));
790
- try {
791
- const osImpl = fakeOs({});
792
- osImpl.hostname = () => "h";
793
- const first = await collectInventory({ agentRoot: root, os: osImpl, execFile: fakeExecFile({}), sdkVersion: "2.11.17" });
794
- assert.equal(first.sdkVersion, "2.11.17");
795
- // Same chip/RAM key → the device inventory is served from the cache, but the
796
- // version (which an autoupdate changes without touching the hardware) and
797
- // the tailnet IP are re-read every time.
798
- const second = await collectInventory({ agentRoot: root, os: osImpl, execFile: fakeExecFile({ tailscale: "100.1.1.1\n" }), sdkVersion: "2.12.0" });
799
- assert.equal(second.sdkVersion, "2.12.0");
800
- assert.equal(second.tailnetIp, "100.1.1.1");
801
- assert.equal(second.hostname, "h");
802
- // The real resolver: this checkout IS the SDK, so it reports its own version.
803
- const real = _internals.readSdkVersion();
804
- assert.match(real, /^\d+\.\d+\.\d+/);
805
- } finally { rmSync(root, { recursive: true, force: true }); }
806
- });
807
-
808
- test("sessionLiveness: a heartbeat younger than 90 s is live; older, missing or malformed is not", () => {
809
- const { sessionLiveness } = _internals;
810
- const now = Date.parse("2026-09-08T12:00:00Z");
811
- assert.deepEqual(sessionLiveness({ ts: "2026-09-08T11:59:30Z", pid: 1, sessionId: "s" }, now), { frontDoor: "session", sessionLive: true });
812
- assert.deepEqual(sessionLiveness({ ts: "2026-09-08T11:58:29Z" }, now), { frontDoor: "session", sessionLive: false });
813
- assert.deepEqual(sessionLiveness({ ts: now - 89_000 }, now), { frontDoor: "session", sessionLive: true });
814
- assert.deepEqual(sessionLiveness(null, now), { frontDoor: "daemon", sessionLive: false });
815
- assert.deepEqual(sessionLiveness({ ts: "not a date" }, now), { frontDoor: "session", sessionLive: false });
816
- assert.deepEqual(sessionLiveness({ ts: "2026-09-08T11:59:30Z" }, now, { staleMs: 10_000 }), { frontDoor: "session", sessionLive: false });
817
- });
818
-
819
- test("collectStatus: machine carries frontDoor + sessionLive from state/session/heartbeat.json; session stays null when idle", async () => {
820
- const root = mkdtempSync(join(tmpdir(), "front-door-"));
821
- try {
822
- const now = Date.parse("2026-09-08T12:00:00Z");
823
- const osImpl = fakeOs({});
824
- osImpl.hostname = () => "seat.local";
825
- const base = { agentRoot: root, now, os: osImpl, execFile: fakeExecFile({}), subAgentsRunning: 0, powermetrics: false, disk: false, spend: false };
826
- // No heartbeat file: the daemon is the front door. No work session → null.
827
- const noHb = await collectStatus(base);
828
- assert.equal(noHb.machine.frontDoor, "daemon");
829
- assert.equal(noHb.machine.sessionLive, false);
830
- assert.equal(noHb.session, null, "an idle seat beats session: null (hq's strict session schema; activity.mjs reads null as 'no work session')");
831
- // Fresh heartbeat → live main session.
832
- mkdirSync(join(root, "state", "session"), { recursive: true });
833
- writeFileSync(join(root, "state", "session", "heartbeat.json"), JSON.stringify({ pid: 4242, sessionId: "abc", name: "ethan-main", ts: "2026-09-08T11:59:50Z" }));
834
- const live = await collectStatus(base);
835
- assert.equal(live.machine.frontDoor, "session");
836
- assert.equal(live.machine.sessionLive, true);
837
- assert.equal(live.session, null);
838
- // Stale (> 90 s) → not live, but the front door is still the session job.
839
- writeFileSync(join(root, "state", "session", "heartbeat.json"), JSON.stringify({ pid: 4242, ts: "2026-09-08T11:50:00Z" }));
840
- const stale = await collectStatus(base);
841
- assert.equal(stale.machine.frontDoor, "session");
842
- assert.equal(stale.machine.sessionLive, false);
843
- // A running work session keeps EXACTLY the keys hq's strict validator knows.
844
- const busy = await collectStatus({ ...base, sessions: [{ sessionId: "s1", kind: "interactive", intent: "session: inbox", model: "sonnet", lastBeat: "2026-09-08T11:59:55Z" }] });
845
- assert.equal(busy.session.id, "s1");
846
- assert.equal(busy.session.taskClass, "inbox");
847
- const allowed = new Set(["id", "taskClass", "model"]);
848
- for (const k of Object.keys(busy.session)) assert.ok(allowed.has(k), `session.${k} would be rejected by hq's strict presence.beat schema`);
849
- assert.equal(busy.machine.frontDoor, "session");
850
- // Inventory identity rides machine.
851
- assert.equal(typeof live.machine.hostname, "string");
852
- assert.match(live.machine.sdkVersion, /^\d+\.\d+\.\d+/);
853
- } finally { rmSync(root, { recursive: true, force: true }); }
854
- });
855
-
856
- test("detectClaudeAuth: in subscription mode the shared probe decides; logs still win for relogin", () => {
857
- const root = mkdtempSync(join(tmpdir(), "auth-probe-"));
858
- try {
859
- writeFileSync(join(root, ".env"), "CLAUDE_CODE_OAUTH_TOKEN=t\nMAESTRO_PREFER_SUBSCRIPTION_AUTH=1\n");
860
- const calls = [];
861
- // An UNVERIFIED "ok" (the CLI merely reports a login — it says loggedIn for
862
- // any token value) must not flip the beat from unknown to ok: that is the
863
- // one state claudeAuth exists to catch. Only a verified ok is an ok.
864
- const probeClaude = (agentRoot) => { calls.push(agentRoot); return { ok: true, mode: "subscription", auth: "ok", verified: false, name: "x" }; };
865
- assert.equal(detectClaudeAuth({ agentRoot: root, now: NOW, probeClaude, env: {} }), "unknown", "reported-not-verified ok + no logs → unknown, as before the probe existed");
866
- assert.deepEqual(calls, [root]);
867
- assert.equal(detectClaudeAuth({ agentRoot: root, now: NOW, env: {}, probeClaude: () => ({ ok: true, auth: "ok", verified: true }) }), "ok");
868
- // The probe says relogin → relogin, even with no logs at all.
869
- assert.equal(detectClaudeAuth({ agentRoot: root, now: NOW, env: {}, probeClaude: () => ({ ok: false, auth: "relogin_required" }) }), "relogin_required");
870
- // Probe unknown → fall back to what the logs say (nothing here → unknown).
871
- assert.equal(detectClaudeAuth({ agentRoot: root, now: NOW, env: {}, probeClaude: () => ({ ok: true, auth: "unknown" }) }), "unknown");
872
- // Clean recent logs + an unverified ok → ok (the logs are the evidence, as before).
873
- mkdirSync(join(root, "logs", "sessions"), { recursive: true });
874
- const p = join(root, "logs", "sessions", "s.log");
875
- writeFileSync(p, "spawn ok, 3 turns, done");
876
- const t = new Date(NOW - 60_000);
877
- utimesSync(p, t, t);
878
- assert.equal(detectClaudeAuth({ agentRoot: root, now: NOW, probeClaude, env: {} }), "ok");
879
- // A recent relogin fingerprint in the logs wins over a probe that says ok.
880
- writeFileSync(p, "… OAuth token has expired …");
881
- utimesSync(p, t, t);
882
- assert.equal(detectClaudeAuth({ agentRoot: root, now: NOW, probeClaude, env: {} }), "relogin_required");
883
- // Not in subscription mode → the probe is never forked from the beat.
884
- writeFileSync(join(root, ".env"), "ANTHROPIC_API_KEY=sk\n");
885
- const before = calls.length;
886
- detectClaudeAuth({ agentRoot: root, now: NOW, probeClaude, env: {} });
887
- assert.equal(calls.length, before);
888
- // A token only in the daemon's ambient env (launchd) is subscription mode too
889
- // (clean logs first — a relogin fingerprint decides before any probe runs).
890
- writeFileSync(p, "spawn ok"); utimesSync(p, t, t);
891
- writeFileSync(join(root, ".env"), "");
892
- detectClaudeAuth({ agentRoot: root, now: NOW, probeClaude, env: { CLAUDE_CODE_OAUTH_TOKEN: "t" } });
893
- assert.equal(calls.length, before + 1);
894
- } finally { rmSync(root, { recursive: true, force: true }); }
895
- });
896
-
897
- // ---------------------------------------------------------------------------
898
- // Last upgrade outcome (WP-M6): machine.upgrade = {at, from, to, ok} from
899
- // state/autoupdate/last.json — fail-open, absent until a first attempt.
900
- // ---------------------------------------------------------------------------
901
-
902
- test("upgradeSummary: the four fleet-view fields from autoupdate's last.json; malformed → null", () => {
903
- const { upgradeSummary } = _internals;
904
- assert.deepEqual(
905
- upgradeSummary({ from: "2.11.17", to: "2.12.0", at: "2026-09-08T12:00:00Z", ok: true, healthy: true, reason: "" }),
906
- { at: "2026-09-08T12:00:00Z", from: "2.11.17", to: "2.12.0", ok: true },
907
- );
908
- assert.deepEqual(
909
- upgradeSummary({ from: "2.11.17", to: "2.12.0", at: "2026-09-08T12:00:00Z", ok: false, healthy: true, reason: "unhealthy-rolled-back" }),
910
- { at: "2026-09-08T12:00:00Z", from: "2.11.17", to: "2.12.0", ok: false },
911
- );
912
- assert.equal(upgradeSummary(null), null);
913
- assert.equal(upgradeSummary({ ok: true }), null, "no at/to → nothing to report");
914
- assert.equal(upgradeSummary("2.12.0"), null);
915
- });
916
-
917
- test("collectStatus: machine.upgrade rides the beat when state/autoupdate/last.json exists, and is absent (not null) when it does not or is corrupt", async () => {
918
- const root = mkdtempSync(join(tmpdir(), "tel-upgrade-"));
919
- try {
920
- const base = { agentRoot: root, os: fakeOs(), execFile: fakeExecFile({}), powermetrics: false, disk: false, spend: false, inventory: false, tailnet: false, sdkVersion: "2.12.0", withAlerts: false, now: Date.parse("2026-09-08T12:00:00Z") };
921
- const none = await collectStatus(base);
922
- assert.ok(!("upgrade" in none.machine), "absent until a first autoupdate attempt");
923
-
924
- mkdirSync(join(root, "state", "autoupdate"), { recursive: true });
925
- writeFileSync(join(root, "state", "autoupdate", "last.json"), JSON.stringify({ from: "2.11.17", to: "2.12.0", at: "2026-09-08T11:30:00Z", ok: true, healthy: true, reason: "" }));
926
- const some = await collectStatus(base);
927
- assert.deepEqual(some.machine.upgrade, { at: "2026-09-08T11:30:00Z", from: "2.11.17", to: "2.12.0", ok: true });
928
- assert.equal(some.session, null, "nothing new lands on the strictly-validated session object");
929
-
930
- writeFileSync(join(root, "state", "autoupdate", "last.json"), "{not json");
931
- const corrupt = await collectStatus(base);
932
- assert.ok(!("upgrade" in corrupt.machine));
933
-
934
- const injected = await collectStatus({ ...base, autoupdateLast: { from: "1", to: "2", at: "2026-09-08T00:00:00Z", ok: false } });
935
- assert.equal(injected.machine.upgrade.ok, false);
936
- } finally { rmSync(root, { recursive: true, force: true }); }
937
- });
938
-
939
- // ---------------------------------------------------------------------------
940
- // machine.sessionNote (WP-M8) — WHY the front door is not live
941
- // ---------------------------------------------------------------------------
942
-
943
- const M8_NOW = Date.parse("2026-09-11T12:00:00Z");
944
- /** The record scripts/session/supervisor.mjs writes via first-run#attentionRecord. */
945
- const attention = (over = {}) => ({
946
- reason: "no-heartbeat",
947
- since: "2026-09-11T11:57:00.000Z",
948
- runMs: 180_000,
949
- attach: "tmux attach -t =maestro-maya",
950
- hint: "The session has been up 180 s without a heartbeat — it is probably waiting on a first-run dialog or a permission prompt. Attach with `tmux attach -t =maestro-maya` and answer it.",
951
- ...over,
952
- });
953
-
954
- test("sessionNote: a live front door says nothing at all", () => {
955
- const { sessionNote } = _internals;
956
- assert.equal(sessionNote({ heartbeat: { ts: M8_NOW - 1_000 }, now: M8_NOW }), null);
957
- assert.equal(sessionNote({ heartbeat: { ts: M8_NOW - 89_000 }, jobInstalled: false, attention: attention(), now: M8_NOW }), null,
958
- "liveness wins over every other input — the note exists only to explain a door that is NOT open");
959
- // A shorter staleness bound makes the same beat stale, and then it does speak.
960
- assert.equal(sessionNote({ heartbeat: { ts: M8_NOW - 30_000 }, jobInstalled: true, now: M8_NOW, staleMs: 10_000 }).reason, "heartbeat-stale");
961
- });
962
-
963
- test("sessionNote: job-absent — no plist for this seat, and only ever from a hard false", () => {
964
- const { sessionNote } = _internals;
965
- const n = sessionNote({ jobInstalled: false, label: "ai.maestro.maya-session", now: M8_NOW });
966
- assert.equal(n.reason, "job-absent");
967
- assert.match(n.detail, /ai\.maestro\.maya-session/);
968
- assert.equal(n.since, undefined, "there is no moment to date — the job was never there");
969
- // It outranks a stale attention record left behind by a previous install.
970
- assert.equal(sessionNote({ jobInstalled: false, attention: attention(), heartbeat: { ts: M8_NOW - 600_000 }, now: M8_NOW }).reason, "job-absent");
971
- // UNKNOWN is not absent: a home we could not read must never accuse the seat.
972
- assert.equal(sessionNote({ jobInstalled: null, now: M8_NOW }).reason, "never-beaten");
973
- assert.equal(sessionNote({ now: M8_NOW }).reason, "never-beaten");
974
- // No label still yields a usable line.
975
- assert.match(sessionNote({ jobInstalled: false, now: M8_NOW }).detail, /main-session launchd job/);
976
- // …and it does not call a deliberate daemon-front-door seat broken: this seat
977
- // reports job-absent on EVERY beat forever, so the line states a configuration.
978
- assert.match(n.detail, /the daemon --print lane is the front door/);
979
- assert.match(n.detail, /by configuration, or the session job was never installed/);
980
- });
981
-
982
- test("sessionNote: awaiting-input passes the supervisor's own reason, since and hint through", () => {
983
- const { sessionNote } = _internals;
984
- const n = sessionNote({ jobInstalled: true, attention: attention(), now: M8_NOW });
985
- assert.equal(n.reason, "awaiting-input", "the beat's vocabulary — the record's own reason rides in detail");
986
- assert.equal(n.since, "2026-09-11T11:57:00.000Z", "attentionRecord.since, normalised to canonical ISO");
987
- assert.match(n.detail, /^up, never beaten: /, "the record's own reason survives, rendered out of the watchdog's vocabulary");
988
- assert.match(n.detail, /tmux attach -t =maestro-maya/, "the attach command is the actionable part");
989
- assert.ok(n.detail.length <= 200);
990
- // screen seats say screen.
991
- assert.match(sessionNote({ attention: attention({ attach: "screen -r maestro-isla", hint: "… Attach with `screen -r maestro-isla` and answer it." }), now: M8_NOW }).detail, /screen -r maestro-isla/);
992
- // It outranks a stale heartbeat: "waiting on a human" is the actionable fact.
993
- assert.equal(sessionNote({ jobInstalled: true, attention: attention(), heartbeat: { ts: M8_NOW - 600_000 }, now: M8_NOW }).reason, "awaiting-input");
994
- });
995
-
996
- test("sessionNote: a heartbeat LATER than the attention record supersedes it", () => {
997
- const { sessionNote } = _internals;
998
- // supervisor.mjs clears attention.json only at the START of the next launch —
999
- // never when the human answers the dialog — so the record outlives its truth.
1000
- // Answered at 11:57, beat until 3 days later, then the session died.
1001
- const beat = M8_NOW + 3 * 86_400_000;
1002
- const n = sessionNote({ jobInstalled: true, attention: attention(), heartbeat: { ts: beat }, now: beat + 86_400_000 });
1003
- assert.equal(n.reason, "heartbeat-stale", "a days-old attach command for a mux session that ended is worse than no note");
1004
- assert.equal(n.since, new Date(beat).toISOString());
1005
- // A record with no orderable `since` loses to a heartbeat that has one.
1006
- assert.equal(sessionNote({ jobInstalled: true, attention: attention({ since: "not a date" }), heartbeat: { ts: M8_NOW - 600_000 }, now: M8_NOW }).reason, "heartbeat-stale");
1007
- // But the record still wins while it is the LATER fact — the ordinary case:
1008
- // the session came up, never beat, and the watchdog flagged it.
1009
- assert.equal(sessionNote({ jobInstalled: true, attention: attention(), heartbeat: { ts: M8_NOW - 600_000 }, now: M8_NOW }).reason, "awaiting-input");
1010
- // …and with no heartbeat at all there is nothing to supersede it.
1011
- assert.equal(sessionNote({ jobInstalled: true, attention: attention(), heartbeat: { pid: 7 }, now: M8_NOW }).reason, "awaiting-input");
1012
- });
1013
-
1014
- test("sessionNote: `since` is re-formatted from a parsed instant, never passed through", () => {
1015
- const { sessionNote } = _internals;
1016
- // V8's legacy date parser accepts trailing prose, so a bare Date.parse check
1017
- // let a home path and a token-shaped run ride the one field the detail
1018
- // sanitiser does not cover. `since` is canonical ISO or it is absent.
1019
- const evil = "Thu, 01 Jan 2026 00:00:00 GMT (token sk-ant-supersecretvalue1234567890 /Users/maya/x)";
1020
- assert.ok(Number.isFinite(Date.parse(evil)), "the hostile string really does parse — that is the hazard");
1021
- const n = sessionNote({ jobInstalled: true, attention: attention({ since: evil }), now: M8_NOW });
1022
- assert.equal(n.since, "2026-01-01T00:00:00.000Z");
1023
- assert.ok(!/Users|sk-ant/.test(n.since));
1024
- // Non-ISO spellings are normalised rather than echoed.
1025
- assert.equal(sessionNote({ jobInstalled: true, attention: attention({ since: "2026-09-11T11:57:00+00:00" }), now: M8_NOW }).since, "2026-09-11T11:57:00.000Z");
1026
- });
1027
-
1028
- test("sessionNote: the attention record's own vocabulary is rendered, not collided with", () => {
1029
- const { sessionNote } = _internals;
1030
- // first-run#heartbeatSilence's "no-heartbeat" means "up, never beaten" — the
1031
- // opposite of what a beat reason of nearly that spelling would mean. Rendering
1032
- // it keeps `{reason, detail}` from reading as a contradiction side by side.
1033
- assert.match(sessionNote({ jobInstalled: true, attention: attention(), now: M8_NOW }).detail, /^up, never beaten: /);
1034
- assert.match(sessionNote({ jobInstalled: true, attention: attention({ reason: "stale-heartbeat" }), now: M8_NOW }).detail, /^no beat since this launch: /);
1035
- // A word we do not know passes through verbatim rather than being invented.
1036
- assert.match(sessionNote({ jobInstalled: true, attention: attention({ reason: "wedged-mcp" }), now: M8_NOW }).detail, /^wedged-mcp: /);
1037
- // And no beat reason collides with a watchdog reason any more.
1038
- const beatReasons = new Set(["job-absent", "awaiting-input", "heartbeat-stale", "heartbeat-unreadable", "never-beaten"]);
1039
- for (const r of ["no-heartbeat", "stale-heartbeat", "within-grace", "beating"]) assert.ok(!beatReasons.has(r), `${r} is the watchdog's word, not the beat's`);
1040
- });
1041
-
1042
- test("sessionNote: heartbeat-stale carries the last beat and its age", () => {
1043
- const { sessionNote } = _internals;
1044
- const n = sessionNote({ jobInstalled: true, heartbeat: { ts: M8_NOW - 600_000, pid: 4242 }, now: M8_NOW });
1045
- assert.deepEqual(n, { reason: "heartbeat-stale", since: "2026-09-11T11:50:00.000Z", detail: "no beat for 600 s" });
1046
- // ISO timestamps are the shape the feed actually writes.
1047
- assert.equal(sessionNote({ jobInstalled: true, heartbeat: { ts: "2026-09-11T11:50:00Z" }, now: M8_NOW }).since, "2026-09-11T11:50:00.000Z");
1048
- // A beat from the future is clock skew, not a negative age.
1049
- assert.equal(sessionNote({ jobInstalled: true, heartbeat: { ts: M8_NOW + 600_000 }, now: M8_NOW }).detail, "last beat is in the future (clock skew)");
1050
- });
1051
-
1052
- test("sessionNote: every malformed input degrades to a coarser note or to null, and nothing throws", () => {
1053
- const { sessionNote } = _internals;
1054
- // No `now` at all → no note (a note with no clock behind it would be a guess).
1055
- assert.equal(sessionNote({ heartbeat: { ts: 1 } }), null);
1056
- assert.equal(sessionNote({ now: NaN, jobInstalled: false }), null);
1057
- assert.equal(sessionNote(), null);
1058
- assert.equal(sessionNote(null), null);
1059
- assert.equal(sessionNote("nonsense"), null);
1060
- // Absent files (the reader's null) → the installed-but-silent note.
1061
- assert.deepEqual(sessionNote({ heartbeat: null, attention: null, jobInstalled: true, now: M8_NOW }),
1062
- { reason: "never-beaten", detail: "the session job is installed but has never written a heartbeat" });
1063
- // …and when job presence was never established, the note says THAT rather
1064
- // than affirming an installation nobody checked.
1065
- assert.deepEqual(sessionNote({ jobInstalled: null, now: M8_NOW }),
1066
- { reason: "never-beaten", detail: "no heartbeat from the session job, and its presence on this seat could not be checked" });
1067
- assert.equal(sessionNote({ now: M8_NOW }).detail, "no heartbeat from the session job, and its presence on this seat could not be checked");
1068
- // A parsed-but-wrong SHAPE must not throw.
1069
- assert.equal(sessionNote({ heartbeat: "{}", attention: "{}", now: M8_NOW }).reason, "never-beaten");
1070
- assert.equal(sessionNote({ heartbeat: [], attention: [], jobInstalled: true, now: M8_NOW }).reason, "never-beaten", "arrays are not records");
1071
- // A heartbeat file that is THERE but says nothing usable is its own fault —
1072
- // the job may well have beaten, so it is never called "never beaten".
1073
- assert.deepEqual(sessionNote({ heartbeat: { pid: 7 }, jobInstalled: true, now: M8_NOW }),
1074
- { reason: "heartbeat-unreadable", detail: "the heartbeat file is present but carries no readable timestamp" });
1075
- assert.equal(sessionNote({ heartbeat: { ts: "not a date" }, jobInstalled: true, now: M8_NOW }).reason, "heartbeat-unreadable");
1076
- assert.deepEqual(sessionNote({ heartbeatUnreadable: true, jobInstalled: true, now: M8_NOW }),
1077
- { reason: "heartbeat-unreadable", detail: "the heartbeat file is present but could not be parsed" });
1078
- // An attention record missing every field is still the right REASON — it is
1079
- // the file's existence that says "the supervisor flagged this seat".
1080
- assert.deepEqual(sessionNote({ attention: {}, jobInstalled: true, now: M8_NOW }), { reason: "awaiting-input" });
1081
- assert.deepEqual(sessionNote({ attention: { since: 12345, reason: 7, hint: null }, jobInstalled: true, now: M8_NOW }), { reason: "awaiting-input" });
1082
- assert.equal(sessionNote({ attention: { since: "not a date", reason: "no-heartbeat" }, jobInstalled: true, now: M8_NOW }).since, undefined);
1083
- });
1084
-
1085
- test("sanitizeNoteDetail: no home paths, no ids, no control characters, never past the cap", () => {
1086
- const { sanitizeNoteDetail } = _internals;
1087
- assert.equal(sanitizeNoteDetail("attach with `tmux attach -t =maestro-maya`"), "attach with `tmux attach -t =maestro-maya`");
1088
- // Home directories name a person and leak the seat's layout.
1089
- assert.equal(sanitizeNoteDetail("blocked in /Users/maya/maya-ai/state"), "blocked in <path>");
1090
- // A home directory with a space in it is ordinary on macOS, and its tail
1091
- // carries the person's surname — the match may not stop at the whitespace.
1092
- assert.equal(sanitizeNoteDetail("blocked in /Users/olivia chen/maya-ai/state/session"), "blocked in <path>");
1093
- assert.equal(sanitizeNoteDetail("blocked in /Users/Olivia Chen/Library/Application Support/x"), "blocked in <path>");
1094
- // …but ordinary prose after a path is not swallowed with it.
1095
- assert.equal(sanitizeNoteDetail("blocked in /Users/maya and then retry"), "blocked in <path> and then retry");
1096
- assert.equal(sanitizeNoteDetail("attach to /Users/maya/x and answer it"), "attach to <path> and answer it");
1097
- assert.equal(sanitizeNoteDetail("blocked in ~/maya-ai and /home/maya/x and /private/var/folders/t/x"), "blocked in <path> and <path> and <path>");
1098
- // Session ids and token-shaped runs.
1099
- assert.equal(sanitizeNoteDetail("resume 3f2a1c94-7b0e-4a11-9c3d-0b7e2f8a6d51 failed"), "resume <redacted> failed");
1100
- assert.equal(sanitizeNoteDetail("token sk-ant-0123456789abcdef rejected"), "token <redacted> rejected");
1101
- // Newlines and control characters collapse; the note is one line.
1102
- assert.equal(sanitizeNoteDetail("a\nb\tc\r\nd"), "a b c d");
1103
- // The cap holds, with an ellipsis so a reader knows it was cut.
1104
- const long = sanitizeNoteDetail("lorem ipsum ".repeat(50));
1105
- assert.equal(long.length, 200);
1106
- assert.ok(long.endsWith("…"));
1107
- assert.ok(sanitizeNoteDetail("ab ".repeat(20), 10).length <= 10, "the cap is a ceiling, never exceeded (a word boundary can land under it)");
1108
- // Nothing to say → "", and the caller then omits the field entirely.
1109
- assert.equal(sanitizeNoteDetail(""), "");
1110
- assert.equal(sanitizeNoteDetail(null), "");
1111
- assert.equal(sanitizeNoteDetail(undefined), "");
1112
- assert.equal(sanitizeNoteDetail(42), "");
1113
- assert.equal(sanitizeNoteDetail(" \n "), "");
1114
- // A hint long enough to overflow is truncated, not dropped.
1115
- const hint = sanitizeNoteDetail(`no-heartbeat: The session has been up 1800 s without a heartbeat — it is probably waiting on a first-run dialog or a permission prompt. Attach with \`tmux attach -t =maestro-maya\` and answer it. ${"tail ".repeat(20)}`);
1116
- assert.ok(hint.length <= 200 && hint.endsWith("…"), "an over-long hint is truncated, not dropped");
1117
- });
1118
-
1119
- test("sessionJobInstalled: a readdir of ~/Library/LaunchAgents, and 'unknown' whenever it cannot be read", () => {
1120
- const { sessionJobInstalled, sessionJobLabel } = _internals;
1121
- const label = "ai.maestro.maya-session";
1122
- assert.equal(sessionJobInstalled({ launchAgents: ["ai.maestro.maya.plist", "ai.maestro.maya-session.plist"] }, label), true);
1123
- assert.equal(sessionJobInstalled({ launchAgents: ["ai.maestro.maya-session"] }, label), true, "bare labels count too");
1124
- assert.equal(sessionJobInstalled({ launchAgents: ["ai.maestro.maya.plist"] }, label), false);
1125
- assert.equal(sessionJobInstalled({ launchAgents: [] }, label), false);
1126
- // Unknowns: no label, an unreadable home, a home that is not there.
1127
- assert.equal(sessionJobInstalled({ launchAgents: ["ai.maestro.maya-session.plist"] }, ""), null);
1128
- assert.equal(sessionJobInstalled({ os: { homedir: () => "" } }, label), null);
1129
- assert.equal(sessionJobInstalled({ os: { homedir: () => { throw new Error("nope"); } } }, label), null);
1130
- assert.equal(sessionJobInstalled({ os: { homedir: () => join(tmpdir(), "no-such-home-xyz") } }, label), null);
1131
- // An explicit override wins (the daemon may already know).
1132
- assert.equal(sessionJobInstalled({ sessionJobInstalled: true, launchAgents: [] }, label), true);
1133
- // A case difference is not an absence: agentFirstName leaves its directory
1134
- // fallback as-is (`~/Maya-ai` -> `Maya`), and the volume is case-insensitive.
1135
- assert.equal(sessionJobInstalled({ launchAgents: ["ai.maestro.Maya-session.plist"] }, "ai.maestro.maya-session"), true);
1136
- assert.equal(sessionJobInstalled({ launchAgents: ["ai.maestro.maya-session.plist"] }, "ai.maestro.Maya-session"), true);
1137
- // The label matches what generate-plists.sh and `maestro session` spell —
1138
- // including its casing, which the generator does not lower-case either.
1139
- assert.equal(sessionJobLabel("/x/maya-ai", "maya"), "ai.maestro.maya-session");
1140
- assert.equal(sessionJobLabel("/x/Maya-ai", "Maya"), "ai.maestro.Maya-session");
1141
- assert.equal(sessionJobLabel("", ""), "");
1142
- });
1143
-
1144
- test("collectStatus: machine.sessionNote appears ONLY when the front door is down, and rides machine (never session)", async () => {
1145
- const root = mkdtempSync(join(tmpdir(), "session-note-"));
1146
- try {
1147
- const now = Date.parse("2026-09-11T12:00:00Z");
1148
- const osImpl = fakeOs({});
1149
- osImpl.hostname = () => "seat.local";
1150
- const base = {
1151
- agentRoot: root, now, os: osImpl, execFile: fakeExecFile({}), subAgentsRunning: 0,
1152
- powermetrics: false, disk: false, spend: false, agentFirst: "maya", launchAgents: [],
1153
- };
1154
- const sess = join(root, "state", "session");
1155
- mkdirSync(sess, { recursive: true });
1156
-
1157
- // 1. No job on the seat — the case A010/A016 could not tell apart.
1158
- const absent = await collectStatus(base);
1159
- assert.equal(absent.machine.sessionLive, false);
1160
- assert.equal(absent.machine.sessionNote.reason, "job-absent");
1161
- assert.match(absent.machine.sessionNote.detail, /ai\.maestro\.maya-session/);
1162
- assert.equal(absent.session, null, "the note NEVER touches session — hq validates that object strictly");
1163
- assert.ok(!("sessionNote" in (absent.session || {})));
1164
-
1165
- // 2. Job installed, session up, waiting on a first-run dialog.
1166
- const installed = { ...base, launchAgents: ["ai.maestro.maya-session.plist"] };
1167
- writeFileSync(join(sess, "attention.json"), JSON.stringify(attention()));
1168
- const waiting = await collectStatus(installed);
1169
- assert.equal(waiting.machine.sessionNote.reason, "awaiting-input");
1170
- assert.equal(waiting.machine.sessionNote.since, "2026-09-11T11:57:00.000Z");
1171
- assert.match(waiting.machine.sessionNote.detail, /tmux attach -t =maestro-maya/);
1172
-
1173
- // 3. The supervisor cleared attention.json and the beat then went stale.
1174
- rmSync(join(sess, "attention.json"));
1175
- writeFileSync(join(sess, "heartbeat.json"), JSON.stringify({ pid: 4242, ts: "2026-09-11T11:50:00Z" }));
1176
- const stale = await collectStatus(installed);
1177
- assert.equal(stale.machine.frontDoor, "session");
1178
- assert.deepEqual(stale.machine.sessionNote, { reason: "heartbeat-stale", since: "2026-09-11T11:50:00.000Z", detail: "no beat for 600 s" });
1179
-
1180
- // 4. A live front door carries NO note — the key is absent, not null.
1181
- writeFileSync(join(sess, "heartbeat.json"), JSON.stringify({ pid: 4242, ts: "2026-09-11T11:59:50Z" }));
1182
- const live = await collectStatus(installed);
1183
- assert.equal(live.machine.sessionLive, true);
1184
- assert.ok(!("sessionNote" in live.machine), "a healthy seat must not pay for a field it has nothing to say in");
1185
-
1186
- // 5. Corrupt state files never break the beat — and a heartbeat file that
1187
- // is THERE and will not parse is reported as such, not as "never beaten".
1188
- writeFileSync(join(sess, "heartbeat.json"), "{not json");
1189
- writeFileSync(join(sess, "attention.json"), "{not json");
1190
- const corrupt = await collectStatus(installed);
1191
- assert.equal(corrupt.machine.sessionLive, false);
1192
- assert.deepEqual(corrupt.machine.sessionNote,
1193
- { reason: "heartbeat-unreadable", detail: "the heartbeat file is present but could not be parsed" });
1194
- assert.equal(typeof corrupt.ts, "string", "the snapshot is intact");
1195
-
1196
- // 5b. No heartbeat file at all, job installed → never-beaten.
1197
- rmSync(join(sess, "heartbeat.json"));
1198
- rmSync(join(sess, "attention.json"));
1199
- const silent = await collectStatus(installed);
1200
- assert.deepEqual(silent.machine.sessionNote,
1201
- { reason: "never-beaten", detail: "the session job is installed but has never written a heartbeat" });
1202
-
1203
- // 6. The whole note is a best-effort extra: a throwing os.homedir drops the
1204
- // launchd read, never the beat — and an unknown job presence is reported
1205
- // as neither an absence nor a presence.
1206
- const hostile = await collectStatus({ ...base, launchAgents: undefined, os: { ...osImpl, homedir: () => { throw new Error("no home"); } } });
1207
- assert.equal(hostile.machine.sessionNote.reason, "never-beaten", "unknown job presence is not an absence");
1208
- assert.equal(hostile.machine.sessionNote.detail, "no heartbeat from the session job, and its presence on this seat could not be checked",
1209
- "…and it is not reported as a presence either");
1210
- assert.equal(hostile.machine.sessionLive, false);
1211
-
1212
- // 7. The emitted shape is exactly {reason, since?, detail?} — nothing else,
1213
- // and nothing unbounded.
1214
- for (const s of [absent, waiting, stale, corrupt, silent, hostile]) {
1215
- const note = s.machine.sessionNote;
1216
- assert.equal(typeof note.reason, "string");
1217
- for (const k of Object.keys(note)) assert.ok(["reason", "since", "detail"].includes(k), `sessionNote.${k} is not in the contract`);
1218
- if (note.since !== undefined) assert.ok(Number.isFinite(Date.parse(note.since)), "since is ISO 8601");
1219
- if (note.detail !== undefined) assert.ok(note.detail.length <= 200 && !/\/Users\//.test(note.detail));
1220
- }
1221
- } finally { rmSync(root, { recursive: true, force: true }); }
1222
- });
1223
-
1224
- /**
1225
- * The front-door signal must not flicker while the session is running.
1226
- *
1227
- * This function and `lib/session/liveness.mjs#heartbeatLiveness` answer one
1228
- * question — "is the main session alive" — and they used to disagree: the
1229
- * runtime accepted a heartbeat up to `staleMs` either side of now, this one
1230
- * demanded `age >= 0`. So a heartbeat written microseconds before the wall
1231
- * clock stepped back read as future-dated and flipped the seat to DOWN, while
1232
- * the session carried on. A039 did exactly that across consecutive beats on
1233
- * 2026-09-10/11 with its session healthy and its clock within 30 s of the
1234
- * server. Symmetric now, and pinned here because the asymmetry was invisible:
1235
- * every test used a past-dated heartbeat.
1236
- */
1237
- test("sessionLiveness tolerates clock skew symmetrically, and reports a far-future beat as not live", () => {
1238
- const now = Date.parse("2026-09-11T12:00:00Z");
1239
- const at = (ms) => ({ ts: new Date(now - ms).toISOString() });
1240
-
1241
- // Fresh, and a beat from the recent past: live, as before.
1242
- assert.equal(sessionLiveness(at(0), now).sessionLive, true);
1243
- assert.equal(sessionLiveness(at(30_000), now).sessionLive, true);
1244
-
1245
- // THE REGRESSION: a beat dated slightly in the FUTURE is the clock moving,
1246
- // not the session dying. Both of these used to return false.
1247
- assert.equal(sessionLiveness(at(-1), now).sessionLive, true, "1 ms ahead is not a dead session");
1248
- assert.equal(sessionLiveness(at(-30_000), now).sessionLive, true, "30 s ahead is ordinary clock motion");
1249
-
1250
- // The window is symmetric and OPEN at both edges — the runtime's rule.
1251
- assert.equal(sessionLiveness(at(SESSION_STALE_MS - 1), now).sessionLive, true);
1252
- assert.equal(sessionLiveness(at(-(SESSION_STALE_MS - 1)), now).sessionLive, true);
1253
- assert.equal(sessionLiveness(at(SESSION_STALE_MS), now).sessionLive, false, "too old");
1254
- assert.equal(sessionLiveness(at(-SESSION_STALE_MS), now).sessionLive, false, "too far ahead to believe");
1255
-
1256
- // The front door is still named in every case a heartbeat exists.
1257
- assert.equal(sessionLiveness(at(10 * SESSION_STALE_MS), now).frontDoor, "session");
1258
- // No heartbeat at all remains the daemon's lane.
1259
- assert.deepEqual(sessionLiveness(null, now), { frontDoor: "daemon", sessionLive: false });
1260
- });
1261
-
1262
- test("sessionLiveness agrees with the session runtime's own window", async () => {
1263
- const { heartbeatLiveness, STALE_MS } = await import("../session/liveness.mjs");
1264
- assert.equal(STALE_MS, SESSION_STALE_MS, "one window, two readers");
1265
- const now = Date.parse("2026-09-11T12:00:00Z");
1266
- for (const offset of [0, 1_000, -1_000, 45_000, -45_000, STALE_MS, -STALE_MS, STALE_MS + 1, -(STALE_MS + 1)]) {
1267
- const hb = { ts: new Date(now - offset).toISOString(), pid: 0 };
1268
- assert.equal(
1269
- sessionLiveness(hb, now).sessionLive,
1270
- heartbeatLiveness(hb, { now }).live,
1271
- `the two readers disagree at offset ${offset} ms`
1272
- );
1273
- }
1274
- });