@mmerterden/multi-agent-pipeline 20.28.0 → 20.30.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 (186) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +19 -6
  3. package/README.tr.md +5 -5
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/docs/facts.json +7 -7
  7. package/install/catalog-history.json +1 -1
  8. package/install/claude.mjs +122 -1
  9. package/install/codex.mjs +2 -0
  10. package/install/copilot.mjs +13 -0
  11. package/install/templates/claude-hooks.json +30 -1
  12. package/manifest.json +189 -77
  13. package/package.json +2 -1
  14. package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -1
  15. package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +13 -15
  16. package/pipeline/commands/multi-agent/office-off/SKILL.md +27 -0
  17. package/pipeline/commands/multi-agent/office-on/SKILL.md +50 -0
  18. package/pipeline/commands/multi-agent/office-status/SKILL.md +29 -0
  19. package/pipeline/commands/multi-agent/resume/SKILL.md +2 -1
  20. package/pipeline/commands/multi-agent/serve/SKILL.md +4 -2
  21. package/pipeline/commands/multi-agent/stack/SKILL.md +15 -8
  22. package/pipeline/commands/multi-agent/steer/SKILL.md +10 -9
  23. package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
  24. package/pipeline/contract/CHANGELOG.md +62 -0
  25. package/pipeline/contract/README.md +52 -6
  26. package/pipeline/contract/fixtures/error-invalid-request.json +1 -1
  27. package/pipeline/contract/fixtures/error-unauthorized.json +1 -1
  28. package/pipeline/contract/fixtures/error-unsigned.json +1 -1
  29. package/pipeline/contract/manifest.json +367 -22
  30. package/pipeline/contract/types/index.d.ts +531 -4
  31. package/pipeline/lib/hook-registration.mjs +252 -0
  32. package/pipeline/lib/prefs-value.mjs +44 -0
  33. package/pipeline/lib/run-completion.mjs +148 -0
  34. package/pipeline/multi-agent-refs/analysis/render.md +4 -4
  35. package/pipeline/multi-agent-refs/cross-cli-contract.md +5 -4
  36. package/pipeline/multi-agent-refs/features/doctor.md +11 -0
  37. package/pipeline/multi-agent-refs/features/office-threat-model.md +91 -0
  38. package/pipeline/multi-agent-refs/help/en.md +3 -0
  39. package/pipeline/multi-agent-refs/help/tr.md +3 -0
  40. package/pipeline/multi-agent-refs/phases/operations.md +17 -0
  41. package/pipeline/multi-agent-refs/phases/phase-0-init.md +6 -2
  42. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -2
  43. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +1 -1
  44. package/pipeline/multi-agent-refs/phases/phase-3-review.md +2 -2
  45. package/pipeline/multi-agent-refs/phases/phase-4-commit.md +7 -6
  46. package/pipeline/multi-agent-refs/phases/phase-5-report.md +5 -4
  47. package/pipeline/office/NOTICE.md +42 -0
  48. package/pipeline/office/assets/BIN.png +0 -0
  49. package/pipeline/office/assets/BOOKSHELF.png +0 -0
  50. package/pipeline/office/assets/CACTUS.png +0 -0
  51. package/pipeline/office/assets/CLOCK.png +0 -0
  52. package/pipeline/office/assets/COFFEE.png +0 -0
  53. package/pipeline/office/assets/COFFEE_TABLE.png +0 -0
  54. package/pipeline/office/assets/CUSHIONED_BENCH.png +0 -0
  55. package/pipeline/office/assets/CUSHIONED_CHAIR_BACK.png +0 -0
  56. package/pipeline/office/assets/CUSHIONED_CHAIR_FRONT.png +0 -0
  57. package/pipeline/office/assets/CUSHIONED_CHAIR_SIDE.png +0 -0
  58. package/pipeline/office/assets/DESK_FRONT.png +0 -0
  59. package/pipeline/office/assets/DESK_SIDE.png +0 -0
  60. package/pipeline/office/assets/DOUBLE_BOOKSHELF.png +0 -0
  61. package/pipeline/office/assets/HANGING_PLANT.png +0 -0
  62. package/pipeline/office/assets/LARGE_PAINTING.png +0 -0
  63. package/pipeline/office/assets/LARGE_PLANT.png +0 -0
  64. package/pipeline/office/assets/PC_BACK.png +0 -0
  65. package/pipeline/office/assets/PC_FRONT_OFF.png +0 -0
  66. package/pipeline/office/assets/PC_FRONT_ON_1.png +0 -0
  67. package/pipeline/office/assets/PC_FRONT_ON_2.png +0 -0
  68. package/pipeline/office/assets/PC_FRONT_ON_3.png +0 -0
  69. package/pipeline/office/assets/PC_SIDE.png +0 -0
  70. package/pipeline/office/assets/PLANT.png +0 -0
  71. package/pipeline/office/assets/PLANT_2.png +0 -0
  72. package/pipeline/office/assets/POT.png +0 -0
  73. package/pipeline/office/assets/SMALL_PAINTING.png +0 -0
  74. package/pipeline/office/assets/SMALL_PAINTING_2.png +0 -0
  75. package/pipeline/office/assets/SMALL_TABLE_FRONT.png +0 -0
  76. package/pipeline/office/assets/SMALL_TABLE_SIDE.png +0 -0
  77. package/pipeline/office/assets/SOFA_BACK.png +0 -0
  78. package/pipeline/office/assets/SOFA_FRONT.png +0 -0
  79. package/pipeline/office/assets/SOFA_SIDE.png +0 -0
  80. package/pipeline/office/assets/TABLE_FRONT.png +0 -0
  81. package/pipeline/office/assets/WHITEBOARD.png +0 -0
  82. package/pipeline/office/assets/WOODEN_BENCH.png +0 -0
  83. package/pipeline/office/assets/WOODEN_CHAIR_BACK.png +0 -0
  84. package/pipeline/office/assets/WOODEN_CHAIR_FRONT.png +0 -0
  85. package/pipeline/office/assets/WOODEN_CHAIR_SIDE.png +0 -0
  86. package/pipeline/office/assets/char_0.png +0 -0
  87. package/pipeline/office/assets/char_1.png +0 -0
  88. package/pipeline/office/assets/char_2.png +0 -0
  89. package/pipeline/office/assets/char_3.png +0 -0
  90. package/pipeline/office/assets/char_4.png +0 -0
  91. package/pipeline/office/assets/char_5.png +0 -0
  92. package/pipeline/office/assets/floor_0.png +0 -0
  93. package/pipeline/office/assets/floor_1.png +0 -0
  94. package/pipeline/office/assets/floor_2.png +0 -0
  95. package/pipeline/office/assets/floor_3.png +0 -0
  96. package/pipeline/office/assets/floor_4.png +0 -0
  97. package/pipeline/office/assets/floor_5.png +0 -0
  98. package/pipeline/office/assets/floor_6.png +0 -0
  99. package/pipeline/office/assets/floor_7.png +0 -0
  100. package/pipeline/office/assets/floor_8.png +0 -0
  101. package/pipeline/office/assets/wall_0.png +0 -0
  102. package/pipeline/office/index.html +5319 -0
  103. package/pipeline/preferences-template.json +7 -0
  104. package/pipeline/rules/figma-pipeline.md +2 -0
  105. package/pipeline/schemas/activity-chat.schema.json +78 -0
  106. package/pipeline/schemas/activity-roles.json +48 -0
  107. package/pipeline/schemas/activity-roles.schema.json +49 -0
  108. package/pipeline/schemas/activity.schema.json +429 -0
  109. package/pipeline/schemas/agent-state.schema.json +20 -5
  110. package/pipeline/schemas/autopilot-on-request.schema.json +33 -0
  111. package/pipeline/schemas/autopilot-on.schema.json +111 -0
  112. package/pipeline/schemas/contract-error.schema.json +33 -3
  113. package/pipeline/schemas/issue-maturity.schema.json +68 -0
  114. package/pipeline/schemas/ledger.schema.json +202 -0
  115. package/pipeline/schemas/permissions.schema.json +47 -0
  116. package/pipeline/schemas/phases.json +3 -2
  117. package/pipeline/schemas/phases.schema.json +5 -1
  118. package/pipeline/schemas/prefs.schema.json +56 -0
  119. package/pipeline/schemas/prs.schema.json +73 -0
  120. package/pipeline/schemas/review-verdict-request.schema.json +20 -0
  121. package/pipeline/schemas/review-verdict.schema.json +62 -0
  122. package/pipeline/schemas/routine-delete.schema.json +59 -0
  123. package/pipeline/schemas/routines.schema.json +38 -0
  124. package/pipeline/schemas/run-evidence.schema.json +29 -0
  125. package/pipeline/schemas/run-results.schema.json +218 -0
  126. package/pipeline/schemas/runs-index.schema.json +1 -1
  127. package/pipeline/schemas/service-read.schema.json +58 -0
  128. package/pipeline/schemas/stack-change.schema.json +82 -0
  129. package/pipeline/schemas/stack-request.schema.json +33 -0
  130. package/pipeline/schemas/stack.schema.json +59 -0
  131. package/pipeline/schemas/steer-request.schema.json +25 -0
  132. package/pipeline/schemas/steer.schema.json +59 -0
  133. package/pipeline/schemas/tool-call-request.schema.json +19 -0
  134. package/pipeline/schemas/tool-call.schema.json +47 -0
  135. package/pipeline/schemas/tools.schema.json +60 -0
  136. package/pipeline/scripts/_active-run.mjs +158 -0
  137. package/pipeline/scripts/_autopilot-outcomes.mjs +1 -0
  138. package/pipeline/scripts/_cost.mjs +66 -1
  139. package/pipeline/scripts/_guard_rules.py +605 -0
  140. package/pipeline/scripts/_office-smoke-lib.sh +115 -0
  141. package/pipeline/scripts/_spawn-async.mjs +73 -0
  142. package/pipeline/scripts/_transcript-tail.mjs +274 -0
  143. package/pipeline/scripts/activity-snapshot.mjs +1027 -0
  144. package/pipeline/scripts/agent-guard.py +118 -7
  145. package/pipeline/scripts/agent-guard.sh +50 -3
  146. package/pipeline/scripts/argv-secret-scan.mjs +114 -0
  147. package/pipeline/scripts/autopilot-control.mjs +214 -9
  148. package/pipeline/scripts/autopilot-runner.mjs +8 -2
  149. package/pipeline/scripts/confirm-command.mjs +70 -0
  150. package/pipeline/scripts/contract-server.mjs +1178 -48
  151. package/pipeline/scripts/cost-analyze.mjs +2 -53
  152. package/pipeline/scripts/cost-table.json +1 -0
  153. package/pipeline/scripts/doctor.mjs +54 -0
  154. package/pipeline/scripts/evidence-gate.mjs +8 -4
  155. package/pipeline/scripts/gate-ledger.mjs +163 -7
  156. package/pipeline/scripts/office-build.mjs +89 -0
  157. package/pipeline/scripts/office-control.mjs +383 -0
  158. package/pipeline/scripts/office-ledger.mjs +409 -0
  159. package/pipeline/scripts/plan-coverage-gate.mjs +9 -8
  160. package/pipeline/scripts/pre-commit-check.sh +12 -0
  161. package/pipeline/scripts/routine-registry.mjs +42 -6
  162. package/pipeline/scripts/run-results.mjs +373 -0
  163. package/pipeline/scripts/run-scope-guard.mjs +188 -0
  164. package/pipeline/scripts/run-scope-guard.sh +23 -0
  165. package/pipeline/scripts/run-stop-guard.mjs +246 -0
  166. package/pipeline/scripts/run-stop-guard.sh +25 -0
  167. package/pipeline/scripts/service-read.mjs +244 -0
  168. package/pipeline/scripts/stack-toggle.mjs +285 -0
  169. package/pipeline/scripts/steer-run.mjs +97 -0
  170. package/pipeline/scripts/toolkit-tools.mjs +599 -0
  171. package/pipeline/scripts/uninstall.mjs +4 -0
  172. package/pipeline/scripts/validate-state.mjs +26 -0
  173. package/pipeline/scripts/work-items.mjs +304 -0
  174. package/pipeline/scripts/write-state.mjs +114 -0
  175. package/pipeline/skills/.skill-manifest.json +19 -7
  176. package/pipeline/skills/.skills-index.json +34 -1
  177. package/pipeline/skills/shared/README.md +6 -3
  178. package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +13 -15
  179. package/pipeline/skills/shared/core/multi-agent-office-off/SKILL.md +16 -0
  180. package/pipeline/skills/shared/core/multi-agent-office-on/SKILL.md +22 -0
  181. package/pipeline/skills/shared/core/multi-agent-office-status/SKILL.md +17 -0
  182. package/pipeline/skills/shared/core/multi-agent-serve/SKILL.md +4 -2
  183. package/pipeline/skills/shared/core/multi-agent-stack/SKILL.md +14 -6
  184. package/pipeline/skills/shared/core/multi-agent-steer/SKILL.md +10 -9
  185. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
  186. package/pipeline/skills/skills-index.md +4 -1
