@mmerterden/multi-agent-pipeline 20.30.0 → 20.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +45 -0
  3. package/README.tr.md +59 -0
  4. package/docs/facts.json +3 -3
  5. package/install/_unattended-profile.mjs +61 -0
  6. package/install/catalog-history.json +1 -1
  7. package/install/claude.mjs +9 -2
  8. package/install/index.mjs +10 -1
  9. package/install/templates/claude-hooks.json +12 -1
  10. package/manifest.json +123 -82
  11. package/package.json +2 -1
  12. package/pipeline/commands/multi-agent/analysis/SKILL.md +4 -4
  13. package/pipeline/commands/multi-agent/office-on/SKILL.md +8 -7
  14. package/pipeline/commands/multi-agent/office-status/SKILL.md +1 -1
  15. package/pipeline/commands/multi-agent/prune-logs/SKILL.md +4 -1
  16. package/pipeline/commands/multi-agent/review/SKILL.md +4 -3
  17. package/pipeline/commands/multi-agent/scenario-audit/SKILL.md +4 -2
  18. package/pipeline/commands/multi-agent/sync/SKILL.md +12 -0
  19. package/pipeline/commands/multi-agent/test-gen/SKILL.md +29 -3
  20. package/pipeline/contract/CHANGELOG.md +46 -0
  21. package/pipeline/contract/README.md +22 -8
  22. package/pipeline/contract/fixtures/error-invalid-request.json +1 -1
  23. package/pipeline/contract/fixtures/error-unauthorized.json +1 -1
  24. package/pipeline/contract/fixtures/error-unsigned.json +1 -1
  25. package/pipeline/contract/fixtures/launch-plan.json +1 -1
  26. package/pipeline/contract/fixtures/runs-awaiting-question.json +6 -1
  27. package/pipeline/contract/fixtures/runs-empty.json +1 -1
  28. package/pipeline/contract/fixtures/runs-failed.json +6 -1
  29. package/pipeline/contract/fixtures/runs-old-schema.json +6 -1
  30. package/pipeline/contract/fixtures/runs-pr-opened-redacted.json +5 -1
  31. package/pipeline/contract/fixtures/runs-pr-opened.json +6 -1
  32. package/pipeline/contract/fixtures/runs-running.json +6 -1
  33. package/pipeline/contract/manifest.json +153 -19
  34. package/pipeline/contract/types/index.d.ts +304 -6
  35. package/pipeline/lib/bg-launch.mjs +106 -0
  36. package/pipeline/lib/code-digest.mjs +49 -0
  37. package/pipeline/lib/credential-resolve.mjs +42 -2
  38. package/pipeline/multi-agent-refs/features/office-threat-model.md +24 -6
  39. package/pipeline/multi-agent-refs/features/unattended-security.md +1 -1
  40. package/pipeline/multi-agent-refs/help/en.md +2 -2
  41. package/pipeline/multi-agent-refs/help/tr.md +2 -2
  42. package/pipeline/multi-agent-refs/phases/modes.md +7 -7
  43. package/pipeline/multi-agent-refs/phases/phase-5-report.md +16 -10
  44. package/pipeline/multi-agent-refs/review-standalone-tracker.md +37 -0
  45. package/pipeline/multi-agent-refs/tracker-contract.md +8 -3
  46. package/pipeline/office/NOTICE.md +4 -1
  47. package/pipeline/office/assets/ARCHIVE_BOXES.png +0 -0
  48. package/pipeline/office/assets/COFFEE_MACHINE.png +0 -0
  49. package/pipeline/office/assets/COUNTER_SINK.png +0 -0
  50. package/pipeline/office/assets/DEVICE_STAND.png +0 -0
  51. package/pipeline/office/assets/FILING_CABINET.png +0 -0
  52. package/pipeline/office/assets/FRIDGE.png +0 -0
  53. package/pipeline/office/assets/MAILBOX.png +0 -0
  54. package/pipeline/office/assets/MOP_BUCKET.png +0 -0
  55. package/pipeline/office/assets/PRINTER.png +0 -0
  56. package/pipeline/office/assets/RECEPTION_DESK.png +0 -0
  57. package/pipeline/office/assets/SERVER_RACK.png +0 -0
  58. package/pipeline/office/assets/SERVER_RACK_2.png +0 -0
  59. package/pipeline/office/assets/TOILET_STALL.png +0 -0
  60. package/pipeline/office/assets/VENDING.png +0 -0
  61. package/pipeline/office/assets/WASHBASIN.png +0 -0
  62. package/pipeline/office/assets/WATER_COOLER.png +0 -0
  63. package/pipeline/office/index.html +3996 -884
  64. package/pipeline/office/toolkit.json +3 -0
  65. package/pipeline/schemas/activity.schema.json +188 -4
  66. package/pipeline/schemas/agent-state.schema.json +493 -115
  67. package/pipeline/schemas/contract-error.schema.json +12 -1
  68. package/pipeline/schemas/job-change.schema.json +72 -0
  69. package/pipeline/schemas/job-request.schema.json +38 -0
  70. package/pipeline/schemas/launch.json +4 -4
  71. package/pipeline/schemas/office-credentials.schema.json +71 -0
  72. package/pipeline/schemas/office-jobs.json +253 -0
  73. package/pipeline/schemas/office-jobs.schema.json +62 -0
  74. package/pipeline/schemas/office-memory.schema.json +105 -0
  75. package/pipeline/schemas/office-settings-request.schema.json +30 -0
  76. package/pipeline/schemas/office-settings.schema.json +67 -0
  77. package/pipeline/schemas/phases.json +10 -2
  78. package/pipeline/schemas/phases.schema.json +86 -19
  79. package/pipeline/schemas/prs.schema.json +5 -0
  80. package/pipeline/schemas/prune-logs-request.schema.json +26 -0
  81. package/pipeline/schemas/prune-logs.schema.json +52 -0
  82. package/pipeline/schemas/repo-change.schema.json +54 -0
  83. package/pipeline/schemas/repo-request.schema.json +29 -0
  84. package/pipeline/schemas/repos.schema.json +49 -1
  85. package/pipeline/schemas/runs-index.schema.json +257 -46
  86. package/pipeline/schemas/system-status.schema.json +111 -0
  87. package/pipeline/schemas/tools.schema.json +5 -0
  88. package/pipeline/schemas/unattended-profile.json +7 -2
  89. package/pipeline/scripts/_active-run.mjs +2 -1
  90. package/pipeline/scripts/_spawn-async.mjs +9 -5
  91. package/pipeline/scripts/activity-snapshot.mjs +219 -23
  92. package/pipeline/scripts/autopilot-runner.mjs +7 -5
  93. package/pipeline/scripts/contract-server.mjs +551 -25
  94. package/pipeline/scripts/gen-mode-dispatch.mjs +3 -1
  95. package/pipeline/scripts/job-launch.mjs +477 -0
  96. package/pipeline/scripts/launch-request.mjs +94 -9
  97. package/pipeline/scripts/office-build.mjs +148 -11
  98. package/pipeline/scripts/office-control.mjs +34 -7
  99. package/pipeline/scripts/office-credentials.mjs +106 -0
  100. package/pipeline/scripts/office-memory.mjs +200 -0
  101. package/pipeline/scripts/office-prune.mjs +125 -0
  102. package/pipeline/scripts/office-settings.mjs +145 -0
  103. package/pipeline/scripts/phase-tracker.sh +12 -5
  104. package/pipeline/scripts/prune-logs.sh +53 -2
  105. package/pipeline/scripts/runs-index.mjs +62 -2
  106. package/pipeline/scripts/system-status.mjs +196 -0
  107. package/pipeline/scripts/task-mirror.mjs +150 -0
  108. package/pipeline/scripts/task-mirror.sh +20 -0
  109. package/pipeline/scripts/toolkit-tools.mjs +33 -7
  110. package/pipeline/scripts/transcript-index.mjs +4 -1
  111. package/pipeline/scripts/work-items.mjs +191 -22
  112. package/pipeline/skills/.skill-manifest.json +6 -6
  113. package/pipeline/skills/.skills-index.json +1 -1
  114. package/pipeline/skills/shared/README.md +1 -1
  115. package/pipeline/skills/shared/core/multi-agent-office-on/SKILL.md +3 -4
  116. package/pipeline/skills/shared/core/multi-agent-prune-logs/SKILL.md +4 -1
  117. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +19 -0
  118. package/pipeline/skills/shared/core/multi-agent-scenario-audit/SKILL.md +4 -2
  119. package/pipeline/skills/shared/core/multi-agent-test-gen/SKILL.md +26 -3
  120. 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,24 @@ 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
