@mmerterden/multi-agent-pipeline 20.29.0 → 20.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +50 -5
  3. package/README.tr.md +64 -5
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/docs/facts.json +9 -9
  7. package/install/_unattended-profile.mjs +61 -0
  8. package/install/catalog-history.json +1 -1
  9. package/install/claude.mjs +19 -3
  10. package/install/codex.mjs +2 -0
  11. package/install/copilot.mjs +13 -0
  12. package/install/index.mjs +10 -1
  13. package/install/templates/claude-hooks.json +12 -1
  14. package/manifest.json +230 -88
  15. package/package.json +3 -1
  16. package/pipeline/commands/multi-agent/analysis/SKILL.md +4 -4
  17. package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +13 -15
  18. package/pipeline/commands/multi-agent/office-off/SKILL.md +27 -0
  19. package/pipeline/commands/multi-agent/office-on/SKILL.md +51 -0
  20. package/pipeline/commands/multi-agent/office-status/SKILL.md +29 -0
  21. package/pipeline/commands/multi-agent/prune-logs/SKILL.md +4 -1
  22. package/pipeline/commands/multi-agent/review/SKILL.md +4 -3
  23. package/pipeline/commands/multi-agent/scenario-audit/SKILL.md +4 -2
  24. package/pipeline/commands/multi-agent/serve/SKILL.md +4 -2
  25. package/pipeline/commands/multi-agent/stack/SKILL.md +15 -8
  26. package/pipeline/commands/multi-agent/steer/SKILL.md +10 -9
  27. package/pipeline/commands/multi-agent/sync/SKILL.md +15 -3
  28. package/pipeline/commands/multi-agent/test-gen/SKILL.md +29 -3
  29. package/pipeline/contract/CHANGELOG.md +108 -0
  30. package/pipeline/contract/README.md +72 -12
  31. package/pipeline/contract/fixtures/error-invalid-request.json +1 -1
  32. package/pipeline/contract/fixtures/error-unauthorized.json +1 -1
  33. package/pipeline/contract/fixtures/error-unsigned.json +1 -1
  34. package/pipeline/contract/fixtures/launch-plan.json +1 -1
  35. package/pipeline/contract/fixtures/runs-awaiting-question.json +6 -1
  36. package/pipeline/contract/fixtures/runs-empty.json +1 -1
  37. package/pipeline/contract/fixtures/runs-failed.json +6 -1
  38. package/pipeline/contract/fixtures/runs-old-schema.json +6 -1
  39. package/pipeline/contract/fixtures/runs-pr-opened-redacted.json +5 -1
  40. package/pipeline/contract/fixtures/runs-pr-opened.json +6 -1
  41. package/pipeline/contract/fixtures/runs-running.json +6 -1
  42. package/pipeline/contract/manifest.json +506 -27
  43. package/pipeline/contract/types/index.d.ts +829 -4
  44. package/pipeline/lib/bg-launch.mjs +106 -0
  45. package/pipeline/lib/code-digest.mjs +49 -0
  46. package/pipeline/lib/credential-resolve.mjs +42 -2
  47. package/pipeline/multi-agent-refs/cross-cli-contract.md +5 -4
  48. package/pipeline/multi-agent-refs/features/office-threat-model.md +109 -0
  49. package/pipeline/multi-agent-refs/features/unattended-security.md +1 -1
  50. package/pipeline/multi-agent-refs/help/en.md +3 -0
  51. package/pipeline/multi-agent-refs/help/tr.md +3 -0
  52. package/pipeline/multi-agent-refs/phases/modes.md +7 -7
  53. package/pipeline/multi-agent-refs/phases/phase-5-report.md +16 -10
  54. package/pipeline/multi-agent-refs/review-standalone-tracker.md +37 -0
  55. package/pipeline/multi-agent-refs/tracker-contract.md +8 -3
  56. package/pipeline/office/NOTICE.md +45 -0
  57. package/pipeline/office/assets/ARCHIVE_BOXES.png +0 -0
  58. package/pipeline/office/assets/BIN.png +0 -0
  59. package/pipeline/office/assets/BOOKSHELF.png +0 -0
  60. package/pipeline/office/assets/CACTUS.png +0 -0
  61. package/pipeline/office/assets/CLOCK.png +0 -0
  62. package/pipeline/office/assets/COFFEE.png +0 -0
  63. package/pipeline/office/assets/COFFEE_MACHINE.png +0 -0
  64. package/pipeline/office/assets/COFFEE_TABLE.png +0 -0
  65. package/pipeline/office/assets/COUNTER_SINK.png +0 -0
  66. package/pipeline/office/assets/CUSHIONED_BENCH.png +0 -0
  67. package/pipeline/office/assets/CUSHIONED_CHAIR_BACK.png +0 -0
  68. package/pipeline/office/assets/CUSHIONED_CHAIR_FRONT.png +0 -0
  69. package/pipeline/office/assets/CUSHIONED_CHAIR_SIDE.png +0 -0
  70. package/pipeline/office/assets/DESK_FRONT.png +0 -0
  71. package/pipeline/office/assets/DESK_SIDE.png +0 -0
  72. package/pipeline/office/assets/DEVICE_STAND.png +0 -0
  73. package/pipeline/office/assets/DOUBLE_BOOKSHELF.png +0 -0
  74. package/pipeline/office/assets/FILING_CABINET.png +0 -0
  75. package/pipeline/office/assets/FRIDGE.png +0 -0
  76. package/pipeline/office/assets/HANGING_PLANT.png +0 -0
  77. package/pipeline/office/assets/LARGE_PAINTING.png +0 -0
  78. package/pipeline/office/assets/LARGE_PLANT.png +0 -0
  79. package/pipeline/office/assets/MAILBOX.png +0 -0
  80. package/pipeline/office/assets/MOP_BUCKET.png +0 -0
  81. package/pipeline/office/assets/PC_BACK.png +0 -0
  82. package/pipeline/office/assets/PC_FRONT_OFF.png +0 -0
  83. package/pipeline/office/assets/PC_FRONT_ON_1.png +0 -0
  84. package/pipeline/office/assets/PC_FRONT_ON_2.png +0 -0
  85. package/pipeline/office/assets/PC_FRONT_ON_3.png +0 -0
  86. package/pipeline/office/assets/PC_SIDE.png +0 -0
  87. package/pipeline/office/assets/PLANT.png +0 -0
  88. package/pipeline/office/assets/PLANT_2.png +0 -0
  89. package/pipeline/office/assets/POT.png +0 -0
  90. package/pipeline/office/assets/PRINTER.png +0 -0
  91. package/pipeline/office/assets/RECEPTION_DESK.png +0 -0
  92. package/pipeline/office/assets/SERVER_RACK.png +0 -0
  93. package/pipeline/office/assets/SERVER_RACK_2.png +0 -0
  94. package/pipeline/office/assets/SMALL_PAINTING.png +0 -0
  95. package/pipeline/office/assets/SMALL_PAINTING_2.png +0 -0
  96. package/pipeline/office/assets/SMALL_TABLE_FRONT.png +0 -0
  97. package/pipeline/office/assets/SMALL_TABLE_SIDE.png +0 -0
  98. package/pipeline/office/assets/SOFA_BACK.png +0 -0
  99. package/pipeline/office/assets/SOFA_FRONT.png +0 -0
  100. package/pipeline/office/assets/SOFA_SIDE.png +0 -0
  101. package/pipeline/office/assets/TABLE_FRONT.png +0 -0
  102. package/pipeline/office/assets/TOILET_STALL.png +0 -0
  103. package/pipeline/office/assets/VENDING.png +0 -0
  104. package/pipeline/office/assets/WASHBASIN.png +0 -0
  105. package/pipeline/office/assets/WATER_COOLER.png +0 -0
  106. package/pipeline/office/assets/WHITEBOARD.png +0 -0
  107. package/pipeline/office/assets/WOODEN_BENCH.png +0 -0
  108. package/pipeline/office/assets/WOODEN_CHAIR_BACK.png +0 -0
  109. package/pipeline/office/assets/WOODEN_CHAIR_FRONT.png +0 -0
  110. package/pipeline/office/assets/WOODEN_CHAIR_SIDE.png +0 -0
  111. package/pipeline/office/assets/char_0.png +0 -0
  112. package/pipeline/office/assets/char_1.png +0 -0
  113. package/pipeline/office/assets/char_2.png +0 -0
  114. package/pipeline/office/assets/char_3.png +0 -0
  115. package/pipeline/office/assets/char_4.png +0 -0
  116. package/pipeline/office/assets/char_5.png +0 -0
  117. package/pipeline/office/assets/floor_0.png +0 -0
  118. package/pipeline/office/assets/floor_1.png +0 -0
  119. package/pipeline/office/assets/floor_2.png +0 -0
  120. package/pipeline/office/assets/floor_3.png +0 -0
  121. package/pipeline/office/assets/floor_4.png +0 -0
  122. package/pipeline/office/assets/floor_5.png +0 -0
  123. package/pipeline/office/assets/floor_6.png +0 -0
  124. package/pipeline/office/assets/floor_7.png +0 -0
  125. package/pipeline/office/assets/floor_8.png +0 -0
  126. package/pipeline/office/assets/wall_0.png +0 -0
  127. package/pipeline/office/index.html +8431 -0
  128. package/pipeline/office/toolkit.json +3 -0
  129. package/pipeline/schemas/activity-chat.schema.json +78 -0
  130. package/pipeline/schemas/activity-roles.json +48 -0
  131. package/pipeline/schemas/activity-roles.schema.json +49 -0
  132. package/pipeline/schemas/activity.schema.json +613 -0
  133. package/pipeline/schemas/agent-state.schema.json +493 -115
  134. package/pipeline/schemas/autopilot-on-request.schema.json +33 -0
  135. package/pipeline/schemas/autopilot-on.schema.json +111 -0
  136. package/pipeline/schemas/contract-error.schema.json +45 -4
  137. package/pipeline/schemas/issue-maturity.schema.json +68 -0
  138. package/pipeline/schemas/job-change.schema.json +72 -0
  139. package/pipeline/schemas/job-request.schema.json +38 -0
  140. package/pipeline/schemas/launch.json +4 -4
  141. package/pipeline/schemas/ledger.schema.json +202 -0
  142. package/pipeline/schemas/office-credentials.schema.json +71 -0
  143. package/pipeline/schemas/office-jobs.json +253 -0
  144. package/pipeline/schemas/office-jobs.schema.json +62 -0
  145. package/pipeline/schemas/office-memory.schema.json +105 -0
  146. package/pipeline/schemas/office-settings-request.schema.json +30 -0
  147. package/pipeline/schemas/office-settings.schema.json +67 -0
  148. package/pipeline/schemas/permissions.schema.json +47 -0
  149. package/pipeline/schemas/phases.json +10 -2
  150. package/pipeline/schemas/phases.schema.json +86 -19
  151. package/pipeline/schemas/prefs.schema.json +19 -0
  152. package/pipeline/schemas/prs.schema.json +78 -0
  153. package/pipeline/schemas/prune-logs-request.schema.json +26 -0
  154. package/pipeline/schemas/prune-logs.schema.json +52 -0
  155. package/pipeline/schemas/repo-change.schema.json +54 -0
  156. package/pipeline/schemas/repo-request.schema.json +29 -0
  157. package/pipeline/schemas/repos.schema.json +49 -1
  158. package/pipeline/schemas/review-verdict-request.schema.json +20 -0
  159. package/pipeline/schemas/review-verdict.schema.json +62 -0
  160. package/pipeline/schemas/routine-delete.schema.json +59 -0
  161. package/pipeline/schemas/routines.schema.json +38 -0
  162. package/pipeline/schemas/run-evidence.schema.json +29 -0
  163. package/pipeline/schemas/run-results.schema.json +218 -0
  164. package/pipeline/schemas/runs-index.schema.json +257 -46
  165. package/pipeline/schemas/service-read.schema.json +58 -0
  166. package/pipeline/schemas/stack-change.schema.json +82 -0
  167. package/pipeline/schemas/stack-request.schema.json +33 -0
  168. package/pipeline/schemas/stack.schema.json +59 -0
  169. package/pipeline/schemas/steer-request.schema.json +25 -0
  170. package/pipeline/schemas/steer.schema.json +59 -0
  171. package/pipeline/schemas/system-status.schema.json +111 -0
  172. package/pipeline/schemas/tool-call-request.schema.json +19 -0
  173. package/pipeline/schemas/tool-call.schema.json +47 -0
  174. package/pipeline/schemas/tools.schema.json +65 -0
  175. package/pipeline/schemas/unattended-profile.json +7 -2
  176. package/pipeline/scripts/_active-run.mjs +2 -1
  177. package/pipeline/scripts/_cost.mjs +66 -1
  178. package/pipeline/scripts/_office-smoke-lib.sh +115 -0
  179. package/pipeline/scripts/_spawn-async.mjs +77 -0
  180. package/pipeline/scripts/_transcript-tail.mjs +274 -0
  181. package/pipeline/scripts/activity-snapshot.mjs +1223 -0
  182. package/pipeline/scripts/autopilot-control.mjs +214 -9
  183. package/pipeline/scripts/autopilot-runner.mjs +7 -5
  184. package/pipeline/scripts/contract-server.mjs +1853 -197
  185. package/pipeline/scripts/cost-analyze.mjs +2 -53
  186. package/pipeline/scripts/cost-table.json +1 -0
  187. package/pipeline/scripts/gen-mode-dispatch.mjs +3 -1
  188. package/pipeline/scripts/job-launch.mjs +477 -0
  189. package/pipeline/scripts/launch-request.mjs +94 -9
  190. package/pipeline/scripts/office-build.mjs +226 -0
  191. package/pipeline/scripts/office-control.mjs +410 -0
  192. package/pipeline/scripts/office-credentials.mjs +106 -0
  193. package/pipeline/scripts/office-ledger.mjs +409 -0
  194. package/pipeline/scripts/office-memory.mjs +200 -0
  195. package/pipeline/scripts/office-prune.mjs +125 -0
  196. package/pipeline/scripts/office-settings.mjs +145 -0
  197. package/pipeline/scripts/phase-tracker.sh +12 -5
  198. package/pipeline/scripts/prune-logs.sh +53 -2
  199. package/pipeline/scripts/routine-registry.mjs +42 -6
  200. package/pipeline/scripts/run-results.mjs +373 -0
  201. package/pipeline/scripts/runs-index.mjs +62 -2
  202. package/pipeline/scripts/service-read.mjs +244 -0
  203. package/pipeline/scripts/stack-toggle.mjs +285 -0
  204. package/pipeline/scripts/steer-run.mjs +97 -0
  205. package/pipeline/scripts/system-status.mjs +196 -0
  206. package/pipeline/scripts/task-mirror.mjs +150 -0
  207. package/pipeline/scripts/task-mirror.sh +20 -0
  208. package/pipeline/scripts/toolkit-tools.mjs +625 -0
  209. package/pipeline/scripts/transcript-index.mjs +4 -1
  210. package/pipeline/scripts/uninstall.mjs +3 -0
  211. package/pipeline/scripts/work-items.mjs +473 -0
  212. package/pipeline/skills/.skill-manifest.json +23 -11
  213. package/pipeline/skills/.skills-index.json +34 -1
  214. package/pipeline/skills/shared/README.md +6 -3
  215. package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +13 -15
  216. package/pipeline/skills/shared/core/multi-agent-office-off/SKILL.md +16 -0
  217. package/pipeline/skills/shared/core/multi-agent-office-on/SKILL.md +21 -0
  218. package/pipeline/skills/shared/core/multi-agent-office-status/SKILL.md +17 -0
  219. package/pipeline/skills/shared/core/multi-agent-prune-logs/SKILL.md +4 -1
  220. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +19 -0
  221. package/pipeline/skills/shared/core/multi-agent-scenario-audit/SKILL.md +4 -2
  222. package/pipeline/skills/shared/core/multi-agent-serve/SKILL.md +4 -2
  223. package/pipeline/skills/shared/core/multi-agent-stack/SKILL.md +14 -6
  224. package/pipeline/skills/shared/core/multi-agent-steer/SKILL.md +10 -9
  225. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
  226. package/pipeline/skills/shared/core/multi-agent-test-gen/SKILL.md +26 -3
  227. package/pipeline/skills/skills-index.md +4 -1
