@mmerterden/multi-agent-pipeline 20.30.0 → 20.34.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 (195) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/README.md +47 -1
  3. package/README.tr.md +61 -1
  4. package/docs/adr/0018-cross-repo-links-not-a-global-graph.md +43 -0
  5. package/docs/adr/README.md +2 -1
  6. package/docs/facts.json +6 -5
  7. package/docs/features.md +1 -1
  8. package/install/_dev-only-files.mjs +1 -0
  9. package/install/_unattended-profile.mjs +61 -0
  10. package/install/catalog-history.json +1 -1
  11. package/install/claude.mjs +16 -2
  12. package/install/index.mjs +10 -1
  13. package/install/templates/claude-hooks.json +23 -1
  14. package/manifest.json +198 -134
  15. package/package.json +4 -1
  16. package/pipeline/agents/code-reviewer.md +9 -0
  17. package/pipeline/agents/explorer.md +4 -0
  18. package/pipeline/agents/plan-critic.md +4 -2
  19. package/pipeline/commands/multi-agent/SKILL.md +17 -136
  20. package/pipeline/commands/multi-agent/analysis/SKILL.md +4 -4
  21. package/pipeline/commands/multi-agent/channels/SKILL.md +15 -297
  22. package/pipeline/commands/multi-agent/graph/SKILL.md +5 -2
  23. package/pipeline/commands/multi-agent/language/SKILL.md +3 -3
  24. package/pipeline/commands/multi-agent/office-on/SKILL.md +8 -7
  25. package/pipeline/commands/multi-agent/office-status/SKILL.md +1 -1
  26. package/pipeline/commands/multi-agent/prune-logs/SKILL.md +4 -1
  27. package/pipeline/commands/multi-agent/resume/SKILL.md +8 -22
  28. package/pipeline/commands/multi-agent/review/SKILL.md +11 -134
  29. package/pipeline/commands/multi-agent/scenario-audit/SKILL.md +4 -2
  30. package/pipeline/commands/multi-agent/setup/SKILL.md +19 -224
  31. package/pipeline/commands/multi-agent/sync/SKILL.md +12 -0
  32. package/pipeline/commands/multi-agent/test-gen/SKILL.md +29 -3
  33. package/pipeline/contract/CHANGELOG.md +46 -0
  34. package/pipeline/contract/README.md +27 -10
  35. package/pipeline/contract/fixtures/error-invalid-request.json +1 -1
  36. package/pipeline/contract/fixtures/error-unauthorized.json +1 -1
  37. package/pipeline/contract/fixtures/error-unsigned.json +1 -1
  38. package/pipeline/contract/fixtures/launch-plan.json +1 -1
  39. package/pipeline/contract/fixtures/runs-awaiting-question.json +6 -1
  40. package/pipeline/contract/fixtures/runs-empty.json +1 -1
  41. package/pipeline/contract/fixtures/runs-failed.json +6 -1
  42. package/pipeline/contract/fixtures/runs-old-schema.json +6 -1
  43. package/pipeline/contract/fixtures/runs-pr-opened-redacted.json +5 -1
  44. package/pipeline/contract/fixtures/runs-pr-opened.json +6 -1
  45. package/pipeline/contract/fixtures/runs-running.json +6 -1
  46. package/pipeline/contract/manifest.json +153 -19
  47. package/pipeline/contract/types/index.d.ts +304 -6
  48. package/pipeline/lib/bg-launch.mjs +106 -0
  49. package/pipeline/lib/code-digest.mjs +49 -0
  50. package/pipeline/lib/credential-resolve.mjs +42 -2
  51. package/pipeline/multi-agent-refs/channels/generator.md +146 -0
  52. package/pipeline/multi-agent-refs/channels/menu.md +55 -0
  53. package/pipeline/multi-agent-refs/channels/multi-repo.md +6 -0
  54. package/pipeline/multi-agent-refs/channels/preview.md +25 -0
  55. package/pipeline/multi-agent-refs/channels/reference.md +98 -0
  56. package/pipeline/multi-agent-refs/features/autopilot-circuit-breaker.md +1 -1
  57. package/pipeline/multi-agent-refs/features/code-graph.md +27 -0
  58. package/pipeline/multi-agent-refs/features/office-threat-model.md +24 -6
  59. package/pipeline/multi-agent-refs/features/plan-critic.md +5 -2
  60. package/pipeline/multi-agent-refs/features/review-delta.md +4 -1
  61. package/pipeline/multi-agent-refs/features/scope-check.md +10 -1
  62. package/pipeline/multi-agent-refs/features/unattended-security.md +1 -1
  63. package/pipeline/multi-agent-refs/help/en.md +2 -2
  64. package/pipeline/multi-agent-refs/help/tr.md +2 -2
  65. package/pipeline/multi-agent-refs/knowledge.md +9 -0
  66. package/pipeline/multi-agent-refs/orchestrator/dispatcher.md +163 -0
  67. package/pipeline/multi-agent-refs/phases/modes.md +7 -7
  68. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +4 -4
  69. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +9 -8
  70. package/pipeline/multi-agent-refs/phases/phase-3-review.md +12 -7
  71. package/pipeline/multi-agent-refs/phases/phase-4-commit.md +7 -3
  72. package/pipeline/multi-agent-refs/phases/phase-5-report.md +19 -10
  73. package/pipeline/multi-agent-refs/picker-contract.md +1 -1
  74. package/pipeline/multi-agent-refs/resume/details.md +35 -0
  75. package/pipeline/multi-agent-refs/review/inputs.md +87 -0
  76. package/pipeline/multi-agent-refs/review/module-guides.md +25 -0
  77. package/pipeline/multi-agent-refs/review/valven.md +42 -0
  78. package/pipeline/multi-agent-refs/review-standalone-tracker.md +37 -0
  79. package/pipeline/multi-agent-refs/setup/accounts.md +27 -0
  80. package/pipeline/multi-agent-refs/setup/app-store-connect.md +5 -0
  81. package/pipeline/multi-agent-refs/setup/firebase.md +5 -0
  82. package/pipeline/multi-agent-refs/setup/google-play.md +4 -0
  83. package/pipeline/multi-agent-refs/setup/hooks-and-plugins.md +21 -0
  84. package/pipeline/multi-agent-refs/setup/language.md +46 -0
  85. package/pipeline/multi-agent-refs/setup/multi-repo-group.md +51 -0
  86. package/pipeline/multi-agent-refs/setup/summaries.md +63 -0
  87. package/pipeline/multi-agent-refs/setup/token-save-flow.md +51 -0
  88. package/pipeline/multi-agent-refs/tracker-contract.md +8 -3
  89. package/pipeline/office/NOTICE.md +4 -1
  90. package/pipeline/office/assets/ARCHIVE_BOXES.png +0 -0
  91. package/pipeline/office/assets/COFFEE_MACHINE.png +0 -0
  92. package/pipeline/office/assets/COUNTER_SINK.png +0 -0
  93. package/pipeline/office/assets/DEVICE_STAND.png +0 -0
  94. package/pipeline/office/assets/FILING_CABINET.png +0 -0
  95. package/pipeline/office/assets/FRIDGE.png +0 -0
  96. package/pipeline/office/assets/MAILBOX.png +0 -0
  97. package/pipeline/office/assets/MOP_BUCKET.png +0 -0
  98. package/pipeline/office/assets/PRINTER.png +0 -0
  99. package/pipeline/office/assets/RECEPTION_DESK.png +0 -0
  100. package/pipeline/office/assets/SERVER_RACK.png +0 -0
  101. package/pipeline/office/assets/SERVER_RACK_2.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/index.html +3936 -819
  107. package/pipeline/office/toolkit.json +3 -0
  108. package/pipeline/rules/response-shape.md +6 -7
  109. package/pipeline/schemas/activity.schema.json +188 -4
  110. package/pipeline/schemas/agent-state.schema.json +511 -115
  111. package/pipeline/schemas/contract-error.schema.json +12 -1
  112. package/pipeline/schemas/job-change.schema.json +72 -0
  113. package/pipeline/schemas/job-request.schema.json +38 -0
  114. package/pipeline/schemas/launch.json +4 -4
  115. package/pipeline/schemas/office-credentials.schema.json +71 -0
  116. package/pipeline/schemas/office-jobs.json +253 -0
  117. package/pipeline/schemas/office-jobs.schema.json +62 -0
  118. package/pipeline/schemas/office-memory.schema.json +105 -0
  119. package/pipeline/schemas/office-settings-request.schema.json +30 -0
  120. package/pipeline/schemas/office-settings.schema.json +67 -0
  121. package/pipeline/schemas/phases.json +14 -6
  122. package/pipeline/schemas/phases.schema.json +86 -19
  123. package/pipeline/schemas/plan-critique.schema.json +9 -2
  124. package/pipeline/schemas/planning-output.schema.json +25 -2
  125. package/pipeline/schemas/prefs.schema.json +24 -1
  126. package/pipeline/schemas/prs.schema.json +5 -0
  127. package/pipeline/schemas/prune-logs-request.schema.json +26 -0
  128. package/pipeline/schemas/prune-logs.schema.json +52 -0
  129. package/pipeline/schemas/repo-change.schema.json +54 -0
  130. package/pipeline/schemas/repo-request.schema.json +29 -0
  131. package/pipeline/schemas/repos.schema.json +49 -1
  132. package/pipeline/schemas/reviewer-output.schema.json +20 -2
  133. package/pipeline/schemas/runs-index.schema.json +257 -46
  134. package/pipeline/schemas/system-status.schema.json +111 -0
  135. package/pipeline/schemas/token-budget.json +4 -4
  136. package/pipeline/schemas/tools.schema.json +5 -0
  137. package/pipeline/schemas/triage-output.schema.json +20 -2
  138. package/pipeline/schemas/unattended-profile.json +7 -2
  139. package/pipeline/scripts/_active-run.mjs +2 -1
  140. package/pipeline/scripts/_graph-analysis.mjs +270 -0
  141. package/pipeline/scripts/_memory-policy.mjs +165 -0
  142. package/pipeline/scripts/_secret-scan.mjs +105 -0
  143. package/pipeline/scripts/_spawn-async.mjs +9 -5
  144. package/pipeline/scripts/_unified-diff.mjs +2 -2
  145. package/pipeline/scripts/activity-snapshot.mjs +219 -23
  146. package/pipeline/scripts/autopilot-runner.mjs +7 -5
  147. package/pipeline/scripts/capture-flush.sh +12 -2
  148. package/pipeline/scripts/check-read-size.sh +9 -0
  149. package/pipeline/scripts/code-graph-rules/android.json +8 -3
  150. package/pipeline/scripts/contract-server.mjs +575 -28
  151. package/pipeline/scripts/debt-ledger.mjs +323 -0
  152. package/pipeline/scripts/file-context.mjs +261 -0
  153. package/pipeline/scripts/file-context.sh +20 -0
  154. package/pipeline/scripts/gen-mode-dispatch.mjs +3 -1
  155. package/pipeline/scripts/graph-links.mjs +230 -0
  156. package/pipeline/scripts/graph-query.mjs +133 -1
  157. package/pipeline/scripts/graph-report.mjs +65 -0
  158. package/pipeline/scripts/job-launch.mjs +477 -0
  159. package/pipeline/scripts/launch-request.mjs +94 -9
  160. package/pipeline/scripts/learn-from-transcripts.mjs +12 -48
  161. package/pipeline/scripts/learnings-ledger.mjs +143 -24
  162. package/pipeline/scripts/office-build.mjs +148 -11
  163. package/pipeline/scripts/office-control.mjs +34 -7
  164. package/pipeline/scripts/office-credentials.mjs +106 -0
  165. package/pipeline/scripts/office-memory.mjs +200 -0
  166. package/pipeline/scripts/office-prune.mjs +125 -0
  167. package/pipeline/scripts/office-settings.mjs +145 -0
  168. package/pipeline/scripts/phase-tracker.sh +12 -5
  169. package/pipeline/scripts/plan-critique-gate.mjs +11 -2
  170. package/pipeline/scripts/prune-logs.sh +53 -2
  171. package/pipeline/scripts/rework-rulings.mjs +106 -0
  172. package/pipeline/scripts/runs-index.mjs +62 -2
  173. package/pipeline/scripts/scope-check-gate.mjs +149 -2
  174. package/pipeline/scripts/system-status.mjs +196 -0
  175. package/pipeline/scripts/task-mirror.mjs +150 -0
  176. package/pipeline/scripts/task-mirror.sh +20 -0
  177. package/pipeline/scripts/toolkit-tools.mjs +33 -7
  178. package/pipeline/scripts/transcript-index.mjs +4 -1
  179. package/pipeline/scripts/triage-memory.mjs +64 -37
  180. package/pipeline/scripts/validate-planning.mjs +68 -4
  181. package/pipeline/scripts/validate-reviewer.mjs +20 -0
  182. package/pipeline/scripts/validate-triage.mjs +107 -2
  183. package/pipeline/scripts/work-items.mjs +191 -22
  184. package/pipeline/skills/.skill-manifest.json +9 -9
  185. package/pipeline/skills/.skills-index.json +1 -1
  186. package/pipeline/skills/shared/README.md +1 -1
  187. package/pipeline/skills/shared/core/multi-agent-graph/SKILL.md +5 -2
  188. package/pipeline/skills/shared/core/multi-agent-language/SKILL.md +4 -4
  189. package/pipeline/skills/shared/core/multi-agent-office-on/SKILL.md +3 -4
  190. package/pipeline/skills/shared/core/multi-agent-prune-logs/SKILL.md +4 -1
  191. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +21 -0
  192. package/pipeline/skills/shared/core/multi-agent-scenario-audit/SKILL.md +4 -2
  193. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +4 -4
  194. package/pipeline/skills/shared/core/multi-agent-test-gen/SKILL.md +26 -3
  195. package/pipeline/skills/skills-index.md +1 -1