@@ -29,6 +29,40 @@
29
29
  * POST /v1/gc gc-plan.mjs preview, then apply (two steps)
30
30
  * GET /v1/autopilot autopilot-control.mjs status
31
31
  * POST /v1/autopilot/off autopilot-control.mjs off [--now]
32
+ * POST /v1/autopilot/on autopilot-control.mjs on-preview, then on (two steps)
33
+ * POST /v1/runs/{id}/steer steer-run.mjs: queue one instruction for the next phase
34
+ * GET /v1/activity activity-snapshot.mjs --json
35
+ * GET /v1/activity/stream the same document as server-sent events, on change
36
+ * GET /v1/agents/{sessionId}/{agentId}/chat activity-snapshot.mjs chat
37
+ * GET /v1/ledger office-ledger.mjs --json
38
+ * GET /v1/runs/{id}/results run-results.mjs: diff, findings, tests, evidence, analysis
39
+ * GET /v1/runs/{id}/evidence/{name} run-results.mjs --evidence: one screenshot
40
+ * POST /v1/runs/{id}/review-verdict work-items.mjs verdict, then --post (two steps)
41
+ * GET /v1/issues/{key}/maturity work-items.mjs maturity (lib/issue-fetcher.sh)
42
+ * GET /v1/prs work-items.mjs prs
43
+ * GET /v1/tools toolkit-tools.mjs list (the registered toolkit MCP server)
44
+ * POST /v1/tools/{name}/call toolkit-tools.mjs call
45
+ * GET /v1/services/{confluence,crashlytics,figma} service-read.mjs
46
+ * GET /v1/permissions pending host permission prompts (none can be read yet)
47
+ * GET /v1/routines routine-registry.mjs list --json
48
+ * DELETE /v1/routines/{name} routine-registry.mjs remove (two steps)
49
+ * GET /v1/stack?repo=R stack-toggle.mjs status
50
+ * POST /v1/stack stack-toggle.mjs set (two steps)
51
+ *
52
+ * Every route carries two tags for a remote carrier to enforce later, and
53
+ * manifest.json repeats them: `remote` is the scope it would need (read,
54
+ * answer, launch, or local-only for anything that widens what runs on this
55
+ * machine: autopilot on, stack, routine removal, kill, gc), and `risk` is the
56
+ * confirmation tier it would need (read, low, high). Locally the bearer token
57
+ * is the only credential either way.
58
+ *
59
+ * GET /office serves the browser panel from MA_OFFICE_PAGE, else
60
+ * prefs.global.office.page, else the installed copy (office/index.html next
61
+ * to this scripts directory), and is 404 when none exists. The page carries
62
+ * no token: it trades a one-time code from its URL fragment for one. It and
63
+ * its sprites under /office/assets/ are the only routes that need none, and
64
+ * its response carries a per-response CSP nonce for its inline script and
65
+ * style.
32
66
  *
33
67
  * Phone routes, the same handlers behind a device signature instead of the
34
68
  * bearer token, always redacted (features/phone-api.md):
@@ -45,7 +79,8 @@
45
79
  * `claude agents --json` (read, for processAlive) and `claude stop <session>`,
46
80
  * for a kill or `autopilot/off` with now.
47
81
  *
48
- * Kill and gc remove things, so each takes two requests. The first returns a
82
+ * Kill, gc, autopilot on, stack and routine removal change what this machine
83
+ * keeps or runs, so each takes two requests. The first returns a
49
84
  * preview and a confirm token: 32 random bytes, kept in this process's memory
50
85
  * only, bound to the route and the run id, valid for CONFIRM_TTL_MS and for one
51
86
  * use. The second sends the token back and acts on exactly what the preview
@@ -63,8 +98,11 @@
63
98
  *
64
99
  * 1. Host must be 127.0.0.1:<port> or localhost:<port>, which is what stops
65
100
  * 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);
101
+ * 2. an Origin header must be this server's own origin (the panel it
102
+ * serves) or one of --allow-origin; any other is refused, and no CORS
103
+ * header is sent to it (403). A write (POST, DELETE) and the chat route
104
+ * accept no Origin but the server's own: a page somewhere else can never
105
+ * drive one, even an allowed one;
68
106
  * 3. an allowed preflight is answered, before authentication, since a
69
107
  * browser sends no credentials on one (204);
70
108
  * 4. a path under /v1/phone/ leaves here for the device-signature checks in
@@ -79,21 +117,33 @@
79
117
  *
80
118
  * `?redact=1` is `--redact`. A request that carries an Origin is a browser
81
119
  * page, which can relay what it reads anywhere, so it is always served
82
- * redacted whatever the query says.
120
+ * redacted whatever the query says. The bearer token is read from the
121
+ * Authorization header only: never a cookie, never a query parameter.
122
+ *
123
+ * Every write route appends one line (who, what, when, outcome; never a
124
+ * body or a token) to ~/.claude/logs/multi-agent/office-actions.jsonl.
83
125
  *
84
126
  * 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
127
+ * default), after --max-hours however busy it is (8 by default; an open event
128
+ * stream does not extend it), and on SIGTERM, SIGINT or SIGHUP, removing its
129
+ * token file either way.
130
+ *
131
+ * Panel bootstrap. `POST /office/code` (bearer, no Origin) mints a code that
132
+ * is valid for 10 seconds and one use; office-control.mjs opens the browser
133
+ * with it in the URL fragment. The page trades it for the token with
134
+ * `POST /office/exchange` (no bearer, own Origin only); a second use, an
135
+ * expired code or an unknown one is 401. The token itself never appears in a
136
+ * URL. It is started on demand by `/multi-agent:serve` or by a client, and is
87
137
  * never installed as a service.
88
138
  *
89
139
  * Zero runtime dependencies (ADR-0004): node:http, node:crypto and the
90
140
  * producers' own modules.
91
141
  *
92
142
  * Usage:
93
- * node contract-server.mjs [--port N] [--idle-minutes N] [--allow-origin <origin>]... [--print-token]
143
+ * node contract-server.mjs [--port N] [--idle-minutes N] [--max-hours N] [--allow-origin <origin>]... [--print-token]
94
144
  *
95
145
  * Stdout: one JSON line once listening - {contractVersion, url, port, pid,
96
- * tokenFile, idleMinutes[, token]}.
146
+ * tokenFile, idleMinutes, maxHours[, token]}.
97
147
  * Exit: 0 stopped (idle or signal), 1 could not listen, 2 usage error.
98
148
  *
99
149
  * @module pipeline/scripts/contract-server
@@ -102,6 +152,7 @@
102
152
  import { Buffer } from "node:buffer";
103
153
  import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
104
154
  import {
155
+ appendFileSync,
105
156
  chmodSync,
106
157
  existsSync,
107
158
  mkdirSync,
@@ -113,15 +164,51 @@ import {
113
164
  } from "node:fs";
114
165
  import { createServer } from "node:http";
115
166
  import { homedir } from "node:os";
116
- import { isAbsolute, join, sep } from "node:path";
117
- import { clearTimeout, setTimeout } from "node:timers";
167
+ import { dirname, isAbsolute, join, sep } from "node:path";
168
+ import { fileURLToPath } from "node:url";
169
+ import { clearInterval, clearTimeout, setInterval, setTimeout } from "node:timers";
118
170
  import { answerStateFile } from "./answer-question.mjs";
171
+ import { CONTRACT_VERSION as AUTOPILOT_VERSION, ControlError } from "./autopilot-control.mjs";
172
+ import { spawnAsync } from "./_spawn-async.mjs";
173
+ import {
174
+ CONTRACT_VERSION as ACTIVITY_VERSION,
175
+ activitySnapshot,
176
+ agentChat,
177
+ createActivityCache,
178
+ } from "./activity-snapshot.mjs";
179
+ import { RoutineError, listRoutines, removeRoutine } from "./routine-registry.mjs";
180
+ import {
181
+ CONTRACT_VERSION as STACK_VERSION,
182
+ StackError,
183
+ stackSet,
184
+ stackStatus,
185
+ } from "./stack-toggle.mjs";
186
+ import { CONTRACT_VERSION as STEER_VERSION, SteerError, queueSteer } from "./steer-run.mjs";
187
+ import { officeLedger } from "./office-ledger.mjs";
188
+ import { EVIDENCE_NAME, ResultsError, runEvidence, runResults } from "./run-results.mjs";
189
+ import {
190
+ WorkError,
191
+ issueMaturity,
192
+ postReviewVerdict,
193
+ pullRequests,
194
+ reviewVerdictPreview,
195
+ } from "./work-items.mjs";
119
196
  import {
120
- CONTRACT_VERSION as AUTOPILOT_VERSION,
121
- autopilotOff,
122
- autopilotStatus,
123
- ControlError,
124
- } from "./autopilot-control.mjs";
197
+ CONTRACT_VERSION as TOOLS_VERSION,
198
+ TOOL_NAME,
199
+ ToolError,
200
+ createToolkitClient,
201
+ } from "./toolkit-tools.mjs";
202
+ import {
203
+ FIGMA_KEY,
204
+ FIGMA_NODE,
205
+ PAGE_ID,
206
+ ServiceError,
207
+ confluencePage,
208
+ crashlyticsIssue,
209
+ figmaFrame,
210
+ } from "./service-read.mjs";
211
+ import { listSessions } from "../lib/claude-sessions.mjs";
125
212
  import { catalog } from "./commands.mjs";