+ */
911
+ const inFlight = new Map();
912
+ const shared = (keyOf, fn) => (ctx) => {
913
+ const key = keyOf(ctx);
914
+ if (!inFlight.has(key)) {
915
+ inFlight.set(
916
+ key,
917
+ Promise.resolve()
918
+ .then(() => fn(ctx))
919
+ .finally(() => inFlight.delete(key)),
920
+ );
921
+ }
922
+ return inFlight.get(key);
923
+ };
924
+
778
925
  /** One request at a time per key; a second one while the first runs is 429. */
779
926
  const running = new Set();
780
927
  const oneAtATime = (key, fn) => async (ctx) => {
@@ -1033,6 +1180,252 @@ function resolveRepo(ref) {
1033
1180
  return hits[0].path;
1034
1181
  }
1035
1182
 
1183
+ /**
1184
+ * POST /v1/repos: add a checkout launch accepts. The first request names a
1185
+ * suggestion (an id from GET /v1/repos?all=1) or an absolute path; the
1186
+ * preview says what would be stored and mints a token; the second request
1187
+ * sends only the token. Only a git checkout is accepted from here.
1188
+ */
1189
+ function repoAddRoute({ body }) {
1190
+ requireBody(
1191
+ body,
1192
+ {
1193
+ confirmToken: isToken,
1194
+ suggestion: (v) => typeof v === "string" && REPO_ID.test(v),
1195
+ path: (v) => typeof v === "string" && v.length <= 4096 && isAbsolute(v),
1196
+ },
1197
+ "repo-request.schema.json",
1198
+ );
1199
+ if (body.confirmToken === undefined) {
1200
+ let path;
1201
+ if (body.suggestion !== undefined && body.path !== undefined) {
1202
+ throw new HttpError(400, "invalid-body", "name a suggestion or a path, not both");
1203
+ }
1204
+ if (body.suggestion !== undefined) {
1205
+ const hit = suggestedRepos().find((r) => r.id === body.suggestion);
1206
+ if (!hit) throw new HttpError(404, "not-found", "no such suggestion");
1207
+ path = hit.path;
1208
+ } else if (body.path !== undefined) {
1209
+ path = realOr(body.path);
1210
+ } else {
1211
+ throw new HttpError(400, "invalid-body", "suggestion or path is required");
1212
+ }
1213
+ if (launchRepoAllowed(path)) {
1214
+ throw new HttpError(409, "already-registered", "launch already accepts that repository");
1215
+ }
1216
+ let real;
1217
+ try {
1218
+ real = registerRepoCheck(path);
1219
+ } catch (err) {
1220
+ throw new HttpError(400, "invalid-body", err.message);
1221
+ }
1222
+ // The page picks a repository by folder name; a second one with the same
1223
+ // name could never be told apart there.
1224
+ if (launchRepos().some((r) => r.name.split("/").pop() === basename(real))) {
1225
+ throw new HttpError(
1226
+ 409,
1227
+ "name-taken",
1228
+ "a repository with that folder name is already on the list",
1229
+ );
1230
+ }
1231
+ return {
1232
+ contractVersion: ACTIVITY_VERSION,
1233
+ preview: { name: basename(real) },
1234
+ ...mintConfirm("repo-add", "", { path: real }),
1235
+ };
1236
+ }
1237
+ if (body.suggestion !== undefined || body.path !== undefined) {
1238
+ throw new HttpError(
1239
+ 400,
1240
+ "invalid-body",
1241
+ "the selection belongs to the preview, not the confirmation",
1242
+ );
1243
+ }
1244
+ const { path } = redeemConfirm(body.confirmToken, "repo-add", "");
1245
+ try {
1246
+ const stored = registerRepo(path, process.env, { requireCheckout: true });
1247
+ return { contractVersion: ACTIVITY_VERSION, registered: { name: basename(stored) } };
1248
+ } catch (err) {
1249
+ // registerRepo's own refusals name no path; anything else (a lock timeout,
1250
+ // a disk error) can, so it stays in the server's log.
1251
+ if (REGISTER_REFUSALS.has(err.message)) throw new HttpError(409, "not-registered", err.message);
1252
+ process.stderr.write(`contract-server: repo-add: ${err.message}\n`);
1253
+ throw new HttpError(409, "not-registered", "the repository could not be stored");
1254
+ }
1255
+ }
1256
+ const REGISTER_REFUSALS = new Set([
1257
+ "the path must be absolute",
1258
+ "the path is not a directory",
1259
+ "the home directory or the root is not a repository",
1260
+ "the path is not a git checkout",
1261
+ ]);
1262
+
1263
+ // What registerRepo would refuse, checked without writing.
1264
+ function registerRepoCheck(path) {
1265
+ const real = realOr(path);
1266
+ let st;
1267
+ try {
1268
+ st = statSync(real);
1269
+ } catch {
1270
+ throw new Error("the path is not a directory");
1271
+ }
1272
+ if (!st.isDirectory()) throw new Error("the path is not a directory");
1273
+ if (real === "/" || real === realOr(homedir())) {
1274
+ throw new Error("the home directory or the root is not a repository");
1275
+ }
1276
+ if (!existsSync(join(real, ".git"))) throw new Error("the path is not a git checkout");
1277
+ return real;
1278
+ }
1279
+
1280
+ /** DELETE /v1/repos/{id}: a registered entry, preview then ?confirm=<token>. */
1281
+ function repoRemoveRoute({ params, q }) {
1282
+ const id = params.id;
1283
+ if (!REPO_ID.test(id)) throw new HttpError(404, "not-found", "no such repository");
1284
+ const entry = [
1285
+ ...launchRepos()
1286
+ .filter((r) => r.source === "registered")
1287
+ .map((r) => ({ id: repoId(r.path), name: r.name })),
1288
+ ...missingRepos().map((r) => ({ id: r.id, name: r.name })),
1289
+ ].find((r) => r.id === id);
1290
+ const token = one(q, "confirm");
1291
+ if (token === null) {
1292
+ if (!entry) throw new HttpError(404, "not-found", "no registered repository has that id");
1293
+ return {
1294
+ contractVersion: ACTIVITY_VERSION,
1295
+ preview: { name: entry.name },
1296
+ ...mintConfirm("repo-remove", id, null),
1297
+ };
1298
+ }
1299
+ redeemConfirm(token, "repo-remove", id);
1300
+ const removed = unregisterRepo(id);
1301
+ if (!removed) throw new HttpError(404, "not-found", "no registered repository has that id");
1302
+ return { contractVersion: ACTIVITY_VERSION, removed: { name: basename(removed) } };
1303
+ }
1304
+
1305
+ const JOB_STATUS = {
1306
+ "unknown-job": 404,
1307
+ "command-not-installed": 409,
1308
+ "invalid-repo": 400,
1309
+ "invalid-args": 400,
1310
+ "invalid-request": 400,
1311
+ "no-launch-shape": 422,
1312
+ "room-busy": 409,
1313
+ "sandbox-unavailable": 409,
1314
+ "launch-failed": 502,
1315
+ };
1316
+
1317
+ function jobError(err) {
1318
+ if (!(err instanceof JobError)) return err;
1319
+ return new HttpError(JOB_STATUS[err.code] ?? 400, err.code, err.message, {
1320
+ ...(err.extra.errors ? { errors: err.extra.errors } : {}),
1321
+ });
1322
+ }
1323
+
1324
+ const knownRun = (id) => selectRuns({ taskId: id }).found;
1325
+
1326
+ /** A session started without agent-guard has no second layer; the office starts none. */
1327
+ function requireGuard(repoPath) {
1328
+ if (guardRegistration({ cwd: repoPath }).ok) return;
1329
+ throw new HttpError(
1330
+ 409,
1331
+ "guard-missing",
1332
+ "agent-guard.sh is not registered for every tool, so nothing starts unattended",
1333
+ );
1334
+ }
1335
+
1336
+ /**
1337
+ * POST /v1/jobs: hand a job to someone in the office. The first request names
1338
+ * jobId, repo and args (and answers for a pipeline run); the preview says
1339
+ * which command starts, with what, and what it writes outside this machine,
1340
+ * and mints a token. The second request sends only the token: the server then
1341
+ * checks the unattended profile, the workspace trust and the unattended
1342
+ * sandbox, starts the session in the background and records it for the floor.
1343
+ */
1344
+ async function jobsPost({ body }) {
1345
+ requireBody(
1346
+ body,
1347
+ {
1348
+ confirmToken: isToken,
1349
+ jobId: (v) => typeof v === "string" && /^[a-z][a-z0-9-]{1,40}$/.test(v),
1350
+ repo: (v) =>
1351
+ typeof v === "string" && v.length <= 4096 && (isAbsolute(v) || REPO_NAME.test(v)),
1352
+ args: (v) => v !== null && typeof v === "object" && !Array.isArray(v),
1353
+ answers: (v) => v !== null && typeof v === "object" && !Array.isArray(v),
1354
+ },
1355
+ "job-request.schema.json",
1356
+ );
1357
+ try {
1358
+ if (body.confirmToken === undefined) {
1359
+ if (!body.jobId || !body.repo) {
1360
+ throw new HttpError(400, "invalid-body", "jobId and repo are required");
1361
+ }
1362
+ const repoPath = resolveRepo(body.repo);
1363
+ requireUnattendedProfile();
1364
+ requireGuard(repoPath);
1365
+ const sessions = cachedSessions();
1366
+ const { job, prompt } = previewJob({
1367
+ jobId: body.jobId,
1368
+ args: body.args ?? {},
1369
+ answers: body.answers,
1370
+ repoPath,
1371
+ knownRun,
1372
+ sessions,
1373
+ });
1374
+ return {
1375
+ contractVersion: JOBS_VERSION,
1376
+ preview: {
1377
+ jobId: job.id,
1378
+ role: job.role,
1379
+ team: job.team,
1380
+ repo: basename(repoPath),
1381
+ prompt,
1382
+ writes: job.writes,
1383
+ },
1384
+ ...mintConfirm("job", "", {
1385
+ jobId: job.id,
1386
+ args: body.args ?? {},
1387
+ answers: body.answers,
1388
+ repoPath,
1389
+ }),
1390
+ };
1391
+ }
1392
+ if (["jobId", "repo", "args", "answers"].some((k) => body[k] !== undefined)) {
1393
+ throw new HttpError(
1394
+ 400,
1395
+ "invalid-body",
1396
+ "the job belongs to the preview, not the confirmation",
1397
+ );
1398
+ }
1399
+ const p = redeemConfirm(body.confirmToken, "job", "");
1400
+ if (!launchRepoAllowed(p.repoPath)) throw notAllowed();
1401
+ requireUnattendedProfile();
1402
+ requireWorkspaceTrust(p.repoPath, true);
1403
+ requireGuard(p.repoPath);
1404
+ const record = await startJob({
1405
+ jobId: p.jobId,
1406
+ args: p.args,
1407
+ answers: p.answers,
1408
+ repoPath: p.repoPath,
1409
+ repoName: basename(p.repoPath),
1410
+ knownRun,
1411
+ sessions: cachedSessions(),
1412
+ precondition: sandboxPrecondition,
1413
+ });
1414
+ return {
1415
+ contractVersion: JOBS_VERSION,
1416
+ started: {
1417
+ sessionId: record.sessionId,
1418
+ jobId: record.jobId,
1419
+ role: record.role,
1420
+ team: record.team,
1421
+ repo: record.repo,
1422
+ },
1423
+ };
1424
+ } catch (err) {
1425
+ throw jobError(err);
1426
+ }
1427
+ }
1428
+
1036
1429
  function stackError(err) {
1037
1430
  if (!(err instanceof StackError)) return err;
1038
1431
  if (err.code === "unreadable-settings") {
@@ -1187,7 +1580,8 @@ function toolError(err) {
1187
1580
 
1188
1581
  async function toolsRoute() {
1189
1582
  try {
1190
- return { contractVersion: TOOLS_VERSION, tools: await toolkitClient().listTools() };
1583
+ const tools = await toolkitClient().listTools();
1584
+ return { contractVersion: TOOLS_VERSION, version: activeToolkitVersion(), tools };
1191
1585
  } catch (err) {
1192
1586
  throw toolError(err);
1193
1587
  }
@@ -1292,8 +1686,25 @@ export const ROUTES = [
1292
1686
  {
1293
1687
  method: "GET",
1294
1688
  path: "/v1/repos",
1689
+ query: ["all"],
1690
+ handle: ({ q, redact }) => reposDocument({ redact, extras: flag(one(q, "all")) }),
1691
+ },
1692
+ {
1693
+ method: "POST",
1694
+ path: "/v1/repos",
1295
1695
  query: [],
1296
- handle: ({ redact }) => reposDocument({ redact }),
1696
+ body: true,
1697
+ remote: "local-only",
1698
+ risk: "high",
1699
+ handle: repoAddRoute,
1700
+ },
1701
+ {
1702
+ method: "DELETE",
1703
+ path: "/v1/repos/{id}",
1704
+ query: ["confirm"],
1705
+ remote: "local-only",
1706
+ risk: "high",
1707
+ handle: repoRemoveRoute,
1297
1708
  },
1298
1709
  {
1299
1710
  method: "POST",
@@ -1350,6 +1761,31 @@ export const ROUTES = [
1350
1761
  risk: "high",
1351
1762
  handle: gcRoute,
1352
1763
  },
1764
+ {
1765
+ method: "POST",
1766
+ path: "/v1/prune-logs",
1767
+ query: [],
1768
+ body: true,
1769
+ remote: "local-only",
1770
+ risk: "high",
1771
+ handle: oneAtATime("prune-logs", pruneRoute),
1772
+ },
1773
+ {
1774
+ method: "GET",
1775
+ path: "/v1/settings",
1776
+ query: [],
1777
+ remote: "local-only",
1778
+ handle: () => readSettings(),
1779
+ },
1780
+ {
1781
+ method: "POST",
1782
+ path: "/v1/settings",
1783
+ query: [],
1784
+ body: true,
1785
+ remote: "local-only",
1786
+ risk: "high",
1787
+ handle: oneAtATime("settings", settingsRoute),
1788
+ },
1353
1789
  {
1354
1790
  method: "GET",
1355
1791
  path: "/v1/autopilot",
@@ -1390,6 +1826,35 @@ export const ROUTES = [
1390
1826
  handle: chatRoute,
1391
1827
  },
1392
1828
  { method: "GET", path: "/v1/ledger", query: [], handle: () => officeLedger() },
1829
+ {
1830
+ method: "GET",
1831
+ path: "/v1/system",
1832
+ query: [],
1833
+ handle: shared(
1834
+ () => "system",
1835
+ () => systemStatus(),
1836
+ ),
1837
+ },
1838
+ {
1839
+ method: "GET",
1840
+ path: "/v1/credentials",
1841
+ query: ["probe"],
1842
+ remote: "local-only",
1843
+ handle: shared(
1844
+ ({ q }) => "credentials:" + (one(q, "probe") === "1"),
1845
+ ({ q }) => officeCredentials({ probe: one(q, "probe") === "1" }),
1846
+ ),
1847
+ },
1848
+ {
1849
+ method: "GET",
1850
+ path: "/v1/memory",
1851
+ query: [],
1852
+ remote: "local-only",
1853
+ handle: shared(
1854
+ () => "memory",
1855
+ () => officeMemory(),
1856
+ ),
1857
+ },
1393
1858
  { method: "GET", path: "/v1/runs/{id}/results", query: [], handle: resultsRoute },
1394
1859
  { method: "GET", path: "/v1/runs/{id}/evidence/{name}", query: [], handle: evidenceRoute },
1395
1860
  {
@@ -1463,6 +1928,16 @@ export const ROUTES = [
1463
1928
  handle: routineDeleteRoute,
1464
1929
  },
1465
1930
  { method: "GET", path: "/v1/stack", query: ["repo"], handle: stackGet },
1931
+ { method: "GET", path: "/v1/jobs", query: [], handle: () => jobsDocument() },
1932
+ {
1933
+ method: "POST",
1934
+ path: "/v1/jobs",
1935
+ query: [],
1936
+ body: true,
1937
+ remote: "local-only",
1938
+ risk: "high",
1939
+ handle: jobsPost,
1940
+ },
1466
1941
  {
1467
1942
  method: "POST",
1468
1943
  path: "/v1/stack",
@@ -1665,14 +2140,14 @@ function parseArgs(argv) {
1665
2140
  opts.port = Number(p);
1666
2141
  } else if (a === "--idle-minutes") {
1667
2142
  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}`);
2143
+ if (!Number.isFinite(n) || n < 0 || n > MAX_IDLE_MINUTES) {
2144
+ throw new Error(`--idle-minutes is 0 (never) to ${MAX_IDLE_MINUTES}`);
1670
2145
  }
1671
2146
  opts.idleMinutes = n;
1672
2147
  } else if (a === "--max-hours") {
1673
2148
  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}`);
2149
+ if (!Number.isFinite(n) || n < 0 || n > MAX_HOURS) {
2150
+ throw new Error(`--max-hours is 0 (no limit) to ${MAX_HOURS}`);
1676
2151
  }
1677
2152
  opts.maxHours = n;
1678
2153
  } else if (a === "--allow-origin") {
@@ -1717,6 +2192,25 @@ export const OFFICE_PATH = "/office";
1717
2192
  export const OFFICE_CODE_PATH = "/office/code";
1718
2193
  /** No bearer, own Origin only: trades a panel code for the token, once. */
1719
2194
  export const OFFICE_EXCHANGE_PATH = "/office/exchange";
2195
+ /** Bearer, own Origin or none: the installed page's build and whether this server is stale. */
2196
+ export const OFFICE_VERSION_PATH = "/office/version";
2197
+ /** Bearer, own Origin or none: a fresh panel code, so the page can reload itself. */
2198
+ export const OFFICE_RELOAD_PATH = "/office/reload";
2199
+
2200
+ const serverDigest = () => codeDigest(dirname(fileURLToPath(import.meta.url)));
2201
+ /** The server's code (scripts/ and lib/) as it was at start; a reinstall changes the files, not the process. */
2202
+ const SERVER_DIGEST = serverDigest();
2203
+
2204
+ /** The build id office-build.mjs stamps into the installed page, or null. */
2205
+ export function officeBuild(env = process.env) {
2206
+ const file = officePage(env);
2207
+ if (!file) return null;
2208
+ try {
2209
+ return /const BUILD = "([0-9a-f]{12})";/.exec(readFileSync(file, "utf8"))?.[1] ?? null;
2210
+ } catch {
2211
+ return null;
2212
+ }
2213
+ }
1720
2214
 
1721
2215
  function readPrefs(env) {
1722
2216
  try {
@@ -1949,7 +2443,7 @@ export function serve(opts) {
1949
2443
  };
1950
2444
  const arm = () => {
1951
2445
  clearTimeout(idleTimer);
1952
- idleTimer = setTimeout(() => stop("idle"), idleMs);
2446
+ if (idleMs > 0) idleTimer = setTimeout(() => stop("idle"), idleMs);
1953
2447
  };
1954
2448
 
1955
2449
  /**
@@ -2049,6 +2543,15 @@ export function serve(opts) {
2049
2543
  serveOffice(res, req.method === "HEAD");
2050
2544
  return;
2051
2545
  }
2546
+ if (url.pathname === "/favicon.ico" && (req.method === "GET" || req.method === "HEAD")) {
2547
+ drain(req);
2548
+ res.writeHead(204, {
2549
+ "Cache-Control": "max-age=86400",
2550
+ "X-Content-Type-Options": "nosniff",
2551
+ });
2552
+ res.end();
2553
+ return;
2554
+ }
2052
2555
  if (url.pathname.startsWith(OFFICE_ASSET_PREFIX)) {
2053
2556
  drain(req);
2054
2557
  if (req.method !== "GET" && req.method !== "HEAD") {
@@ -2095,6 +2598,30 @@ export function serve(opts) {
2095
2598
  if (!authorized(req.headers.authorization, tokenDigest)) {
2096
2599
  throw new HttpError(401, "unauthorized", "a bearer token is required");
2097
2600
  }
2601
+ if (url.pathname === OFFICE_VERSION_PATH || url.pathname === OFFICE_RELOAD_PATH) {
2602
+ drain(req);
2603
+ if (origin !== undefined && !own) {
2604
+ throw new HttpError(403, "forbidden-origin", "only the panel page asks this");
2605
+ }
2606
+ if (url.pathname === OFFICE_VERSION_PATH) {
2607
+ if (req.method !== "GET") {
2608
+ throw new HttpError(405, "method-not-allowed", "use GET", { allow: "GET" });
2609
+ }
2610
+ send(res, 200, {
2611
+ contractVersion: CONTRACT_VERSION,
2612
+ build: officeBuild(),
2613
+ serverStale: serverDigest() !== SERVER_DIGEST,
2614
+ });
2615
+ return;
2616
+ }
2617
+ if (req.method !== "POST") {
2618
+ throw new HttpError(405, "method-not-allowed", "use POST", { allow: "POST" });
2619
+ }
2620
+ // The page already holds the token; a code lets it reload itself onto
2621
+ // the page now installed without the token ever entering a URL.
2622
+ send(res, 200, { contractVersion: CONTRACT_VERSION, ...mintCode() });
2623
+ return;
2624
+ }
2098
2625
  if (url.pathname === OFFICE_CODE_PATH) {
2099
2626
  drain(req);
2100
2627
  if (req.method !== "POST") {
@@ -2227,10 +2754,9 @@ export function serve(opts) {
2227
2754
  tokenFile = writeTokenFile({ ...record, token, startedAt: started.toISOString() });
2228
2755
  for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) process.on(sig, () => stop(sig));
2229
2756
  arm();
2230
- lifeTimer = setTimeout(
2231
- () => stop("lifetime"),
2232
- Math.round((opts.maxHours ?? DEFAULT_MAX_HOURS) * 3600000),
2233
- );
2757
+ const maxHours = opts.maxHours ?? DEFAULT_MAX_HOURS;
2758
+ if (maxHours > 0)
2759
+ lifeTimer = setTimeout(() => stop("lifetime"), Math.round(maxHours * 3600000));
2234
2760
  resolve({ ...record, tokenFile, ...(opts.printToken ? { token } : {}) });
2235
2761
  });
2236
2762
  });