@@ -20,13 +20,19 @@
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]
32
38
  * POST /v1/autopilot/on autopilot-control.mjs on-preview, then on (two steps)
@@ -35,6 +41,9 @@
35
41
  * GET /v1/activity/stream the same document as server-sent events, on change
36
42
  * GET /v1/agents/{sessionId}/{agentId}/chat activity-snapshot.mjs chat
37
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
38
47
  * GET /v1/runs/{id}/results run-results.mjs: diff, findings, tests, evidence, analysis
39
48
  * GET /v1/runs/{id}/evidence/{name} run-results.mjs --evidence: one screenshot
40
49
  * POST /v1/runs/{id}/review-verdict work-items.mjs verdict, then --post (two steps)
@@ -48,6 +57,8 @@
48
57
  * DELETE /v1/routines/{name} routine-registry.mjs remove (two steps)
49
58
  * GET /v1/stack?repo=R stack-toggle.mjs status
50
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)
51
62
  *
52
63
  * Every route carries two tags for a remote carrier to enforce later, and
53
64
  * manifest.json repeats them: `remote` is the scope it would need (read,
@@ -60,7 +71,8 @@
60
71
  * prefs.global.office.page, else the installed copy (office/index.html next
61
72
  * to this scripts directory), and is 404 when none exists. The page carries
62
73
  * 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
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
64
76
  * its response carries a per-response CSP nonce for its inline script and
65
77
  * style.
66
78
  *
@@ -72,12 +84,16 @@
72
84
  * POST /v1/phone/runs/{id}/answer scope answer
73
85
  * POST /v1/phone/launch?repo=P scope launch, and only after `phone-devices.mjs launch on`
74
86
  *
75
- * There is deliberately no route that merges, marks ready, runs a command or
76
- * starts a host. POST /v1/launch and POST /v1/runs/{id}/resume return the argv
77
- * and environment launch.json declares; the client starts the process, and
78
- * nothing here starts a session. The only `claude` this process runs is
79
- * `claude agents --json` (read, for processAlive) and `claude stop <session>`,
80
- * 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.
81
97
  *
82
98
  * Kill, gc, autopilot on, stack and routine removal change what this machine
83
99
  * keeps or runs, so each takes two requests. The first returns a
@@ -124,16 +140,20 @@
124
140
  * body or a token) to ~/.claude/logs/multi-agent/office-actions.jsonl.