@@ -20,15 +20,61 @@
20
20
  * GET /v1/issues issues.mjs --json [--source S] [--project K]... [--max N]
21
21
  * GET /v1/worktrees worktrees.mjs --json [--measure] [--redact]
22
22
  * GET /v1/launch-spec launch-request.mjs spec
23
- * GET /v1/repos launch-request.mjs repos [--redact]
23
+ * GET /v1/repos launch-request.mjs repos [--redact]; ?all=1 adds
24
+ * the suggested and the missing registered ones
25
+ * POST /v1/repos launch-request.mjs register-repo (two steps)
26
+ * DELETE /v1/repos/{id} remove a registered repository (two steps)
24
27
  * POST /v1/launch?repo=P write a launch request, return launch-request.mjs plan
25
28
  * POST /v1/runs/{id}/answer answer-question.mjs --question Q --answer IDS
26
29
  * GET /v1/runs/{id}/log run-log.mjs {id} --tail N
27
30
  * POST /v1/runs/{id}/resume launch-request.mjs resume: the plan that resumes it
28
31
  * POST /v1/runs/{id}/kill run-kill.mjs preview, then apply (two steps)
29
32
  * POST /v1/gc gc-plan.mjs preview, then apply (two steps)
33
+ * POST /v1/prune-logs office-prune.mjs preview, then apply the listed folders (two steps)
34
+ * GET /v1/settings office-settings.mjs --json
35
+ * POST /v1/settings office-settings.mjs set S V, after a preview (two steps)
30
36
  * GET /v1/autopilot autopilot-control.mjs status
31
37
  * POST /v1/autopilot/off autopilot-control.mjs off [--now]
38
+ * POST /v1/autopilot/on autopilot-control.mjs on-preview, then on (two steps)
39
+ * POST /v1/runs/{id}/steer steer-run.mjs: queue one instruction for the next phase
40
+ * GET /v1/activity activity-snapshot.mjs --json
41
+ * GET /v1/activity/stream the same document as server-sent events, on change
42
+ * GET /v1/agents/{sessionId}/{agentId}/chat activity-snapshot.mjs chat
43
+ * GET /v1/ledger office-ledger.mjs --json
44
+ * GET /v1/system system-status.mjs --json
45
+ * GET /v1/credentials office-credentials.mjs --json [--probe]
46
+ * GET /v1/memory office-memory.mjs --json
47
+ * GET /v1/runs/{id}/results run-results.mjs: diff, findings, tests, evidence, analysis
48
+ * GET /v1/runs/{id}/evidence/{name} run-results.mjs --evidence: one screenshot
49
+ * POST /v1/runs/{id}/review-verdict work-items.mjs verdict, then --post (two steps)
50
+ * GET /v1/issues/{key}/maturity work-items.mjs maturity (lib/issue-fetcher.sh)
51
+ * GET /v1/prs work-items.mjs prs
52
+ * GET /v1/tools toolkit-tools.mjs list (the registered toolkit MCP server)
53
+ * POST /v1/tools/{name}/call toolkit-tools.mjs call
54
+ * GET /v1/services/{confluence,crashlytics,figma} service-read.mjs
55
+ * GET /v1/permissions pending host permission prompts (none can be read yet)
56
+ * GET /v1/routines routine-registry.mjs list --json
57
+ * DELETE /v1/routines/{name} routine-registry.mjs remove (two steps)
58
+ * GET /v1/stack?repo=R stack-toggle.mjs status
59
+ * POST /v1/stack stack-toggle.mjs set (two steps)
60
+ * GET /v1/jobs job-launch.mjs list: the jobs the office can start
61
+ * POST /v1/jobs start one in the background (two steps)
62
+ *
63
+ * Every route carries two tags for a remote carrier to enforce later, and
64
+ * manifest.json repeats them: `remote` is the scope it would need (read,
65
+ * answer, launch, or local-only for anything that widens what runs on this
66
+ * machine: autopilot on, stack, routine removal, kill, gc), and `risk` is the
67
+ * confirmation tier it would need (read, low, high). Locally the bearer token
68
+ * is the only credential either way.
69
+ *
70
+ * GET /office serves the browser panel from MA_OFFICE_PAGE, else
71
+ * prefs.global.office.page, else the installed copy (office/index.html next
72
+ * to this scripts directory), and is 404 when none exists. The page carries
73
+ * no token: it trades a one-time code from its URL fragment for one. It and
74
+ * its sprites under /office/assets/ are the only routes that need none (with
75
+ * /favicon.ico, an empty 204, because a browser asks for it on its own), and
76
+ * its response carries a per-response CSP nonce for its inline script and
77
+ * style.
32
78
  *
33
79
  * Phone routes, the same handlers behind a device signature instead of the
34
80
  * bearer token, always redacted (features/phone-api.md):
@@ -38,14 +84,19 @@
38
84
  * POST /v1/phone/runs/{id}/answer scope answer
39
85
  * POST /v1/phone/launch?repo=P scope launch, and only after `phone-devices.mjs launch on`
40
86
  *
41
- * There is deliberately no route that merges, marks ready, runs a command or
42
- * starts a host. POST /v1/launch and POST /v1/runs/{id}/resume return the argv
43
- * and environment launch.json declares; the client starts the process, and
44
- * nothing here starts a session. The only `claude` this process runs is
45
- * `claude agents --json` (read, for processAlive) and `claude stop <session>`,
46
- * for a kill or `autopilot/off` with now.
87
+ * There is deliberately no route that merges, marks ready, runs a shell
88
+ * command or takes a prompt. POST /v1/launch and POST /v1/runs/{id}/resume
89
+ * return the argv and environment launch.json declares for a client to start.
90
+ * POST /v1/jobs is the one route that starts a session itself, for the office:
91
+ * a job from schemas/office-jobs.json with typed arguments, two requests, the
92
+ * unattended profile, workspace trust and the autopilot runner's sandbox check
93
+ * first, then `claude --bg` without a shell (lib/bg-launch.mjs), never from a
94
+ * phone. Besides that the only `claude` this process runs is `claude agents
95
+ * --json` (read, for processAlive) and `claude stop <session>`, for a kill or
96
+ * `autopilot/off` with now.
47
97
  *
48
- * Kill and gc remove things, so each takes two requests. The first returns a
98
+ * Kill, gc, autopilot on, stack and routine removal change what this machine
99
+ * keeps or runs, so each takes two requests. The first returns a
49
100
  * preview and a confirm token: 32 random bytes, kept in this process's memory
50
101
  * only, bound to the route and the run id, valid for CONFIRM_TTL_MS and for one
51
102
  * use. The second sends the token back and acts on exactly what the preview
@@ -63,8 +114,11 @@
63
114
  *
64
115
  * 1. Host must be 127.0.0.1:<port> or localhost:<port>, which is what stops
65
116
  * a DNS-rebound page from reaching the socket under another name (403);
66
- * 2. an Origin header must be one of --allow-origin; without the flag no
67
- * Origin is accepted and no CORS header is ever sent (403);
117
+ * 2. an Origin header must be this server's own origin (the panel it
118
+ * serves) or one of --allow-origin; any other is refused, and no CORS
119
+ * header is sent to it (403). A write (POST, DELETE) and the chat route
120
+ * accept no Origin but the server's own: a page somewhere else can never
121
+ * drive one, even an allowed one;
68
122
  * 3. an allowed preflight is answered, before authentication, since a
69
123
  * browser sends no credentials on one (204);
70
124
  * 4. a path under /v1/phone/ leaves here for the device-signature checks in
@@ -79,21 +133,37 @@
79
133
  *
80
134
  * `?redact=1` is `--redact`. A request that carries an Origin is a browser
81
135
  * page, which can relay what it reads anywhere, so it is always served
82
- * redacted whatever the query says.
136
+ * redacted whatever the query says. The bearer token is read from the
137
+ * Authorization header only: never a cookie, never a query parameter.
138
+ *
139
+ * Every write route appends one line (who, what, when, outcome; never a
140
+ * body or a token) to ~/.claude/logs/multi-agent/office-actions.jsonl.
83
141
  *
84
142
  * Lifetime. The server exits after --idle-minutes without a request (15 by
85
- * default) and on SIGTERM, SIGINT or SIGHUP, removing its token file either
86
- * way. It is started on demand by `/multi-agent:serve` or by a client, and is
143
+ * default, 0 never), after --max-hours however busy it is (8 by default, 0 no
144
+ * limit; an open event stream does not extend it), and on SIGTERM, SIGINT or
145
+ * SIGHUP, removing its token file either way. office-on starts it with both at
146
+ * 0, so the panel runs until office-off.
147
+ *
148
+ * Panel bootstrap. `POST /office/code` (bearer, no Origin) mints a code that
149
+ * is valid for 10 seconds and one use; office-control.mjs opens the browser
150
+ * with it in the URL fragment. The page trades it for the token with
151
+ * `POST /office/exchange` (no bearer, own Origin only); a second use, an
152
+ * expired code or an unknown one is 401. The token itself never appears in a
153
+ * URL. `GET /office/version` gives the installed page's build id and whether
154
+ * this server's code changed on disk since it started; `POST /office/reload`
155
+ * mints a fresh code for the page itself, so it can reload onto a newly
156
+ * installed page. It is started on demand by `/multi-agent:serve` or by a client, and is
87
157
  * never installed as a service.
88
158
  *
89
159
  * Zero runtime dependencies (ADR-0004): node:http, node:crypto and the
90
160
  * producers' own modules.
91
161
  *
92
162
  * Usage:
93
- * node contract-server.mjs [--port N] [--idle-minutes N] [--allow-origin <origin>]... [--print-token]
163
+ * node contract-server.mjs [--port N] [--idle-minutes N] [--max-hours N] [--allow-origin <origin>]... [--print-token]
94
164
  *
95
165
  * Stdout: one JSON line once listening - {contractVersion, url, port, pid,
96
- * tokenFile, idleMinutes[, token]}.
166
+ * tokenFile, idleMinutes, maxHours[, token]}.
97
167
  * Exit: 0 stopped (idle or signal), 1 could not listen, 2 usage error.
98
168
  *
99
169
  * @module pipeline/scripts/contract-server
@@ -102,6 +172,7 @@
102
172
  import { Buffer } from "node:buffer";
103
173
  import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
104
174
  import {
175
+ appendFileSync,
105
176
  chmodSync,
106
177
  existsSync,
107
178
  mkdirSync,
@@ -113,15 +184,63 @@ import {
113
184
  } from "node:fs";
114
185
  import { createServer } from "node:http";
115
186
  import { homedir } from "node:os";
116
- import { isAbsolute, join, sep } from "node:path";
117
- import { clearTimeout, setTimeout } from "node:timers";
187
+ import { basename, dirname, isAbsolute, join, sep } from "node:path";
188
+ import { fileURLToPath } from "node:url";
189
+ import { clearInterval, clearTimeout, setInterval, setTimeout } from "node:timers";
118
190
  import { answerStateFile } from "./answer-question.mjs";
191
+ import { CONTRACT_VERSION as AUTOPILOT_VERSION, ControlError } from "./autopilot-control.mjs";
192
+ import { spawnAsync } from "./_spawn-async.mjs";
193
+ import {
194
+ CONTRACT_VERSION as ACTIVITY_VERSION,
195
+ activitySnapshot,
196
+ agentChat,
197
+ createActivityCache,
198
+ } from "./activity-snapshot.mjs";
199
+ import { RoutineError, listRoutines, removeRoutine } from "./routine-registry.mjs";
200
+ import {
201
+ CONTRACT_VERSION as STACK_VERSION,
202
+ StackError,
203
+ stackSet,
204
+ stackStatus,
205
+ } from "./stack-toggle.mjs";
206
+ import { CONTRACT_VERSION as STEER_VERSION, SteerError, queueSteer } from "./steer-run.mjs";
207
+ import { officeLedger } from "./office-ledger.mjs";
208
+ import { systemStatus } from "./system-status.mjs";
209
+ import { officeCredentials } from "./office-credentials.mjs";
210
+ import { officeMemory } from "./office-memory.mjs";
211
+ import { AGES as PRUNE_AGES, pruneApply, prunePreview } from "./office-prune.mjs";
212
+ import {
213
+ CHOICES,
214
+ SettingsError,
215
+ applySetting,
216
+ freeChainOf,
217
+ readSettings,
218
+ } from "./office-settings.mjs";
219
+ import { EVIDENCE_NAME, ResultsError, runEvidence, runResults } from "./run-results.mjs";
220
+ import {
221
+ WorkError,
222
+ issueMaturity,
223
+ postReviewVerdict,
224
+ pullRequests,
225
+ reviewVerdictPreview,
226
+ } from "./work-items.mjs";
119
227
  import {
120
- CONTRACT_VERSION as AUTOPILOT_VERSION,
121
- autopilotOff,
122
- autopilotStatus,
123
- ControlError,
124
- } from "./autopilot-control.mjs";
228
+ CONTRACT_VERSION as TOOLS_VERSION,
229
+ activeToolkitVersion,
230
+ TOOL_NAME,
231
+ ToolError,
232
+ createToolkitClient,
233
+ } from "./toolkit-tools.mjs";
234
+ import {
235
+ FIGMA_KEY,
236
+ FIGMA_NODE,
237
+ PAGE_ID,
238
+ ServiceError,
239
+ confluencePage,
240
+ crashlyticsIssue,
241
+ figmaFrame,
242
+ } from "./service-read.mjs";
243
+ import { listSessions } from "../lib/claude-sessions.mjs";
125
244
  import { catalog } from "./commands.mjs";
126
245
  import { collect } from "./issues.mjs";