126
213
  import { collect } from "./issues.mjs";
127
214
  import {
@@ -132,6 +219,7 @@ import {
132
219
  } from "./gc-plan.mjs";
133
220
  import {
134
221
  launchRepoAllowed,
222
+ launchRepos,
135
223
  launchSpec,
136
224
  planLaunch,
137
225
  planResume,
@@ -171,7 +259,7 @@ import { runMain } from "../lib/fatal.mjs";
171
259
  import { invokedDirectly } from "../lib/invoked-directly.mjs";
172
260
 
173
261
  /** The contract as a whole; pipeline/contract/manifest.json carries the same. */
174
- export const CONTRACT_VERSION = "1.3.0";
262
+ export const CONTRACT_VERSION = "1.4.0";
175
263
  /** The majors `?v=` accepts. A new major keeps the previous one here for one minor. */
176
264
  export const SUPPORTED_MAJORS = [1];
177
265
  export const BIND_HOST = "127.0.0.1";
@@ -180,12 +268,35 @@ export const MAX_BODY_BYTES = 64 * 1024;
180
268
  const MAX_DRAIN_BYTES = 1024 * 1024;
181
269
  const DEFAULT_IDLE_MINUTES = 15;
182
270
  const MAX_IDLE_MINUTES = 24 * 60;
271
+ /** Absolute lifetime: the server exits after this however busy it is. */
272
+ const DEFAULT_MAX_HOURS = 8;
273
+ const MAX_HOURS = 24;
274
+ /** Confirm tokens held at once; the oldest is dropped past this. */
275
+ export const MAX_CONFIRMS = 256;
276
+ /** Connections at once, and how long a phone request may take to send its body. */
277
+ const MAX_CONNECTIONS = 64;
278
+ const PHONE_BODY_TIMEOUT_MS = 5000;
279
+ /** A panel code: single use, valid this long, at most this many outstanding. */
280
+ export const CODE_TTL_MS = 10 * 1000;
281
+ const MAX_CODES = 16;
282
+ const PANEL_CODE = /^[A-Za-z0-9_-]{16,128}$/;
183
283
  const RUN_ID = /^[A-Za-z0-9#][A-Za-z0-9#._-]{0,127}$/;
184
284
  const PROJECT_KEY = /^[A-Z][A-Z0-9_]*$/;
185
285
  const QUESTION_ID = /^[a-z][a-z0-9-]*$/;
186
286
  const OPTION_ID = /^[a-z0-9][a-z0-9+_-]*$/;
187
287
  const CONTRACT_VERSION_PATTERN = /^1\.[0-9]+\.[0-9]+$/;
188
288
  const CONFIRM_TOKEN = /^[A-Za-z0-9_-]{16,128}$/;
289
+ const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
290
+ const AGENT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
291
+ const ROUTINE_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
292
+ const REPO_NAME = /^[A-Za-z0-9._-]{1,100}(?:\/[A-Za-z0-9._-]{1,100})?$/;
293
+ /** Server-sent event streams open at once; one more is refused. */
294
+ export const MAX_STREAMS = 4;
295
+ const STREAM_POLL_MS = 2000;
296
+ const STREAM_HEARTBEAT_MS = 15000;
297
+ const SESSIONS_TTL_MS = 10000;
298
+ const MAX_CHAT_LIMIT = 500;
299
+ const HERE = dirname(fileURLToPath(import.meta.url));
189
300
  /** How long a kill or gc confirm token stays valid. */
190
301
  export const CONFIRM_TTL_MS = 120 * 1000;
191
302
 
@@ -201,6 +312,13 @@ const confirmTtlMs = (() => {
201
312
  return n >= 1 && n <= CONFIRM_TTL_MS ? n : CONFIRM_TTL_MS;
202
313
  })();
203
314
 
315
+ /** As confirmTtlMs, for panel codes: MA_OFFICE_CODE_TTL_MS can only shorten it (tests). */
316
+ const codeTtlMs = (() => {
317
+ const raw = process.env.MA_OFFICE_CODE_TTL_MS;
318
+ const n = /^[0-9]{1,9}$/.test(raw ?? "") ? Number(raw) : NaN;
319
+ return n >= 1 && n <= CODE_TTL_MS ? n : CODE_TTL_MS;
320
+ })();
321
+
204
322
  /** Where token files live. Fixed under HOME, never under LOGS_ROOT. */
205
323
  export function tokenDir() {
206
324
  return join(homedir(), ".claude", "logs", "multi-agent", "contract-server");
@@ -229,7 +347,16 @@ const Q = {
229
347
  source: { check: (v) => ["jira", "github", "all"].includes(v) },
230
348
  project: { repeat: true, check: (v) => PROJECT_KEY.test(v) },
231
349
  max: { check: (v) => /^[0-9]{1,3}$/.test(v) && Number(v) >= 1 && Number(v) <= 200 },
232
- repo: { check: (v) => v.length <= 4096 && isAbsolute(v) },
350
+ repo: { check: (v) => v.length <= 4096 && (isAbsolute(v) || REPO_NAME.test(v)) },
351
+ before: { check: (v) => /^[0-9]{1,15}$/.test(v) },
352
+ limit: {
353
+ check: (v) => /^[0-9]{1,3}$/.test(v) && Number(v) >= 1 && Number(v) <= MAX_CHAT_LIMIT,
354
+ },
355
+ confirm: { check: (v) => CONFIRM_TOKEN.test(v) },
356
+ page: { check: (v) => PAGE_ID.test(v) || (v.length <= 2048 && /^https:\/\//.test(v)) },
357
+ issue: { check: (v) => v.length <= 2048 && /^https:\/\//.test(v) },
358
+ file: { check: (v) => FIGMA_KEY.test(v) },
359
+ node: { check: (v) => FIGMA_NODE.test(v) },
233
360
  tail: { check: (v) => /^[0-9]{1,4}$/.test(v) && Number(v) >= 1 && Number(v) <= MAX_TAIL },
234
361
  };
235
362
 
@@ -336,8 +463,11 @@ function requireWorkspaceTrust(repoPath, redact) {
336
463
  * only. Either way the repo must be a configured or registered one.
337
464
  */
338
465
  function launchRun({ q, body, redact, channel = "desktop" }) {
339
- const repoPath = one(q, "repo");
466
+ let repoPath = one(q, "repo");
340
467
  if (!repoPath) throw new HttpError(400, "invalid-query", "repo is required");
468
+ // A name from GET /v1/repos is accepted too: a browser reads that list
469
+ // redacted, without paths. Either way the repository must be an allowed one.
470
+ if (!isAbsolute(repoPath)) repoPath = resolveRepo(repoPath);
341
471
  if (!existsSync(repoPath) || !statSync(repoPath).isDirectory()) {
342
472
  throw new HttpError(400, "invalid-query", "repo is not a directory");
343
473
  }
@@ -430,6 +560,7 @@ const confirms = new Map();
430
560
  function mintConfirm(route, id, payload) {
431
561
  const now = Date.now();
432
562
  for (const [t, c] of confirms) if (c.expires <= now) confirms.delete(t);
563
+ while (confirms.size >= MAX_CONFIRMS) confirms.delete(confirms.keys().next().value);
433
564
  const token = randomBytes(32).toString("base64url");
434
565
  const expires = now + confirmTtlMs;
435
566
  confirms.set(token, { route, id, expires, payload });
@@ -617,9 +748,54 @@ function gcRoute({ body, redact }) {
617
748
  return { contractVersion: GC_VERSION, ...out, failed };
618
749
  }
619
750
 
620
- function autopilotGet() {
751
+ /**
752
+ * autopilot-control.mjs run as a child process: its status script, credential
753
+ * probes and launchctl calls are synchronous, and must not hold up every
754
+ * other route while they run. The child reports a refusal as
755
+ * {error: {code, message}} on stdout (MA_CONTROL_JSON_ERRORS).
756
+ */
757
+ async function control(args) {
758
+ const r = await spawnAsync(process.execPath, [join(HERE, "autopilot-control.mjs"), ...args], {
759
+ env: { ...process.env, MA_CONTROL_JSON_ERRORS: "1" },
760
+ timeoutMs: 180_000,
761
+ maxBytes: 4 * 1024 * 1024,
762
+ });
763
+ let doc;
764
+ try {
765
+ doc = JSON.parse(r.stdout);
766
+ } catch {
767
+ doc = null;
768
+ }
769
+ if (doc?.error && typeof doc.error.code === "string") {
770
+ throw new ControlError(String(doc.error.message ?? "refused"), doc.error.code);
771
+ }
772
+ if (r.status !== 0 || !doc || typeof doc !== "object") {
773
+ throw new ControlError("autopilot-control did not finish");
774
+ }
775
+ return doc;
776
+ }
777
+
778
+ /** One request at a time per key; a second one while the first runs is 429. */
779
+ const running = new Set();
780
+ const oneAtATime = (key, fn) => async (ctx) => {
781
+ if (running.has(key)) {
782
+ throw new HttpError(
783
+ 429,
784
+ "busy",
785
+ "the same request is still running; try again when it finishes",
786
+ );
787
+ }
788
+ running.add(key);
789
+ try {
790
+ return await fn(ctx);
791
+ } finally {
792
+ running.delete(key);
793
+ }
794
+ };
795
+
796
+ async function autopilotGet() {
621
797
  try {
622
- return { ...autopilotStatus(), contractVersion: AUTOPILOT_VERSION };
798
+ return { ...(await control(["status"])), contractVersion: AUTOPILOT_VERSION };
623
799
  } catch (err) {
624
800
  if (err instanceof ControlError) throw new HttpError(500, "internal", err.message);
625
801
  throw err;
@@ -628,12 +804,443 @@ function autopilotGet() {
628
804
 
629
805
  async function autopilotOffRoute({ body }) {
630
806
  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
- };
807
+ try {
808
+ return {
809
+ contractVersion: AUTOPILOT_VERSION,
810
+ ...(await control(body.now === true ? ["off", "--now"] : ["off"])),
811
+ };
812
+ } catch (err) {
813
+ if (err instanceof ControlError) throw new HttpError(500, "internal", err.message);
814
+ throw err;
815
+ }
816
+ }
817
+
818
+ const repoArgs = (repos) => repos.flatMap((r) => ["--repo", r]);
819
+
820
+ /**
821
+ * POST /v1/autopilot/on. Without confirmToken: what would be kept, dropped and
822
+ * probed, and a token bound to exactly that selection. With it: the mode is
823
+ * turned on for that selection (autopilot-control.mjs on).
824
+ */
825
+ async function autopilotOnRoute({ body }) {
826
+ requireBody(
827
+ body,
828
+ {
829
+ confirmToken: isToken,
830
+ repos: (v) =>
831
+ Array.isArray(v) &&
832
+ v.length >= 1 &&
833
+ v.length <= 50 &&
834
+ v.every((x) => typeof x === "string" && REPO_NAME.test(x)),
835
+ configureOnly: (v) => typeof v === "boolean",
836
+ },
837
+ "autopilot-on-request.schema.json",
838
+ );
839
+ try {
840
+ if (body.confirmToken === undefined) {
841
+ if (!body.repos) throw new HttpError(400, "invalid-body", "repos is required");
842
+ const plan = await control(["on-preview", ...repoArgs(body.repos)]);
843
+ const payload = { repos: plan.keep, configureOnly: body.configureOnly === true };
844
+ return {
845
+ contractVersion: AUTOPILOT_VERSION,
846
+ preview: { ...plan, configureOnly: payload.configureOnly },
847
+ ...mintConfirm("autopilot-on", "", payload),
848
+ };
849
+ }
850
+ if (body.repos !== undefined || body.configureOnly !== undefined) {
851
+ throw new HttpError(
852
+ 400,
853
+ "invalid-body",
854
+ "the selection belongs to the preview, not the confirmation",
855
+ );
856
+ }
857
+ const payload = redeemConfirm(body.confirmToken, "autopilot-on", "");
858
+ return {
859
+ contractVersion: AUTOPILOT_VERSION,
860
+ ...(await control([
861
+ "on",
862
+ ...repoArgs(payload.repos),
863
+ ...(payload.configureOnly ? ["--configure-only"] : []),
864
+ ])),
865
+ };
866
+ } catch (err) {
867
+ if (err instanceof ControlError) {
868
+ if (err.code === "internal" || err.code === "launchd-refused") {
869
+ throw new HttpError(500, "internal", err.message);
870
+ }
871
+ throw new HttpError(409, err.code, err.message);
872
+ }
873
+ throw err;
874
+ }
875
+ }
876
+
877
+ /** POST /v1/runs/{id}/steer: one instruction for the run's next phase. */
878
+ /**
879
+ * POST /v1/runs/{id}/steer. Free text for an unattended agent, so it takes
880
+ * two requests like every high-risk write: the first names the text and gets
881
+ * a preview (the run, its phase, the unconsumed instruction it would replace)
882
+ * and a token bound to this run and exactly this text; the second sends only
883
+ * the token.
884
+ */
885
+ async function steerRoute({ params, body }) {
886
+ requireBody(
887
+ body,
888
+ { text: (v) => typeof v === "string", confirmToken: isToken },
889
+ "steer-request.schema.json",
890
+ );
891
+ const [record] = runRecordFor(params.id);
892
+ const file = stateFileOf(record);
893
+ if (!file) throw new HttpError(409, "not-steerable", "this run has no agent state to steer");
894
+ try {
895
+ if (body.confirmToken === undefined) {
896
+ if (typeof body.text !== "string" || !body.text.trim() || body.text.length > 4000) {
897
+ throw new HttpError(400, "invalid-body", "text is required, 1 to 4000 characters");
898
+ }
899
+ const { state } = readState(record);
900
+ if (["complete", "completed", "done"].includes(state.status)) {
901
+ throw new HttpError(
902
+ 409,
903
+ "not-steerable",
904
+ "the run is finished; nothing would read the instruction",
905
+ );
906
+ }
907
+ const prev = state.pendingSteer;
908
+ return {
909
+ contractVersion: STEER_VERSION,
910
+ taskId: record.taskId,
911
+ preview: {
912
+ text: scrub(body.text),
913
+ status: typeof state.status === "string" ? state.status : null,
914
+ currentPhase: Number.isFinite(Number(state.currentPhase))
915
+ ? Number(state.currentPhase)
916
+ : null,
917
+ replaces:
918
+ prev && !prev.appliedAt && typeof prev.text === "string" ? scrub(prev.text) : null,
919
+ },
920
+ ...mintConfirm("steer", record.taskId, { text: body.text }),
921
+ };
922
+ }
923
+ if (body.text !== undefined) {
924
+ throw new HttpError(
925
+ 400,
926
+ "invalid-body",
927
+ "the text belongs to the preview, not the confirmation",
928
+ );
929
+ }
930
+ const { text } = redeemConfirm(body.confirmToken, "steer", record.taskId);
931
+ return { ...(await queueSteer(file, text)), taskId: record.taskId };
932
+ } catch (err) {
933
+ if (err instanceof SteerError) {
934
+ if (err.code === "finished") throw new HttpError(409, "not-steerable", err.message);
935
+ throw new HttpError(400, "invalid-body", err.message);
936
+ }
937
+ throw err;
938
+ }
939
+ }
940
+
941
+ /** The sessions `claude agents --json` lists, read at most every SESSIONS_TTL_MS. */
942
+ let sessionsCache = { at: 0, list: undefined };
943
+ function cachedSessions() {
944
+ if (Date.now() - sessionsCache.at > SESSIONS_TTL_MS) {
945
+ sessionsCache = { at: Date.now(), list: listSessions() };
946
+ }
947
+ return sessionsCache.list;
948
+ }
949
+
950
+ const activityCache = createActivityCache();
951
+ const activityNow = () => activitySnapshot({ sessions: cachedSessions(), cache: activityCache });
952
+
953
+ async function chatRoute({ params, q }) {
954
+ if (!SESSION_ID.test(params.sessionId)) throw new HttpError(404, "not-found", "no such agent");
955
+ const agentId = params.agentId === "main" ? null : params.agentId;
956
+ if (agentId !== null && !AGENT_ID.test(agentId)) {
957
+ throw new HttpError(404, "not-found", "no such agent");
958
+ }
959
+ const page = await agentChat({
960
+ sessionId: params.sessionId,
961
+ agentId,
962
+ before: one(q, "before") === null ? null : Number(one(q, "before")),
963
+ limit: one(q, "limit") === null ? 200 : Number(one(q, "limit")),
964
+ });
965
+ if (!page) throw new HttpError(404, "not-found", "no such agent");
966
+ return page;
967
+ }
968
+
969
+ const routinesDocument = () => ({
970
+ contractVersion: ACTIVITY_VERSION,
971
+ routines: listRoutines(homedir()).map((r) => ({
972
+ name: String(r?.name ?? ""),
973
+ description: typeof r?.description === "string" ? scrub(r.description) : "",
974
+ createdAt: typeof r?.createdAt === "string" ? r.createdAt : null,
975
+ source: typeof r?.source === "string" ? scrub(r.source) : null,
976
+ })),
977
+ });
978
+
979
+ const routinesGet = () => {
980
+ try {
981
+ return routinesDocument();
982
+ } catch (err) {
983
+ if (err instanceof RoutineError) throw new HttpError(500, "internal", err.message);
984
+ throw err;
985
+ }
986
+ };
987
+
988
+ /** DELETE /v1/routines/{name}: a preview and token, then ?confirm=<token> removes it. */
989
+ function routineDeleteRoute({ params, q }) {
990
+ const name = params.name;
991
+ if (!ROUTINE_NAME.test(name)) throw new HttpError(404, "not-found", "no such routine");
992
+ try {
993
+ const token = one(q, "confirm");
994
+ if (token === null) {
995
+ const entry = routinesDocument().routines.find((r) => r.name === name);
996
+ if (!entry) throw new HttpError(404, "not-found", "no such routine");
997
+ return {
998
+ contractVersion: ACTIVITY_VERSION,
999
+ preview: entry,
1000
+ ...mintConfirm("routine-delete", name, null),
1001
+ };
1002
+ }
1003
+ redeemConfirm(token, "routine-delete", name);
1004
+ return { contractVersion: ACTIVITY_VERSION, removed: removeRoutine(homedir(), name) };
1005
+ } catch (err) {
1006
+ if (err instanceof RoutineError) throw new HttpError(409, "not-removable", err.message);
1007
+ throw err;
1008
+ }
1009
+ }
1010
+
1011
+ const notAllowed = () =>
1012
+ new HttpError(
1013
+ 403,
1014
+ "repo-not-allowed",
1015
+ "repo is neither a configured autopilot repo nor one registered with launch-request.mjs register-repo",
1016
+ );
1017
+
1018
+ /**
1019
+ * The checkout a panel request names: an absolute path or a name from
1020
+ * GET /v1/repos, and either way one of the repositories launch accepts.
1021
+ */
1022
+ function resolveRepo(ref) {
1023
+ if (!ref) throw new HttpError(400, "invalid-query", "repo is required");
1024
+ if (isAbsolute(ref)) {
1025
+ if (!launchRepoAllowed(ref)) throw notAllowed();
1026
+ return realOr(ref);
1027
+ }
1028
+ const hits = launchRepos().filter((r) => r.name === ref);
1029
+ if (hits.length > 1) {
1030
+ throw new HttpError(409, "repo-ambiguous", "more than one repository has that name");
1031
+ }
1032
+ if (!hits.length) throw notAllowed();
1033
+ return hits[0].path;
1034
+ }
1035
+
1036
+ function stackError(err) {
1037
+ if (!(err instanceof StackError)) return err;
1038
+ if (err.code === "unreadable-settings") {
1039
+ return new HttpError(409, "unreadable-settings", err.message);
1040
+ }
1041
+ return new HttpError(400, "invalid-body", err.message);
1042
+ }
1043
+
1044
+ function stackGet({ q }) {
1045
+ const repo = resolveRepo(one(q, "repo"));
1046
+ try {
1047
+ return stackStatus(repo);
1048
+ } catch (err) {
1049
+ throw stackError(err);
1050
+ }
1051
+ }
1052
+
1053
+ /** POST /v1/stack: the planned enabledPlugins and a token, then the write. */
1054
+ function stackPost({ body }) {
1055
+ requireBody(
1056
+ body,
1057
+ {
1058
+ confirmToken: isToken,
1059
+ repo: (v) =>
1060
+ typeof v === "string" && v.length <= 4096 && (isAbsolute(v) || REPO_NAME.test(v)),
1061
+ stacks: (v) =>
1062
+ Array.isArray(v) &&
1063
+ v.length >= 1 &&
1064
+ v.length <= 8 &&
1065
+ v.every((x) => typeof x === "string" && /^[a-z]{2,16}$/.test(x)),
1066
+ },
1067
+ "stack-request.schema.json",
1068
+ );
1069
+ try {
1070
+ if (body.confirmToken === undefined) {
1071
+ if (!body.repo || !body.stacks) {
1072
+ throw new HttpError(400, "invalid-body", "repo and stacks are required");
1073
+ }
1074
+ const repo = resolveRepo(body.repo);
1075
+ const plan = stackSet(repo, body.stacks, { dryRun: true });
1076
+ return {
1077
+ contractVersion: STACK_VERSION,
1078
+ preview: {
1079
+ stacks: plan.stacks,
1080
+ changed: plan.changed,
1081
+ enabledPlugins: plan.enabledPlugins,
1082
+ dropped: plan.dropped,
1083
+ },
1084
+ ...mintConfirm("stack", "", { repo, stacks: body.stacks }),
1085
+ };
1086
+ }
1087
+ if (body.repo !== undefined || body.stacks !== undefined) {
1088
+ throw new HttpError(
1089
+ 400,
1090
+ "invalid-body",
1091
+ "the selection belongs to the preview, not the confirmation",
1092
+ );
1093
+ }
1094
+ const { repo, stacks } = redeemConfirm(body.confirmToken, "stack", "");
1095
+ return { ...stackSet(repo, stacks), contractVersion: STACK_VERSION };
1096
+ } catch (err) {
1097
+ throw stackError(err);
1098
+ }
1099
+ }
1100
+
1101
+ function resultsRoute({ params }) {
1102
+ if (!RUN_ID.test(params.id)) throw new HttpError(404, "not-found", "no such run");
1103
+ try {
1104
+ return runResults(params.id);
1105
+ } catch (err) {
1106
+ if (err instanceof ResultsError) throw new HttpError(404, "not-found", "no such run");
1107
+ throw err;
1108
+ }
1109
+ }
1110
+
1111
+ function evidenceRoute({ params }) {
1112
+ if (!RUN_ID.test(params.id) || !EVIDENCE_NAME.test(params.name)) {
1113
+ throw new HttpError(404, "not-found", "no such image");
1114
+ }
1115
+ try {
1116
+ return runEvidence(params.id, params.name);
1117
+ } catch (err) {
1118
+ if (err instanceof ResultsError) throw new HttpError(404, "not-found", "no such image");
1119
+ throw err;
1120
+ }
1121
+ }
1122
+
1123
+ function workError(err) {
1124
+ if (!(err instanceof WorkError)) return err;
1125
+ if (err.code === "no-run" || err.code === "bad-key")
1126
+ return new HttpError(404, "not-found", err.message);
1127
+ if (err.code === "no-review" || err.code === "no-findings") {
1128
+ return new HttpError(409, "no-review", err.message);
1129
+ }
1130
+ if (err.code === "not-installed") return new HttpError(409, "not-installed", err.message);
1131
+ if (err.code === "review-incomplete") return new HttpError(409, "review-incomplete", err.message);
1132
+ if (err.code === "changed") return new HttpError(409, "confirm-mismatch", err.message);
1133
+ return new HttpError(502, "upstream-failed", err.message);
1134
+ }
1135
+
1136
+ async function maturityRoute({ params }) {
1137
+ try {
1138
+ return await issueMaturity(params.key);
1139
+ } catch (err) {
1140
+ throw workError(err);
1141
+ }
1142
+ }
1143
+
1144
+ /**
1145
+ * POST /v1/runs/{id}/review-verdict: the verdict post-pr-review.sh would post
1146
+ * and a token, then the post itself. The verdict follows from the findings the
1147
+ * review recorded; no request chooses it. Only a finished review whose every
1148
+ * finding is triaged can be previewed, and the token is bound to the decision
1149
+ * and the findings digest it showed: when either changed before the
1150
+ * confirmation, it is refused with confirm-mismatch.
1151
+ */
1152
+ async function reviewVerdictRoute({ params, body }) {
1153
+ requireBody(body, { confirmToken: isToken }, "review-verdict-request.schema.json");
1154
+ try {
1155
+ const [record] = runRecordFor(params.id);
1156
+ if (body.confirmToken === undefined) {
1157
+ const preview = reviewVerdictPreview(record.taskId);
1158
+ const bound = { decision: preview.decision, findingsDigest: preview.findingsDigest };
1159
+ return { ...preview, ...mintConfirm("review-verdict", record.taskId, bound) };
1160
+ }
1161
+ const expected = redeemConfirm(body.confirmToken, "review-verdict", record.taskId);
1162
+ return await postReviewVerdict(record.taskId, process.env, expected);
1163
+ } catch (err) {
1164
+ throw workError(err);
1165
+ }
1166
+ }
1167
+
1168
+ /** The toolkit MCP client of this server, started on first use, closed on stop. */
1169
+ let toolkit = null;
1170
+ const toolkitClient = () => (toolkit ??= createToolkitClient());
1171
+ export const closeToolkit = () => {
1172
+ toolkit?.close();
1173
+ toolkit = null;
1174
+ };
1175
+
1176
+ function toolError(err) {
1177
+ if (!(err instanceof ToolError)) return err;
1178
+ if (err.code === "unknown-tool") return new HttpError(404, "not-found", "no such tool");
1179
+ if (err.code === "tool-not-allowed") return new HttpError(403, "tool-not-allowed", err.message);
1180
+ if (err.code === "unpinned") return new HttpError(409, "not-installed", err.message);
1181
+ if (err.code === "invalid-arguments") {
1182
+ return new HttpError(400, "invalid-body", err.message, { errors: err.extra?.errors ?? [] });
1183
+ }
1184
+ if (err.code === "not-registered") return new HttpError(409, "not-installed", err.message);
1185
+ return new HttpError(502, "upstream-failed", err.message);
1186
+ }
1187
+
1188
+ async function toolsRoute() {
1189
+ try {
1190
+ return { contractVersion: TOOLS_VERSION, tools: await toolkitClient().listTools() };
1191
+ } catch (err) {
1192
+ throw toolError(err);
1193
+ }
1194
+ }
1195
+
1196
+ async function toolCallRoute({ params, body }) {
1197
+ requireBody(
1198
+ body,
1199
+ { arguments: (v) => v && typeof v === "object" && !Array.isArray(v) },
1200
+ "tool-call-request.schema.json",
1201
+ );
1202
+ if (!TOOL_NAME.test(params.name)) throw new HttpError(404, "not-found", "no such tool");
1203
+ try {
1204
+ return await toolkitClient().callTool(params.name, body.arguments ?? {});
1205
+ } catch (err) {
1206
+ throw toolError(err);
1207
+ }
1208
+ }
1209
+
1210
+ function serviceError(err) {
1211
+ if (!(err instanceof ServiceError)) return err;
1212
+ if (err.code === "bad-input") return new HttpError(400, "invalid-query", err.message);
1213
+ if (err.code === "not-installed" || err.code === "not-configured") {
1214
+ return new HttpError(409, "not-installed", err.message);
1215
+ }
1216
+ if (err.code === "host-not-allowed") return new HttpError(409, "host-not-allowed", err.message);
1217
+ return new HttpError(502, "upstream-failed", err.message);
635
1218
  }
636
1219
 
1220
+ const serviceRoute = (key, fn) =>
1221
+ oneAtATime(key, async (ctx) => {
1222
+ try {
1223
+ return await fn(ctx);
1224
+ } catch (err) {
1225
+ throw serviceError(err);
1226
+ }
1227
+ });
1228
+
1229
+ /**
1230
+ * GET /v1/permissions. Claude Code offers no mechanism that records a pending
1231
+ * permission prompt outside the session that shows it, so there is nothing to
1232
+ * list yet. The shape is fixed now so a client can render it, and `available`
1233
+ * says plainly that nothing is forwarded; pipeline questions are answered
1234
+ * through POST /v1/runs/{id}/answer.
1235
+ */
1236
+ const permissionsDocument = () => ({
1237
+ contractVersion: TOOLS_VERSION,
1238
+ available: false,
1239
+ reason:
1240
+ "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",
1241
+ requests: [],
1242
+ });
1243
+
637
1244
  const listRuns = ({ q, redact }) =>
638
1245
  envelope(selectRuns({ group: one(q, "group") }).records, { redact });
639
1246
  const oneRun = ({ params, redact }) => envelope(runRecordFor(params.id), { redact });
@@ -643,6 +1250,12 @@ const oneRun = ({ params, redact }) => envelope(runRecordFor(params.id), { redac
643
1250
  * route accepts; `body` marks a route that reads a JSON body. A route with
644
1251
  * `scope` is a phone route: it is reached only with a device signature, never
645
1252
  * with the bearer token, and is always served redacted.
1253
+ *
1254
+ * `remote` and `risk` are the tags a remote carrier would enforce: the scope a
1255
+ * caller would need (read, answer, launch, local-only) and the confirmation
1256
+ * tier (read, low, high). `stream` marks the server-sent event route,
1257
+ * `selfOrigin` a route a foreign page may never read even when its origin is
1258
+ * allowed (the chat transcript), and `write` a route whose call is logged.
646
1259
  */
647
1260
  export const ROUTES = [
648
1261
  { method: "GET", path: "/v1/runs", query: ["group"], handle: listRuns },
@@ -682,14 +1295,183 @@ export const ROUTES = [
682
1295
  query: [],
683
1296
  handle: ({ redact }) => reposDocument({ redact }),
684
1297
  },
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 },
1298
+ {
1299
+ method: "POST",
1300
+ path: "/v1/launch",
1301
+ query: ["repo"],
1302
+ body: true,
1303
+ remote: "launch",
1304
+ risk: "high",
1305
+ handle: launchRun,
1306
+ },
1307
+ {
1308
+ method: "POST",
1309
+ path: "/v1/runs/{id}/answer",
1310
+ query: [],
1311
+ body: true,
1312
+ remote: "answer",
1313
+ risk: "low",
1314
+ handle: answerRun,
1315
+ },
687
1316
  { 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 },
1317
+ {
1318
+ method: "POST",
1319
+ path: "/v1/runs/{id}/resume",
1320
+ query: [],
1321
+ body: true,
1322
+ remote: "launch",
1323
+ risk: "high",
1324
+ handle: resumeRun,
1325
+ },
1326
+ {
1327
+ method: "POST",
1328
+ path: "/v1/runs/{id}/steer",
1329
+ query: [],
1330
+ body: true,
1331
+ remote: "launch",
1332
+ risk: "high",
1333
+ handle: steerRoute,
1334
+ },
1335
+ {
1336
+ method: "POST",
1337
+ path: "/v1/runs/{id}/kill",
1338
+ query: [],
1339
+ body: true,
1340
+ remote: "local-only",
1341
+ risk: "high",
1342
+ handle: killRoute,
1343
+ },
1344
+ {
1345
+ method: "POST",
1346
+ path: "/v1/gc",
1347
+ query: [],
1348
+ body: true,
1349
+ remote: "local-only",
1350
+ risk: "high",
1351
+ handle: gcRoute,
1352
+ },
1353
+ {
1354
+ method: "GET",
1355
+ path: "/v1/autopilot",
1356
+ query: [],
1357
+ handle: oneAtATime("autopilot-status", autopilotGet),
1358
+ },
1359
+ {
1360
+ method: "POST",
1361
+ path: "/v1/autopilot/off",
1362
+ query: [],
1363
+ body: true,
1364
+ remote: "launch",
1365
+ risk: "low",
1366
+ handle: oneAtATime("autopilot", autopilotOffRoute),
1367
+ },
1368
+ {
1369
+ method: "POST",
1370
+ path: "/v1/autopilot/on",
1371
+ query: [],
1372
+ body: true,
1373
+ remote: "local-only",
1374
+ risk: "high",
1375
+ handle: oneAtATime("autopilot", autopilotOnRoute),
1376
+ },
1377
+ { method: "GET", path: "/v1/activity", query: [], handle: () => activityNow() },
1378
+ {
1379
+ method: "GET",
1380
+ path: "/v1/activity/stream",
1381
+ query: [],
1382
+ stream: true,
1383
+ handle: () => activityNow(),
1384
+ },
1385
+ {
1386
+ method: "GET",
1387
+ path: "/v1/agents/{sessionId}/{agentId}/chat",
1388
+ query: ["before", "limit"],
1389
+ selfOrigin: true,
1390
+ handle: chatRoute,
1391
+ },
1392
+ { method: "GET", path: "/v1/ledger", query: [], handle: () => officeLedger() },
1393
+ { method: "GET", path: "/v1/runs/{id}/results", query: [], handle: resultsRoute },
1394
+ { method: "GET", path: "/v1/runs/{id}/evidence/{name}", query: [], handle: evidenceRoute },
1395
+ {
1396
+ method: "POST",
1397
+ path: "/v1/runs/{id}/review-verdict",
1398
+ query: [],
1399
+ body: true,
1400
+ remote: "launch",
1401
+ risk: "high",
1402
+ handle: oneAtATime("review-verdict", reviewVerdictRoute),
1403
+ },
1404
+ {
1405
+ method: "GET",
1406
+ path: "/v1/issues/{key}/maturity",
1407
+ query: [],
1408
+ handle: oneAtATime("maturity", maturityRoute),
1409
+ },
1410
+ {
1411
+ method: "GET",
1412
+ path: "/v1/prs",
1413
+ query: [],
1414
+ handle: oneAtATime("prs", ({ redact }) => pullRequests({ redact })),
1415
+ },
1416
+ { method: "GET", path: "/v1/tools", query: [], handle: toolsRoute },
1417
+ {
1418
+ method: "POST",
1419
+ path: "/v1/tools/{name}/call",
1420
+ query: [],
1421
+ body: true,
1422
+ remote: "local-only",
1423
+ risk: "high",
1424
+ handle: toolCallRoute,
1425
+ },
1426
+ {
1427
+ method: "GET",
1428
+ path: "/v1/services/confluence",
1429
+ query: ["page"],
1430
+ handle: serviceRoute("confluence", ({ q }) => {
1431
+ if (one(q, "page") === null) throw new HttpError(400, "invalid-query", "page is required");
1432
+ return confluencePage(one(q, "page"));
1433
+ }),
1434
+ },
1435
+ {
1436
+ method: "GET",
1437
+ path: "/v1/services/crashlytics",
1438
+ query: ["issue"],
1439
+ handle: serviceRoute("crashlytics", ({ q }) => {
1440
+ if (one(q, "issue") === null) throw new HttpError(400, "invalid-query", "issue is required");
1441
+ return crashlyticsIssue(one(q, "issue"));
1442
+ }),
1443
+ },
1444
+ {
1445
+ method: "GET",
1446
+ path: "/v1/services/figma",
1447
+ query: ["file", "node"],
1448
+ handle: serviceRoute("figma", ({ q }) => {
1449
+ if (one(q, "file") === null || one(q, "node") === null) {
1450
+ throw new HttpError(400, "invalid-query", "file and node are required");
1451
+ }
1452
+ return figmaFrame(one(q, "file"), one(q, "node"));
1453
+ }),
1454
+ },
1455
+ { method: "GET", path: "/v1/permissions", query: [], handle: permissionsDocument },
1456
+ { method: "GET", path: "/v1/routines", query: [], handle: routinesGet },
1457
+ {
1458
+ method: "DELETE",
1459
+ path: "/v1/routines/{name}",
1460
+ query: ["confirm"],
1461
+ remote: "local-only",
1462
+ risk: "high",
1463
+ handle: routineDeleteRoute,
1464
+ },
1465
+ { method: "GET", path: "/v1/stack", query: ["repo"], handle: stackGet },
1466
+ {
1467
+ method: "POST",
1468
+ path: "/v1/stack",
1469
+ query: [],
1470
+ body: true,
1471
+ remote: "local-only",
1472
+ risk: "high",
1473
+ handle: stackPost,
1474
+ },
693
1475
  { method: "GET", path: "/v1/phone/runs", query: ["group"], scope: "read", handle: listRuns },
694
1476
  { method: "GET", path: "/v1/phone/runs/{id}", query: [], scope: "read", handle: oneRun },
695
1477
  {
@@ -698,6 +1480,7 @@ export const ROUTES = [
698
1480
  query: [],
699
1481
  body: true,
700
1482
  scope: "answer",
1483
+ risk: "low",
701
1484
  handle: answerRun,
702
1485
  },
703
1486
  {
@@ -706,6 +1489,7 @@ export const ROUTES = [
706
1489
  query: ["repo"],
707
1490
  body: true,
708
1491
  scope: "launch",
1492
+ risk: "high",
709
1493
  handle: (ctx) => {
710
1494
  if (!ctx.registry.launchEnabled) {
711
1495
  throw new HttpError(403, "launch-disabled", "launch from a phone is not enabled here");
@@ -715,8 +1499,12 @@ export const ROUTES = [
715
1499
  },
716
1500
  ].map((r) => ({
717
1501
  ...r,
1502
+ remote: r.remote ?? r.scope ?? "read",
1503
+ risk: r.risk ?? "read",
1504
+ write: r.method !== "GET",
718
1505
  phone: r.path.startsWith(PHONE_PREFIX),
719
- pattern: new RegExp(`^${r.path.replace("{id}", "(?<id>[^/]+)")}$`),
1506
+ params: [...r.path.matchAll(/\{([A-Za-z]+)\}/g)].map((m) => m[1]),
1507
+ pattern: new RegExp(`^${r.path.replace(/\{([A-Za-z]+)\}/g, "(?<$1>[^/]+)")}$`),
720
1508
  }));
721
1509
 
722
1510
  /**
@@ -747,11 +1535,11 @@ export function match(method, pathname) {
747
1535
  },
748
1536
  );
749
1537
  }
750
- if (hit.params.id !== undefined) {
1538
+ for (const [k, v] of Object.entries(hit.params)) {
751
1539
  try {
752
- hit.params.id = decodeURIComponent(hit.params.id);
1540
+ hit.params[k] = decodeURIComponent(v);
753
1541
  } catch {
754
- throw new HttpError(404, "not-found", "no such run");
1542
+ throw new HttpError(404, "not-found", k === "id" ? "no such run" : "no such resource");
755
1543
  }
756
1544
  }
757
1545
  return hit;
@@ -801,11 +1589,19 @@ export function authorized(header, tokenDigest) {
801
1589
  return timingSafeEqual(sha(m ? m[1] : ""), tokenDigest) && Boolean(m);
802
1590
  }
803
1591
 
804
- function readRawBody(req) {
1592
+ function readRawBody(req, timeoutMs = 0) {
805
1593
  return new Promise((resolve, reject) => {
806
1594
  const chunks = [];
807
1595
  let size = 0;
808
1596
  let over = false;
1597
+ if (timeoutMs > 0) {
1598
+ const timer = setTimeout(
1599
+ () => reject(new HttpError(408, "invalid-body", "the body did not arrive in time")),
1600
+ timeoutMs,
1601
+ );
1602
+ req.on("end", () => clearTimeout(timer));
1603
+ req.on("close", () => clearTimeout(timer));
1604
+ }
809
1605
  req.on("data", (chunk) => {
810
1606
  size += chunk.length;
811
1607
  if (size > MAX_BODY_BYTES) over = true;
@@ -846,7 +1642,13 @@ function drain(req) {
846
1642
  }
847
1643
 
848
1644
  function parseArgs(argv) {
849
- const opts = { port: 0, idleMinutes: DEFAULT_IDLE_MINUTES, origins: [], printToken: false };
1645
+ const opts = {
1646
+ port: 0,
1647
+ idleMinutes: DEFAULT_IDLE_MINUTES,
1648
+ maxHours: DEFAULT_MAX_HOURS,
1649
+ origins: [],
1650
+ printToken: false,
1651
+ };
850
1652
  for (let i = 0; i < argv.length; i++) {
851
1653
  const a = argv[i];
852
1654
  const value = () => {
@@ -867,6 +1669,12 @@ function parseArgs(argv) {
867
1669
  throw new Error(`--idle-minutes is more than 0 and at most ${MAX_IDLE_MINUTES}`);
868
1670
  }
869
1671
  opts.idleMinutes = n;
1672
+ } else if (a === "--max-hours") {
1673
+ const n = Number(value());
1674
+ if (!Number.isFinite(n) || n <= 0 || n > MAX_HOURS) {
1675
+ throw new Error(`--max-hours is more than 0 and at most ${MAX_HOURS}`);
1676
+ }
1677
+ opts.maxHours = n;
870
1678
  } else if (a === "--allow-origin") {
871
1679
  const o = value();
872
1680
  let parsed;
@@ -904,6 +1712,135 @@ function writeTokenFile(record) {
904
1712
  return file;
905
1713
  }
906
1714
 
1715
+ export const OFFICE_PATH = "/office";
1716
+ /** Bearer, no Origin: mints a one-time panel code (office-control.mjs). */
1717
+ export const OFFICE_CODE_PATH = "/office/code";
1718
+ /** No bearer, own Origin only: trades a panel code for the token, once. */
1719
+ export const OFFICE_EXCHANGE_PATH = "/office/exchange";
1720
+
1721
+ function readPrefs(env) {
1722
+ try {
1723
+ return JSON.parse(
1724
+ readFileSync(
1725
+ env.MULTI_AGENT_PREFS || join(homedir(), ".claude", "multi-agent-preferences.json"),
1726
+ "utf8",
1727
+ ),
1728
+ );
1729
+ } catch {
1730
+ return null;
1731
+ }
1732
+ }
1733
+
1734
+ function prefsOfficePage(env) {
1735
+ return readPrefs(env)?.global?.office?.page ?? null;
1736
+ }
1737
+
1738
+ /**
1739
+ * The page language: "tr" when prefs.global.outputLanguage is Turkish, else
1740
+ * "en". Only these two values ever reach the page, so the stamp needs no
1741
+ * escaping.
1742
+ */
1743
+ export function officeLang(env = process.env) {
1744
+ const lang = readPrefs(env)?.global?.outputLanguage;
1745
+ return typeof lang === "string" && /^tr\b/i.test(lang) ? "tr" : "en";
1746
+ }
1747
+
1748
+ /**
1749
+ * The panel page, when one is on disk: MA_OFFICE_PAGE, else
1750
+ * prefs.global.office.page, else (neither set) the copy beside this package.
1751
+ * A configured path is the only candidate, so pointing it elsewhere never
1752
+ * falls back. Absolute, regular files only; none is not an error, the route
1753
+ * is 404.
1754
+ */
1755
+ export function officePage(env = process.env) {
1756
+ const configured = env.MA_OFFICE_PAGE || prefsOfficePage(env);
1757
+ const candidates = configured ? [configured] : [join(HERE, "..", "office", "index.html")];
1758
+ for (const p of candidates) {
1759
+ if (typeof p !== "string" || !isAbsolute(p)) continue;
1760
+ try {
1761
+ if (statSync(p).isFile()) return p;
1762
+ } catch {
1763
+ // Not there: the route answers 404.
1764
+ }
1765
+ }
1766
+ return null;
1767
+ }
1768
+
1769
+ /** Sprites of the office floor, served beside the page under this prefix. */
1770
+ export const OFFICE_ASSET_PREFIX = "/office/assets/";
1771
+ const OFFICE_ASSET_NAME = /^[A-Za-z0-9_]{1,48}\.png$/;
1772
+ const OFFICE_ASSET_MAX_BYTES = 256 * 1024;
1773
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
1774
+
1775
+ /**
1776
+ * A sprite of the installed page: the raw name matches a plain file name
1777
+ * pattern (no separator, no escape), the file is a regular PNG of at most
1778
+ * 256 KiB whose real path is directly inside the page's assets directory.
1779
+ * Anything else is null, which the route answers with 404.
1780
+ */
1781
+ export function officeAsset(name, env = process.env) {
1782
+ if (!OFFICE_ASSET_NAME.test(name)) return null;
1783
+ const page = officePage(env);
1784
+ if (!page) return null;
1785
+ try {
1786
+ const dir = realpathSync(join(dirname(page), "assets"));
1787
+ const file = realpathSync(join(dir, name));
1788
+ if (dirname(file) !== dir) return null;
1789
+ const st = statSync(file);
1790
+ if (!st.isFile() || st.size > OFFICE_ASSET_MAX_BYTES) return null;
1791
+ const body = readFileSync(file);
1792
+ return body.subarray(0, 8).equals(PNG_SIGNATURE) ? body : null;
1793
+ } catch {
1794
+ return null;
1795
+ }
1796
+ }
1797
+
1798
+ /** The panel's CSP: nothing but its own nonce'd inline code, same-origin calls and images. */
1799
+ export const officeCsp = (nonce) =>
1800
+ [
1801
+ "default-src 'none'",
1802
+ `script-src 'nonce-${nonce}'`,
1803
+ `style-src 'nonce-${nonce}'`,
1804
+ "img-src 'self' data:",
1805
+ "connect-src 'self'",
1806
+ "frame-ancestors 'none'",
1807
+ "base-uri 'none'",
1808
+ "form-action 'none'",
1809
+ ].join("; ");
1810
+
1811
+ /** Where every write route leaves its line. */
1812
+ export function actionsLog() {
1813
+ return join(homedir(), ".claude", "logs", "multi-agent", "office-actions.jsonl");
1814
+ }
1815
+
1816
+ /**
1817
+ * One line per write: who (the local bearer holder, and whether the request
1818
+ * came from the panel page), what (route, path parameters, preview or
1819
+ * confirm), when and the outcome; a tool call adds the sha256 of its
1820
+ * arguments. Never a body, a query value or a token.
1821
+ * A log that cannot be written does not fail the request.
1822
+ */
1823
+ function logAction({ route, params, own, confirm, status, error, argsSha256 }) {
1824
+ const line = {
1825
+ at: new Date().toISOString(),
1826
+ who: "local",
1827
+ via: own ? "panel" : "client",
1828
+ route,
1829
+ params,
1830
+ step: confirm ? "confirm" : "request",
1831
+ status,
1832
+ ...(argsSha256 ? { argsSha256 } : {}),
1833
+ ...(error ? { error } : {}),
1834
+ };
1835
+ try {
1836
+ const file = actionsLog();
1837
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
1838
+ appendFileSync(file, JSON.stringify(line) + "\n", { mode: 0o600 });
1839
+ } catch {
1840
+ // The action itself already happened or was refused; the log is best effort.
1841
+ }
1842
+ }
1843
+
907
1844
  /**
908
1845
  * Start listening. Resolves with the ready record once the port is known.
909
1846
  *
@@ -915,11 +1852,28 @@ export function serve(opts) {
915
1852
  const origins = new Set(opts.origins ?? []);
916
1853
  const idleMs = Math.round((opts.idleMinutes ?? DEFAULT_IDLE_MINUTES) * 60000);
917
1854
  let hosts = new Set();
1855
+ let selfOrigins = new Set();
1856
+ const streams = new Set();
918
1857
  let startSec = Infinity;
919
1858
  const nonces = createNonceStore({ file: noncesPath() });
920
1859
  let tokenFile = null;
921
1860
  let idleTimer = null;
1861
+ let lifeTimer = null;
922
1862
  let stopping = false;
1863
+ const codes = new Map();
1864
+ const mintCode = () => {
1865
+ const now = Date.now();
1866
+ for (const [c, exp] of codes) if (exp <= now) codes.delete(c);
1867
+ while (codes.size >= MAX_CODES) codes.delete(codes.keys().next().value);
1868
+ const code = randomBytes(32).toString("base64url");
1869
+ codes.set(code, now + codeTtlMs);
1870
+ return { code, expiresAt: new Date(now + codeTtlMs).toISOString() };
1871
+ };
1872
+ const redeemCode = (code) => {
1873
+ const exp = typeof code === "string" && PANEL_CODE.test(code) ? codes.get(code) : undefined;
1874
+ if (exp !== undefined) codes.delete(code);
1875
+ return exp !== undefined && exp > Date.now();
1876
+ };
923
1877
 
924
1878
  // requireHostHeader off: a request without Host reaches the Host check below
925
1879
  // and is refused there with a contract error body, like any other wrong Host.
@@ -929,6 +1883,7 @@ export function serve(opts) {
929
1883
  requireHostHeader: false,
930
1884
  });
931
1885
  server.maxHeadersCount = 50;
1886
+ server.maxConnections = MAX_CONNECTIONS;
932
1887
 
933
1888
  const send = (res, status, obj, extra = {}) => {
934
1889
  const body = serialize(obj);
@@ -937,11 +1892,37 @@ export function serve(opts) {
937
1892
  "Content-Length": Buffer.byteLength(body),
938
1893
  "Cache-Control": "no-store",
939
1894
  "X-Content-Type-Options": "nosniff",
1895
+ "Referrer-Policy": "no-referrer",
1896
+ "Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
940
1897
  "X-Contract-Version": CONTRACT_VERSION,
941
1898
  ...extra,
942
1899
  });
943
1900
  res.end(body);
944
1901
  };
1902
+
1903
+ /**
1904
+ * The panel, with a fresh nonce in its CSP and in each inline script and
1905
+ * style tag, so no other inline code runs, and its language stamped in.
1906
+ * 404 when no page is installed.
1907
+ */
1908
+ const serveOffice = (res, headOnly) => {
1909
+ const file = officePage();
1910
+ if (!file) throw new HttpError(404, "not-found", "no panel is installed on this machine");
1911
+ const nonce = randomBytes(18).toString("base64url");
1912
+ const html = readFileSync(file, "utf8")
1913
+ .replaceAll("{{NONCE}}", nonce)
1914
+ .replaceAll("{{LANG}}", officeLang());
1915
+ res.writeHead(200, {
1916
+ "Content-Type": "text/html; charset=utf-8",
1917
+ "Content-Length": Buffer.byteLength(html),
1918
+ "Cache-Control": "no-store",
1919
+ "X-Content-Type-Options": "nosniff",
1920
+ "Referrer-Policy": "no-referrer",
1921
+ "Cross-Origin-Opener-Policy": "same-origin",
1922
+ "Content-Security-Policy": officeCsp(nonce),
1923
+ });
1924
+ res.end(headOnly ? undefined : html);
1925
+ };
945
1926
  const fail = (res, err, extra) => {
946
1927
  const body = { contractVersion: CONTRACT_VERSION, error: err.code, message: err.message };
947
1928
  if (err.extra?.errors) body.errors = err.extra.errors;
@@ -957,7 +1938,10 @@ export function serve(opts) {
957
1938
  if (stopping) return;
958
1939
  stopping = true;
959
1940
  clearTimeout(idleTimer);
1941
+ clearTimeout(lifeTimer);
960
1942
  removeTokenFile();
1943
+ for (const st of streams) clearInterval(st.timer);
1944
+ closeToolkit();
961
1945
  process.stderr.write(`contract-server: stopping (${why})\n`);
962
1946
  server.close(() => process.exit(0));
963
1947
  server.closeAllConnections();
@@ -977,7 +1961,7 @@ export function serve(opts) {
977
1961
  const audit = { deviceId: null, method: req.method, verb: verbOf(url.pathname) };
978
1962
  let insecure = null;
979
1963
  try {
980
- const raw = await readRawBody(req);
1964
+ const raw = await readRawBody(req, PHONE_BODY_TIMEOUT_MS);
981
1965
  const registry = readRegistry();
982
1966
  insecure = registry.insecure;
983
1967
  const v = verifyRequest({
@@ -1019,6 +2003,7 @@ export function serve(opts) {
1019
2003
  if (!(err instanceof HttpError))
1020
2004
  process.stderr.write(`contract-server: ${err?.message ?? err}\n`);
1021
2005
  fail(res, e, cors);
2006
+ if (e.status === 408) req.destroy();
1022
2007
  const outcome = e.code === "unknown-device" && insecure ? insecure : e.code;
1023
2008
  appendAudit({ ...audit, status: e.status, outcome });
1024
2009
  }
@@ -1028,22 +2013,24 @@ export function serve(opts) {
1028
2013
  arm();
1029
2014
  res.on("finish", arm);
1030
2015
  const origin = req.headers.origin;
2016
+ const own = origin !== undefined && selfOrigins.has(origin);
1031
2017
  const cors =
1032
- origin && origins.has(origin)
2018
+ origin && !own && origins.has(origin)
1033
2019
  ? { "Access-Control-Allow-Origin": origin, Vary: "Origin" }
1034
2020
  : {};
2021
+ let audit = null;
1035
2022
  try {
1036
2023
  if (!hosts.has(String(req.headers.host ?? "").toLowerCase())) {
1037
2024
  throw new HttpError(403, "forbidden-host", "the Host header does not name this server");
1038
2025
  }
1039
- if (origin !== undefined && !origins.has(origin)) {
2026
+ if (origin !== undefined && !own && !origins.has(origin)) {
1040
2027
  throw new HttpError(403, "forbidden-origin", "this origin is not allowed");
1041
2028
  }
1042
2029
  if (req.method === "OPTIONS" && origin !== undefined) {
1043
2030
  drain(req);
1044
2031
  res.writeHead(204, {
1045
2032
  ...cors,
1046
- "Access-Control-Allow-Methods": "GET, POST",
2033
+ "Access-Control-Allow-Methods": "GET",
1047
2034
  "Access-Control-Allow-Headers": "Authorization, Content-Type",
1048
2035
  "Access-Control-Max-Age": "600",
1049
2036
  "Cache-Control": "no-store",
@@ -1056,10 +2043,77 @@ export function serve(opts) {
1056
2043
  await servePhone(req, res, url, cors);
1057
2044
  return;
1058
2045
  }
2046
+ if (url.pathname === OFFICE_PATH && (req.method === "GET" || req.method === "HEAD")) {
2047
+ drain(req);
2048
+ if (url.search) throw new HttpError(400, "invalid-query", "the panel takes no query");
2049
+ serveOffice(res, req.method === "HEAD");
2050
+ return;
2051
+ }
2052
+ if (url.pathname.startsWith(OFFICE_ASSET_PREFIX)) {
2053
+ drain(req);
2054
+ if (req.method !== "GET" && req.method !== "HEAD") {
2055
+ throw new HttpError(405, "method-not-allowed", "use GET", { allow: "GET" });
2056
+ }
2057
+ if (url.search) throw new HttpError(400, "invalid-query", "an asset takes no query");
2058
+ const body = officeAsset(url.pathname.slice(OFFICE_ASSET_PREFIX.length));
2059
+ if (!body) throw new HttpError(404, "not-found", "no such asset");
2060
+ res.writeHead(200, {
2061
+ "Content-Type": "image/png",
2062
+ "Content-Length": body.length,
2063
+ "Cache-Control": "max-age=3600",
2064
+ "X-Content-Type-Options": "nosniff",
2065
+ "Cross-Origin-Resource-Policy": "same-origin",
2066
+ "Content-Security-Policy": "default-src 'none'",
2067
+ });
2068
+ res.end(req.method === "HEAD" ? undefined : body);
2069
+ return;
2070
+ }
2071
+ if (url.pathname === OFFICE_EXCHANGE_PATH) {
2072
+ if (req.method !== "POST" || url.search) {
2073
+ drain(req);
2074
+ throw new HttpError(405, "method-not-allowed", "use POST", { allow: "POST" });
2075
+ }
2076
+ if (!own) {
2077
+ drain(req);
2078
+ throw new HttpError(403, "forbidden-origin", "only the panel page exchanges a code");
2079
+ }
2080
+ if (!/^application\/json(\s*;.*)?$/i.test(String(req.headers["content-type"] ?? ""))) {
2081
+ drain(req);
2082
+ throw new HttpError(415, "unsupported-media-type", "the body must be application/json");
2083
+ }
2084
+ const b = await readBody(req);
2085
+ if (!redeemCode(b?.code)) {
2086
+ throw new HttpError(
2087
+ 401,
2088
+ "unauthorized",
2089
+ "the code is unknown, used or expired; run /multi-agent:office-on again",
2090
+ );
2091
+ }
2092
+ send(res, 200, { contractVersion: CONTRACT_VERSION, token });
2093
+ return;
2094
+ }
1059
2095
  if (!authorized(req.headers.authorization, tokenDigest)) {
1060
2096
  throw new HttpError(401, "unauthorized", "a bearer token is required");
1061
2097
  }
2098
+ if (url.pathname === OFFICE_CODE_PATH) {
2099
+ drain(req);
2100
+ if (req.method !== "POST") {
2101
+ throw new HttpError(405, "method-not-allowed", "use POST", { allow: "POST" });
2102
+ }
2103
+ if (origin !== undefined) {
2104
+ throw new HttpError(403, "forbidden-origin", "a page may not mint a panel code");
2105
+ }
2106
+ send(res, 200, { contractVersion: CONTRACT_VERSION, ...mintCode() });
2107
+ return;
2108
+ }
1062
2109
  const { route, params } = match(req.method, url.pathname);
2110
+ // A write, and a read of a transcript, are refused to every page but the
2111
+ // panel this server serves: an allowed origin may read the contract, it
2112
+ // may not drive this machine or read what an agent said.
2113
+ if ((route.write || route.selfOrigin) && origin !== undefined && !own) {
2114
+ throw new HttpError(403, "forbidden-origin", "this origin may not call this route");
2115
+ }
2116
+ if (route.write) audit = { route: `${route.method} ${route.path}`, params, own };
1063
2117
  const q = parseQuery(route, url.searchParams);
1064
2118
  const redact = flag(one(q, "redact")) || origin !== undefined;
1065
2119
  let body = null;
@@ -1073,34 +2127,110 @@ export function serve(opts) {
1073
2127
  } else {
1074
2128
  drain(req);
1075
2129
  }
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);
2130
+ if (audit) audit.confirm = Boolean(body?.confirmToken ?? one(q, "confirm"));
2131
+ if (audit && route.path === "/v1/tools/{name}/call") {
2132
+ audit.argsSha256 = createHash("sha256")
2133
+ .update(JSON.stringify(body?.arguments ?? {}))
2134
+ .digest("hex");
2135
+ }
2136
+ if (route.stream) {
2137
+ serveStream(req, res, route, cors);
1080
2138
  return;
1081
2139
  }
1082
- process.stderr.write(`contract-server: ${err?.message ?? err}\n`);
1083
- fail(res, new HttpError(500, "internal", "internal error"), cors);
2140
+ const doc = await route.handle({ q, params, body, redact });
2141
+ if (audit) logAction({ ...audit, status: 200 });
2142
+ send(res, 200, doc, cors);
2143
+ } catch (err) {
2144
+ const e = err instanceof HttpError ? err : new HttpError(500, "internal", "internal error");
2145
+ if (!(err instanceof HttpError))
2146
+ process.stderr.write(`contract-server: ${err?.message ?? err}\n`);
2147
+ if (audit) logAction({ ...audit, status: e.status, error: e.code });
2148
+ fail(res, e, cors);
1084
2149
  }
1085
2150
  });
1086
2151
 
2152
+ /**
2153
+ * GET /v1/activity/stream: the activity document as server-sent events, sent
2154
+ * when it changes and checked every STREAM_POLL_MS, with a comment line as a
2155
+ * heartbeat. Each event re-arms the idle clock, so an open panel keeps the
2156
+ * server up and a closed one lets it stop. At most MAX_STREAMS at once.
2157
+ */
2158
+ const serveStream = (req, res, route, cors) => {
2159
+ if (streams.size >= MAX_STREAMS) {
2160
+ throw new HttpError(429, "too-many-streams", `at most ${MAX_STREAMS} streams at once`);
2161
+ }
2162
+ res.writeHead(200, {
2163
+ "Content-Type": "text/event-stream; charset=utf-8",
2164
+ "Cache-Control": "no-store",
2165
+ Connection: "keep-alive",
2166
+ "X-Content-Type-Options": "nosniff",
2167
+ "Referrer-Policy": "no-referrer",
2168
+ "X-Contract-Version": CONTRACT_VERSION,
2169
+ ...cors,
2170
+ });
2171
+ let last = null;
2172
+ let busy = false;
2173
+ let lastSent = Date.now();
2174
+ const tick = async () => {
2175
+ if (busy) return;
2176
+ busy = true;
2177
+ try {
2178
+ const doc = await route.handle({});
2179
+ const { generatedAt: _g, ...stable } = doc;
2180
+ const key = JSON.stringify(stable);
2181
+ if (key !== last) {
2182
+ last = key;
2183
+ res.write(`event: activity\ndata: ${JSON.stringify(doc)}\n\n`);
2184
+ lastSent = Date.now();
2185
+ arm();
2186
+ } else if (Date.now() - lastSent >= STREAM_HEARTBEAT_MS) {
2187
+ res.write(": keep-alive\n\n");
2188
+ lastSent = Date.now();
2189
+ arm();
2190
+ }
2191
+ } catch (err) {
2192
+ process.stderr.write(`contract-server: stream: ${err?.message ?? err}\n`);
2193
+ res.write(`event: error\ndata: {"error":"internal"}\n\n`);
2194
+ } finally {
2195
+ busy = false;
2196
+ }
2197
+ };
2198
+ const timer = setInterval(tick, STREAM_POLL_MS);
2199
+ const entry = { res, timer };
2200
+ streams.add(entry);
2201
+ const close = () => {
2202
+ clearInterval(timer);
2203
+ streams.delete(entry);
2204
+ };
2205
+ // The response's close, not the request's: a request emits close as soon
2206
+ // as its (empty) body has been read.
2207
+ res.on("close", close);
2208
+ tick();
2209
+ };
2210
+
1087
2211
  return new Promise((resolve, reject) => {
1088
2212
  server.once("error", reject);
1089
2213
  server.listen(opts.port ?? 0, BIND_HOST, () => {
1090
2214
  const { port } = server.address();
1091
2215
  hosts = new Set([`${BIND_HOST}:${port}`, `localhost:${port}`]);
2216
+ selfOrigins = new Set([`http://${BIND_HOST}:${port}`, `http://localhost:${port}`]);
1092
2217
  const record = {
1093
2218
  contractVersion: CONTRACT_VERSION,
1094
2219
  url: `http://${BIND_HOST}:${port}`,
1095
2220
  port,
1096
2221
  pid: process.pid,
1097
2222
  idleMinutes: opts.idleMinutes ?? DEFAULT_IDLE_MINUTES,
2223
+ maxHours: opts.maxHours ?? DEFAULT_MAX_HOURS,
1098
2224
  };
1099
2225
  const started = new Date();
1100
2226
  startSec = Math.floor(started.getTime() / 1000);
1101
2227
  tokenFile = writeTokenFile({ ...record, token, startedAt: started.toISOString() });
1102
2228
  for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) process.on(sig, () => stop(sig));
1103
2229
  arm();
2230
+ lifeTimer = setTimeout(
2231
+ () => stop("lifetime"),
2232
+ Math.round((opts.maxHours ?? DEFAULT_MAX_HOURS) * 3600000),
2233
+ );
1104
2234
  resolve({ ...record, tokenFile, ...(opts.printToken ? { token } : {}) });
1105
2235
  });
1106
2236
  });
@@ -1116,7 +2246,7 @@ async function main(argv) {
1116
2246
  }
1117
2247
  if (opts.help) {
1118
2248
  process.stdout.write(
1119
- "Usage: contract-server.mjs [--port N] [--idle-minutes N] [--allow-origin <origin>]... [--print-token]\n",
2249
+ "Usage: contract-server.mjs [--port N] [--idle-minutes N] [--max-hours N] [--allow-origin <origin>]... [--print-token]\n",
1120
2250
  );
1121
2251
  return 0;
1122
2252
  }