125
141
  *
126
142
  * Lifetime. The server exits after --idle-minutes without a request (15 by
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.
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.
130
147
  *
131
148
  * Panel bootstrap. `POST /office/code` (bearer, no Origin) mints a code that
132
149
  * is valid for 10 seconds and one use; office-control.mjs opens the browser
133
150
  * with it in the URL fragment. The page trades it for the token with
134
151
  * `POST /office/exchange` (no bearer, own Origin only); a second use, an
135
152
  * 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
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
137
157
  * never installed as a service.
138
158
  *
139
159
  * Zero runtime dependencies (ADR-0004): node:http, node:crypto and the
@@ -164,7 +184,7 @@ import {
164
184
  } from "node:fs";
165
185
  import { createServer } from "node:http";
166
186
  import { homedir } from "node:os";
167
- import { dirname, isAbsolute, join, sep } from "node:path";
187
+ import { basename, dirname, isAbsolute, join, sep } from "node:path";
168
188
  import { fileURLToPath } from "node:url";
169
189
  import { clearInterval, clearTimeout, setInterval, setTimeout } from "node:timers";
170
190
  import { answerStateFile } from "./answer-question.mjs";
@@ -185,6 +205,17 @@ import {
185
205
  } from "./stack-toggle.mjs";
186
206
  import { CONTRACT_VERSION as STEER_VERSION, SteerError, queueSteer } from "./steer-run.mjs";
187
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";
188
219
  import { EVIDENCE_NAME, ResultsError, runEvidence, runResults } from "./run-results.mjs";
189
220
  import {
190
221
  WorkError,
@@ -195,6 +226,7 @@ import {
195
226
  } from "./work-items.mjs";
196
227
  import {
197
228
  CONTRACT_VERSION as TOOLS_VERSION,
229
+ activeToolkitVersion,
198
230
  TOOL_NAME,
199
231
  ToolError,
200
232
  createToolkitClient,
@@ -224,12 +256,26 @@ import {
224
256
  planLaunch,
225
257
  planResume,
226
258
  questionsDocument,
259
+ registerRepo,
227
260
  reposDocument,
261
+ repoId,
228
262
  requestsDir,
263
+ suggestedRepos,
264
+ unregisterRepo,
265
+ missingRepos,
229
266
  workingTreeState,
230
267
  writeRequestFile,
231
268
  } from "./launch-request.mjs";
232
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";
233
279
  import { CONTRACT_VERSION as KILL_VERSION, KillError, killPreview, killRun } from "./run-kill.mjs";
234
280
  import {
235
281
  CONTRACT_VERSION as LOG_VERSION,
@@ -259,7 +305,7 @@ import { runMain } from "../lib/fatal.mjs";
259
305
  import { invokedDirectly } from "../lib/invoked-directly.mjs";
260
306
 
261
307
  /** The contract as a whole; pipeline/contract/manifest.json carries the same. */
262
- export const CONTRACT_VERSION = "1.4.0";
308
+ export const CONTRACT_VERSION = "1.6.0";
263
309
  /** The majors `?v=` accepts. A new major keeps the previous one here for one minor. */
264
310
  export const SUPPORTED_MAJORS = [1];
265
311
  export const BIND_HOST = "127.0.0.1";
@@ -290,6 +336,7 @@ const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
290
336
  const AGENT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
291
337
  const ROUTINE_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
292
338
  const REPO_NAME = /^[A-Za-z0-9._-]{1,100}(?:\/[A-Za-z0-9._-]{1,100})?$/;
339
+ const REPO_ID = /^[0-9a-f]{16}$/;
293
340
  /** Server-sent event streams open at once; one more is refused. */
294
341
  export const MAX_STREAMS = 4;
295
342
  const STREAM_POLL_MS = 2000;
@@ -344,6 +391,7 @@ const Q = {
344
391
  redact: { check: (v) => v === "0" || v === "1" },
345
392
  group: { check: (v) => GROUP_NAMES.includes(v) },
346
393
  measure: { check: (v) => v === "0" || v === "1" },
394
+ all: { check: (v) => v === "0" || v === "1" },
347
395
  source: { check: (v) => ["jira", "github", "all"].includes(v) },
348
396
  project: { repeat: true, check: (v) => PROJECT_KEY.test(v) },
349
397
  max: { check: (v) => /^[0-9]{1,3}$/.test(v) && Number(v) >= 1 && Number(v) <= 200 },
@@ -716,6 +764,87 @@ async function killRoute({ params, body, redact }) {
716
764
  }
717
765
  }
718
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
+
719
848
  function gcRoute({ body, redact }) {
720
849
  requireBody(
721
850
  body,
@@ -775,6 +904,39 @@ async function control(args) {
775
904
  return doc;
776
905
  }
777
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
+ * With `limit`, at most `limit.max` distinct keys of `limit.group` run at
911
+ * once; one more is 429, like `oneAtATime`.
912
+ */
913
+ const inFlight = new Map();
914
+ const groupRunning = new Map();
915
+ const shared =
916
+ (keyOf, fn, limit = null) =>
917
+ (ctx) => {
918
+ const key = keyOf(ctx);
919
+ if (!inFlight.has(key)) {
920
+ if (limit) {
921
+ const n = groupRunning.get(limit.group) ?? 0;
922
+ if (n >= limit.max) {
923
+ throw new HttpError(429, "busy", "too many of these are running; try again shortly");
924
+ }
925
+ groupRunning.set(limit.group, n + 1);
926
+ }
927
+ inFlight.set(
928
+ key,
929
+ Promise.resolve()
930
+ .then(() => fn(ctx))
931
+ .finally(() => {
932
+ inFlight.delete(key);
933
+ if (limit) groupRunning.set(limit.group, groupRunning.get(limit.group) - 1);
934
+ }),
935
+ );
936
+ }
937
+ return inFlight.get(key);
938
+ };
939
+
778
940
  /** One request at a time per key; a second one while the first runs is 429. */
779
941
  const running = new Set();
780
942
  const oneAtATime = (key, fn) => async (ctx) => {
@@ -1033,6 +1195,252 @@ function resolveRepo(ref) {
1033
1195
  return hits[0].path;
1034
1196
  }
1035
1197
 
1198
+ /**
1199
+ * POST /v1/repos: add a checkout launch accepts. The first request names a
1200
+ * suggestion (an id from GET /v1/repos?all=1) or an absolute path; the
1201
+ * preview says what would be stored and mints a token; the second request
1202
+ * sends only the token. Only a git checkout is accepted from here.
1203
+ */
1204
+ function repoAddRoute({ body }) {
1205
+ requireBody(
1206
+ body,
1207
+ {
1208
+ confirmToken: isToken,
1209
+ suggestion: (v) => typeof v === "string" && REPO_ID.test(v),
1210
+ path: (v) => typeof v === "string" && v.length <= 4096 && isAbsolute(v),
1211
+ },
1212
+ "repo-request.schema.json",
1213
+ );
1214
+ if (body.confirmToken === undefined) {
1215
+ let path;
1216
+ if (body.suggestion !== undefined && body.path !== undefined) {
1217
+ throw new HttpError(400, "invalid-body", "name a suggestion or a path, not both");
1218
+ }
1219
+ if (body.suggestion !== undefined) {
1220
+ const hit = suggestedRepos().find((r) => r.id === body.suggestion);
1221
+ if (!hit) throw new HttpError(404, "not-found", "no such suggestion");
1222
+ path = hit.path;
1223
+ } else if (body.path !== undefined) {
1224
+ path = realOr(body.path);
1225
+ } else {
1226
+ throw new HttpError(400, "invalid-body", "suggestion or path is required");
1227
+ }
1228
+ if (launchRepoAllowed(path)) {
1229
+ throw new HttpError(409, "already-registered", "launch already accepts that repository");
1230
+ }
1231
+ let real;
1232
+ try {
1233
+ real = registerRepoCheck(path);
1234
+ } catch (err) {
1235
+ throw new HttpError(400, "invalid-body", err.message);
1236
+ }
1237
+ // The page picks a repository by folder name; a second one with the same
1238
+ // name could never be told apart there.
1239
+ if (launchRepos().some((r) => r.name.split("/").pop() === basename(real))) {
1240
+ throw new HttpError(
1241
+ 409,
1242
+ "name-taken",
1243
+ "a repository with that folder name is already on the list",
1244
+ );
1245
+ }
1246
+ return {
1247
+ contractVersion: ACTIVITY_VERSION,
1248
+ preview: { name: basename(real) },
1249
+ ...mintConfirm("repo-add", "", { path: real }),
1250
+ };
1251
+ }
1252
+ if (body.suggestion !== undefined || body.path !== undefined) {
1253
+ throw new HttpError(
1254
+ 400,
1255
+ "invalid-body",
1256
+ "the selection belongs to the preview, not the confirmation",
1257
+ );
1258
+ }
1259
+ const { path } = redeemConfirm(body.confirmToken, "repo-add", "");
1260
+ try {
1261
+ const stored = registerRepo(path, process.env, { requireCheckout: true });
1262
+ return { contractVersion: ACTIVITY_VERSION, registered: { name: basename(stored) } };
1263
+ } catch (err) {
1264
+ // registerRepo's own refusals name no path; anything else (a lock timeout,
1265
+ // a disk error) can, so it stays in the server's log.
1266
+ if (REGISTER_REFUSALS.has(err.message)) throw new HttpError(409, "not-registered", err.message);
1267
+ process.stderr.write(`contract-server: repo-add: ${err.message}\n`);
1268
+ throw new HttpError(409, "not-registered", "the repository could not be stored");
1269
+ }
1270
+ }
1271
+ const REGISTER_REFUSALS = new Set([
1272
+ "the path must be absolute",
1273
+ "the path is not a directory",
1274
+ "the home directory or the root is not a repository",
1275
+ "the path is not a git checkout",
1276
+ ]);
1277
+
1278
+ // What registerRepo would refuse, checked without writing.
1279
+ function registerRepoCheck(path) {
1280
+ const real = realOr(path);
1281
+ let st;
1282
+ try {
1283
+ st = statSync(real);
1284
+ } catch {
1285
+ throw new Error("the path is not a directory");
1286
+ }
1287
+ if (!st.isDirectory()) throw new Error("the path is not a directory");
1288
+ if (real === "/" || real === realOr(homedir())) {
1289
+ throw new Error("the home directory or the root is not a repository");
1290
+ }
1291
+ if (!existsSync(join(real, ".git"))) throw new Error("the path is not a git checkout");
1292
+ return real;
1293
+ }
1294
+
1295
+ /** DELETE /v1/repos/{id}: a registered entry, preview then ?confirm=<token>. */
1296
+ function repoRemoveRoute({ params, q }) {
1297
+ const id = params.id;
1298
+ if (!REPO_ID.test(id)) throw new HttpError(404, "not-found", "no such repository");
1299
+ const entry = [
1300
+ ...launchRepos()
1301
+ .filter((r) => r.source === "registered")
1302
+ .map((r) => ({ id: repoId(r.path), name: r.name })),
1303
+ ...missingRepos().map((r) => ({ id: r.id, name: r.name })),
1304
+ ].find((r) => r.id === id);
1305
+ const token = one(q, "confirm");
1306
+ if (token === null) {
1307
+ if (!entry) throw new HttpError(404, "not-found", "no registered repository has that id");
1308
+ return {
1309
+ contractVersion: ACTIVITY_VERSION,
1310
+ preview: { name: entry.name },
1311
+ ...mintConfirm("repo-remove", id, null),
1312
+ };
1313
+ }
1314
+ redeemConfirm(token, "repo-remove", id);
1315
+ const removed = unregisterRepo(id);
1316
+ if (!removed) throw new HttpError(404, "not-found", "no registered repository has that id");
1317
+ return { contractVersion: ACTIVITY_VERSION, removed: { name: basename(removed) } };
1318
+ }
1319
+
1320
+ const JOB_STATUS = {
1321
+ "unknown-job": 404,
1322
+ "command-not-installed": 409,
1323
+ "invalid-repo": 400,
1324
+ "invalid-args": 400,
1325
+ "invalid-request": 400,
1326
+ "no-launch-shape": 422,
1327
+ "room-busy": 409,
1328
+ "sandbox-unavailable": 409,
1329
+ "launch-failed": 502,
1330
+ };
1331
+
1332
+ function jobError(err) {
1333
+ if (!(err instanceof JobError)) return err;
1334
+ return new HttpError(JOB_STATUS[err.code] ?? 400, err.code, err.message, {
1335
+ ...(err.extra.errors ? { errors: err.extra.errors } : {}),
1336
+ });
1337
+ }
1338
+
1339
+ const knownRun = (id) => selectRuns({ taskId: id }).found;
1340
+
1341
+ /** A session started without agent-guard has no second layer; the office starts none. */
1342
+ function requireGuard(repoPath) {
1343
+ if (guardRegistration({ cwd: repoPath }).ok) return;
1344
+ throw new HttpError(
1345
+ 409,
1346
+ "guard-missing",
1347
+ "agent-guard.sh is not registered for every tool, so nothing starts unattended",
1348
+ );
1349
+ }
1350
+
1351
+ /**
1352
+ * POST /v1/jobs: hand a job to someone in the office. The first request names
1353
+ * jobId, repo and args (and answers for a pipeline run); the preview says
1354
+ * which command starts, with what, and what it writes outside this machine,
1355
+ * and mints a token. The second request sends only the token: the server then
1356
+ * checks the unattended profile, the workspace trust and the unattended
1357
+ * sandbox, starts the session in the background and records it for the floor.
1358
+ */
1359
+ async function jobsPost({ body }) {
1360
+ requireBody(
1361
+ body,
1362
+ {
1363
+ confirmToken: isToken,
1364
+ jobId: (v) => typeof v === "string" && /^[a-z][a-z0-9-]{1,40}$/.test(v),
1365
+ repo: (v) =>
1366
+ typeof v === "string" && v.length <= 4096 && (isAbsolute(v) || REPO_NAME.test(v)),
1367
+ args: (v) => v !== null && typeof v === "object" && !Array.isArray(v),
1368
+ answers: (v) => v !== null && typeof v === "object" && !Array.isArray(v),
1369
+ },
1370
+ "job-request.schema.json",
1371
+ );
1372
+ try {
1373
+ if (body.confirmToken === undefined) {
1374
+ if (!body.jobId || !body.repo) {
1375
+ throw new HttpError(400, "invalid-body", "jobId and repo are required");
1376
+ }
1377
+ const repoPath = resolveRepo(body.repo);
1378
+ requireUnattendedProfile();
1379
+ requireGuard(repoPath);
1380
+ const sessions = cachedSessions();
1381
+ const { job, prompt } = previewJob({
1382
+ jobId: body.jobId,
1383
+ args: body.args ?? {},
1384
+ answers: body.answers,
1385
+ repoPath,
1386
+ knownRun,
1387
+ sessions,
1388
+ });
1389
+ return {
1390
+ contractVersion: JOBS_VERSION,
1391
+ preview: {
1392
+ jobId: job.id,
1393
+ role: job.role,
1394
+ team: job.team,
1395
+ repo: basename(repoPath),
1396
+ prompt,
1397
+ writes: job.writes,
1398
+ },
1399
+ ...mintConfirm("job", "", {
1400
+ jobId: job.id,
1401
+ args: body.args ?? {},
1402
+ answers: body.answers,
1403
+ repoPath,
1404
+ }),
1405
+ };
1406
+ }
1407
+ if (["jobId", "repo", "args", "answers"].some((k) => body[k] !== undefined)) {
1408
+ throw new HttpError(
1409
+ 400,
1410
+ "invalid-body",
1411
+ "the job belongs to the preview, not the confirmation",
1412
+ );
1413
+ }
1414
+ const p = redeemConfirm(body.confirmToken, "job", "");
1415
+ if (!launchRepoAllowed(p.repoPath)) throw notAllowed();
1416
+ requireUnattendedProfile();
1417
+ requireWorkspaceTrust(p.repoPath, true);
1418
+ requireGuard(p.repoPath);
1419
+ const record = await startJob({
1420
+ jobId: p.jobId,
1421
+ args: p.args,
1422
+ answers: p.answers,
1423
+ repoPath: p.repoPath,
1424
+ repoName: basename(p.repoPath),
1425
+ knownRun,
1426
+ sessions: cachedSessions(),
1427
+ precondition: sandboxPrecondition,
1428
+ });
1429
+ return {
1430
+ contractVersion: JOBS_VERSION,
1431
+ started: {
1432
+ sessionId: record.sessionId,
1433
+ jobId: record.jobId,
1434
+ role: record.role,
1435
+ team: record.team,
1436
+ repo: record.repo,
1437
+ },
1438
+ };
1439
+ } catch (err) {
1440
+ throw jobError(err);
1441
+ }
1442
+ }
1443
+
1036
1444
  function stackError(err) {
1037
1445
  if (!(err instanceof StackError)) return err;
1038
1446
  if (err.code === "unreadable-settings") {
@@ -1187,7 +1595,8 @@ function toolError(err) {
1187
1595
 
1188
1596
  async function toolsRoute() {
1189
1597
  try {
1190
- return { contractVersion: TOOLS_VERSION, tools: await toolkitClient().listTools() };
1598
+ const tools = await toolkitClient().listTools();
1599
+ return { contractVersion: TOOLS_VERSION, version: activeToolkitVersion(), tools };
1191
1600
  } catch (err) {
1192
1601
  throw toolError(err);
1193
1602
  }
@@ -1292,8 +1701,25 @@ export const ROUTES = [
1292
1701
  {
1293
1702
  method: "GET",
1294
1703
  path: "/v1/repos",
1704
+ query: ["all"],
1705
+ handle: ({ q, redact }) => reposDocument({ redact, extras: flag(one(q, "all")) }),
1706
+ },
1707
+ {
1708
+ method: "POST",
1709
+ path: "/v1/repos",
1295
1710
  query: [],
1296
- handle: ({ redact }) => reposDocument({ redact }),
1711
+ body: true,
1712
+ remote: "local-only",
1713
+ risk: "high",
1714
+ handle: repoAddRoute,
1715
+ },
1716
+ {
1717
+ method: "DELETE",
1718
+ path: "/v1/repos/{id}",
1719
+ query: ["confirm"],
1720
+ remote: "local-only",
1721
+ risk: "high",
1722
+ handle: repoRemoveRoute,
1297
1723
  },
1298
1724
  {
1299
1725
  method: "POST",
@@ -1350,11 +1776,36 @@ export const ROUTES = [
1350
1776
  risk: "high",
1351
1777
  handle: gcRoute,
1352
1778
  },
1779
+ {
1780
+ method: "POST",
1781
+ path: "/v1/prune-logs",
1782
+ query: [],
1783
+ body: true,
1784
+ remote: "local-only",
1785
+ risk: "high",
1786
+ handle: oneAtATime("prune-logs", pruneRoute),
1787
+ },
1788
+ {
1789
+ method: "GET",
1790
+ path: "/v1/settings",
1791
+ query: [],
1792
+ remote: "local-only",
1793
+ handle: () => readSettings(),
1794
+ },
1795
+ {
1796
+ method: "POST",
1797
+ path: "/v1/settings",
1798
+ query: [],
1799
+ body: true,
1800
+ remote: "local-only",
1801
+ risk: "high",
1802
+ handle: oneAtATime("settings", settingsRoute),
1803
+ },
1353
1804
  {
1354
1805
  method: "GET",
1355
1806
  path: "/v1/autopilot",
1356
1807
  query: [],
1357
- handle: oneAtATime("autopilot-status", autopilotGet),
1808
+ handle: shared(() => "autopilot-status", autopilotGet),
1358
1809
  },
1359
1810
  {
1360
1811
  method: "POST",
@@ -1390,6 +1841,35 @@ export const ROUTES = [
1390
1841
  handle: chatRoute,
1391
1842
  },
1392
1843
  { method: "GET", path: "/v1/ledger", query: [], handle: () => officeLedger() },
1844
+ {
1845
+ method: "GET",
1846
+ path: "/v1/system",
1847
+ query: [],
1848
+ handle: shared(
1849
+ () => "system",
1850
+ () => systemStatus(),
1851
+ ),
1852
+ },
1853
+ {
1854
+ method: "GET",
1855
+ path: "/v1/credentials",
1856
+ query: ["probe"],
1857
+ remote: "local-only",
1858
+ handle: shared(
1859
+ ({ q }) => "credentials:" + (one(q, "probe") === "1"),
1860
+ ({ q }) => officeCredentials({ probe: one(q, "probe") === "1" }),
1861
+ ),
1862
+ },
1863
+ {
1864
+ method: "GET",
1865
+ path: "/v1/memory",
1866
+ query: [],
1867
+ remote: "local-only",
1868
+ handle: shared(
1869
+ () => "memory",
1870
+ () => officeMemory(),
1871
+ ),
1872
+ },
1393
1873
  { method: "GET", path: "/v1/runs/{id}/results", query: [], handle: resultsRoute },
1394
1874
  { method: "GET", path: "/v1/runs/{id}/evidence/{name}", query: [], handle: evidenceRoute },
1395
1875
  {
@@ -1405,13 +1885,19 @@ export const ROUTES = [
1405
1885
  method: "GET",
1406
1886
  path: "/v1/issues/{key}/maturity",
1407
1887
  query: [],
1408
- handle: oneAtATime("maturity", maturityRoute),
1888
+ handle: shared(({ params }) => `maturity:${params.key}`, maturityRoute, {
1889
+ group: "maturity",
1890
+ max: 2,
1891
+ }),
1409
1892
  },
1410
1893
  {
1411
1894
  method: "GET",
1412
1895
  path: "/v1/prs",
1413
1896
  query: [],
1414
- handle: oneAtATime("prs", ({ redact }) => pullRequests({ redact })),
1897
+ handle: shared(
1898
+ ({ redact }) => `prs:${redact ? 1 : 0}`,
1899
+ ({ redact }) => pullRequests({ redact }),
1900
+ ),
1415
1901
  },
1416
1902
  { method: "GET", path: "/v1/tools", query: [], handle: toolsRoute },
1417
1903
  {
@@ -1463,6 +1949,16 @@ export const ROUTES = [
1463
1949
  handle: routineDeleteRoute,
1464
1950
  },
1465
1951
  { method: "GET", path: "/v1/stack", query: ["repo"], handle: stackGet },
1952
+ { method: "GET", path: "/v1/jobs", query: [], handle: () => jobsDocument() },
1953
+ {
1954
+ method: "POST",
1955
+ path: "/v1/jobs",
1956
+ query: [],
1957
+ body: true,
1958
+ remote: "local-only",
1959
+ risk: "high",
1960
+ handle: jobsPost,
1961
+ },
1466
1962
  {
1467
1963
  method: "POST",
1468
1964
  path: "/v1/stack",
@@ -1665,14 +2161,14 @@ function parseArgs(argv) {
1665
2161
  opts.port = Number(p);
1666
2162
  } else if (a === "--idle-minutes") {
1667
2163
  const n = Number(value());
1668
- if (!Number.isFinite(n) || n <= 0 || n > MAX_IDLE_MINUTES) {
1669
- throw new Error(`--idle-minutes is more than 0 and at most ${MAX_IDLE_MINUTES}`);
2164
+ if (!Number.isFinite(n) || n < 0 || n > MAX_IDLE_MINUTES) {
2165
+ throw new Error(`--idle-minutes is 0 (never) to ${MAX_IDLE_MINUTES}`);
1670
2166
  }
1671
2167
  opts.idleMinutes = n;
1672
2168
  } else if (a === "--max-hours") {
1673
2169
  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}`);
2170
+ if (!Number.isFinite(n) || n < 0 || n > MAX_HOURS) {
2171
+ throw new Error(`--max-hours is 0 (no limit) to ${MAX_HOURS}`);
1676
2172
  }
1677
2173
  opts.maxHours = n;
1678
2174
  } else if (a === "--allow-origin") {
@@ -1717,6 +2213,25 @@ export const OFFICE_PATH = "/office";
1717
2213
  export const OFFICE_CODE_PATH = "/office/code";
1718
2214
  /** No bearer, own Origin only: trades a panel code for the token, once. */
1719
2215
  export const OFFICE_EXCHANGE_PATH = "/office/exchange";
2216
+ /** Bearer, own Origin or none: the installed page's build and whether this server is stale. */
2217
+ export const OFFICE_VERSION_PATH = "/office/version";
2218
+ /** Bearer, own Origin or none: a fresh panel code, so the page can reload itself. */
2219
+ export const OFFICE_RELOAD_PATH = "/office/reload";
2220
+
2221
+ const serverDigest = () => codeDigest(dirname(fileURLToPath(import.meta.url)));
2222
+ /** The server's code (scripts/ and lib/) as it was at start; a reinstall changes the files, not the process. */
2223
+ const SERVER_DIGEST = serverDigest();
2224
+
2225
+ /** The build id office-build.mjs stamps into the installed page, or null. */
2226
+ export function officeBuild(env = process.env) {
2227
+ const file = officePage(env);
2228
+ if (!file) return null;
2229
+ try {
2230
+ return /const BUILD = "([0-9a-f]{12})";/.exec(readFileSync(file, "utf8"))?.[1] ?? null;
2231
+ } catch {
2232
+ return null;
2233
+ }
2234
+ }
1720
2235
 
1721
2236
  function readPrefs(env) {
1722
2237
  try {
@@ -1949,7 +2464,7 @@ export function serve(opts) {
1949
2464
  };
1950
2465
  const arm = () => {
1951
2466
  clearTimeout(idleTimer);
1952
- idleTimer = setTimeout(() => stop("idle"), idleMs);
2467
+ if (idleMs > 0) idleTimer = setTimeout(() => stop("idle"), idleMs);
1953
2468
  };
1954
2469
 
1955
2470
  /**
@@ -2049,6 +2564,15 @@ export function serve(opts) {
2049
2564
  serveOffice(res, req.method === "HEAD");
2050
2565
  return;
2051
2566
  }
2567
+ if (url.pathname === "/favicon.ico" && (req.method === "GET" || req.method === "HEAD")) {
2568
+ drain(req);
2569
+ res.writeHead(204, {
2570
+ "Cache-Control": "max-age=86400",
2571
+ "X-Content-Type-Options": "nosniff",
2572
+ });
2573
+ res.end();
2574
+ return;
2575
+ }
2052
2576
  if (url.pathname.startsWith(OFFICE_ASSET_PREFIX)) {
2053
2577
  drain(req);
2054
2578
  if (req.method !== "GET" && req.method !== "HEAD") {
@@ -2095,6 +2619,30 @@ export function serve(opts) {
2095
2619
  if (!authorized(req.headers.authorization, tokenDigest)) {
2096
2620
  throw new HttpError(401, "unauthorized", "a bearer token is required");
2097
2621
  }
2622
+ if (url.pathname === OFFICE_VERSION_PATH || url.pathname === OFFICE_RELOAD_PATH) {
2623
+ drain(req);
2624
+ if (origin !== undefined && !own) {
2625
+ throw new HttpError(403, "forbidden-origin", "only the panel page asks this");
2626
+ }
2627
+ if (url.pathname === OFFICE_VERSION_PATH) {
2628
+ if (req.method !== "GET") {
2629
+ throw new HttpError(405, "method-not-allowed", "use GET", { allow: "GET" });
2630
+ }
2631
+ send(res, 200, {
2632
+ contractVersion: CONTRACT_VERSION,
2633
+ build: officeBuild(),
2634
+ serverStale: serverDigest() !== SERVER_DIGEST,
2635
+ });
2636
+ return;
2637
+ }
2638
+ if (req.method !== "POST") {
2639
+ throw new HttpError(405, "method-not-allowed", "use POST", { allow: "POST" });
2640
+ }
2641
+ // The page already holds the token; a code lets it reload itself onto
2642
+ // the page now installed without the token ever entering a URL.
2643
+ send(res, 200, { contractVersion: CONTRACT_VERSION, ...mintCode() });
2644
+ return;
2645
+ }
2098
2646
  if (url.pathname === OFFICE_CODE_PATH) {
2099
2647
  drain(req);
2100
2648
  if (req.method !== "POST") {
@@ -2227,10 +2775,9 @@ export function serve(opts) {
2227
2775
  tokenFile = writeTokenFile({ ...record, token, startedAt: started.toISOString() });
2228
2776
  for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) process.on(sig, () => stop(sig));
2229
2777
  arm();
2230
- lifeTimer = setTimeout(
2231
- () => stop("lifetime"),
2232
- Math.round((opts.maxHours ?? DEFAULT_MAX_HOURS) * 3600000),
2233
- );
2778
+ const maxHours = opts.maxHours ?? DEFAULT_MAX_HOURS;
2779
+ if (maxHours > 0)
2780
+ lifeTimer = setTimeout(() => stop("lifetime"), Math.round(maxHours * 3600000));
2234
2781
  resolve({ ...record, tokenFile, ...(opts.printToken ? { token } : {}) });
2235
2782
  });
2236
2783
  });