127
246
  import {
@@ -132,16 +251,31 @@ import {
132
251
  } from "./gc-plan.mjs";
133
252
  import {
134
253
  launchRepoAllowed,
254
+ launchRepos,
135
255
  launchSpec,
136
256
  planLaunch,
137
257
  planResume,
138
258
  questionsDocument,
259
+ registerRepo,
139
260
  reposDocument,
261
+ repoId,
140
262
  requestsDir,
263
+ suggestedRepos,
264
+ unregisterRepo,
265
+ missingRepos,
141
266
  workingTreeState,
142
267
  writeRequestFile,
143
268
  } from "./launch-request.mjs";
144
269
  import { GROUP_NAMES, envelope, selectRuns } from "./runs-index.mjs";
270
+ import {
271
+ CONTRACT_VERSION as JOBS_VERSION,
272
+ JobError,
273
+ jobsDocument,
274
+ previewJob,
275
+ startJob,
276
+ } from "./job-launch.mjs";
277
+ import { guardRegistration, sandboxPrecondition } from "./autopilot-runner.mjs";
278
+ import { codeDigest } from "../lib/code-digest.mjs";
145
279
  import { CONTRACT_VERSION as KILL_VERSION, KillError, killPreview, killRun } from "./run-kill.mjs";
146
280
  import {
147
281
  CONTRACT_VERSION as LOG_VERSION,
@@ -171,7 +305,7 @@ import { runMain } from "../lib/fatal.mjs";
171
305
  import { invokedDirectly } from "../lib/invoked-directly.mjs";
172
306
 
173
307
  /** The contract as a whole; pipeline/contract/manifest.json carries the same. */
174
- export const CONTRACT_VERSION = "1.3.0";
308
+ export const CONTRACT_VERSION = "1.6.0";
175
309
  /** The majors `?v=` accepts. A new major keeps the previous one here for one minor. */
176
310
  export const SUPPORTED_MAJORS = [1];
177
311
  export const BIND_HOST = "127.0.0.1";
@@ -180,12 +314,36 @@ export const MAX_BODY_BYTES = 64 * 1024;
180
314
  const MAX_DRAIN_BYTES = 1024 * 1024;
181
315
  const DEFAULT_IDLE_MINUTES = 15;
182
316
  const MAX_IDLE_MINUTES = 24 * 60;
317
+ /** Absolute lifetime: the server exits after this however busy it is. */
318
+ const DEFAULT_MAX_HOURS = 8;
319
+ const MAX_HOURS = 24;
320
+ /** Confirm tokens held at once; the oldest is dropped past this. */
321
+ export const MAX_CONFIRMS = 256;
322
+ /** Connections at once, and how long a phone request may take to send its body. */
323
+ const MAX_CONNECTIONS = 64;
324
+ const PHONE_BODY_TIMEOUT_MS = 5000;
325
+ /** A panel code: single use, valid this long, at most this many outstanding. */
326
+ export const CODE_TTL_MS = 10 * 1000;
327
+ const MAX_CODES = 16;
328
+ const PANEL_CODE = /^[A-Za-z0-9_-]{16,128}$/;
183
329
  const RUN_ID = /^[A-Za-z0-9#][A-Za-z0-9#._-]{0,127}$/;
184
330
  const PROJECT_KEY = /^[A-Z][A-Z0-9_]*$/;
185
331
  const QUESTION_ID = /^[a-z][a-z0-9-]*$/;
186
332
  const OPTION_ID = /^[a-z0-9][a-z0-9+_-]*$/;
187
333
  const CONTRACT_VERSION_PATTERN = /^1\.[0-9]+\.[0-9]+$/;
188
334
  const CONFIRM_TOKEN = /^[A-Za-z0-9_-]{16,128}$/;
335
+ const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
336
+ const AGENT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
337
+ const ROUTINE_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
338
+ const REPO_NAME = /^[A-Za-z0-9._-]{1,100}(?:\/[A-Za-z0-9._-]{1,100})?$/;
339
+ const REPO_ID = /^[0-9a-f]{16}$/;
340
+ /** Server-sent event streams open at once; one more is refused. */
341
+ export const MAX_STREAMS = 4;
342
+ const STREAM_POLL_MS = 2000;
343
+ const STREAM_HEARTBEAT_MS = 15000;
344
+ const SESSIONS_TTL_MS = 10000;
345
+ const MAX_CHAT_LIMIT = 500;
346
+ const HERE = dirname(fileURLToPath(import.meta.url));
189
347
  /** How long a kill or gc confirm token stays valid. */
190
348
  export const CONFIRM_TTL_MS = 120 * 1000;
191
349
 
@@ -201,6 +359,13 @@ const confirmTtlMs = (() => {
201
359
  return n >= 1 && n <= CONFIRM_TTL_MS ? n : CONFIRM_TTL_MS;
202
360
  })();
203
361
 
362
+ /** As confirmTtlMs, for panel codes: MA_OFFICE_CODE_TTL_MS can only shorten it (tests). */
363
+ const codeTtlMs = (() => {
364
+ const raw = process.env.MA_OFFICE_CODE_TTL_MS;
365
+ const n = /^[0-9]{1,9}$/.test(raw ?? "") ? Number(raw) : NaN;
366
+ return n >= 1 && n <= CODE_TTL_MS ? n : CODE_TTL_MS;
367
+ })();
368
+
204
369
  /** Where token files live. Fixed under HOME, never under LOGS_ROOT. */
205
370
  export function tokenDir() {
206
371
  return join(homedir(), ".claude", "logs", "multi-agent", "contract-server");
@@ -226,10 +391,20 @@ const Q = {
226
391
  redact: { check: (v) => v === "0" || v === "1" },
227
392
  group: { check: (v) => GROUP_NAMES.includes(v) },
228
393
  measure: { check: (v) => v === "0" || v === "1" },
394
+ all: { check: (v) => v === "0" || v === "1" },
229
395
  source: { check: (v) => ["jira", "github", "all"].includes(v) },
230
396
  project: { repeat: true, check: (v) => PROJECT_KEY.test(v) },
231
397
  max: { check: (v) => /^[0-9]{1,3}$/.test(v) && Number(v) >= 1 && Number(v) <= 200 },
232
- repo: { check: (v) => v.length <= 4096 && isAbsolute(v) },
398
+ repo: { check: (v) => v.length <= 4096 && (isAbsolute(v) || REPO_NAME.test(v)) },
399
+ before: { check: (v) => /^[0-9]{1,15}$/.test(v) },
400
+ limit: {
401
+ check: (v) => /^[0-9]{1,3}$/.test(v) && Number(v) >= 1 && Number(v) <= MAX_CHAT_LIMIT,
402
+ },
403
+ confirm: { check: (v) => CONFIRM_TOKEN.test(v) },
404
+ page: { check: (v) => PAGE_ID.test(v) || (v.length <= 2048 && /^https:\/\//.test(v)) },
405
+ issue: { check: (v) => v.length <= 2048 && /^https:\/\//.test(v) },
406
+ file: { check: (v) => FIGMA_KEY.test(v) },
407
+ node: { check: (v) => FIGMA_NODE.test(v) },
233
408
  tail: { check: (v) => /^[0-9]{1,4}$/.test(v) && Number(v) >= 1 && Number(v) <= MAX_TAIL },
234
409
  };
235
410
 
@@ -336,8 +511,11 @@ function requireWorkspaceTrust(repoPath, redact) {
336
511
  * only. Either way the repo must be a configured or registered one.
337
512
  */
338
513
  function launchRun({ q, body, redact, channel = "desktop" }) {
339
- const repoPath = one(q, "repo");
514
+ let repoPath = one(q, "repo");
340
515
  if (!repoPath) throw new HttpError(400, "invalid-query", "repo is required");
516
+ // A name from GET /v1/repos is accepted too: a browser reads that list
517
+ // redacted, without paths. Either way the repository must be an allowed one.
518
+ if (!isAbsolute(repoPath)) repoPath = resolveRepo(repoPath);
341
519
  if (!existsSync(repoPath) || !statSync(repoPath).isDirectory()) {
342
520
  throw new HttpError(400, "invalid-query", "repo is not a directory");
343
521
  }
@@ -430,6 +608,7 @@ const confirms = new Map();
430
608
  function mintConfirm(route, id, payload) {
431
609
  const now = Date.now();
432
610
  for (const [t, c] of confirms) if (c.expires <= now) confirms.delete(t);
611
+ while (confirms.size >= MAX_CONFIRMS) confirms.delete(confirms.keys().next().value);
433
612
  const token = randomBytes(32).toString("base64url");
434
613
  const expires = now + confirmTtlMs;
435
614
  confirms.set(token, { route, id, expires, payload });
@@ -585,6 +764,87 @@ async function killRoute({ params, body, redact }) {
585
764
  }
586
765
  }
587
766
 
767
+ /**
768
+ * POST /v1/prune-logs: what prune-logs.sh would remove for an age, and a token
769
+ * bound to that age and folder list; then a prune of exactly that list
770
+ * (pruneApply), refused with 409 changed when the folders moved. The age is
771
+ * one of a fixed set.
772
+ */
773
+ async function pruneRoute({ body }) {
774
+ requireBody(
775
+ body,
776
+ { confirmToken: isToken, olderThanDays: (v) => PRUNE_AGES.includes(v) },
777
+ "prune-logs-request.schema.json",
778
+ );
779
+ if (body.confirmToken === undefined) {
780
+ if (body.olderThanDays === undefined)
781
+ throw new HttpError(400, "invalid-body", "olderThanDays is required for the preview");
782
+ const { all, ...doc } = await prunePreview(body.olderThanDays, process.env, {
783
+ keepAll: true,
784
+ });
785
+ return {
786
+ ...doc,
787
+ ...mintConfirm("prune-logs", "", { days: body.olderThanDays, all, count: doc.count }),
788
+ };
789
+ }
790
+ if (body.olderThanDays !== undefined)
791
+ throw new HttpError(
792
+ 400,
793
+ "invalid-body",
794
+ "olderThanDays belongs to the preview, not the confirmation",
795
+ );
796
+ const { days, all, count } = redeemConfirm(body.confirmToken, "prune-logs", "");
797
+ try {
798
+ return await pruneApply(days, process.env, { all, count });
799
+ } catch (e) {
800
+ if (e.code === "changed") throw new HttpError(409, "changed", e.message);
801
+ throw e;
802
+ }
803
+ }
804
+
805
+ /**
806
+ * POST /v1/settings: the change a setting would make and a token, then the
807
+ * same script the terminal command runs. The token is bound to that setting
808
+ * and value.
809
+ */
810
+ async function settingsRoute({ body }) {
811
+ requireBody(
812
+ body,
813
+ {
814
+ confirmToken: isToken,
815
+ setting: (v) => Object.hasOwn(CHOICES, v),
816
+ value: (v) => typeof v === "string",
817
+ },
818
+ "office-settings-request.schema.json",
819
+ );
820
+ if (body.confirmToken === undefined) {
821
+ if (!body.setting || !CHOICES[body.setting].includes(body.value))
822
+ throw new HttpError(400, "invalid-body", "setting and one of its values are required");
823
+ const now = readSettings();
824
+ return {
825
+ ...now,
826
+ change: {
827
+ setting: body.setting,
828
+ from: now[body.setting],
829
+ to: body.value,
830
+ ...(body.setting === "route" && body.value === "on" && freeChainOf()
831
+ ? { freeChain: freeChainOf() }
832
+ : {}),
833
+ },
834
+ ...mintConfirm("settings", "", { setting: body.setting, value: body.value }),
835
+ };
836
+ }
837
+ if (body.setting !== undefined || body.value !== undefined)
838
+ throw new HttpError(400, "invalid-body", "the confirmation carries only its token");
839
+ const { setting, value } = redeemConfirm(body.confirmToken, "settings", "");
840
+ try {
841
+ return await applySetting(setting, value);
842
+ } catch (e) {
843
+ if (e instanceof SettingsError) throw new HttpError(409, "not-applied", e.message);
844
+ throw e;
845
+ }
846
+ }
847
+
588
848
  function gcRoute({ body, redact }) {
589
849
  requireBody(
590
850
  body,
@@ -617,9 +877,72 @@ function gcRoute({ body, redact }) {
617
877
  return { contractVersion: GC_VERSION, ...out, failed };
618
878
  }
619
879
 
620
- function autopilotGet() {
880
+ /**
881
+ * autopilot-control.mjs run as a child process: its status script, credential
882
+ * probes and launchctl calls are synchronous, and must not hold up every
883
+ * other route while they run. The child reports a refusal as
884
+ * {error: {code, message}} on stdout (MA_CONTROL_JSON_ERRORS).
885
+ */
886
+ async function control(args) {
887
+ const r = await spawnAsync(process.execPath, [join(HERE, "autopilot-control.mjs"), ...args], {
888
+ env: { ...process.env, MA_CONTROL_JSON_ERRORS: "1" },
889
+ timeoutMs: 180_000,
890
+ maxBytes: 4 * 1024 * 1024,
891
+ });
892
+ let doc;
893
+ try {
894
+ doc = JSON.parse(r.stdout);
895
+ } catch {
896
+ doc = null;
897
+ }
898
+ if (doc?.error && typeof doc.error.code === "string") {
899
+ throw new ControlError(String(doc.error.message ?? "refused"), doc.error.code);
900
+ }
901
+ if (r.status !== 0 || !doc || typeof doc !== "object") {
902
+ throw new ControlError("autopilot-control did not finish");
903
+ }
904
+ return doc;
905
+ }
906
+
907
+ /**
908
+ * For a read whose answer is the same for every caller: a request that
909
+ * arrives while one runs gets that one's answer instead of starting another.
910
+ */
911
+ const inFlight = new Map();
912
+ const shared = (keyOf, fn) => (ctx) => {
913
+ const key = keyOf(ctx);
914
+ if (!inFlight.has(key)) {
915
+ inFlight.set(
916
+ key,
917
+ Promise.resolve()
918
+ .then(() => fn(ctx))
919
+ .finally(() => inFlight.delete(key)),
920
+ );
921
+ }
922
+ return inFlight.get(key);
923
+ };
924
+
925
+ /** One request at a time per key; a second one while the first runs is 429. */
926
+ const running = new Set();
927
+ const oneAtATime = (key, fn) => async (ctx) => {
928
+ if (running.has(key)) {
929
+ throw new HttpError(
930
+ 429,
931
+ "busy",
932
+ "the same request is still running; try again when it finishes",
933
+ );
934
+ }
935
+ running.add(key);
936
+ try {
937
+ return await fn(ctx);
938
+ } finally {
939
+ running.delete(key);
940
+ }
941
+ };
942
+
943
+ async function autopilotGet() {
621
944
  try {
622
- return { ...autopilotStatus(), contractVersion: AUTOPILOT_VERSION };
945
+ return { ...(await control(["status"])), contractVersion: AUTOPILOT_VERSION };
623
946
  } catch (err) {
624
947
  if (err instanceof ControlError) throw new HttpError(500, "internal", err.message);
625
948
  throw err;
@@ -628,184 +951,1132 @@ function autopilotGet() {
628
951
 
629
952
  async function autopilotOffRoute({ body }) {
630
953
  requireBody(body, { now: (v) => typeof v === "boolean" }, "autopilot-off-request.schema.json");
631
- return {
632
- contractVersion: AUTOPILOT_VERSION,
633
- ...(await autopilotOff({ now: body.now === true })),
634
- };
954
+ try {
955
+ return {
956
+ contractVersion: AUTOPILOT_VERSION,
957
+ ...(await control(body.now === true ? ["off", "--now"] : ["off"])),
958
+ };
959
+ } catch (err) {
960
+ if (err instanceof ControlError) throw new HttpError(500, "internal", err.message);
961
+ throw err;
962
+ }
635
963
  }
636
964
 
637
- const listRuns = ({ q, redact }) =>
638
- envelope(selectRuns({ group: one(q, "group") }).records, { redact });
639
- const oneRun = ({ params, redact }) => envelope(runRecordFor(params.id), { redact });
965
+ const repoArgs = (repos) => repos.flatMap((r) => ["--repo", r]);
640
966
 
641
967
  /**
642
- * Every route. `query` names the parameters beyond `v` and `redact` that a
643
- * route accepts; `body` marks a route that reads a JSON body. A route with
644
- * `scope` is a phone route: it is reached only with a device signature, never
645
- * with the bearer token, and is always served redacted.
968
+ * POST /v1/autopilot/on. Without confirmToken: what would be kept, dropped and
969
+ * probed, and a token bound to exactly that selection. With it: the mode is
970
+ * turned on for that selection (autopilot-control.mjs on).
646
971
  */
647
- export const ROUTES = [
648
- { method: "GET", path: "/v1/runs", query: ["group"], handle: listRuns },
649
- { method: "GET", path: "/v1/runs/{id}", query: [], handle: oneRun },
650
- {
651
- method: "GET",
652
- path: "/v1/commands",
653
- query: [],
654
- handle: () => {
655
- const out = catalog();
656
- if (!out) throw new HttpError(500, "internal", "no command tree found");
657
- return out;
972
+ async function autopilotOnRoute({ body }) {
973
+ requireBody(
974
+ body,
975
+ {
976
+ confirmToken: isToken,
977
+ repos: (v) =>
978
+ Array.isArray(v) &&
979
+ v.length >= 1 &&
980
+ v.length <= 50 &&
981
+ v.every((x) => typeof x === "string" && REPO_NAME.test(x)),
982
+ configureOnly: (v) => typeof v === "boolean",
658
983
  },
659
- },
660
- { method: "GET", path: "/v1/questions", query: [], handle: () => questionsDocument() },
661
- {
662
- method: "GET",
663
- path: "/v1/issues",
664
- query: ["source", "project", "max"],
665
- handle: ({ q }) =>
666
- collect({
667
- source: one(q, "source") ?? "all",
668
- projects: q.project ?? [],
669
- max: Number(one(q, "max") ?? 50),
670
- }),
671
- },
672
- {
673
- method: "GET",
674
- path: "/v1/worktrees",
675
- query: ["measure"],
676
- handle: ({ q, redact }) => buildList({ doMeasure: flag(one(q, "measure")), redact }),
677
- },
678
- { method: "GET", path: "/v1/launch-spec", query: [], handle: () => launchSpec() },
679
- {
680
- method: "GET",
681
- path: "/v1/repos",
682
- query: [],
683
- handle: ({ redact }) => reposDocument({ redact }),
684
- },
685
- { method: "POST", path: "/v1/launch", query: ["repo"], body: true, handle: launchRun },
686
- { method: "POST", path: "/v1/runs/{id}/answer", query: [], body: true, handle: answerRun },
687
- { method: "GET", path: "/v1/runs/{id}/log", query: ["tail"], handle: logRun },
688
- { method: "POST", path: "/v1/runs/{id}/resume", query: [], body: true, handle: resumeRun },
689
- { method: "POST", path: "/v1/runs/{id}/kill", query: [], body: true, handle: killRoute },
690
- { method: "POST", path: "/v1/gc", query: [], body: true, handle: gcRoute },
691
- { method: "GET", path: "/v1/autopilot", query: [], handle: autopilotGet },
692
- { method: "POST", path: "/v1/autopilot/off", query: [], body: true, handle: autopilotOffRoute },
693
- { method: "GET", path: "/v1/phone/runs", query: ["group"], scope: "read", handle: listRuns },
694
- { method: "GET", path: "/v1/phone/runs/{id}", query: [], scope: "read", handle: oneRun },
695
- {
696
- method: "POST",
697
- path: "/v1/phone/runs/{id}/answer",
698
- query: [],
699
- body: true,
700
- scope: "answer",
701
- handle: answerRun,
702
- },
703
- {
704
- method: "POST",
705
- path: "/v1/phone/launch",
706
- query: ["repo"],
707
- body: true,
708
- scope: "launch",
709
- handle: (ctx) => {
710
- if (!ctx.registry.launchEnabled) {
711
- throw new HttpError(403, "launch-disabled", "launch from a phone is not enabled here");
984
+ "autopilot-on-request.schema.json",
985
+ );
986
+ try {
987
+ if (body.confirmToken === undefined) {
988
+ if (!body.repos) throw new HttpError(400, "invalid-body", "repos is required");
989
+ const plan = await control(["on-preview", ...repoArgs(body.repos)]);
990
+ const payload = { repos: plan.keep, configureOnly: body.configureOnly === true };
991
+ return {
992
+ contractVersion: AUTOPILOT_VERSION,
993
+ preview: { ...plan, configureOnly: payload.configureOnly },
994
+ ...mintConfirm("autopilot-on", "", payload),
995
+ };
996
+ }
997
+ if (body.repos !== undefined || body.configureOnly !== undefined) {
998
+ throw new HttpError(
999
+ 400,
1000
+ "invalid-body",
1001
+ "the selection belongs to the preview, not the confirmation",
1002
+ );
1003
+ }
1004
+ const payload = redeemConfirm(body.confirmToken, "autopilot-on", "");
1005
+ return {
1006
+ contractVersion: AUTOPILOT_VERSION,
1007
+ ...(await control([
1008
+ "on",
1009
+ ...repoArgs(payload.repos),
1010
+ ...(payload.configureOnly ? ["--configure-only"] : []),
1011
+ ])),
1012
+ };
1013
+ } catch (err) {
1014
+ if (err instanceof ControlError) {
1015
+ if (err.code === "internal" || err.code === "launchd-refused") {
1016
+ throw new HttpError(500, "internal", err.message);
712
1017
  }
713
- return launchRun({ ...ctx, channel: "phone" });
714
- },
715
- },
716
- ].map((r) => ({
717
- ...r,
718
- phone: r.path.startsWith(PHONE_PREFIX),
719
- pattern: new RegExp(`^${r.path.replace("{id}", "(?<id>[^/]+)")}$`),
720
- }));
721
-
722
- /**
723
- * The route for a path and method, or the HttpError that says why not. A phone
724
- * path is looked up among phone routes only and a desktop path among desktop
725
- * routes only, so neither credential can reach the other family.
726
- */
727
- export function match(method, pathname) {
728
- const phone = isPhonePath(pathname);
729
- const hits = [];
730
- for (const r of ROUTES) {
731
- if (r.phone !== phone) continue;
732
- const m = r.pattern.exec(pathname);
733
- if (m) hits.push({ route: r, params: m.groups ?? {} });
734
- }
735
- if (!hits.length) {
736
- if (phone) throw new HttpError(404, "unknown-verb", "the phone API has no such verb");
737
- throw new HttpError(404, "not-found", "no such route");
738
- }
739
- const hit = hits.find((h) => h.route.method === method);
740
- if (!hit) {
741
- throw new HttpError(
742
- 405,
743
- "method-not-allowed",
744
- `use ${hits.map((h) => h.route.method).join(", ")}`,
745
- {
746
- allow: hits.map((h) => h.route.method).join(", "),
747
- },
748
- );
749
- }
750
- if (hit.params.id !== undefined) {
751
- try {
752
- hit.params.id = decodeURIComponent(hit.params.id);
753
- } catch {
754
- throw new HttpError(404, "not-found", "no such run");
1018
+ throw new HttpError(409, err.code, err.message);
755
1019
  }
1020
+ throw err;
756
1021
  }
757
- return hit;
758
1022
  }
759
1023
 
760
- /** Query parameters as {name: [values]}, after every rule a route declares. */
761
- export function parseQuery(route, searchParams) {
762
- const q = {};
763
- for (const [k, v] of searchParams) {
764
- if (k !== "v" && k !== "redact" && !route.query.includes(k)) {
765
- throw new HttpError(400, "invalid-query", `unknown query parameter ${k}`);
766
- }
767
- (q[k] ??= []).push(v);
768
- }
769
- for (const [k, values] of Object.entries(q)) {
770
- if (k === "v") {
771
- if (
772
- values.length !== 1 ||
773
- !SUPPORTED_MAJORS.includes(Number(values[0])) ||
774
- !/^[0-9]+$/.test(values[0])
775
- ) {
776
- throw new HttpError(400, "unsupported-version", "this server does not serve that major", {
777
- supported: SUPPORTED_MAJORS,
778
- });
1024
+ /** POST /v1/runs/{id}/steer: one instruction for the run's next phase. */
1025
+ /**
1026
+ * POST /v1/runs/{id}/steer. Free text for an unattended agent, so it takes
1027
+ * two requests like every high-risk write: the first names the text and gets
1028
+ * a preview (the run, its phase, the unconsumed instruction it would replace)
1029
+ * and a token bound to this run and exactly this text; the second sends only
1030
+ * the token.
1031
+ */
1032
+ async function steerRoute({ params, body }) {
1033
+ requireBody(
1034
+ body,
1035
+ { text: (v) => typeof v === "string", confirmToken: isToken },
1036
+ "steer-request.schema.json",
1037
+ );
1038
+ const [record] = runRecordFor(params.id);
1039
+ const file = stateFileOf(record);
1040
+ if (!file) throw new HttpError(409, "not-steerable", "this run has no agent state to steer");
1041
+ try {
1042
+ if (body.confirmToken === undefined) {
1043
+ if (typeof body.text !== "string" || !body.text.trim() || body.text.length > 4000) {
1044
+ throw new HttpError(400, "invalid-body", "text is required, 1 to 4000 characters");
779
1045
  }
780
- continue;
1046
+ const { state } = readState(record);
1047
+ if (["complete", "completed", "done"].includes(state.status)) {
1048
+ throw new HttpError(
1049
+ 409,
1050
+ "not-steerable",
1051
+ "the run is finished; nothing would read the instruction",
1052
+ );
1053
+ }
1054
+ const prev = state.pendingSteer;
1055
+ return {
1056
+ contractVersion: STEER_VERSION,
1057
+ taskId: record.taskId,
1058
+ preview: {
1059
+ text: scrub(body.text),
1060
+ status: typeof state.status === "string" ? state.status : null,
1061
+ currentPhase: Number.isFinite(Number(state.currentPhase))
1062
+ ? Number(state.currentPhase)
1063
+ : null,
1064
+ replaces:
1065
+ prev && !prev.appliedAt && typeof prev.text === "string" ? scrub(prev.text) : null,
1066
+ },
1067
+ ...mintConfirm("steer", record.taskId, { text: body.text }),
1068
+ };
781
1069
  }
782
- const rule = Q[k];
783
- if (values.length > 1 && !rule.repeat)
784
- throw new HttpError(400, "invalid-query", `${k} is given twice`);
785
- for (const v of values) {
786
- if (!rule.check(v)) throw new HttpError(400, "invalid-query", `${k} has an invalid value`);
1070
+ if (body.text !== undefined) {
1071
+ throw new HttpError(
1072
+ 400,
1073
+ "invalid-body",
1074
+ "the text belongs to the preview, not the confirmation",
1075
+ );
1076
+ }
1077
+ const { text } = redeemConfirm(body.confirmToken, "steer", record.taskId);
1078
+ return { ...(await queueSteer(file, text)), taskId: record.taskId };
1079
+ } catch (err) {
1080
+ if (err instanceof SteerError) {
1081
+ if (err.code === "finished") throw new HttpError(409, "not-steerable", err.message);
1082
+ throw new HttpError(400, "invalid-body", err.message);
787
1083
  }
1084
+ throw err;
788
1085
  }
789
- return q;
790
1086
  }
791
1087
 
792
- const sha = (s) => createHash("sha256").update(s).digest();
1088
+ /** The sessions `claude agents --json` lists, read at most every SESSIONS_TTL_MS. */
1089
+ let sessionsCache = { at: 0, list: undefined };
1090
+ function cachedSessions() {
1091
+ if (Date.now() - sessionsCache.at > SESSIONS_TTL_MS) {
1092
+ sessionsCache = { at: Date.now(), list: listSessions() };
1093
+ }
1094
+ return sessionsCache.list;
1095
+ }
793
1096
 
794
- /** Does this path belong to the phone family? */
795
- export const isPhonePath = (pathname) =>
796
- pathname === PHONE_PREFIX.slice(0, -1) || pathname.startsWith(PHONE_PREFIX);
1097
+ const activityCache = createActivityCache();
1098
+ const activityNow = () => activitySnapshot({ sessions: cachedSessions(), cache: activityCache });
797
1099
 
798
- /** Constant-time bearer check: equal-length digests, whatever the input. */
799
- export function authorized(header, tokenDigest) {
800
- const m = /^Bearer ([A-Za-z0-9_-]{1,512})$/.exec(String(header ?? ""));
801
- return timingSafeEqual(sha(m ? m[1] : ""), tokenDigest) && Boolean(m);
1100
+ async function chatRoute({ params, q }) {
1101
+ if (!SESSION_ID.test(params.sessionId)) throw new HttpError(404, "not-found", "no such agent");
1102
+ const agentId = params.agentId === "main" ? null : params.agentId;
1103
+ if (agentId !== null && !AGENT_ID.test(agentId)) {
1104
+ throw new HttpError(404, "not-found", "no such agent");
1105
+ }
1106
+ const page = await agentChat({
1107
+ sessionId: params.sessionId,
1108
+ agentId,
1109
+ before: one(q, "before") === null ? null : Number(one(q, "before")),
1110
+ limit: one(q, "limit") === null ? 200 : Number(one(q, "limit")),
1111
+ });
1112
+ if (!page) throw new HttpError(404, "not-found", "no such agent");
1113
+ return page;
802
1114
  }
803
1115
 
804
- function readRawBody(req) {
805
- return new Promise((resolve, reject) => {
806
- const chunks = [];
1116
+ const routinesDocument = () => ({
1117
+ contractVersion: ACTIVITY_VERSION,
1118
+ routines: listRoutines(homedir()).map((r) => ({
1119
+ name: String(r?.name ?? ""),
1120
+ description: typeof r?.description === "string" ? scrub(r.description) : "",
1121
+ createdAt: typeof r?.createdAt === "string" ? r.createdAt : null,
1122
+ source: typeof r?.source === "string" ? scrub(r.source) : null,
1123
+ })),
1124
+ });
1125
+
1126
+ const routinesGet = () => {
1127
+ try {
1128
+ return routinesDocument();
1129
+ } catch (err) {
1130
+ if (err instanceof RoutineError) throw new HttpError(500, "internal", err.message);
1131
+ throw err;
1132
+ }
1133
+ };
1134
+
1135
+ /** DELETE /v1/routines/{name}: a preview and token, then ?confirm=<token> removes it. */
1136
+ function routineDeleteRoute({ params, q }) {
1137
+ const name = params.name;
1138
+ if (!ROUTINE_NAME.test(name)) throw new HttpError(404, "not-found", "no such routine");
1139
+ try {
1140
+ const token = one(q, "confirm");
1141
+ if (token === null) {
1142
+ const entry = routinesDocument().routines.find((r) => r.name === name);
1143
+ if (!entry) throw new HttpError(404, "not-found", "no such routine");
1144
+ return {
1145
+ contractVersion: ACTIVITY_VERSION,
1146
+ preview: entry,
1147
+ ...mintConfirm("routine-delete", name, null),
1148
+ };
1149
+ }
1150
+ redeemConfirm(token, "routine-delete", name);
1151
+ return { contractVersion: ACTIVITY_VERSION, removed: removeRoutine(homedir(), name) };
1152
+ } catch (err) {
1153
+ if (err instanceof RoutineError) throw new HttpError(409, "not-removable", err.message);
1154
+ throw err;
1155
+ }
1156
+ }
1157
+
1158
+ const notAllowed = () =>
1159
+ new HttpError(
1160
+ 403,
1161
+ "repo-not-allowed",
1162
+ "repo is neither a configured autopilot repo nor one registered with launch-request.mjs register-repo",
1163
+ );
1164
+
1165
+ /**
1166
+ * The checkout a panel request names: an absolute path or a name from
1167
+ * GET /v1/repos, and either way one of the repositories launch accepts.
1168
+ */
1169
+ function resolveRepo(ref) {
1170
+ if (!ref) throw new HttpError(400, "invalid-query", "repo is required");
1171
+ if (isAbsolute(ref)) {
1172
+ if (!launchRepoAllowed(ref)) throw notAllowed();
1173
+ return realOr(ref);
1174
+ }
1175
+ const hits = launchRepos().filter((r) => r.name === ref);
1176
+ if (hits.length > 1) {
1177
+ throw new HttpError(409, "repo-ambiguous", "more than one repository has that name");
1178
+ }
1179
+ if (!hits.length) throw notAllowed();
1180
+ return hits[0].path;
1181
+ }
1182
+
1183
+ /**
1184
+ * POST /v1/repos: add a checkout launch accepts. The first request names a
1185
+ * suggestion (an id from GET /v1/repos?all=1) or an absolute path; the
1186
+ * preview says what would be stored and mints a token; the second request
1187
+ * sends only the token. Only a git checkout is accepted from here.
1188
+ */
1189
+ function repoAddRoute({ body }) {
1190
+ requireBody(
1191
+ body,
1192
+ {
1193
+ confirmToken: isToken,
1194
+ suggestion: (v) => typeof v === "string" && REPO_ID.test(v),
1195
+ path: (v) => typeof v === "string" && v.length <= 4096 && isAbsolute(v),
1196
+ },
1197
+ "repo-request.schema.json",
1198
+ );
1199
+ if (body.confirmToken === undefined) {
1200
+ let path;
1201
+ if (body.suggestion !== undefined && body.path !== undefined) {
1202
+ throw new HttpError(400, "invalid-body", "name a suggestion or a path, not both");
1203
+ }
1204
+ if (body.suggestion !== undefined) {
1205
+ const hit = suggestedRepos().find((r) => r.id === body.suggestion);
1206
+ if (!hit) throw new HttpError(404, "not-found", "no such suggestion");
1207
+ path = hit.path;
1208
+ } else if (body.path !== undefined) {
1209
+ path = realOr(body.path);
1210
+ } else {
1211
+ throw new HttpError(400, "invalid-body", "suggestion or path is required");
1212
+ }
1213
+ if (launchRepoAllowed(path)) {
1214
+ throw new HttpError(409, "already-registered", "launch already accepts that repository");
1215
+ }
1216
+ let real;
1217
+ try {
1218
+ real = registerRepoCheck(path);
1219
+ } catch (err) {
1220
+ throw new HttpError(400, "invalid-body", err.message);
1221
+ }
1222
+ // The page picks a repository by folder name; a second one with the same
1223
+ // name could never be told apart there.
1224
+ if (launchRepos().some((r) => r.name.split("/").pop() === basename(real))) {
1225
+ throw new HttpError(
1226
+ 409,
1227
+ "name-taken",
1228
+ "a repository with that folder name is already on the list",
1229
+ );
1230
+ }
1231
+ return {
1232
+ contractVersion: ACTIVITY_VERSION,
1233
+ preview: { name: basename(real) },
1234
+ ...mintConfirm("repo-add", "", { path: real }),
1235
+ };
1236
+ }
1237
+ if (body.suggestion !== undefined || body.path !== undefined) {
1238
+ throw new HttpError(
1239
+ 400,
1240
+ "invalid-body",
1241
+ "the selection belongs to the preview, not the confirmation",
1242
+ );
1243
+ }
1244
+ const { path } = redeemConfirm(body.confirmToken, "repo-add", "");
1245
+ try {
1246
+ const stored = registerRepo(path, process.env, { requireCheckout: true });
1247
+ return { contractVersion: ACTIVITY_VERSION, registered: { name: basename(stored) } };
1248
+ } catch (err) {
1249
+ // registerRepo's own refusals name no path; anything else (a lock timeout,
1250
+ // a disk error) can, so it stays in the server's log.
1251
+ if (REGISTER_REFUSALS.has(err.message)) throw new HttpError(409, "not-registered", err.message);
1252
+ process.stderr.write(`contract-server: repo-add: ${err.message}\n`);
1253
+ throw new HttpError(409, "not-registered", "the repository could not be stored");
1254
+ }
1255
+ }
1256
+ const REGISTER_REFUSALS = new Set([
1257
+ "the path must be absolute",
1258
+ "the path is not a directory",
1259
+ "the home directory or the root is not a repository",
1260
+ "the path is not a git checkout",
1261
+ ]);
1262
+
1263
+ // What registerRepo would refuse, checked without writing.
1264
+ function registerRepoCheck(path) {
1265
+ const real = realOr(path);
1266
+ let st;
1267
+ try {
1268
+ st = statSync(real);
1269
+ } catch {
1270
+ throw new Error("the path is not a directory");
1271
+ }
1272
+ if (!st.isDirectory()) throw new Error("the path is not a directory");
1273
+ if (real === "/" || real === realOr(homedir())) {
1274
+ throw new Error("the home directory or the root is not a repository");
1275
+ }
1276
+ if (!existsSync(join(real, ".git"))) throw new Error("the path is not a git checkout");
1277
+ return real;
1278
+ }
1279
+
1280
+ /** DELETE /v1/repos/{id}: a registered entry, preview then ?confirm=<token>. */
1281
+ function repoRemoveRoute({ params, q }) {
1282
+ const id = params.id;
1283
+ if (!REPO_ID.test(id)) throw new HttpError(404, "not-found", "no such repository");
1284
+ const entry = [
1285
+ ...launchRepos()
1286
+ .filter((r) => r.source === "registered")
1287
+ .map((r) => ({ id: repoId(r.path), name: r.name })),
1288
+ ...missingRepos().map((r) => ({ id: r.id, name: r.name })),
1289
+ ].find((r) => r.id === id);
1290
+ const token = one(q, "confirm");
1291
+ if (token === null) {
1292
+ if (!entry) throw new HttpError(404, "not-found", "no registered repository has that id");
1293
+ return {
1294
+ contractVersion: ACTIVITY_VERSION,
1295
+ preview: { name: entry.name },
1296
+ ...mintConfirm("repo-remove", id, null),
1297
+ };
1298
+ }
1299
+ redeemConfirm(token, "repo-remove", id);
1300
+ const removed = unregisterRepo(id);
1301
+ if (!removed) throw new HttpError(404, "not-found", "no registered repository has that id");
1302
+ return { contractVersion: ACTIVITY_VERSION, removed: { name: basename(removed) } };
1303
+ }
1304
+
1305
+ const JOB_STATUS = {
1306
+ "unknown-job": 404,
1307
+ "command-not-installed": 409,
1308
+ "invalid-repo": 400,
1309
+ "invalid-args": 400,
1310
+ "invalid-request": 400,
1311
+ "no-launch-shape": 422,
1312
+ "room-busy": 409,
1313
+ "sandbox-unavailable": 409,
1314
+ "launch-failed": 502,
1315
+ };
1316
+
1317
+ function jobError(err) {
1318
+ if (!(err instanceof JobError)) return err;
1319
+ return new HttpError(JOB_STATUS[err.code] ?? 400, err.code, err.message, {
1320
+ ...(err.extra.errors ? { errors: err.extra.errors } : {}),
1321
+ });
1322
+ }
1323
+
1324
+ const knownRun = (id) => selectRuns({ taskId: id }).found;
1325
+
1326
+ /** A session started without agent-guard has no second layer; the office starts none. */
1327
+ function requireGuard(repoPath) {
1328
+ if (guardRegistration({ cwd: repoPath }).ok) return;
1329
+ throw new HttpError(
1330
+ 409,
1331
+ "guard-missing",
1332
+ "agent-guard.sh is not registered for every tool, so nothing starts unattended",
1333
+ );
1334
+ }
1335
+
1336
+ /**
1337
+ * POST /v1/jobs: hand a job to someone in the office. The first request names
1338
+ * jobId, repo and args (and answers for a pipeline run); the preview says
1339
+ * which command starts, with what, and what it writes outside this machine,
1340
+ * and mints a token. The second request sends only the token: the server then
1341
+ * checks the unattended profile, the workspace trust and the unattended
1342
+ * sandbox, starts the session in the background and records it for the floor.
1343
+ */
1344
+ async function jobsPost({ body }) {
1345
+ requireBody(
1346
+ body,
1347
+ {
1348
+ confirmToken: isToken,
1349
+ jobId: (v) => typeof v === "string" && /^[a-z][a-z0-9-]{1,40}$/.test(v),
1350
+ repo: (v) =>
1351
+ typeof v === "string" && v.length <= 4096 && (isAbsolute(v) || REPO_NAME.test(v)),
1352
+ args: (v) => v !== null && typeof v === "object" && !Array.isArray(v),
1353
+ answers: (v) => v !== null && typeof v === "object" && !Array.isArray(v),
1354
+ },
1355
+ "job-request.schema.json",
1356
+ );
1357
+ try {
1358
+ if (body.confirmToken === undefined) {
1359
+ if (!body.jobId || !body.repo) {
1360
+ throw new HttpError(400, "invalid-body", "jobId and repo are required");
1361
+ }
1362
+ const repoPath = resolveRepo(body.repo);
1363
+ requireUnattendedProfile();
1364
+ requireGuard(repoPath);
1365
+ const sessions = cachedSessions();
1366
+ const { job, prompt } = previewJob({
1367
+ jobId: body.jobId,
1368
+ args: body.args ?? {},
1369
+ answers: body.answers,
1370
+ repoPath,
1371
+ knownRun,
1372
+ sessions,
1373
+ });
1374
+ return {
1375
+ contractVersion: JOBS_VERSION,
1376
+ preview: {
1377
+ jobId: job.id,
1378
+ role: job.role,
1379
+ team: job.team,
1380
+ repo: basename(repoPath),
1381
+ prompt,
1382
+ writes: job.writes,
1383
+ },
1384
+ ...mintConfirm("job", "", {
1385
+ jobId: job.id,
1386
+ args: body.args ?? {},
1387
+ answers: body.answers,
1388
+ repoPath,
1389
+ }),
1390
+ };
1391
+ }
1392
+ if (["jobId", "repo", "args", "answers"].some((k) => body[k] !== undefined)) {
1393
+ throw new HttpError(
1394
+ 400,
1395
+ "invalid-body",
1396
+ "the job belongs to the preview, not the confirmation",
1397
+ );
1398
+ }
1399
+ const p = redeemConfirm(body.confirmToken, "job", "");
1400
+ if (!launchRepoAllowed(p.repoPath)) throw notAllowed();
1401
+ requireUnattendedProfile();
1402
+ requireWorkspaceTrust(p.repoPath, true);
1403
+ requireGuard(p.repoPath);
1404
+ const record = await startJob({
1405
+ jobId: p.jobId,
1406
+ args: p.args,
1407
+ answers: p.answers,
1408
+ repoPath: p.repoPath,
1409
+ repoName: basename(p.repoPath),
1410
+ knownRun,
1411
+ sessions: cachedSessions(),
1412
+ precondition: sandboxPrecondition,
1413
+ });
1414
+ return {
1415
+ contractVersion: JOBS_VERSION,
1416
+ started: {
1417
+ sessionId: record.sessionId,
1418
+ jobId: record.jobId,
1419
+ role: record.role,
1420
+ team: record.team,
1421
+ repo: record.repo,
1422
+ },
1423
+ };
1424
+ } catch (err) {
1425
+ throw jobError(err);
1426
+ }
1427
+ }
1428
+
1429
+ function stackError(err) {
1430
+ if (!(err instanceof StackError)) return err;
1431
+ if (err.code === "unreadable-settings") {
1432
+ return new HttpError(409, "unreadable-settings", err.message);
1433
+ }
1434
+ return new HttpError(400, "invalid-body", err.message);
1435
+ }
1436
+
1437
+ function stackGet({ q }) {
1438
+ const repo = resolveRepo(one(q, "repo"));
1439
+ try {
1440
+ return stackStatus(repo);
1441
+ } catch (err) {
1442
+ throw stackError(err);
1443
+ }
1444
+ }
1445
+
1446
+ /** POST /v1/stack: the planned enabledPlugins and a token, then the write. */
1447
+ function stackPost({ body }) {
1448
+ requireBody(
1449
+ body,
1450
+ {
1451
+ confirmToken: isToken,
1452
+ repo: (v) =>
1453
+ typeof v === "string" && v.length <= 4096 && (isAbsolute(v) || REPO_NAME.test(v)),
1454
+ stacks: (v) =>
1455
+ Array.isArray(v) &&
1456
+ v.length >= 1 &&
1457
+ v.length <= 8 &&
1458
+ v.every((x) => typeof x === "string" && /^[a-z]{2,16}$/.test(x)),
1459
+ },
1460
+ "stack-request.schema.json",
1461
+ );
1462
+ try {
1463
+ if (body.confirmToken === undefined) {
1464
+ if (!body.repo || !body.stacks) {
1465
+ throw new HttpError(400, "invalid-body", "repo and stacks are required");
1466
+ }
1467
+ const repo = resolveRepo(body.repo);
1468
+ const plan = stackSet(repo, body.stacks, { dryRun: true });
1469
+ return {
1470
+ contractVersion: STACK_VERSION,
1471
+ preview: {
1472
+ stacks: plan.stacks,
1473
+ changed: plan.changed,
1474
+ enabledPlugins: plan.enabledPlugins,
1475
+ dropped: plan.dropped,
1476
+ },
1477
+ ...mintConfirm("stack", "", { repo, stacks: body.stacks }),
1478
+ };
1479
+ }
1480
+ if (body.repo !== undefined || body.stacks !== undefined) {
1481
+ throw new HttpError(
1482
+ 400,
1483
+ "invalid-body",
1484
+ "the selection belongs to the preview, not the confirmation",
1485
+ );
1486
+ }
1487
+ const { repo, stacks } = redeemConfirm(body.confirmToken, "stack", "");
1488
+ return { ...stackSet(repo, stacks), contractVersion: STACK_VERSION };
1489
+ } catch (err) {
1490
+ throw stackError(err);
1491
+ }
1492
+ }
1493
+
1494
+ function resultsRoute({ params }) {
1495
+ if (!RUN_ID.test(params.id)) throw new HttpError(404, "not-found", "no such run");
1496
+ try {
1497
+ return runResults(params.id);
1498
+ } catch (err) {
1499
+ if (err instanceof ResultsError) throw new HttpError(404, "not-found", "no such run");
1500
+ throw err;
1501
+ }
1502
+ }
1503
+
1504
+ function evidenceRoute({ params }) {
1505
+ if (!RUN_ID.test(params.id) || !EVIDENCE_NAME.test(params.name)) {
1506
+ throw new HttpError(404, "not-found", "no such image");
1507
+ }
1508
+ try {
1509
+ return runEvidence(params.id, params.name);
1510
+ } catch (err) {
1511
+ if (err instanceof ResultsError) throw new HttpError(404, "not-found", "no such image");
1512
+ throw err;
1513
+ }
1514
+ }
1515
+
1516
+ function workError(err) {
1517
+ if (!(err instanceof WorkError)) return err;
1518
+ if (err.code === "no-run" || err.code === "bad-key")
1519
+ return new HttpError(404, "not-found", err.message);
1520
+ if (err.code === "no-review" || err.code === "no-findings") {
1521
+ return new HttpError(409, "no-review", err.message);
1522
+ }
1523
+ if (err.code === "not-installed") return new HttpError(409, "not-installed", err.message);
1524
+ if (err.code === "review-incomplete") return new HttpError(409, "review-incomplete", err.message);
1525
+ if (err.code === "changed") return new HttpError(409, "confirm-mismatch", err.message);
1526
+ return new HttpError(502, "upstream-failed", err.message);
1527
+ }
1528
+
1529
+ async function maturityRoute({ params }) {
1530
+ try {
1531
+ return await issueMaturity(params.key);
1532
+ } catch (err) {
1533
+ throw workError(err);
1534
+ }
1535
+ }
1536
+
1537
+ /**
1538
+ * POST /v1/runs/{id}/review-verdict: the verdict post-pr-review.sh would post
1539
+ * and a token, then the post itself. The verdict follows from the findings the
1540
+ * review recorded; no request chooses it. Only a finished review whose every
1541
+ * finding is triaged can be previewed, and the token is bound to the decision
1542
+ * and the findings digest it showed: when either changed before the
1543
+ * confirmation, it is refused with confirm-mismatch.
1544
+ */
1545
+ async function reviewVerdictRoute({ params, body }) {
1546
+ requireBody(body, { confirmToken: isToken }, "review-verdict-request.schema.json");
1547
+ try {
1548
+ const [record] = runRecordFor(params.id);
1549
+ if (body.confirmToken === undefined) {
1550
+ const preview = reviewVerdictPreview(record.taskId);
1551
+ const bound = { decision: preview.decision, findingsDigest: preview.findingsDigest };
1552
+ return { ...preview, ...mintConfirm("review-verdict", record.taskId, bound) };
1553
+ }
1554
+ const expected = redeemConfirm(body.confirmToken, "review-verdict", record.taskId);
1555
+ return await postReviewVerdict(record.taskId, process.env, expected);
1556
+ } catch (err) {
1557
+ throw workError(err);
1558
+ }
1559
+ }
1560
+
1561
+ /** The toolkit MCP client of this server, started on first use, closed on stop. */
1562
+ let toolkit = null;
1563
+ const toolkitClient = () => (toolkit ??= createToolkitClient());
1564
+ export const closeToolkit = () => {
1565
+ toolkit?.close();
1566
+ toolkit = null;
1567
+ };
1568
+
1569
+ function toolError(err) {
1570
+ if (!(err instanceof ToolError)) return err;
1571
+ if (err.code === "unknown-tool") return new HttpError(404, "not-found", "no such tool");
1572
+ if (err.code === "tool-not-allowed") return new HttpError(403, "tool-not-allowed", err.message);
1573
+ if (err.code === "unpinned") return new HttpError(409, "not-installed", err.message);
1574
+ if (err.code === "invalid-arguments") {
1575
+ return new HttpError(400, "invalid-body", err.message, { errors: err.extra?.errors ?? [] });
1576
+ }
1577
+ if (err.code === "not-registered") return new HttpError(409, "not-installed", err.message);
1578
+ return new HttpError(502, "upstream-failed", err.message);
1579
+ }
1580
+
1581
+ async function toolsRoute() {
1582
+ try {
1583
+ const tools = await toolkitClient().listTools();
1584
+ return { contractVersion: TOOLS_VERSION, version: activeToolkitVersion(), tools };
1585
+ } catch (err) {
1586
+ throw toolError(err);
1587
+ }
1588
+ }
1589
+
1590
+ async function toolCallRoute({ params, body }) {
1591
+ requireBody(
1592
+ body,
1593
+ { arguments: (v) => v && typeof v === "object" && !Array.isArray(v) },
1594
+ "tool-call-request.schema.json",
1595
+ );
1596
+ if (!TOOL_NAME.test(params.name)) throw new HttpError(404, "not-found", "no such tool");
1597
+ try {
1598
+ return await toolkitClient().callTool(params.name, body.arguments ?? {});
1599
+ } catch (err) {
1600
+ throw toolError(err);
1601
+ }
1602
+ }
1603
+
1604
+ function serviceError(err) {
1605
+ if (!(err instanceof ServiceError)) return err;
1606
+ if (err.code === "bad-input") return new HttpError(400, "invalid-query", err.message);
1607
+ if (err.code === "not-installed" || err.code === "not-configured") {
1608
+ return new HttpError(409, "not-installed", err.message);
1609
+ }
1610
+ if (err.code === "host-not-allowed") return new HttpError(409, "host-not-allowed", err.message);
1611
+ return new HttpError(502, "upstream-failed", err.message);
1612
+ }
1613
+
1614
+ const serviceRoute = (key, fn) =>
1615
+ oneAtATime(key, async (ctx) => {
1616
+ try {
1617
+ return await fn(ctx);
1618
+ } catch (err) {
1619
+ throw serviceError(err);
1620
+ }
1621
+ });
1622
+
1623
+ /**
1624
+ * GET /v1/permissions. Claude Code offers no mechanism that records a pending
1625
+ * permission prompt outside the session that shows it, so there is nothing to
1626
+ * list yet. The shape is fixed now so a client can render it, and `available`
1627
+ * says plainly that nothing is forwarded; pipeline questions are answered
1628
+ * through POST /v1/runs/{id}/answer.
1629
+ */
1630
+ const permissionsDocument = () => ({
1631
+ contractVersion: TOOLS_VERSION,
1632
+ available: false,
1633
+ reason:
1634
+ "the host does not record pending permission prompts where another process can read them; pipeline questions are in GET /v1/activity and answered with POST /v1/runs/{id}/answer",
1635
+ requests: [],
1636
+ });
1637
+
1638
+ const listRuns = ({ q, redact }) =>
1639
+ envelope(selectRuns({ group: one(q, "group") }).records, { redact });
1640
+ const oneRun = ({ params, redact }) => envelope(runRecordFor(params.id), { redact });
1641
+
1642
+ /**
1643
+ * Every route. `query` names the parameters beyond `v` and `redact` that a
1644
+ * route accepts; `body` marks a route that reads a JSON body. A route with
1645
+ * `scope` is a phone route: it is reached only with a device signature, never
1646
+ * with the bearer token, and is always served redacted.
1647
+ *
1648
+ * `remote` and `risk` are the tags a remote carrier would enforce: the scope a
1649
+ * caller would need (read, answer, launch, local-only) and the confirmation
1650
+ * tier (read, low, high). `stream` marks the server-sent event route,
1651
+ * `selfOrigin` a route a foreign page may never read even when its origin is
1652
+ * allowed (the chat transcript), and `write` a route whose call is logged.
1653
+ */
1654
+ export const ROUTES = [
1655
+ { method: "GET", path: "/v1/runs", query: ["group"], handle: listRuns },
1656
+ { method: "GET", path: "/v1/runs/{id}", query: [], handle: oneRun },
1657
+ {
1658
+ method: "GET",
1659
+ path: "/v1/commands",
1660
+ query: [],
1661
+ handle: () => {
1662
+ const out = catalog();
1663
+ if (!out) throw new HttpError(500, "internal", "no command tree found");
1664
+ return out;
1665
+ },
1666
+ },
1667
+ { method: "GET", path: "/v1/questions", query: [], handle: () => questionsDocument() },
1668
+ {
1669
+ method: "GET",
1670
+ path: "/v1/issues",
1671
+ query: ["source", "project", "max"],
1672
+ handle: ({ q }) =>
1673
+ collect({
1674
+ source: one(q, "source") ?? "all",
1675
+ projects: q.project ?? [],
1676
+ max: Number(one(q, "max") ?? 50),
1677
+ }),
1678
+ },
1679
+ {
1680
+ method: "GET",
1681
+ path: "/v1/worktrees",
1682
+ query: ["measure"],
1683
+ handle: ({ q, redact }) => buildList({ doMeasure: flag(one(q, "measure")), redact }),
1684
+ },
1685
+ { method: "GET", path: "/v1/launch-spec", query: [], handle: () => launchSpec() },
1686
+ {
1687
+ method: "GET",
1688
+ path: "/v1/repos",
1689
+ query: ["all"],
1690
+ handle: ({ q, redact }) => reposDocument({ redact, extras: flag(one(q, "all")) }),
1691
+ },
1692
+ {
1693
+ method: "POST",
1694
+ path: "/v1/repos",
1695
+ query: [],
1696
+ body: true,
1697
+ remote: "local-only",
1698
+ risk: "high",
1699
+ handle: repoAddRoute,
1700
+ },
1701
+ {
1702
+ method: "DELETE",
1703
+ path: "/v1/repos/{id}",
1704
+ query: ["confirm"],
1705
+ remote: "local-only",
1706
+ risk: "high",
1707
+ handle: repoRemoveRoute,
1708
+ },
1709
+ {
1710
+ method: "POST",
1711
+ path: "/v1/launch",
1712
+ query: ["repo"],
1713
+ body: true,
1714
+ remote: "launch",
1715
+ risk: "high",
1716
+ handle: launchRun,
1717
+ },
1718
+ {
1719
+ method: "POST",
1720
+ path: "/v1/runs/{id}/answer",
1721
+ query: [],
1722
+ body: true,
1723
+ remote: "answer",
1724
+ risk: "low",
1725
+ handle: answerRun,
1726
+ },
1727
+ { method: "GET", path: "/v1/runs/{id}/log", query: ["tail"], handle: logRun },
1728
+ {
1729
+ method: "POST",
1730
+ path: "/v1/runs/{id}/resume",
1731
+ query: [],
1732
+ body: true,
1733
+ remote: "launch",
1734
+ risk: "high",
1735
+ handle: resumeRun,
1736
+ },
1737
+ {
1738
+ method: "POST",
1739
+ path: "/v1/runs/{id}/steer",
1740
+ query: [],
1741
+ body: true,
1742
+ remote: "launch",
1743
+ risk: "high",
1744
+ handle: steerRoute,
1745
+ },
1746
+ {
1747
+ method: "POST",
1748
+ path: "/v1/runs/{id}/kill",
1749
+ query: [],
1750
+ body: true,
1751
+ remote: "local-only",
1752
+ risk: "high",
1753
+ handle: killRoute,
1754
+ },
1755
+ {
1756
+ method: "POST",
1757
+ path: "/v1/gc",
1758
+ query: [],
1759
+ body: true,
1760
+ remote: "local-only",
1761
+ risk: "high",
1762
+ handle: gcRoute,
1763
+ },
1764
+ {
1765
+ method: "POST",
1766
+ path: "/v1/prune-logs",
1767
+ query: [],
1768
+ body: true,
1769
+ remote: "local-only",
1770
+ risk: "high",
1771
+ handle: oneAtATime("prune-logs", pruneRoute),
1772
+ },
1773
+ {
1774
+ method: "GET",
1775
+ path: "/v1/settings",
1776
+ query: [],
1777
+ remote: "local-only",
1778
+ handle: () => readSettings(),
1779
+ },
1780
+ {
1781
+ method: "POST",
1782
+ path: "/v1/settings",
1783
+ query: [],
1784
+ body: true,
1785
+ remote: "local-only",
1786
+ risk: "high",
1787
+ handle: oneAtATime("settings", settingsRoute),
1788
+ },
1789
+ {
1790
+ method: "GET",
1791
+ path: "/v1/autopilot",
1792
+ query: [],
1793
+ handle: oneAtATime("autopilot-status", autopilotGet),
1794
+ },
1795
+ {
1796
+ method: "POST",
1797
+ path: "/v1/autopilot/off",
1798
+ query: [],
1799
+ body: true,
1800
+ remote: "launch",
1801
+ risk: "low",
1802
+ handle: oneAtATime("autopilot", autopilotOffRoute),
1803
+ },
1804
+ {
1805
+ method: "POST",
1806
+ path: "/v1/autopilot/on",
1807
+ query: [],
1808
+ body: true,
1809
+ remote: "local-only",
1810
+ risk: "high",
1811
+ handle: oneAtATime("autopilot", autopilotOnRoute),
1812
+ },
1813
+ { method: "GET", path: "/v1/activity", query: [], handle: () => activityNow() },
1814
+ {
1815
+ method: "GET",
1816
+ path: "/v1/activity/stream",
1817
+ query: [],
1818
+ stream: true,
1819
+ handle: () => activityNow(),
1820
+ },
1821
+ {
1822
+ method: "GET",
1823
+ path: "/v1/agents/{sessionId}/{agentId}/chat",
1824
+ query: ["before", "limit"],
1825
+ selfOrigin: true,
1826
+ handle: chatRoute,
1827
+ },
1828
+ { method: "GET", path: "/v1/ledger", query: [], handle: () => officeLedger() },
1829
+ {
1830
+ method: "GET",
1831
+ path: "/v1/system",
1832
+ query: [],
1833
+ handle: shared(
1834
+ () => "system",
1835
+ () => systemStatus(),
1836
+ ),
1837
+ },
1838
+ {
1839
+ method: "GET",
1840
+ path: "/v1/credentials",
1841
+ query: ["probe"],
1842
+ remote: "local-only",
1843
+ handle: shared(
1844
+ ({ q }) => "credentials:" + (one(q, "probe") === "1"),
1845
+ ({ q }) => officeCredentials({ probe: one(q, "probe") === "1" }),
1846
+ ),
1847
+ },
1848
+ {
1849
+ method: "GET",
1850
+ path: "/v1/memory",
1851
+ query: [],
1852
+ remote: "local-only",
1853
+ handle: shared(
1854
+ () => "memory",
1855
+ () => officeMemory(),
1856
+ ),
1857
+ },
1858
+ { method: "GET", path: "/v1/runs/{id}/results", query: [], handle: resultsRoute },
1859
+ { method: "GET", path: "/v1/runs/{id}/evidence/{name}", query: [], handle: evidenceRoute },
1860
+ {
1861
+ method: "POST",
1862
+ path: "/v1/runs/{id}/review-verdict",
1863
+ query: [],
1864
+ body: true,
1865
+ remote: "launch",
1866
+ risk: "high",
1867
+ handle: oneAtATime("review-verdict", reviewVerdictRoute),
1868
+ },
1869
+ {
1870
+ method: "GET",
1871
+ path: "/v1/issues/{key}/maturity",
1872
+ query: [],
1873
+ handle: oneAtATime("maturity", maturityRoute),
1874
+ },
1875
+ {
1876
+ method: "GET",
1877
+ path: "/v1/prs",
1878
+ query: [],
1879
+ handle: oneAtATime("prs", ({ redact }) => pullRequests({ redact })),
1880
+ },
1881
+ { method: "GET", path: "/v1/tools", query: [], handle: toolsRoute },
1882
+ {
1883
+ method: "POST",
1884
+ path: "/v1/tools/{name}/call",
1885
+ query: [],
1886
+ body: true,
1887
+ remote: "local-only",
1888
+ risk: "high",
1889
+ handle: toolCallRoute,
1890
+ },
1891
+ {
1892
+ method: "GET",
1893
+ path: "/v1/services/confluence",
1894
+ query: ["page"],
1895
+ handle: serviceRoute("confluence", ({ q }) => {
1896
+ if (one(q, "page") === null) throw new HttpError(400, "invalid-query", "page is required");
1897
+ return confluencePage(one(q, "page"));
1898
+ }),
1899
+ },
1900
+ {
1901
+ method: "GET",
1902
+ path: "/v1/services/crashlytics",
1903
+ query: ["issue"],
1904
+ handle: serviceRoute("crashlytics", ({ q }) => {
1905
+ if (one(q, "issue") === null) throw new HttpError(400, "invalid-query", "issue is required");
1906
+ return crashlyticsIssue(one(q, "issue"));
1907
+ }),
1908
+ },
1909
+ {
1910
+ method: "GET",
1911
+ path: "/v1/services/figma",
1912
+ query: ["file", "node"],
1913
+ handle: serviceRoute("figma", ({ q }) => {
1914
+ if (one(q, "file") === null || one(q, "node") === null) {
1915
+ throw new HttpError(400, "invalid-query", "file and node are required");
1916
+ }
1917
+ return figmaFrame(one(q, "file"), one(q, "node"));
1918
+ }),
1919
+ },
1920
+ { method: "GET", path: "/v1/permissions", query: [], handle: permissionsDocument },
1921
+ { method: "GET", path: "/v1/routines", query: [], handle: routinesGet },
1922
+ {
1923
+ method: "DELETE",
1924
+ path: "/v1/routines/{name}",
1925
+ query: ["confirm"],
1926
+ remote: "local-only",
1927
+ risk: "high",
1928
+ handle: routineDeleteRoute,
1929
+ },
1930
+ { method: "GET", path: "/v1/stack", query: ["repo"], handle: stackGet },
1931
+ { method: "GET", path: "/v1/jobs", query: [], handle: () => jobsDocument() },
1932
+ {
1933
+ method: "POST",
1934
+ path: "/v1/jobs",
1935
+ query: [],
1936
+ body: true,
1937
+ remote: "local-only",
1938
+ risk: "high",
1939
+ handle: jobsPost,
1940
+ },
1941
+ {
1942
+ method: "POST",
1943
+ path: "/v1/stack",
1944
+ query: [],
1945
+ body: true,
1946
+ remote: "local-only",
1947
+ risk: "high",
1948
+ handle: stackPost,
1949
+ },
1950
+ { method: "GET", path: "/v1/phone/runs", query: ["group"], scope: "read", handle: listRuns },
1951
+ { method: "GET", path: "/v1/phone/runs/{id}", query: [], scope: "read", handle: oneRun },
1952
+ {
1953
+ method: "POST",
1954
+ path: "/v1/phone/runs/{id}/answer",
1955
+ query: [],
1956
+ body: true,
1957
+ scope: "answer",
1958
+ risk: "low",
1959
+ handle: answerRun,
1960
+ },
1961
+ {
1962
+ method: "POST",
1963
+ path: "/v1/phone/launch",
1964
+ query: ["repo"],
1965
+ body: true,
1966
+ scope: "launch",
1967
+ risk: "high",
1968
+ handle: (ctx) => {
1969
+ if (!ctx.registry.launchEnabled) {
1970
+ throw new HttpError(403, "launch-disabled", "launch from a phone is not enabled here");
1971
+ }
1972
+ return launchRun({ ...ctx, channel: "phone" });
1973
+ },
1974
+ },
1975
+ ].map((r) => ({
1976
+ ...r,
1977
+ remote: r.remote ?? r.scope ?? "read",
1978
+ risk: r.risk ?? "read",
1979
+ write: r.method !== "GET",
1980
+ phone: r.path.startsWith(PHONE_PREFIX),
1981
+ params: [...r.path.matchAll(/\{([A-Za-z]+)\}/g)].map((m) => m[1]),
1982
+ pattern: new RegExp(`^${r.path.replace(/\{([A-Za-z]+)\}/g, "(?<$1>[^/]+)")}$`),
1983
+ }));
1984
+
1985
+ /**
1986
+ * The route for a path and method, or the HttpError that says why not. A phone
1987
+ * path is looked up among phone routes only and a desktop path among desktop
1988
+ * routes only, so neither credential can reach the other family.
1989
+ */
1990
+ export function match(method, pathname) {
1991
+ const phone = isPhonePath(pathname);
1992
+ const hits = [];
1993
+ for (const r of ROUTES) {
1994
+ if (r.phone !== phone) continue;
1995
+ const m = r.pattern.exec(pathname);
1996
+ if (m) hits.push({ route: r, params: m.groups ?? {} });
1997
+ }
1998
+ if (!hits.length) {
1999
+ if (phone) throw new HttpError(404, "unknown-verb", "the phone API has no such verb");
2000
+ throw new HttpError(404, "not-found", "no such route");
2001
+ }
2002
+ const hit = hits.find((h) => h.route.method === method);
2003
+ if (!hit) {
2004
+ throw new HttpError(
2005
+ 405,
2006
+ "method-not-allowed",
2007
+ `use ${hits.map((h) => h.route.method).join(", ")}`,
2008
+ {
2009
+ allow: hits.map((h) => h.route.method).join(", "),
2010
+ },
2011
+ );
2012
+ }
2013
+ for (const [k, v] of Object.entries(hit.params)) {
2014
+ try {
2015
+ hit.params[k] = decodeURIComponent(v);
2016
+ } catch {
2017
+ throw new HttpError(404, "not-found", k === "id" ? "no such run" : "no such resource");
2018
+ }
2019
+ }
2020
+ return hit;
2021
+ }
2022
+
2023
+ /** Query parameters as {name: [values]}, after every rule a route declares. */
2024
+ export function parseQuery(route, searchParams) {
2025
+ const q = {};
2026
+ for (const [k, v] of searchParams) {
2027
+ if (k !== "v" && k !== "redact" && !route.query.includes(k)) {
2028
+ throw new HttpError(400, "invalid-query", `unknown query parameter ${k}`);
2029
+ }
2030
+ (q[k] ??= []).push(v);
2031
+ }
2032
+ for (const [k, values] of Object.entries(q)) {
2033
+ if (k === "v") {
2034
+ if (
2035
+ values.length !== 1 ||
2036
+ !SUPPORTED_MAJORS.includes(Number(values[0])) ||
2037
+ !/^[0-9]+$/.test(values[0])
2038
+ ) {
2039
+ throw new HttpError(400, "unsupported-version", "this server does not serve that major", {
2040
+ supported: SUPPORTED_MAJORS,
2041
+ });
2042
+ }
2043
+ continue;
2044
+ }
2045
+ const rule = Q[k];
2046
+ if (values.length > 1 && !rule.repeat)
2047
+ throw new HttpError(400, "invalid-query", `${k} is given twice`);
2048
+ for (const v of values) {
2049
+ if (!rule.check(v)) throw new HttpError(400, "invalid-query", `${k} has an invalid value`);
2050
+ }
2051
+ }
2052
+ return q;
2053
+ }
2054
+
2055
+ const sha = (s) => createHash("sha256").update(s).digest();
2056
+
2057
+ /** Does this path belong to the phone family? */
2058
+ export const isPhonePath = (pathname) =>
2059
+ pathname === PHONE_PREFIX.slice(0, -1) || pathname.startsWith(PHONE_PREFIX);
2060
+
2061
+ /** Constant-time bearer check: equal-length digests, whatever the input. */
2062
+ export function authorized(header, tokenDigest) {
2063
+ const m = /^Bearer ([A-Za-z0-9_-]{1,512})$/.exec(String(header ?? ""));
2064
+ return timingSafeEqual(sha(m ? m[1] : ""), tokenDigest) && Boolean(m);
2065
+ }
2066
+
2067
+ function readRawBody(req, timeoutMs = 0) {
2068
+ return new Promise((resolve, reject) => {
2069
+ const chunks = [];
807
2070
  let size = 0;
808
2071
  let over = false;
2072
+ if (timeoutMs > 0) {
2073
+ const timer = setTimeout(
2074
+ () => reject(new HttpError(408, "invalid-body", "the body did not arrive in time")),
2075
+ timeoutMs,
2076
+ );
2077
+ req.on("end", () => clearTimeout(timer));
2078
+ req.on("close", () => clearTimeout(timer));
2079
+ }
809
2080
  req.on("data", (chunk) => {
810
2081
  size += chunk.length;
811
2082
  if (size > MAX_BODY_BYTES) over = true;
@@ -846,7 +2117,13 @@ function drain(req) {
846
2117
  }
847
2118
 
848
2119
  function parseArgs(argv) {
849
- const opts = { port: 0, idleMinutes: DEFAULT_IDLE_MINUTES, origins: [], printToken: false };
2120
+ const opts = {
2121
+ port: 0,
2122
+ idleMinutes: DEFAULT_IDLE_MINUTES,
2123
+ maxHours: DEFAULT_MAX_HOURS,
2124
+ origins: [],
2125
+ printToken: false,
2126
+ };
850
2127
  for (let i = 0; i < argv.length; i++) {
851
2128
  const a = argv[i];
852
2129
  const value = () => {
@@ -863,10 +2140,16 @@ function parseArgs(argv) {
863
2140
  opts.port = Number(p);
864
2141
  } else if (a === "--idle-minutes") {
865
2142
  const n = Number(value());
866
- if (!Number.isFinite(n) || n <= 0 || n > MAX_IDLE_MINUTES) {
867
- throw new Error(`--idle-minutes is more than 0 and at most ${MAX_IDLE_MINUTES}`);
2143
+ if (!Number.isFinite(n) || n < 0 || n > MAX_IDLE_MINUTES) {
2144
+ throw new Error(`--idle-minutes is 0 (never) to ${MAX_IDLE_MINUTES}`);
868
2145
  }
869
2146
  opts.idleMinutes = n;
2147
+ } else if (a === "--max-hours") {
2148
+ const n = Number(value());
2149
+ if (!Number.isFinite(n) || n < 0 || n > MAX_HOURS) {
2150
+ throw new Error(`--max-hours is 0 (no limit) to ${MAX_HOURS}`);
2151
+ }
2152
+ opts.maxHours = n;
870
2153
  } else if (a === "--allow-origin") {
871
2154
  const o = value();
872
2155
  let parsed;
@@ -904,6 +2187,154 @@ function writeTokenFile(record) {
904
2187
  return file;
905
2188
  }
906
2189
 
2190
+ export const OFFICE_PATH = "/office";
2191
+ /** Bearer, no Origin: mints a one-time panel code (office-control.mjs). */
2192
+ export const OFFICE_CODE_PATH = "/office/code";
2193
+ /** No bearer, own Origin only: trades a panel code for the token, once. */
2194
+ export const OFFICE_EXCHANGE_PATH = "/office/exchange";
2195
+ /** Bearer, own Origin or none: the installed page's build and whether this server is stale. */
2196
+ export const OFFICE_VERSION_PATH = "/office/version";
2197
+ /** Bearer, own Origin or none: a fresh panel code, so the page can reload itself. */
2198
+ export const OFFICE_RELOAD_PATH = "/office/reload";
2199
+
2200
+ const serverDigest = () => codeDigest(dirname(fileURLToPath(import.meta.url)));
2201
+ /** The server's code (scripts/ and lib/) as it was at start; a reinstall changes the files, not the process. */
2202
+ const SERVER_DIGEST = serverDigest();
2203
+
2204
+ /** The build id office-build.mjs stamps into the installed page, or null. */
2205
+ export function officeBuild(env = process.env) {
2206
+ const file = officePage(env);
2207
+ if (!file) return null;
2208
+ try {
2209
+ return /const BUILD = "([0-9a-f]{12})";/.exec(readFileSync(file, "utf8"))?.[1] ?? null;
2210
+ } catch {
2211
+ return null;
2212
+ }
2213
+ }
2214
+
2215
+ function readPrefs(env) {
2216
+ try {
2217
+ return JSON.parse(
2218
+ readFileSync(
2219
+ env.MULTI_AGENT_PREFS || join(homedir(), ".claude", "multi-agent-preferences.json"),
2220
+ "utf8",
2221
+ ),
2222
+ );
2223
+ } catch {
2224
+ return null;
2225
+ }
2226
+ }
2227
+
2228
+ function prefsOfficePage(env) {
2229
+ return readPrefs(env)?.global?.office?.page ?? null;
2230
+ }
2231
+
2232
+ /**
2233
+ * The page language: "tr" when prefs.global.outputLanguage is Turkish, else
2234
+ * "en". Only these two values ever reach the page, so the stamp needs no
2235
+ * escaping.
2236
+ */
2237
+ export function officeLang(env = process.env) {
2238
+ const lang = readPrefs(env)?.global?.outputLanguage;
2239
+ return typeof lang === "string" && /^tr\b/i.test(lang) ? "tr" : "en";
2240
+ }
2241
+
2242
+ /**
2243
+ * The panel page, when one is on disk: MA_OFFICE_PAGE, else
2244
+ * prefs.global.office.page, else (neither set) the copy beside this package.
2245
+ * A configured path is the only candidate, so pointing it elsewhere never
2246
+ * falls back. Absolute, regular files only; none is not an error, the route
2247
+ * is 404.
2248
+ */
2249
+ export function officePage(env = process.env) {
2250
+ const configured = env.MA_OFFICE_PAGE || prefsOfficePage(env);
2251
+ const candidates = configured ? [configured] : [join(HERE, "..", "office", "index.html")];
2252
+ for (const p of candidates) {
2253
+ if (typeof p !== "string" || !isAbsolute(p)) continue;
2254
+ try {
2255
+ if (statSync(p).isFile()) return p;
2256
+ } catch {
2257
+ // Not there: the route answers 404.
2258
+ }
2259
+ }
2260
+ return null;
2261
+ }
2262
+
2263
+ /** Sprites of the office floor, served beside the page under this prefix. */
2264
+ export const OFFICE_ASSET_PREFIX = "/office/assets/";
2265
+ const OFFICE_ASSET_NAME = /^[A-Za-z0-9_]{1,48}\.png$/;
2266
+ const OFFICE_ASSET_MAX_BYTES = 256 * 1024;
2267
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
2268
+
2269
+ /**
2270
+ * A sprite of the installed page: the raw name matches a plain file name
2271
+ * pattern (no separator, no escape), the file is a regular PNG of at most
2272
+ * 256 KiB whose real path is directly inside the page's assets directory.
2273
+ * Anything else is null, which the route answers with 404.
2274
+ */
2275
+ export function officeAsset(name, env = process.env) {
2276
+ if (!OFFICE_ASSET_NAME.test(name)) return null;
2277
+ const page = officePage(env);
2278
+ if (!page) return null;
2279
+ try {
2280
+ const dir = realpathSync(join(dirname(page), "assets"));
2281
+ const file = realpathSync(join(dir, name));
2282
+ if (dirname(file) !== dir) return null;
2283
+ const st = statSync(file);
2284
+ if (!st.isFile() || st.size > OFFICE_ASSET_MAX_BYTES) return null;
2285
+ const body = readFileSync(file);
2286
+ return body.subarray(0, 8).equals(PNG_SIGNATURE) ? body : null;
2287
+ } catch {
2288
+ return null;
2289
+ }
2290
+ }
2291
+
2292
+ /** The panel's CSP: nothing but its own nonce'd inline code, same-origin calls and images. */
2293
+ export const officeCsp = (nonce) =>
2294
+ [
2295
+ "default-src 'none'",
2296
+ `script-src 'nonce-${nonce}'`,
2297
+ `style-src 'nonce-${nonce}'`,
2298
+ "img-src 'self' data:",
2299
+ "connect-src 'self'",
2300
+ "frame-ancestors 'none'",
2301
+ "base-uri 'none'",
2302
+ "form-action 'none'",
2303
+ ].join("; ");
2304
+
2305
+ /** Where every write route leaves its line. */
2306
+ export function actionsLog() {
2307
+ return join(homedir(), ".claude", "logs", "multi-agent", "office-actions.jsonl");
2308
+ }
2309
+
2310
+ /**
2311
+ * One line per write: who (the local bearer holder, and whether the request
2312
+ * came from the panel page), what (route, path parameters, preview or
2313
+ * confirm), when and the outcome; a tool call adds the sha256 of its
2314
+ * arguments. Never a body, a query value or a token.
2315
+ * A log that cannot be written does not fail the request.
2316
+ */
2317
+ function logAction({ route, params, own, confirm, status, error, argsSha256 }) {
2318
+ const line = {
2319
+ at: new Date().toISOString(),
2320
+ who: "local",
2321
+ via: own ? "panel" : "client",
2322
+ route,
2323
+ params,
2324
+ step: confirm ? "confirm" : "request",
2325
+ status,
2326
+ ...(argsSha256 ? { argsSha256 } : {}),
2327
+ ...(error ? { error } : {}),
2328
+ };
2329
+ try {
2330
+ const file = actionsLog();
2331
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
2332
+ appendFileSync(file, JSON.stringify(line) + "\n", { mode: 0o600 });
2333
+ } catch {
2334
+ // The action itself already happened or was refused; the log is best effort.
2335
+ }
2336
+ }
2337
+
907
2338
  /**
908
2339
  * Start listening. Resolves with the ready record once the port is known.
909
2340
  *
@@ -915,11 +2346,28 @@ export function serve(opts) {
915
2346
  const origins = new Set(opts.origins ?? []);
916
2347
  const idleMs = Math.round((opts.idleMinutes ?? DEFAULT_IDLE_MINUTES) * 60000);
917
2348
  let hosts = new Set();
2349
+ let selfOrigins = new Set();
2350
+ const streams = new Set();
918
2351
  let startSec = Infinity;
919
2352
  const nonces = createNonceStore({ file: noncesPath() });
920
2353
  let tokenFile = null;
921
2354
  let idleTimer = null;
2355
+ let lifeTimer = null;
922
2356
  let stopping = false;
2357
+ const codes = new Map();
2358
+ const mintCode = () => {
2359
+ const now = Date.now();
2360
+ for (const [c, exp] of codes) if (exp <= now) codes.delete(c);
2361
+ while (codes.size >= MAX_CODES) codes.delete(codes.keys().next().value);
2362
+ const code = randomBytes(32).toString("base64url");
2363
+ codes.set(code, now + codeTtlMs);
2364
+ return { code, expiresAt: new Date(now + codeTtlMs).toISOString() };
2365
+ };
2366
+ const redeemCode = (code) => {
2367
+ const exp = typeof code === "string" && PANEL_CODE.test(code) ? codes.get(code) : undefined;
2368
+ if (exp !== undefined) codes.delete(code);
2369
+ return exp !== undefined && exp > Date.now();
2370
+ };
923
2371
 
924
2372
  // requireHostHeader off: a request without Host reaches the Host check below
925
2373
  // and is refused there with a contract error body, like any other wrong Host.
@@ -929,6 +2377,7 @@ export function serve(opts) {
929
2377
  requireHostHeader: false,
930
2378
  });
931
2379
  server.maxHeadersCount = 50;
2380
+ server.maxConnections = MAX_CONNECTIONS;
932
2381
 
933
2382
  const send = (res, status, obj, extra = {}) => {
934
2383
  const body = serialize(obj);
@@ -937,11 +2386,37 @@ export function serve(opts) {
937
2386
  "Content-Length": Buffer.byteLength(body),
938
2387
  "Cache-Control": "no-store",
939
2388
  "X-Content-Type-Options": "nosniff",
2389
+ "Referrer-Policy": "no-referrer",
2390
+ "Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
940
2391
  "X-Contract-Version": CONTRACT_VERSION,
941
2392
  ...extra,
942
2393
  });
943
2394
  res.end(body);
944
2395
  };
2396
+
2397
+ /**
2398
+ * The panel, with a fresh nonce in its CSP and in each inline script and
2399
+ * style tag, so no other inline code runs, and its language stamped in.
2400
+ * 404 when no page is installed.
2401
+ */
2402
+ const serveOffice = (res, headOnly) => {
2403
+ const file = officePage();
2404
+ if (!file) throw new HttpError(404, "not-found", "no panel is installed on this machine");
2405
+ const nonce = randomBytes(18).toString("base64url");
2406
+ const html = readFileSync(file, "utf8")
2407
+ .replaceAll("{{NONCE}}", nonce)
2408
+ .replaceAll("{{LANG}}", officeLang());
2409
+ res.writeHead(200, {
2410
+ "Content-Type": "text/html; charset=utf-8",
2411
+ "Content-Length": Buffer.byteLength(html),
2412
+ "Cache-Control": "no-store",
2413
+ "X-Content-Type-Options": "nosniff",
2414
+ "Referrer-Policy": "no-referrer",
2415
+ "Cross-Origin-Opener-Policy": "same-origin",
2416
+ "Content-Security-Policy": officeCsp(nonce),
2417
+ });
2418
+ res.end(headOnly ? undefined : html);
2419
+ };
945
2420
  const fail = (res, err, extra) => {
946
2421
  const body = { contractVersion: CONTRACT_VERSION, error: err.code, message: err.message };
947
2422
  if (err.extra?.errors) body.errors = err.extra.errors;
@@ -957,7 +2432,10 @@ export function serve(opts) {
957
2432
  if (stopping) return;
958
2433
  stopping = true;
959
2434
  clearTimeout(idleTimer);
2435
+ clearTimeout(lifeTimer);
960
2436
  removeTokenFile();
2437
+ for (const st of streams) clearInterval(st.timer);
2438
+ closeToolkit();
961
2439
  process.stderr.write(`contract-server: stopping (${why})\n`);
962
2440
  server.close(() => process.exit(0));
963
2441
  server.closeAllConnections();
@@ -965,7 +2443,7 @@ export function serve(opts) {
965
2443
  };
966
2444
  const arm = () => {
967
2445
  clearTimeout(idleTimer);
968
- idleTimer = setTimeout(() => stop("idle"), idleMs);
2446
+ if (idleMs > 0) idleTimer = setTimeout(() => stop("idle"), idleMs);
969
2447
  };
970
2448
 
971
2449
  /**
@@ -977,7 +2455,7 @@ export function serve(opts) {
977
2455
  const audit = { deviceId: null, method: req.method, verb: verbOf(url.pathname) };
978
2456
  let insecure = null;
979
2457
  try {
980
- const raw = await readRawBody(req);
2458
+ const raw = await readRawBody(req, PHONE_BODY_TIMEOUT_MS);
981
2459
  const registry = readRegistry();
982
2460
  insecure = registry.insecure;
983
2461
  const v = verifyRequest({
@@ -1019,6 +2497,7 @@ export function serve(opts) {
1019
2497
  if (!(err instanceof HttpError))
1020
2498
  process.stderr.write(`contract-server: ${err?.message ?? err}\n`);
1021
2499
  fail(res, e, cors);
2500
+ if (e.status === 408) req.destroy();
1022
2501
  const outcome = e.code === "unknown-device" && insecure ? insecure : e.code;
1023
2502
  appendAudit({ ...audit, status: e.status, outcome });
1024
2503
  }
@@ -1028,22 +2507,24 @@ export function serve(opts) {
1028
2507
  arm();
1029
2508
  res.on("finish", arm);
1030
2509
  const origin = req.headers.origin;
2510
+ const own = origin !== undefined && selfOrigins.has(origin);
1031
2511
  const cors =
1032
- origin && origins.has(origin)
2512
+ origin && !own && origins.has(origin)
1033
2513
  ? { "Access-Control-Allow-Origin": origin, Vary: "Origin" }
1034
2514
  : {};
2515
+ let audit = null;
1035
2516
  try {
1036
2517
  if (!hosts.has(String(req.headers.host ?? "").toLowerCase())) {
1037
2518
  throw new HttpError(403, "forbidden-host", "the Host header does not name this server");
1038
2519
  }
1039
- if (origin !== undefined && !origins.has(origin)) {
2520
+ if (origin !== undefined && !own && !origins.has(origin)) {
1040
2521
  throw new HttpError(403, "forbidden-origin", "this origin is not allowed");
1041
2522
  }
1042
2523
  if (req.method === "OPTIONS" && origin !== undefined) {
1043
2524
  drain(req);
1044
2525
  res.writeHead(204, {
1045
2526
  ...cors,
1046
- "Access-Control-Allow-Methods": "GET, POST",
2527
+ "Access-Control-Allow-Methods": "GET",
1047
2528
  "Access-Control-Allow-Headers": "Authorization, Content-Type",
1048
2529
  "Access-Control-Max-Age": "600",
1049
2530
  "Cache-Control": "no-store",
@@ -1056,10 +2537,110 @@ export function serve(opts) {
1056
2537
  await servePhone(req, res, url, cors);
1057
2538
  return;
1058
2539
  }
2540
+ if (url.pathname === OFFICE_PATH && (req.method === "GET" || req.method === "HEAD")) {
2541
+ drain(req);
2542
+ if (url.search) throw new HttpError(400, "invalid-query", "the panel takes no query");
2543
+ serveOffice(res, req.method === "HEAD");
2544
+ return;
2545
+ }
2546
+ if (url.pathname === "/favicon.ico" && (req.method === "GET" || req.method === "HEAD")) {
2547
+ drain(req);
2548
+ res.writeHead(204, {
2549
+ "Cache-Control": "max-age=86400",
2550
+ "X-Content-Type-Options": "nosniff",
2551
+ });
2552
+ res.end();
2553
+ return;
2554
+ }
2555
+ if (url.pathname.startsWith(OFFICE_ASSET_PREFIX)) {
2556
+ drain(req);
2557
+ if (req.method !== "GET" && req.method !== "HEAD") {
2558
+ throw new HttpError(405, "method-not-allowed", "use GET", { allow: "GET" });
2559
+ }
2560
+ if (url.search) throw new HttpError(400, "invalid-query", "an asset takes no query");
2561
+ const body = officeAsset(url.pathname.slice(OFFICE_ASSET_PREFIX.length));
2562
+ if (!body) throw new HttpError(404, "not-found", "no such asset");
2563
+ res.writeHead(200, {
2564
+ "Content-Type": "image/png",
2565
+ "Content-Length": body.length,
2566
+ "Cache-Control": "max-age=3600",
2567
+ "X-Content-Type-Options": "nosniff",
2568
+ "Cross-Origin-Resource-Policy": "same-origin",
2569
+ "Content-Security-Policy": "default-src 'none'",
2570
+ });
2571
+ res.end(req.method === "HEAD" ? undefined : body);
2572
+ return;
2573
+ }
2574
+ if (url.pathname === OFFICE_EXCHANGE_PATH) {
2575
+ if (req.method !== "POST" || url.search) {
2576
+ drain(req);
2577
+ throw new HttpError(405, "method-not-allowed", "use POST", { allow: "POST" });
2578
+ }
2579
+ if (!own) {
2580
+ drain(req);
2581
+ throw new HttpError(403, "forbidden-origin", "only the panel page exchanges a code");
2582
+ }
2583
+ if (!/^application\/json(\s*;.*)?$/i.test(String(req.headers["content-type"] ?? ""))) {
2584
+ drain(req);
2585
+ throw new HttpError(415, "unsupported-media-type", "the body must be application/json");
2586
+ }
2587
+ const b = await readBody(req);
2588
+ if (!redeemCode(b?.code)) {
2589
+ throw new HttpError(
2590
+ 401,
2591
+ "unauthorized",
2592
+ "the code is unknown, used or expired; run /multi-agent:office-on again",
2593
+ );
2594
+ }
2595
+ send(res, 200, { contractVersion: CONTRACT_VERSION, token });
2596
+ return;
2597
+ }
1059
2598
  if (!authorized(req.headers.authorization, tokenDigest)) {
1060
2599
  throw new HttpError(401, "unauthorized", "a bearer token is required");
1061
2600
  }
2601
+ if (url.pathname === OFFICE_VERSION_PATH || url.pathname === OFFICE_RELOAD_PATH) {
2602
+ drain(req);
2603
+ if (origin !== undefined && !own) {
2604
+ throw new HttpError(403, "forbidden-origin", "only the panel page asks this");
2605
+ }
2606
+ if (url.pathname === OFFICE_VERSION_PATH) {
2607
+ if (req.method !== "GET") {
2608
+ throw new HttpError(405, "method-not-allowed", "use GET", { allow: "GET" });
2609
+ }
2610
+ send(res, 200, {
2611
+ contractVersion: CONTRACT_VERSION,
2612
+ build: officeBuild(),
2613
+ serverStale: serverDigest() !== SERVER_DIGEST,
2614
+ });
2615
+ return;
2616
+ }
2617
+ if (req.method !== "POST") {
2618
+ throw new HttpError(405, "method-not-allowed", "use POST", { allow: "POST" });
2619
+ }
2620
+ // The page already holds the token; a code lets it reload itself onto
2621
+ // the page now installed without the token ever entering a URL.
2622
+ send(res, 200, { contractVersion: CONTRACT_VERSION, ...mintCode() });
2623
+ return;
2624
+ }
2625
+ if (url.pathname === OFFICE_CODE_PATH) {
2626
+ drain(req);
2627
+ if (req.method !== "POST") {
2628
+ throw new HttpError(405, "method-not-allowed", "use POST", { allow: "POST" });
2629
+ }
2630
+ if (origin !== undefined) {
2631
+ throw new HttpError(403, "forbidden-origin", "a page may not mint a panel code");
2632
+ }
2633
+ send(res, 200, { contractVersion: CONTRACT_VERSION, ...mintCode() });
2634
+ return;
2635
+ }
1062
2636
  const { route, params } = match(req.method, url.pathname);
2637
+ // A write, and a read of a transcript, are refused to every page but the
2638
+ // panel this server serves: an allowed origin may read the contract, it
2639
+ // may not drive this machine or read what an agent said.
2640
+ if ((route.write || route.selfOrigin) && origin !== undefined && !own) {
2641
+ throw new HttpError(403, "forbidden-origin", "this origin may not call this route");
2642
+ }
2643
+ if (route.write) audit = { route: `${route.method} ${route.path}`, params, own };
1063
2644
  const q = parseQuery(route, url.searchParams);
1064
2645
  const redact = flag(one(q, "redact")) || origin !== undefined;
1065
2646
  let body = null;
@@ -1073,34 +2654,109 @@ export function serve(opts) {
1073
2654
  } else {
1074
2655
  drain(req);
1075
2656
  }
1076
- send(res, 200, await route.handle({ q, params, body, redact }), cors);
1077
- } catch (err) {
1078
- if (err instanceof HttpError) {
1079
- fail(res, err, cors);
2657
+ if (audit) audit.confirm = Boolean(body?.confirmToken ?? one(q, "confirm"));
2658
+ if (audit && route.path === "/v1/tools/{name}/call") {
2659
+ audit.argsSha256 = createHash("sha256")
2660
+ .update(JSON.stringify(body?.arguments ?? {}))
2661
+ .digest("hex");
2662
+ }
2663
+ if (route.stream) {
2664
+ serveStream(req, res, route, cors);
1080
2665
  return;
1081
2666
  }
1082
- process.stderr.write(`contract-server: ${err?.message ?? err}\n`);
1083
- fail(res, new HttpError(500, "internal", "internal error"), cors);
2667
+ const doc = await route.handle({ q, params, body, redact });
2668
+ if (audit) logAction({ ...audit, status: 200 });
2669
+ send(res, 200, doc, cors);
2670
+ } catch (err) {
2671
+ const e = err instanceof HttpError ? err : new HttpError(500, "internal", "internal error");
2672
+ if (!(err instanceof HttpError))
2673
+ process.stderr.write(`contract-server: ${err?.message ?? err}\n`);
2674
+ if (audit) logAction({ ...audit, status: e.status, error: e.code });
2675
+ fail(res, e, cors);
1084
2676
  }
1085
2677
  });
1086
2678
 
2679
+ /**
2680
+ * GET /v1/activity/stream: the activity document as server-sent events, sent
2681
+ * when it changes and checked every STREAM_POLL_MS, with a comment line as a
2682
+ * heartbeat. Each event re-arms the idle clock, so an open panel keeps the
2683
+ * server up and a closed one lets it stop. At most MAX_STREAMS at once.
2684
+ */
2685
+ const serveStream = (req, res, route, cors) => {
2686
+ if (streams.size >= MAX_STREAMS) {
2687
+ throw new HttpError(429, "too-many-streams", `at most ${MAX_STREAMS} streams at once`);
2688
+ }
2689
+ res.writeHead(200, {
2690
+ "Content-Type": "text/event-stream; charset=utf-8",
2691
+ "Cache-Control": "no-store",
2692
+ Connection: "keep-alive",
2693
+ "X-Content-Type-Options": "nosniff",
2694
+ "Referrer-Policy": "no-referrer",
2695
+ "X-Contract-Version": CONTRACT_VERSION,
2696
+ ...cors,
2697
+ });
2698
+ let last = null;
2699
+ let busy = false;
2700
+ let lastSent = Date.now();
2701
+ const tick = async () => {
2702
+ if (busy) return;
2703
+ busy = true;
2704
+ try {
2705
+ const doc = await route.handle({});
2706
+ const { generatedAt: _g, ...stable } = doc;
2707
+ const key = JSON.stringify(stable);
2708
+ if (key !== last) {
2709
+ last = key;
2710
+ res.write(`event: activity\ndata: ${JSON.stringify(doc)}\n\n`);
2711
+ lastSent = Date.now();
2712
+ arm();
2713
+ } else if (Date.now() - lastSent >= STREAM_HEARTBEAT_MS) {
2714
+ res.write(": keep-alive\n\n");
2715
+ lastSent = Date.now();
2716
+ arm();
2717
+ }
2718
+ } catch (err) {
2719
+ process.stderr.write(`contract-server: stream: ${err?.message ?? err}\n`);
2720
+ res.write(`event: error\ndata: {"error":"internal"}\n\n`);
2721
+ } finally {
2722
+ busy = false;
2723
+ }
2724
+ };
2725
+ const timer = setInterval(tick, STREAM_POLL_MS);
2726
+ const entry = { res, timer };
2727
+ streams.add(entry);
2728
+ const close = () => {
2729
+ clearInterval(timer);
2730
+ streams.delete(entry);
2731
+ };
2732
+ // The response's close, not the request's: a request emits close as soon
2733
+ // as its (empty) body has been read.
2734
+ res.on("close", close);
2735
+ tick();
2736
+ };
2737
+
1087
2738
  return new Promise((resolve, reject) => {
1088
2739
  server.once("error", reject);
1089
2740
  server.listen(opts.port ?? 0, BIND_HOST, () => {
1090
2741
  const { port } = server.address();
1091
2742
  hosts = new Set([`${BIND_HOST}:${port}`, `localhost:${port}`]);
2743
+ selfOrigins = new Set([`http://${BIND_HOST}:${port}`, `http://localhost:${port}`]);
1092
2744
  const record = {
1093
2745
  contractVersion: CONTRACT_VERSION,
1094
2746
  url: `http://${BIND_HOST}:${port}`,
1095
2747
  port,
1096
2748
  pid: process.pid,
1097
2749
  idleMinutes: opts.idleMinutes ?? DEFAULT_IDLE_MINUTES,
2750
+ maxHours: opts.maxHours ?? DEFAULT_MAX_HOURS,
1098
2751
  };
1099
2752
  const started = new Date();
1100
2753
  startSec = Math.floor(started.getTime() / 1000);
1101
2754
  tokenFile = writeTokenFile({ ...record, token, startedAt: started.toISOString() });
1102
2755
  for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) process.on(sig, () => stop(sig));
1103
2756
  arm();
2757
+ const maxHours = opts.maxHours ?? DEFAULT_MAX_HOURS;
2758
+ if (maxHours > 0)
2759
+ lifeTimer = setTimeout(() => stop("lifetime"), Math.round(maxHours * 3600000));
1104
2760
  resolve({ ...record, tokenFile, ...(opts.printToken ? { token } : {}) });
1105
2761
  });
1106
2762
  });
@@ -1116,7 +2772,7 @@ async function main(argv) {
1116
2772
  }
1117
2773
  if (opts.help) {
1118
2774
  process.stdout.write(
1119
- "Usage: contract-server.mjs [--port N] [--idle-minutes N] [--allow-origin <origin>]... [--print-token]\n",
2775
+ "Usage: contract-server.mjs [--port N] [--idle-minutes N] [--max-hours N] [--allow-origin <origin>]... [--print-token]\n",
1120
2776
  );
1121
2777
  return 0;
1122
2778
  }