@azure-id/orc 1.7.1 → 1.8.2

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 (63) hide show
  1. package/CHANGELOG.md +3869 -3381
  2. package/README-id.md +942 -844
  3. package/README.md +882 -788
  4. package/bin/build-agents.js +43 -27
  5. package/bin/cli.js +45460 -44169
  6. package/bin/graph-extract.js +3216 -0
  7. package/bin/graph-gain.js +404 -0
  8. package/bin/graph-map.js +232 -0
  9. package/bin/graph-notes.js +229 -0
  10. package/bin/graph-query.js +1770 -0
  11. package/bin/graph-resolve.js +255 -0
  12. package/bin/graph-shard.js +325 -0
  13. package/bin/graph-signals.js +277 -0
  14. package/bin/graph.js +658 -0
  15. package/bin/verify-contracts.js +4809 -4553
  16. package/bin/verify-package.js +640 -616
  17. package/bin/webui/api.js +1425 -1414
  18. package/bin/webui/fixtures/index.js +584 -576
  19. package/bin/webui/fixtures/knowledge.js +356 -291
  20. package/bin/webui/fixtures/stats.js +107 -104
  21. package/bin/webui/i18n/en/knowledge.json +182 -151
  22. package/bin/webui/i18n/en/overview.json +101 -100
  23. package/bin/webui/i18n/id/knowledge.json +182 -151
  24. package/bin/webui/i18n/id/overview.json +101 -100
  25. package/bin/webui/js/panels/knowledge.js +1130 -1006
  26. package/bin/webui/js/panels/overview.js +492 -488
  27. package/package.json +39 -39
  28. package/templates/agents/MODEL-MAPPING.md +163 -158
  29. package/templates/agents/orc-executor-haiku-4-5.md +128 -121
  30. package/templates/agents/orc-executor-opus-4-7-high.md +129 -122
  31. package/templates/agents/orc-executor-opus-4-7-med.md +129 -122
  32. package/templates/agents/orc-executor-opus-4-8-high.md +129 -122
  33. package/templates/agents/orc-executor-opus-5-high.md +129 -122
  34. package/templates/agents/orc-executor-opus-5-low.md +129 -122
  35. package/templates/agents/orc-executor-opus-5-med.md +129 -122
  36. package/templates/agents/orc-executor-sonnet-4-6-high.md +129 -122
  37. package/templates/agents/orc-executor-sonnet-4-6-med.md +129 -122
  38. package/templates/agents/orc-executor-sonnet-5-high.md +129 -122
  39. package/templates/agents/orc-graph-noter-sonnet-4-6-med.md +89 -0
  40. package/templates/hooks/README.md +454 -396
  41. package/templates/hooks/orc-graph-hook.js +471 -0
  42. package/templates/hooks/orc-statusline-render.js +922 -921
  43. package/templates/hooks/orc-statusline.js +1596 -1545
  44. package/templates/skills/_shared/README.md +4 -0
  45. package/templates/skills/_shared/code-graph.md +348 -0
  46. package/templates/skills/_shared/opus5-only.md +4 -0
  47. package/templates/skills/_shared/phases/execution.md +168 -147
  48. package/templates/skills/_shared/phases/planning.md +149 -135
  49. package/templates/skills/_shared/phases/preflight.md +132 -118
  50. package/templates/skills/_shared/phases/review.md +63 -53
  51. package/templates/skills/_shared/phases/ship.md +100 -88
  52. package/templates/skills/_shared/phases/trace.md +8 -0
  53. package/templates/skills/_shared/phases/wiki-consult.md +194 -189
  54. package/templates/skills/_shared/read-ladder.md +132 -102
  55. package/templates/skills/_shared/return-validation.md +259 -250
  56. package/templates/skills/orc/SKILL.md +255 -254
  57. package/templates/skills/orc-analyze/SKILL.md +13 -13
  58. package/templates/skills/orc-diy/references/flow-schema.md +101 -100
  59. package/templates/skills/orc-fast/SKILL.md +236 -229
  60. package/templates/skills/orc-mini/SKILL.md +267 -259
  61. package/templates/skills/orc-quick/SKILL.md +378 -361
  62. package/templates/skills/orc-quick/references/dispatch-gate.md +6 -0
  63. package/templates/skills/orc-wiki/references/staleness.md +294 -288
package/bin/webui/api.js CHANGED
@@ -1,1414 +1,1425 @@
1
- "use strict";
2
- /**
3
- * api.js — the /api router for `orc ui`.
4
- *
5
- * THE ONE ARCHITECTURAL RULE: this file never re-implements CLI logic. Every
6
- * endpoint spawns `node bin/cli.js <cmd> --json` and forwards the parsed
7
- * object. That makes UI/CLI drift structurally impossible — the UI *is* the
8
- * CLI — and it means every write inherits the CLI's validators, the LEGACY_KEYS
9
- * aliasing and the shadowing announcements for free, with zero duplicated
10
- * logic.
11
- *
12
- * The alternative (requiring cli.js as a library) is off the table: cli.js ends
13
- * with a bare IIFE, has no require.main guard, prints and process.exit()s
14
- * directly, and is pinned by contract tokens with binFiles: ["bin/cli.js"].
15
- *
16
- * It also never RUNS a lane and never calls a model API. Since v0.43.4 there is
17
- * exactly one deliberate exception to "never spawns claude": the Experiment
18
- * panel can open a TERMINAL with `claude` in it and then forget about it (see
19
- * `launchClaude`). No model output ever flows back through this server, no
20
- * session is proxied, no API key is held — the panel is still not an AI client.
21
- */
22
-
23
- const { spawn, spawnSync } = require("child_process");
24
- const fs = require("fs");
25
- const os = require("os");
26
- const path = require("path");
27
- const zlib = require("zlib");
28
- const fixtures = require("./fixtures/index.js");
29
-
30
- const CLI = path.join(__dirname, "..", "cli.js");
31
-
32
- // A single-user localhost panel re-reads the same command several times while
33
- // one page renders. A short TTL collapses that without ever showing stale data
34
- // across a user action: every mutation clears the cache outright.
35
- const READ_TTL_MS = 2500;
36
- const chr10 = String.fromCharCode(10);
37
- const CLI_TIMEOUT_MS = 30_000;
38
-
39
- const cache = new Map();
40
-
41
- function cacheKey(argv) {
42
- return argv.join("\u0000");
43
- }
44
-
45
- function clearCache() {
46
- cache.clear();
47
- }
48
-
49
- // v1.4.0 — the board a statusline request is about. A value this server does
50
- // not recognise is DROPPED rather than forwarded: the CLI would refuse it, and
51
- // a query string is user input like any other.
52
- function slBoard(q) {
53
- const b = q && q.board ? String(q.board) : "";
54
- return b === "subagent" ? ["--board", "subagent"] : [];
55
- }
56
-
57
- // ── running the CLI ─────────────────────────────────────────────────────────
58
-
59
- // Several commands use a NON-ZERO exit as a normal answer, not a failure:
60
- // `pattern status` (1 = absent, 2 = unknown key), `gotcha list` (1 = none),
61
- // `wiki impact` (2 = delta, 3 = full), `pr stack status` (1 = not ready),
62
- // `doctor` (1 = issues found), `resume` (1 = nothing waiting). So the exit code
63
- // is DATA here, never an error condition — a run counts as failed only when it
64
- // produced no parseable object.
65
- // `input` (v0.50.0) is stdin for the child, and it exists for exactly one
66
- // caller: the connection test, which takes a pasted API key on line 1 and an
67
- // optional passphrase on line 2. It is a parameter rather than a second spawn
68
- // helper because a secret must travel the SAME path everything else does —
69
- // never argv (world-readable in a process list), never a temp file, never a log
70
- // line. Nothing here ever echoes it back, and `command` below is built from
71
- // argv alone.
72
- function runCli(argv, ctx, { json = false, input = undefined } = {}) {
73
- const args = [...argv];
74
- if (json) args.push("--json");
75
- // Always target the project explicitly: the server's cwd is not a reliable
76
- // way to reach the same .claude the launching command resolved.
77
- if (ctx.projectRoot) args.push("--dir", ctx.projectRoot);
78
- // ORC_NO_UPDATE_CHECK exists here to protect the --json contract: most
79
- // commands end with `maybeNudge()`, which prints an "update available" line to
80
- // STDOUT and would sit beside the object this parses.
81
- //
82
- // `version` and `changelog` are the exceptions, and forcing the flag on them
83
- // was a real bug: neither nudges, and for both the check IS the payload — so
84
- // the panel asked whether an update existed with the check switched off and
85
- // was told `check_disabled: true` forever. A blanket env var silenced the one
86
- // command whose entire job is to answer that question.
87
- const CHECKS_UPDATES = argv[0] === "version" || argv[0] === "changelog";
88
- const env = { ...process.env, NO_COLOR: "1" };
89
- if (!CHECKS_UPDATES) env.ORC_NO_UPDATE_CHECK = "1";
90
- const r = spawnSync(process.execPath, [CLI, ...args], {
91
- encoding: "utf8",
92
- timeout: CLI_TIMEOUT_MS,
93
- windowsHide: true,
94
- env,
95
- input,
96
- });
97
- const stdout = r.stdout || "";
98
- let data = null;
99
- try {
100
- data = JSON.parse(stdout);
101
- } catch (_) {}
102
- return {
103
- ok: data !== null,
104
- exit_code: r.status === null ? -1 : r.status,
105
- data,
106
- stderr: (r.stderr || "").trim(),
107
- stdout: data === null ? stdout.trim() : "",
108
- command: "orc " + argv.join(" "),
109
- };
110
- }
111
-
112
- // The one-line reason a read failed, in the CLI's own words. `crashed` is the
113
- // envelope the CLI itself emits when a `--json` route throws (v0.49.2); anything
114
- // else is a command that wrote nothing parseable at all.
115
- function readFailReason(out) {
116
- const first = (out.stderr || out.stdout || "")
117
- .split(chr10)
118
- .map((l) => l.trim())
119
- .filter(Boolean)[0];
120
- const tail = first ? " \u2014 " + first : "";
121
- return `${out.command} produced no JSON (exit ${out.exit_code})${tail}`;
122
- }
123
-
124
- function readCli(argv, ctx) {
125
- const key = cacheKey([ctx.projectRoot || "", ...argv]);
126
- const hit = cache.get(key);
127
- if (hit && Date.now() - hit.at < READ_TTL_MS) return hit.value;
128
- const value = runCli(argv, ctx, { json: true });
129
- cache.set(key, { at: Date.now(), value });
130
- return value;
131
- }
132
-
133
- // ── jobs (the mutation half) ────────────────────────────────────────────────
134
- // SINGLE-FLIGHT: one mutation at a time, process-wide. The client goes
135
- // read-only while a job runs, so a second one is a bug, not a race to win.
136
- // Output accumulates in memory and the client polls /api/job — which is what
137
- // makes `orc update` and `orc upgrade` show their real output as it happens
138
- // instead of a spinner and a verdict.
139
-
140
- let job = null;
141
- let jobSeq = 0;
142
-
143
- function jobView() {
144
- if (!job) return { id: null, running: false };
145
- return {
146
- id: job.id,
147
- command: job.command,
148
- running: job.running,
149
- exit_code: job.exit_code,
150
- output: job.output,
151
- started_ms: job.started_ms,
152
- // THE PANEL IS SERVING CODE THAT THIS JOB MAY HAVE JUST REPLACED. Reported
153
- // only on a job that SUCCEEDED and whose action is declared as touching the
154
- // install — a failed upgrade changed nothing, so restarting after one would
155
- // be motion with no reason. The client acts on it; the server does not
156
- // restart itself, so the job's output survives long enough to be read.
157
- restart_pending: !!(job.restart_ui && !job.running && job.exit_code === 0),
158
- };
159
- }
160
-
161
- function startJob(argv, ctx, opts) {
162
- if (job && job.running) return { error: "busy", job: jobView() };
163
- const args = [...argv];
164
- if (ctx.projectRoot) args.push("--dir", ctx.projectRoot);
165
- const id = ++jobSeq;
166
- job = {
167
- id,
168
- command: "orc " + argv.join(" "),
169
- running: true,
170
- exit_code: null,
171
- output: "",
172
- started_ms: Date.now(),
173
- restart_ui: !!(opts && opts.restartUi),
174
- };
175
- const child = spawn(process.execPath, [CLI, ...args], {
176
- windowsHide: true,
177
- env: { ...process.env, NO_COLOR: "1" },
178
- });
179
- const append = (buf) => {
180
- job.output += buf.toString("utf8");
181
- // A runaway job must not grow the server's heap without bound.
182
- if (job.output.length > 400_000) job.output = job.output.slice(-400_000);
183
- };
184
- child.stdout.on("data", append);
185
- child.stderr.on("data", append);
186
- child.on("error", (e) => {
187
- job.output += "\n" + String(e.message);
188
- job.exit_code = -1;
189
- job.running = false;
190
- });
191
- child.on("close", (code) => {
192
- job.exit_code = code === null ? -1 : code;
193
- job.running = false;
194
- clearCache(); // every panel refetches against the new truth
195
- });
196
- return { job: jobView() };
197
- }
198
-
199
- // ── the endpoint table ──────────────────────────────────────────────────────
200
- // READS map a route to CLI argv. WRITES are separate and POST-only — a GET can
201
- // never mutate, so a prefetch, a bookmark or a browser retry is always safe.
202
-
203
- const READS = {
204
- "/api/version": () => ["version"],
205
- "/api/changelog": () => ["changelog"],
206
- "/api/where": () => ["where"],
207
- "/api/doctor": () => ["doctor"],
208
- "/api/config": () => ["config", "list"],
209
- "/api/config/profiles": () => ["config", "profile"],
210
- "/api/config/recommend": () => ["config", "recommend"],
211
- // v1.0.0 W16 — the `orc lane` noun, rendered. All three are READS and all
212
- // three are answers the CLI already computes in full: which lanes exist and
213
- // how many keys each reads, which SHARED phases a lane runs and in what
214
- // order, and the whole call catalogue. The panel draws them and decides
215
- // nothing about them — the Flow-stepper rule, applied to the lane model.
216
- // `lane phases` and `lane config` both exit 2 on an unknown lane, which is
217
- // DATA here exactly like `pattern status` and `wiki impact` above.
218
- "/api/lanes": () => ["lane", "list"],
219
- "/api/lane/phases": (q) => ["lane", "phases", String(q.lane || "")],
220
- "/api/lane/calls": (q) => (q.lane ? ["lane", "calls", String(q.lane)] : ["lane", "calls", "--all"]),
221
- "/api/runs": (q) => ["run", "list", "--limit", String(Math.min(200, Number(q.limit) || 40))],
222
- "/api/run": (q) => ["run", "show", String(q.slug || "")],
223
- "/api/wiki": () => ["wiki", "status"],
224
- "/api/wiki/impact": () => ["wiki", "impact"],
225
- // v0.46.0. Every one is a READ with an exit-code contract, so the exit code is
226
- // DATA here exactly like `pattern status` and `wiki impact` above: pact 0/1/2/3,
227
- // boundary 0/1/2/3, handoff 0/1, budget 0/1/2/3, aftermath 0/1/2/3,
228
- // wiki plan 0/1/2/3, wiki debt 0/1/3, export --check 0/1.
229
- "/api/wiki/plan": () => ["wiki", "plan"],
230
- "/api/wiki/debt": () => ["wiki", "debt"],
231
- "/api/wiki/usage": () => ["wiki", "usage"],
232
- // v0.49.1 — the wiki's CONTENTS, not only its temperature. All three are
233
- // READS whose exit code is DATA (docs 0/1/3, show 0/2/3, coverage 0/1), and
234
- // `coverage` deliberately has no threshold: nothing branches on it, here or
235
- // anywhere else. `--body` is opt-in and one artifact at a time — the
236
- // /api/doc/section precedent.
237
- "/api/wiki/docs": () => ["wiki", "docs"],
238
- "/api/wiki/show": (q) => ["wiki", "show", String(q.doc || ""), ...(q.body ? ["--body"] : [])],
239
- "/api/wiki/coverage": () => ["wiki", "coverage"],
240
- // The pattern file is injected LITERALLY into every executor slice, and until
241
- // now nothing would show you a line of it. Same 0/1/2 contract `pattern
242
- // status` has had since v0.34.8.
243
- "/api/pattern/show": (q) => ["pattern", "show", String(q.lang || ""), ...(q.body ? ["--body"] : [])],
244
- "/api/gotcha/show": (q) => ["gotcha", "show", String(q.id || "")],
245
- "/api/gotchas/archived": () => ["gotcha", "list", "--archived"],
246
- // Preview-then-apply: A COUNT IS NOT CONSENT. The Apply button stays disabled
247
- // until this has been fetched, and it names every entry eviction would touch.
248
- "/api/gotcha/prune/preview": () => ["gotcha", "prune", "--dry-run"],
249
- // v1.1.0 — the wait. Three READS, and every one of them is a state the panel
250
- // renders and never derives: `usage check` 0/1/2 (and `unknown` is a state,
251
- // not a failure), the lane table, and the run's block. The panel CANNOT start
252
- // a wait — a wait lives in a Claude Code session, and `orc ui` never runs a
253
- // lane. It configures the defaults, shows a wait, and cancels one.
254
- "/api/usage": () => ["usage", "check"],
255
- // v1.3.0 — the CLI Hook Interface. THE PANEL DRAWS THE CATALOGUE AND DERIVES
256
- // NONE OF IT: the groups, the renderers, their option sets and the rendered
257
- // sample per renderer all come from `statusline components --json`, and a
258
- // test greps the panel for component ids, renderer names, glyph-set names,
259
- // ramp names, colour tokens and state words. It must name none of them.
260
- //
261
- // `preview` is the one that earns the panel its place: it renders through the
262
- // SAME engine the hook does, so what you see is what the bar will print.
263
- // v1.4.0 — TWO BOARDS through one set of routes. `--board` is passed
264
- // through verbatim; the CLI owns which boards exist and which components
265
- // each may hold, and the panel — as everywhere else — decides none of it.
266
- "/api/statusline/components": (q) => ["statusline", "components", ...slBoard(q)],
267
- "/api/statusline/show": (q) => ["statusline", "show", ...slBoard(q)],
268
- "/api/statusline/presets": (q) => ["statusline", "presets", ...slBoard(q)],
269
- "/api/statusline/preview": (q) => {
270
- const argv = ["statusline", "preview", ...slBoard(q)];
271
- if (q.width) argv.push("--width", String(q.width));
272
- if (q.state) argv.push("--state", String(q.state));
273
- // v1.4.2 — a RENDER-ONLY override, so the panel can draw the same layout
274
- // under each colour set and each symbol set. It writes nothing: the CLI
275
- // applies these to a copy of the saved layout before it compiles.
276
- if (q.theme) argv.push("--theme", String(q.theme));
277
- if (q.glyphs) argv.push("--glyphs", String(q.glyphs));
278
- return argv;
279
- },
280
- "/api/statusline/explain": (q) => ["statusline", "explain", String(q.at || "1:1"), ...slBoard(q)],
281
- "/api/wait/lanes": () => ["wait", "lanes"],
282
- "/api/wait/status": (q) => (q.slug ? ["wait", "status", String(q.slug)] : ["wait", "status"]),
283
- "/api/pact": () => ["pact", "status"],
284
- "/api/boundary": (q) => (q.path ? ["boundary", "status", String(q.path)] : ["boundary", "status"]),
285
- "/api/handoff": () => ["handoff", "surfaces"],
286
- "/api/budget/rates": () => ["budget", "rates"],
287
- // A forecast takes a PLAN PATH. The browser sends a path the folder picker
288
- // produced; the server passes it straight to the CLI and never opens the file
289
- // itself — /api/fs/list stays a directory LISTER, and widening it to read a
290
- // plan would be the one change that turns this panel into a file reader.
291
- "/api/budget/forecast": (q) => ["budget", "forecast", String(q.plan || "")],
292
- "/api/budget/actual": (q) => ["budget", "actual", String(q.slug || "")],
293
- "/api/aftermath": (q) => (q.since ? ["aftermath", "status", "--since", String(q.since)] : ["aftermath", "status"]),
294
- "/api/export": () => ["export", "--check"],
295
- // v0.47.0 — /orc-challenge. Same shape: every one is a READ whose exit code is
296
- // DATA (list 0/1/3, status 0/1/2/3, diff 0/1/2/3, lint 0/1/2), and the panel
297
- // derives nothing from them — not the state word, not the iteration order, not
298
- // the pass decision, not the expected revision path.
299
- "/api/challenge": () => ["challenge", "list"],
300
- "/api/challenge/one": (q) => ["challenge", "status", String(q.slug || "")],
301
- "/api/challenge/show": (q) => [
302
- "challenge",
303
- "show",
304
- String(q.slug || ""),
305
- ...(q.iteration ? ["--iteration", String(q.iteration)] : []),
306
- ],
307
- "/api/challenge/diff": (q) => ["challenge", "diff", String(q.slug || "")],
308
- // v0.49.1 — the council. `roles` is STATIC (it works with no cycle at all),
309
- // and it is the ONE catalogue: the panel names no lens, no class and no
310
- // disposition itself, exactly as it names no flow step. `council` is 0 set /
311
- // 1 unset / 3 unknown, and UNSET is an ANSWER, not an error.
312
- "/api/challenge/roles": (q) => ["challenge", "roles", ...(q.kind ? ["--kind", String(q.kind)] : [])],
313
- "/api/challenge/council": (q) => ["challenge", "council", String(q.slug || "")],
314
- "/api/challenge/lint": (q) => [
315
- "challenge",
316
- "lint",
317
- String(q.path || ""),
318
- ...(q.template ? ["--template", String(q.template)] : []),
319
- ],
320
- // v0.48.0 — /orc-doc. Same shape again: every one is a READ whose exit code is
321
- // DATA (list 0, status 0/1/2, map 0/2, plan 0/1, lint 0/1/2), and the panel
322
- // derives nothing from them — not the section order, not a line range, not a
323
- // state word, not the batching, not a lint rule name. It draws what the CLI
324
- // computed.
325
- //
326
- // `/api/doc/section` is the ONE route that returns any of the document's
327
- // prose, it returns exactly ONE section, and only on an explicit Reveal click.
328
- // The rule this lane lives by is that nothing HOLDS the document — not that
329
- // the text is secret — and the panel renders it as DOM through `renderMd`,
330
- // never as HTML.
331
- "/api/doc": () => ["doc", "list"],
332
- "/api/doc/one": (q) => ["doc", "status", String(q.slug || "")],
333
- "/api/doc/show": (q) => ["doc", "show", String(q.slug || "")],
334
- "/api/doc/section": (q) => ["doc", "show", String(q.slug || ""), "--section", String(q.section || "")],
335
- "/api/doc/map": (q) => ["doc", "map", String(q.slug || "")],
336
- "/api/doc/lint": (q) => [
337
- "doc",
338
- "lint",
339
- String(q.slug || ""),
340
- ...(q.target ? ["--target", String(q.target)] : []),
341
- ],
342
- "/api/doc/plan": (q) => ["doc", "plan", String(q.slug || ""), "--role", String(q.role || "write")],
343
- "/api/doc/templates": () => ["doc", "templates"],
344
- "/api/doc/targets": () => ["doc", "targets"],
345
- // v0.48.1 — the score, the drift report and the memory surface. Every one is
346
- // a subprocess of the real command: the panel decides nothing about the
347
- // pipeline order, nothing about which drift classes exist, and nothing about
348
- // which journal rows are the user's own words.
349
- //
350
- // There is deliberately NO route for `orc doc log`: the SKILL records a
351
- // request, because the skill is what took one. A panel that could write a
352
- // journal entry could write one nobody said.
353
- // v0.49.0 — the SECTION FILES. This is the one read that works before a
354
- // single compile has ever run, because the files ARE the progress.
355
- "/api/doc/parts": (q) => ["doc", "parts", String(q.slug || "")],
356
- "/api/doc/next": (q) => ["doc", "next", String(q.slug || "")],
357
- "/api/doc/audit": (q) => ["doc", "audit", String(q.slug || "")],
358
- "/api/doc/journal": (q) => ["doc", "journal", String(q.slug || "")],
359
- "/api/doc/context": (q) => ["doc", "context", String(q.slug || "")],
360
- "/api/doc/extra": (q) => ["doc", "extra", String(q.slug || "")],
361
- // v0.49.2 — the project's own house rules, the frozen set of one document,
362
- // the run map and the cost report. All four are READS; the panel decides
363
- // nothing about a priority, an order, a wave shape or a number.
364
- "/api/doc/rules": () => ["doc", "rules"],
365
- "/api/doc/rules/one": (q) => ["doc", "rules", String(q.slug || "")],
366
- // v1.7.0 — the anti-slop rule surface. Five READS. The panel decides nothing:
367
- // not the precedence order, not a rule's tier, not the override count, and
368
- // not which of the 65 rules the lint can actually prove.
369
- //
370
- // `lint` takes a path from the query string. It is user input like any other,
371
- // so it is forwarded as ONE argv element and never through a shell — the same
372
- // reason every other route here spawns an array rather than a string.
373
- "/api/rules": () => ["rules"],
374
- "/api/rules/user": () => ["rules", "user"],
375
- "/api/rules/packs": () => ["rules", "packs"],
376
- "/api/rules/credits": () => ["rules", "credits"],
377
- "/api/rules/lint": (q) => ["rules", "lint", String(q.path || ".")],
378
- // v1.7.0 — the wiki's one-doc-at-a-time probes. Both free, neither scans.
379
- "/api/wiki/resolve": (q) => ["wiki", "resolve", String(q.topic || "")],
380
- "/api/wiki/refs": () => ["wiki", "refs", "--check"],
381
- "/api/doc/forecast": (q) => ["doc", "forecast", String(q.slug || "")],
382
- "/api/doc/cost": (q) => ["doc", "cost", String(q.slug || "")],
383
- // v0.50.0 — `orc extra`. Every one is a READ, and every one is a subprocess of
384
- // the real command: the panel decides nothing about a provider, a model id, a
385
- // verification state, a band or a price. It renders what the CLI computed.
386
- //
387
- // There is deliberately NO read that returns a credential. `orc extra list`
388
- // and `orc extra show` go through `redactProfile`, which is an ALLOW-LIST, so
389
- // a field added later that carries a secret has to be let through on purpose.
390
- "/api/extra": () => ["extra", "list"],
391
- "/api/extra/providers": () => ["extra", "providers"],
392
- "/api/extra/show": (q) => ["extra", "show", String(q.profile || "")],
393
- "/api/extra/models": (q) => ["extra", "models", String(q.profile || "")],
394
- // v0.51.0 — the LOCAL TOOLS read, and the credential-route read. Both are the
395
- // real commands, so the panel decides nothing about an install command, a
396
- // platform, a version floor or which of three credential routes applies.
397
- // `tools` exits 1 when no tool is ready, which is exit-code-as-DATA like
398
- // `pattern status` — never an error.
399
- "/api/extra/tools": () => ["extra", "tools"],
400
- "/api/extra/keyhelp": (q) => ["extra", "keyhelp", String(q.profile || "")],
401
- "/api/extra/route": () => ["extra", "route"],
402
- // v0.55.0 — THE POSITIONS, the non-scored half of routing. It exits 1 when
403
- // nothing routes, which is exit-code-as-DATA like `pattern status` — an empty
404
- // result is an ANSWER and it still returns its whole object.
405
- "/api/extra/role": () => ["extra", "role", "list"],
406
- // v0.52.0 (D6) — WHICH LANE a band governs, computed through the same
407
- // resolver every dispatch uses. The panel renders it and derives nothing:
408
- // a band with no lane attached is not a routing decision.
409
- "/api/extra/lanes": () => ["extra", "lanes"],
410
- // `stats` exits 1 with a real object when no foreign dispatch has been traced
411
- // yet, and `rates` exits 1 when a pair has no price — both are exit-code-as-
412
- // DATA, like `pattern status` and `wiki impact`, never an error.
413
- "/api/extra/stats": (q) => (q.since ? ["extra", "stats", "--since", String(q.since)] : ["extra", "stats"]),
414
- "/api/extra/rates": () => ["extra", "rates"],
415
- "/api/extra/doctor": () => ["extra", "doctor"],
416
- // v0.54.0 — RECOVERY. Both are FREE reads (zero model tokens), which is why
417
- // both are real buttons; `resume-slice` and the dispatch that follows it cost
418
- // money and are copy-able commands instead. `reconcile` exits 0-4 as an
419
- // exit-code-as-DATA contract like `pattern status`, so no state here is an
420
- // error — including `in-flight`, which is a REFUSAL the panel must render as
421
- // one rather than as a dead control.
422
- "/api/extra/journal": () => ["extra", "journal", "list"],
423
- // v1.0.0 W16 — the RUN DEMOTION, read. Exit 0 armed · 1 demoted · 2 unknown
424
- // run, which is exit-code-as-DATA like every other gate command here. The
425
- // run is optional: with none given the CLI reads the trace pointer, which is
426
- // exactly what a panel wants — "the run that is open right now".
427
- "/api/extra/demotion": (q) => ["extra", "demotion", ...(q.run ? [String(q.run)] : [])],
428
- "/api/extra/reconcile": (q) => ["extra", "reconcile", String(q.task || "")],
429
- "/api/extra/journal/prune/preview": () => ["extra", "journal", "prune", "--dry-run"],
430
- // v1.5.0 — /orc-test, the lane that RUNS the test. THREE reads, and every one
431
- // of them is a READ in the strict sense this lane needs: none re-scans the
432
- // repository, none probes the target, and none writes the ledger. Opening a
433
- // page must never be a measurement — a measurement nobody asked for is
434
- // traffic nobody authorized.
435
- //
436
- // `orc test show` is the whole computed view (`--json is not a summary`), so
437
- // the surface, the case matrix, the closed OWASP set, the runs and the
438
- // findings all arrive in ONE object the panel renders and derives nothing
439
- // from: not a state word, not a severity, not an OWASP id, not the tier.
440
- // `status` 0 always, `show` 0 / 2 no run, `ui tools` 0 ready / 1 not ready /
441
- // 2 the driver is `none` — and 2 there is a SETTING, not a missing tool.
442
- "/api/test": () => ["test", "status"],
443
- "/api/test/one": (q) => ["test", "show", String(q.slug || "")],
444
- "/api/test/ui": () => ["test", "ui", "tools"],
445
- "/api/patterns": () => ["pattern", "status"],
446
- "/api/gotchas": () => ["gotcha", "list"],
447
- "/api/stats": (q) => (q.since ? ["stats", "--since", String(q.since)] : ["stats"]),
448
- "/api/diy": () => ["diy", "show"],
449
- "/api/crosslink": () => ["crosslink", "list"],
450
- "/api/crosslink/kinds": () => ["crosslink", "kinds"],
451
- "/api/mocks": () => ["mock", "list"],
452
- "/api/mock": (q) => ["mock", "show", String(q.slug || "")],
453
- "/api/stack": (q) => (q.slug ? ["pr", "stack", "status", String(q.slug)] : ["pr", "stack", "status"]),
454
- };
455
-
456
- // v0.46.0 writes. THE LINE THIS PANEL DOES NOT CROSS: a button exists only for an
457
- // action that costs NO model tokens. `orc pact check` runs the ledger's own cheap
458
- // proofs (a test, a command, a grep the user wrote); `orc handoff set` edits one
459
- // graded surface; `orc export` compiles files already on disk. Every one is
460
- // deterministic and every one is a real CLI command.
461
- //
462
- // The conversational half of each lane — reconciling a promise, deciding a
463
- // verdict, walking somebody through a change — costs model tokens, so the panel
464
- // COPIES those commands and never runs them. A test greps this object to make
465
- // sure no lane name ever appears inside it.
466
- const WRITES = {
467
- "/api/config/set": (b) => ["config", "set", String(b.key), String(b.value)],
468
- "/api/config/reset": (b) => (b.key ? ["config", "reset", String(b.key)] : ["config", "reset"]),
469
- "/api/config/profile": (b) => ["config", "profile", String(b.name)],
470
- "/api/diy/set": (b) => ["diy", "set", String(b.key), String(b.value)],
471
- "/api/diy/compile": () => ["diy", "compile"],
472
- // The bootstrap the TTY composer offers as its first question, and the one
473
- // piece of DIY the panel had no way to reach (v0.44.0). `--force` is what
474
- // makes it an ANSWER rather than an error on an already-configured project:
475
- // `orc diy init` refuses to overwrite without it. That is destructive, so the
476
- // UI confirms with the preset's own diff and the exact command on screen.
477
- // An empty name is the wizard's "full-lane defaults" — a real invocation,
478
- // not a synthesised one.
479
- "/api/diy/preset": (b) => {
480
- const argv = ["diy", "init", "--force"];
481
- if (b.name) argv.push("--preset", String(b.name));
482
- return argv;
483
- },
484
- // v1.1.0 — the only two wait mutations this panel may make, and neither
485
- // starts one. `unblock` restores a gate the user vetoed; `cancel` ends a wait
486
- // already running. A BLOCK cannot be created here on purpose: it needs a
487
- // reason typed in the moment, and a reason typed into a settings page days
488
- // later is not the record that makes the risk demonstrably the user's.
489
- // v1.3.0 — the layout's writers. Every one shells the same validator the CLI
490
- // uses, so an illegal placement is refused BY NAME here exactly as it is
491
- // there. The board makes the illegal drop impossible; this is the guarantee.
492
- "/api/statusline/set": (b) => {
493
- const argv = ["statusline", "set", String(b.line), String(b.pos)];
494
- if (b.type) argv.push(String(b.type));
495
- for (const [k, f] of [
496
- ["render", "--render"], ["label", "--label"], ["color", "--color"],
497
- ["label_color", "--label-color"], ["value_color", "--value-color"],
498
- ["bg", "--bg"], ["ramp", "--ramp"], ["glyphs", "--glyphs"],
499
- ["format", "--format"], ["case", "--case"], ["truncate", "--truncate"],
500
- ["compact", "--compact"], ["prefix", "--prefix"], ["suffix", "--suffix"],
501
- ["emphasis", "--emphasis"], ["hide_when", "--hide-when"],
502
- ["width", "--width"], ["precision", "--precision"],
503
- ["min_width", "--min-width"], ["min_cols", "--min-cols"],
504
- ["max_cols", "--max-cols"], ["priority", "--priority"],
505
- ]) {
506
- if (b[k] !== undefined && b[k] !== null && b[k] !== "") argv.push(f, String(b[k]));
507
- }
508
- if (b.draw_empty) argv.push("--draw-empty");
509
- return argv.concat(slBoard(b));
510
- },
511
- "/api/statusline/move": (b) => ["statusline", "move", String(b.from), String(b.to), ...slBoard(b)],
512
- "/api/statusline/remove": (b) => ["statusline", "remove", String(b.at), ...slBoard(b)],
513
- "/api/statusline/line": (b) => {
514
- const argv = ["statusline", "line", String(b.line)];
515
- if (b.separator !== undefined) argv.push("--separator", String(b.separator));
516
- if (b.theme) argv.push("--theme", String(b.theme));
517
- if (b.max_width !== undefined) argv.push("--max-width", String(b.max_width));
518
- return argv.concat(slBoard(b));
519
- },
520
- // A preset REPLACES the layout, so the panel always confirms it and names
521
- // the loss — the `orc diy init --force` rule.
522
- // THE DOCUMENT-LEVEL SETTINGS. `line` is per-LINE and `doc` is the whole
523
- // layout; the colour set is a document fact, and routing it through `line`
524
- // wrote one third of the bar while the picker read the other value back.
525
- "/api/statusline/doc": (b) => {
526
- const argv = ["statusline", "doc"];
527
- if (b.theme) argv.push("--theme", String(b.theme));
528
- if (b.glyphs) argv.push("--glyphs", String(b.glyphs));
529
- if (b.ansi) argv.push("--ansi", String(b.ansi));
530
- if (b.align_columns !== undefined) argv.push("--align-columns", b.align_columns ? "on" : "off");
531
- return argv.concat(slBoard(b));
532
- },
533
- "/api/statusline/apply": (b) => ["statusline", "apply", String(b.name), ...slBoard(b)],
534
- // v1.3.0 W5. `group` wraps 2-4 as one object, `expand` is its inverse and is
535
- // also how a composite becomes editable, `clone` is for two `config` chips on
536
- // different keys — a normal thing to want.
537
- "/api/statusline/group": (b) => ["statusline", "group", ...(b.refs || []).map(String), ...slBoard(b)],
538
- "/api/statusline/expand": (b) => ["statusline", "expand", String(b.at), ...slBoard(b)],
539
- "/api/statusline/clone": (b) => ["statusline", "clone", String(b.at), ...slBoard(b)],
540
- "/api/statusline/reset": (b) => ["statusline", "reset", ...slBoard(b)],
541
- "/api/statusline/compile": (b) => ["statusline", "compile", ...slBoard(b)],
542
- "/api/wait/unblock": (b) => (b.slug ? ["wait", "unblock", String(b.slug)] : ["wait", "unblock"]),
543
- "/api/wait/cancel": (b) => (b.slug ? ["wait", "cancel", String(b.slug)] : ["wait", "cancel"]),
544
- "/api/wiki/sync": () => ["wiki", "sync"],
545
- "/api/wiki/usage/rebuild": () => ["wiki", "usage", "--rebuild"],
546
- "/api/gotcha/prune": () => ["gotcha", "prune"],
547
- "/api/pact/check": (b) => (b.id ? ["pact", "check", String(b.id)] : ["pact", "check"]),
548
- "/api/pact/sync": () => ["pact", "sync"],
549
- // The surface id, the key and the value all come from the browser — and all
550
- // three are validated by the CLI, which refuses a RED surface, an unknown id,
551
- // a key that does not already exist, and a write at all when handoff_write is
552
- // false. There is no second idea of a safe edit anywhere in this panel.
553
- "/api/handoff/set": (b) => ["handoff", "set", String(b.id), String(b.key), String(b.value)],
554
- "/api/budget/calibrate": () => ["budget", "calibrate"],
555
- // v0.47.0. All three are FREE and deterministic, so all three get a button.
556
- // Running an ITERATION costs model tokens, so it is a copy-able command and
557
- // there is deliberately no route for it here. `accept` and `rebut` both refuse
558
- // without a reason — the CLI decides that, not the form.
559
- "/api/challenge/accept": (b) => ["challenge", "accept", String(b.slug), String(b.id), String(b.reason || "")],
560
- "/api/challenge/rebut": (b) => ["challenge", "rebut", String(b.slug), String(b.id), String(b.reason || "")],
561
- "/api/challenge/report": (b) => ["challenge", "report", String(b.slug)],
562
- // v0.49.1. Both are FREE and both REFUSE without a reason, so both are
563
- // buttons. There is deliberately NO route for `council --set`: changing the
564
- // roster mid-cycle is a decision with a recorded reason that the LANE takes in
565
- // the conversation — the same reasoning that keeps `orc doc log` and
566
- // `orc doc mode` off the panel. Adopting a premise needs a goals FILE, which
567
- // the panel must not invent, so that one stays a copy-able command too.
568
- "/api/challenge/premise": (b) => [
569
- "challenge", "premise", String(b.slug), String(b.id), "--dismiss", "--reason", String(b.reason || ""),
570
- ],
571
- "/api/challenge/opportunity": (b) => [
572
- "challenge", "opportunity", String(b.slug), String(b.id), b.take ? "--take" : "--drop", "--reason", String(b.reason || ""),
573
- ],
574
- // v0.48.0. `assemble` — now `compile` — is the ONE /orc-doc write that costs
575
- // nothing: it concatenates section files that are already on disk, in an order
576
- // the outline already fixed. Writing a section, checking one and editing one
577
- // all cost model tokens, so they are copy-able commands and there is
578
- // deliberately no route for any of them.
579
- "/api/doc/assemble": (b) => ["doc", "assemble", String(b.slug)],
580
- // v0.49.0. Both free, both non-destructive: `compile` rebuilds the artifact
581
- // from disk, and `migrate` never deletes document.md and refuses what it
582
- // cannot parse. `orc doc mode` deliberately has NO route — it is a USER
583
- // decision the skill asks (the `orc doc log` precedent).
584
- "/api/doc/compile": (b) => {
585
- const argv = ["doc", "compile", String(b.slug)];
586
- if (b.partial) argv.push("--partial");
587
- return argv;
588
- },
589
- "/api/doc/migrate": (b) => ["doc", "migrate", String(b.slug)],
590
- // v0.48.1. Shipping is a DECISION, so it is a write — and `--where` has no
591
- // default here either, because the CLI refuses without it and the panel must
592
- // never invent an argument the human path demands.
593
- "/api/doc/ship": (b) => {
594
- const argv = ["doc", "ship", String(b.slug), "--where", String(b.where || "")];
595
- if (b.note) argv.push("--note", String(b.note));
596
- if (b.force) argv.push("--force", "--reason", String(b.reason || ""));
597
- return argv;
598
- },
599
- "/api/doc/unship": (b) => ["doc", "unship", String(b.slug), "--reason", String(b.reason || "")],
600
- // v0.49.5 — the house-rule ledger is a PLAIN TEXT config, so the panel writes
601
- // it the way a text config is written: the whole file, once, from one
602
- // textarea. `set-all` is the only write route it needs — the per-priority
603
- // `set`/`add`/`clear` commands stay a CLI convenience, and `--reset` still has
604
- // no route because throwing away a project's standing rules is a CLI act.
605
- //
606
- // Every validator is still the CLI's. The panel has no second idea of what a
607
- // house rule looks like, and the argv is a plain array, so a multi-line value
608
- // needs no escaping and no temp file.
609
- "/api/doc/rules/setAll": (b) => ["doc", "rules", "set-all", "--text", String(b.text || "")],
610
- // v1.7.0 — the project's own anti-slop rules. ONE write route, for the same
611
- // reason the doc ledger has one: it is a plain text config, so the panel
612
- // writes the whole file from one textarea and the CLI is still the only
613
- // writer and the only validator.
614
- //
615
- // There is deliberately NO route for the SHIPPED packs. They are read-only,
616
- // `orc rules set --pack …` is refused by name, and a route that existed only
617
- // to be refused would be a control that lies about what it can do.
618
- // `--reset` also has no route: throwing away a project's standing rules is a
619
- // CLI act.
620
- "/api/rules/setAll": (b) => ["rules", "set-all", "--text", String(b.text || "")],
621
- "/api/doc/rules/sync": (b) => ["doc", "rules", String(b.slug), "--sync"],
622
- // v0.52.0 (D9) — per document, because a document's voice is the deliverable.
623
- // The CLI owns the resolution order and the shadowing announcement; the panel
624
- // renders both and decides neither.
625
- "/api/doc/extra/set": (b) => ["doc", "extra", String(b.slug), "--set", String(b.mode)],
626
- // v0.49.2. Closing a run is FREE, deterministic, reversible, and it DELETES
627
- // NOTHING — `RESUME.md` is moved aside, not removed. The CLI refuses without a
628
- // reason, so the form does not have to: there is one idea of a valid close and
629
- // it lives in `bin/cli.js`.
630
- "/api/run/close": (b) => ["run", "close", String(b.slug), "--reason", String(b.reason || "")],
631
- "/api/run/reopen": (b) => ["run", "reopen", String(b.slug)],
632
- // v0.50.0 — `orc extra`. Adding a connection writes a PROFILE and nothing
633
- // else: no key, no route, no change to how anything builds. Removing one
634
- // REFUSES without a reason — the same rule the promise ledger retires an
635
- // invariant under — and names the route rows it drops, so the form does not
636
- // have to: there is one idea of a valid removal and it lives in bin/cli.js.
637
- //
638
- // The routing table (W12). Both are STAGED in the panel and applied one at a
639
- // time in staged order, so a refused row never aborts the rest — and the CLI
640
- // is still what refuses an overlap, an unverified profile or a bad band spec,
641
- // BY NAME. There is deliberately no `--key <value>` anywhere, because the CLI
642
- // refuses it BY NAME — argv is world-readable. A pasted key travels on the
643
- // connection test's STDIN and nowhere else.
644
- "/api/extra/add": (b) => {
645
- const argv = ["extra", "add", String(b.name), "--provider", String(b.provider), "--engine", String(b.engine)];
646
- if (b.region && b.region !== "default") argv.push("--region", String(b.region));
647
- if (b.base_url) argv.push("--base-url", String(b.base_url));
648
- if (b.anthropic_base_url) argv.push("--anthropic-base-url", String(b.anthropic_base_url));
649
- if (b.cli_bin) argv.push("--cli", String(b.cli_bin));
650
- if (b.cli_agent) argv.push("--cli-agent", String(b.cli_agent));
651
- // v0.52.0 — the THIRD credential source, checked FIRST. A local tool that
652
- // already holds its own credential needs no key from ORC at all, and the
653
- // panel forcing such a profile into the vault is what locked a run that had
654
- // no business having a passphrase. ORC never writes another tool's
655
- // credential store; `--tool-auth` is how that is said out loud.
656
- if (b.tool_auth) argv.push("--tool-auth");
657
- else if (b.vault) argv.push("--key-stdin");
658
- else if (b.env_key) argv.push("--env-key", String(b.env_key));
659
- return argv;
660
- },
661
- "/api/extra/remove": (b) => ["extra", "remove", String(b.name), "--reason", String(b.reason || "")],
662
- // v0.52.0 — forgetting a saved passphrase carries no secret, so it is an
663
- // ordinary argv write. SAVING one does, and has its own handler below: the
664
- // passphrase travels on STDIN and `--passphrase <value>` is refused BY NAME in
665
- // the CLI, so there is no argv path on either side.
666
- "/api/extra/session/forget": (b) => ["extra", "session", String(b.profile), "--forget"],
667
- "/api/extra/route/set": (b) => {
668
- const argv = ["extra", "route", "set", String(b.band), String(b.target)];
669
- if (b.small_model) argv.push("--small-model", String(b.small_model));
670
- if (b.max_turns) argv.push("--max-turns", String(b.max_turns));
671
- return argv;
672
- },
673
- "/api/extra/route/rm": (b) => ["extra", "route", "rm", String(b.band)],
674
- // A slot is a POINT, not an interval, so there is no overlap to refuse and
675
- // `set` on an occupied one REPLACES — which is why the panel confirms it and
676
- // names what it replaces. A count is not consent.
677
- "/api/extra/role/set": (b) => {
678
- const argv = ["extra", "role", "set", String(b.slot), String(b.target)];
679
- if (b.small_model) argv.push("--small-model", String(b.small_model));
680
- if (b.max_turns) argv.push("--max-turns", String(b.max_turns));
681
- return argv;
682
- },
683
- "/api/extra/role/rm": (b) => ["extra", "role", "rm", String(b.slot)],
684
- // v0.51.0 — running the install in the USER'S OWN TERMINAL. It is a POST
685
- // because it launches something; it writes no config, stores no state and
686
- // never elevates. A launch that could not happen comes back exit 0 carrying
687
- // the command to paste (the `openBrowser` rule), so there is no failure path
688
- // that leaves the card without an answer.
689
- "/api/extra/install": (b) => {
690
- const argv = ["extra", "install", String(b.provider)];
691
- if (b.manager) argv.push("--manager", String(b.manager));
692
- return argv;
693
- },
694
- // A live model list is a FREE re-read of the provider's own catalogue, and a
695
- // per-model test is the PAID rung scoped to one id — the only thing that tells
696
- // a LISTED model from a WORKING one.
697
- "/api/extra/models/refresh": (b) => ["extra", "models", String(b.profile), "--refresh"],
698
- // Preview-then-apply, and the preview NAMES EVERY DIRECTORY — a count is not
699
- // consent. Only a journal whose every attempt closed `done` 30+ days ago is
700
- // ever a candidate, so this can never delete the record of a dispatch that
701
- // never reported back.
702
- "/api/extra/journal/prune": () => ["extra", "journal", "prune"],
703
- // A PROMOTE IS A HUMAN ACTION AND A REASON IS REQUIRED (v1.0.0 W5). The CLI
704
- // refuses without one — exit 2, `reason-required` — so the panel does not
705
- // validate it a second time; it collects it and lets the CLI decide, which is
706
- // the same contract every other write on this server keeps. There is
707
- // deliberately NO demote route: demoting by hand is a diagnostic somebody
708
- // reaches for at a terminal, and a button for it would invite muting a
709
- // provider instead of fixing it.
710
- "/api/extra/promote": (b) => ["extra", "promote", String(b.run || ""), "--reason", String(b.reason || "")],
711
- "/api/crosslink/remove": (b) => ["crosslink", "remove", String(b.name)],
712
- // The UI assembles no YAML. It hands the CLI the same arguments the
713
- // interactive prompt collects, and every rejection the user sees is the
714
- // CLI's own validator speaking — there is no second idea of a valid slug,
715
- // a valid kind or a valid edge target anywhere in this panel.
716
- // v1.5.0 — the THREE /orc-test mutations this panel may make, and NONE of
717
- // them sends a request to the system under test. `orc test run` is where the
718
- // traffic is, and it is a COPY-ABLE COMMAND here and never a button: a page
719
- // that can start a scan against a live host is a page that can start one by
720
- // accident.
721
- //
722
- // · `surface` is the FREE PASS — it reads the repository and the target's
723
- // own spec document, and it is what every later step is derived from.
724
- // · `env` is a HEALTH PROBE, the /orc-test equivalent of `orc extra ping`:
725
- // it asks the target whether it is up. It never starts, stops or fixes
726
- // anything, and on a REMOTE target it refuses BY NAME.
727
- // · `report` RENDERS the ledger into REPORT.md and derives nothing.
728
- //
729
- // Every one costs zero model tokens, which is the line this panel does not
730
- // cross. Nothing here dispatches an agent.
731
- "/api/test/surface": (b) => ["test", "surface", String(b.slug)],
732
- "/api/test/env": (b) => ["test", "env", String(b.slug)],
733
- "/api/test/report": (b) => ["test", "report", String(b.slug)],
734
- "/api/crosslink/add": (b) => {
735
- const argv = ["crosslink", "add", String(b.name), String(b.repo_path), "--kinds", String(b.kinds)];
736
- if (b.direction) argv.push("--direction", String(b.direction));
737
- if (b.via) argv.push("--via", String(b.via));
738
- if (b.target) argv.push("--target", String(b.target));
739
- return argv;
740
- },
741
- };
742
-
743
- // Maintenance: the safety-critical panel. Each action is a PAIR — a read-only
744
- // preview and the apply that the preview is consent for. The apply route can
745
- // never be reached without the UI having fetched the preview first, and the
746
- // exact command is part of the preview payload so it is always visible in the
747
- // confirmation, and always typeable by hand instead.
748
- // `restarts_ui` — DOES THIS ACTION REPLACE WHAT THE PANEL IS SERVING?
749
- //
750
- // `orc upgrade` installs a new package over the one this process is running
751
- // from, and `orc update` / `--prune` / `doctor --fix` rewrite the payload every
752
- // panel reads. Node loaded bin/webui at require time and `STATIC` is a one-time
753
- // walk at boot, so neither is visible until the server is replaced — which used
754
- // to mean stop the server, re-run `orc ui`, open the new URL. The flag is
755
- // DECLARED per action rather than inferred, because "this command changed the
756
- // code under me" is not something a command's output can be read for.
757
- //
758
- // `update-global` is deliberately FALSE: it re-copies the payload into
759
- // ~/.claude, which is not what this server is running and not what any panel
760
- // here reads.
761
- const MAINTENANCE = {
762
- update: {
763
- apply: ["update"],
764
- restarts_ui: true,
765
- label: "Re-copy this package's payload over the installed one",
766
- // `orc doctor --json` already itemises exactly what an update would change
767
- // (version skew, missing files, orphans) — a preview with no second engine.
768
- preview: ["doctor"],
769
- },
770
- prune: {
771
- apply: ["update", "--prune"],
772
- restarts_ui: true,
773
- label: "Update AND delete ORC-named orphans from a pre-manifest install",
774
- preview: ["doctor"],
775
- // A count is not consent for a deletion: the UI must name every file, and
776
- // doctor's findings carry `paths` for exactly the two orphan findings.
777
- names_files: true,
778
- },
779
- fix: {
780
- apply: ["doctor", "--fix"],
781
- restarts_ui: true,
782
- label: "Apply every fix orc doctor found (= update + prune + settings re-merge)",
783
- preview: ["doctor"],
784
- },
785
- upgrade: {
786
- apply: ["upgrade"],
787
- restarts_ui: true,
788
- label: "Fetch the LATEST package from the network, then apply it",
789
- preview: ["version"],
790
- network: true,
791
- },
792
- // v0.46.0 — the portable export. It belongs on Maintenance by the v0.43.6 rule
793
- // (a caution routes to the panel that can CLEAR it): `export-stale` is cleared
794
- // by `orc export`, which is a CLI write this panel can run. Its preview is the
795
- // `--check` report, which names WHICH source drifted — a count of stale sources
796
- // would not be consent to overwrite a committed file.
797
- export: {
798
- apply: ["export"],
799
- label: "Recompile AGENTS.md from the wiki, patterns, PACT.md and boundary cards",
800
- preview: ["export", "--check"],
801
- },
802
- // ADVANCED (v0.44.0) — the one action on this panel that does not target the
803
- // project. Every other route pins `--dir <projectRoot>`; `--global` outranks
804
- // `--dir` in `resolveClaudeDir`, so this pair reaches ~/.claude and its
805
- // preview reports on the same place it would write.
806
- //
807
- // It is here because a stale GLOBAL install is a real failure this panel
808
- // already REPORTS (the persistent banner, and doctor's global-skew finding)
809
- // and previously could not act on — the fix was "go type it in a terminal".
810
- // It stays boxed off as advanced, and it is still the only global reach the
811
- // panel has: config is never written globally, because config does not merge
812
- // and a global write would silently outrank the file every panel here edits.
813
- "update-global": {
814
- apply: ["update", "--global"],
815
- label: "Re-copy this package's payload over the GLOBAL install in ~/.claude",
816
- preview: ["doctor", "--global"],
817
- advanced: true,
818
- },
819
- };
820
-
821
- // ── the Experiment panel ────────────────────────────────────────────────────
822
- //
823
- // THE BOUNDARY MOVES EXACTLY ONE STEP, AND NO FURTHER. `orc ui` still renders
824
- // no model output, proxies no session and holds no API key — it does not become
825
- // an AI client. What it gains is a HANDOFF: it can open a terminal, in this
826
- // project, with `claude` running in it, and then forget about it. The spawned
827
- // process is not a child this server manages, reads or reports on; it is the
828
- // same detached-launch mechanism `openBrowser()` has always used.
829
- //
830
- // The lanes are a SERVER-SIDE catalog and the browser sends only an id. No
831
- // string typed in a browser ever reaches a shell — the cwd is always the
832
- // server's own projectRoot, and an unknown id is a 400. That constraint is the
833
- // only reason a launch button is safe on a write surface at all.
834
- const LANES = [
835
- { id: "orc", cmd: "/orc", what: "Full pipeline: intake → plan → scored parallel waves → review → verify → ship." },
836
- { id: "orc-quick", cmd: "/orc-quick", what: "Ask for anything. Look → ask once → do, and it always asks which agent." },
837
- { id: "orc-mini", cmd: "/orc-mini", what: "One executor, smoke gate, ship. No full review or verify phase." },
838
- { id: "orc-fast", cmd: "/orc-fast", what: "Fastest lane. Needs a fresh wiki AND a cached pattern, or it falls back." },
839
- { id: "orc-ultra", cmd: "/orc-ultra", what: "Maximum rigor: an advisor plus three judgment gates. Cost accepted." },
840
- { id: "orc-plan", cmd: "/orc-plan", what: "Turn a request or analyst spec into a grounded task plan. Plan only." },
841
- { id: "orc-analyze", cmd: "/orc-analyze", what: "Turn a document or a vague requirement into code-grounded requirements." },
842
- { id: "orc-wiki", cmd: "/orc-wiki", what: "Build or refresh the project wiki. Expensive; always asks first." },
843
- { id: "orc-pattern", cmd: "/orc-pattern", what: "Learn this project's real code conventions and cache them per language." },
844
- { id: "orc-verify", cmd: "/orc-verify", what: "Verify the git-modified changes in the working tree. Read-only." },
845
- { id: "orc-learn", cmd: "/orc-learn", what: "Generate per-feature onboarding docs. Local and git-ignored." },
846
- { id: "orc-retro", cmd: "/orc-retro", what: "Mine the behavior traces for calibration. Read-only, report-only." },
847
- ];
848
-
849
- // ── the folder picker ───────────────────────────────────────────────────────
850
- //
851
- // The THIRD endpoint with no CLI behind it (after /api/learn's shipped content
852
- // and /api/experiment's lane catalog), and for the same reason: there is no
853
- // `orc` command that lists directories, so there is nothing to shell. It exists
854
- // because a crosslink repo path typed by hand is the one field in this panel
855
- // where a typo is invisible until the edge silently resolves to nothing — the
856
- // CLI then saves it as a PENDING edge and you find out much later.
857
- //
858
- // It is a DIRECTORY LISTER and nothing more, and the limits are the design:
859
- // · directory names only — never a file list, never file contents, never a
860
- // stat beyond "does .git / .claude/wiki exist here";
861
- // · dotfolders are hidden (`.git`, `node_modules` and friends are noise here);
862
- // · it reads, it never writes, and no path it is handed can reach a shell;
863
- // · a path that cannot be read is an ANSWER (`error`), never a 500.
864
- // Nothing is copied out of the folders it lists, so a wrong click costs a
865
- // re-click. The browser is already loopback + token gated; this adds no reach
866
- // beyond what the person at the keyboard already has.
867
- const FS_LIST_MAX = 400;
868
-
869
- function fsList(dir, ctx) {
870
- const target = path.resolve(dir || ctx.projectRoot || os.homedir());
871
- let entries;
872
- try {
873
- entries = fs.readdirSync(target, { withFileTypes: true });
874
- } catch (e) {
875
- return { path: target, error: String(e.code || e.message), dirs: [] };
876
- }
877
- const dirs = [];
878
- for (const e of entries) {
879
- if (dirs.length >= FS_LIST_MAX) break;
880
- if (!e.isDirectory() || e.name.startsWith(".") || e.name === "node_modules") continue;
881
- const full = path.join(target, e.name);
882
- dirs.push({
883
- name: e.name,
884
- path: full,
885
- // The two facts that decide whether a folder is worth linking at all.
886
- // Both are a single existsSync — nothing inside either one is read.
887
- is_repo: fs.existsSync(path.join(full, ".git")),
888
- has_wiki: fs.existsSync(path.join(full, ".claude", "wiki")),
889
- });
890
- }
891
- dirs.sort((a, b) => a.name.localeCompare(b.name));
892
- const parent = path.dirname(target);
893
- return {
894
- path: target,
895
- parent: parent === target ? null : parent, // null AT a filesystem root
896
- sep: path.sep,
897
- home: os.homedir(),
898
- project_root: ctx.projectRoot || null,
899
- is_project_root: !!ctx.projectRoot && path.resolve(ctx.projectRoot) === target,
900
- // What the crosslink config actually stores. Computed here rather than in
901
- // the browser because only the server knows the real path separator, and a
902
- // Windows path assembled with "/" is the kind of thing that works until it
903
- // does not.
904
- relative: ctx.projectRoot ? path.relative(ctx.projectRoot, target).split(path.sep).join("/") || "." : null,
905
- truncated: dirs.length >= FS_LIST_MAX,
906
- dirs,
907
- };
908
- }
909
-
910
- // Open a terminal running `claude` in the project root. Best-effort and never
911
- // fatal — a failed launch is reported so the user can copy the command instead,
912
- // which is why the command is always on screen anyway.
913
- function launchClaude(ctx) {
914
- const cwd = ctx.projectRoot;
915
- let cmd, args;
916
- if (process.platform === "win32") {
917
- // `start` needs an empty title argument first, or a quoted path becomes it.
918
- cmd = "cmd";
919
- args = ["/c", "start", "", "cmd", "/k", "claude"];
920
- } else if (process.platform === "darwin") {
921
- cmd = "osascript";
922
- args = ["-e", `tell application "Terminal" to do script "cd ${JSON.stringify(cwd).slice(1, -1)} && claude"`, "-e", 'tell application "Terminal" to activate'];
923
- } else {
924
- cmd = "x-terminal-emulator";
925
- args = ["-e", "claude"];
926
- }
927
- try {
928
- const child = spawn(cmd, args, { cwd, detached: true, stdio: "ignore", windowsHide: false });
929
- child.unref();
930
- return { ok: true };
931
- } catch (e) {
932
- return { ok: false, error: String(e && e.message) };
933
- }
934
- }
935
-
936
- // ── request handling ────────────────────────────────────────────────────────
937
-
938
- // A RESPONSE OVER 64 KiB IS A HAZARD ON WINDOWS LOOPBACK (v1.5.0).
939
- //
940
- // Measured, and NOT an ORC bug: a twenty-line plain `http.createServer` on this
941
- // platform loses the tail of a response larger than the 64 KiB socket buffer
942
- // roughly one time in six, after a handful of short-lived connections. The
943
- // client receives ~65,077 of 69,177 bytes, the server's `finish` fires and
944
- // `writableFinished` is true, and nineteen seconds later the client gets
945
- // ECONNRESET with nothing in any log to say why. Under 60,000 bytes it never
946
- // happened in any run.
947
- //
948
- // `/api/config` was 64,934 bytes — six hundred short of that line — so the
949
- // Settings tab was one config key away from failing with no message, and it
950
- // crossed on the release that added five. Two things answer it, and neither is
951
- // a size limit on what this API may say (`--json is not a summary`):
952
- //
953
- // 1. DECLARE THE LENGTH. Without `content-length` Node falls back to chunked
954
- // encoding, which is a worse shape for a body of known size and gives the
955
- // client no way to tell a truncated read from a complete one.
956
- // 2. COMPRESS IT WHEN THE CLIENT ASKS. Every browser sends
957
- // `accept-encoding: gzip`, and the panel IS a browser — gzip takes that
958
- // 69 KB answer to about 8 KB, far below the cliff. It is skipped for a
959
- // small body, where the CPU is not worth it, and skipped entirely for a
960
- // client that did not ask.
961
- const GZIP_MIN_BYTES = 32 * 1024;
962
- // `gzip` as a WHOLE token in the header's comma list, so `x-gzip-not-really`
963
- // never matches. Written out rather than with a regex word boundary, which is
964
- // not one beside a hyphen.
965
- const GZIP_ACCEPTED = /(^|,)\s*gzip\s*(;|,|$)/;
966
-
967
- // ONE implementation, and serve.js uses it for STATIC files too (v1.5.0). The
968
- // 64 KiB cliff is a property of the SOCKET, not of the content type: it does not
969
- // care whether the bytes are an API answer or a panel script. Leaving the static
970
- // path uncompressed left the two largest files in the app — `extra.js` at 138 KB
971
- // and `hookui.js` at 76 KB — riding the exact path this was written to fix. It
972
- // showed up as an intermittent ECONNRESET in the asset-walk test, and it would
973
- // show up in a real browser as a panel script that stops mid-function.
974
- //
975
- // Returns the bytes to send plus the headers that describe them. A caller that
976
- // forgets to declare the length is the other half of the same bug, so the length
977
- // is set HERE rather than left to each call site.
978
- function encodeBody(raw, acceptEncoding) {
979
- const headers = {};
980
- let body = raw;
981
- if (raw.length >= GZIP_MIN_BYTES && GZIP_ACCEPTED.test(String(acceptEncoding || ""))) {
982
- try {
983
- body = zlib.gzipSync(raw);
984
- headers["content-encoding"] = "gzip";
985
- // A cache keyed on the URL alone would hand a gzip body to a client that
986
- // cannot read one. Nothing caches on loopback today; saying so costs one
987
- // header and removes the whole class.
988
- headers["vary"] = "accept-encoding";
989
- } catch (_) {
990
- body = raw;
991
- }
992
- }
993
- headers["content-length"] = String(body.length);
994
- return { body, headers };
995
- }
996
-
997
- function json(res, status, obj) {
998
- const raw = Buffer.from(JSON.stringify(obj), "utf8");
999
- const { body, headers } = encodeBody(raw, res.req && res.req.headers && res.req.headers["accept-encoding"]);
1000
- res.writeHead(
1001
- status,
1002
- Object.assign(
1003
- {
1004
- "content-type": "application/json; charset=utf-8",
1005
- "cache-control": "no-store",
1006
- // Belt and braces on top of the loopback + token checks in serve.js.
1007
- "x-content-type-options": "nosniff",
1008
- },
1009
- headers
1010
- )
1011
- );
1012
- res.end(body);
1013
- }
1014
-
1015
- function readBody(req) {
1016
- return new Promise((resolve, reject) => {
1017
- let raw = "";
1018
- req.on("data", (c) => {
1019
- raw += c;
1020
- if (raw.length > 64_000) reject(new Error("body too large"));
1021
- });
1022
- req.on("end", () => {
1023
- if (!raw) return resolve({});
1024
- try {
1025
- resolve(JSON.parse(raw));
1026
- } catch (_) {
1027
- reject(new Error("body is not JSON"));
1028
- }
1029
- });
1030
- req.on("error", reject);
1031
- });
1032
- }
1033
-
1034
- // The Overview panel needs four commands at once. Doing that in one request
1035
- // keeps the first paint to a single round trip.
1036
- function overview(ctx) {
1037
- const doctor = readCli(["doctor"], ctx);
1038
- const runs = readCli(["run", "list", "--limit", "200"], ctx);
1039
- const waiting = runs.data ? runs.data.runs.filter((r) => r.status === "waiting") : [];
1040
- return {
1041
- where: readCli(["where"], ctx).data,
1042
- doctor: doctor.data,
1043
- wiki: readCli(["wiki", "status"], ctx).data,
1044
- patterns: readCli(["pattern", "status"], ctx).data,
1045
- runs_total: runs.data ? runs.data.total : 0,
1046
- // Rows, not bare slugs (v0.49.2). The Overview card has an age column and
1047
- // rendered it empty because the payload never carried the number — and the
1048
- // "mark as done" button needs the slug beside a real timestamp to be worth
1049
- // showing at all.
1050
- waiting: waiting.map((r) => ({ slug: r.slug, updated_ms: r.updated_ms, lane: r.lane || null })),
1051
- diy: readCli(["diy", "show"], ctx).data,
1052
- // v0.46.0 chips. Each is the CLI's OWN answer — the panel repeats the state
1053
- // words and never derives them. A chip with nothing to say still renders its
1054
- // good state, so "healthy" and "not measured" never look the same.
1055
- pact: readCli(["pact", "status"], ctx).data,
1056
- boundary: readCli(["boundary", "status"], ctx).data,
1057
- wiki_debt: readCli(["wiki", "debt"], ctx).data,
1058
- // v0.54.0 — foreign dispatches that never reported back. Money spent and
1059
- // work half-done that nothing will look at again unless somebody is told.
1060
- // It is a FINDING, never a stop, and the Overview never resumes one.
1061
- extra_journal: readCli(["extra", "journal", "list"], ctx).data,
1062
- };
1063
- }
1064
-
1065
- async function handleApi(req, res, url, ctx) {
1066
- const route = url.pathname;
1067
- const q = Object.fromEntries(url.searchParams);
1068
-
1069
- // Liveness. The page pings this every 15s; no ping from any client for the
1070
- // grace window and the server exits, so a closed tab does not leave a write
1071
- // surface holding a valid token.
1072
- if (route === "/api/ping") {
1073
- ctx.onHeartbeat();
1074
- return json(res, 200, { ok: true, job: jobView() });
1075
- }
1076
-
1077
- // sendBeacon on beforeunload — a best-effort fast path to the same shutdown
1078
- // the heartbeat timeout would reach a minute later.
1079
- if (route === "/api/bye") {
1080
- ctx.onBye();
1081
- return json(res, 200, { ok: true });
1082
- }
1083
-
1084
- if (route === "/api/meta") {
1085
- return json(res, 200, {
1086
- project_root: ctx.projectRoot,
1087
- fixtures: ctx.fixtures,
1088
- version: ctx.version,
1089
- port: ctx.port,
1090
- idle_minutes: ctx.idleMinutes,
1091
- started_ms: ctx.startedMs,
1092
- });
1093
- }
1094
-
1095
- if (route === "/api/job") return json(res, 200, jobView());
1096
-
1097
- // Fixture mode short-circuits every data route: canned JSON, no project, no
1098
- // spawn. This is what makes the STALE chip and the unhealthy doctor panel
1099
- // designable on a machine where everything is green (see the plan, §9).
1100
- if (ctx.fixtures) {
1101
- if (req.method !== "GET") {
1102
- // Almost every mutation answers "nothing ran", which is the honest reply
1103
- // in a mode that runs nothing. The ONE exception is the connection test:
1104
- // its two outcomes are states the Extra panel is largely about, and a
1105
- // state with no fixture is a state nobody has ever looked at. A canned
1106
- // answer carries `data` and NOT the `fixture` flag, so the panel renders
1107
- // the real result shape — the command string is what says it was canned.
1108
- let body = {};
1109
- try {
1110
- body = await readBody(req);
1111
- } catch (_) {}
1112
- const canned = fixtures.post(route, body);
1113
- if (canned)
1114
- return json(res, 200, {
1115
- ok: true,
1116
- exit_code: canned.exit_code,
1117
- data: canned.data,
1118
- command: "(fixtures — nothing ran)",
1119
- });
1120
- return json(res, 200, { ok: true, fixture: true, command: "(fixtures — nothing ran)" });
1121
- }
1122
- const canned = fixtures.get(route, q);
1123
- if (canned === undefined) return json(res, 404, { error: "no fixture for " + route });
1124
- return json(res, 200, { ok: true, exit_code: 0, data: canned, fixture: true });
1125
- }
1126
-
1127
- if (req.method === "GET") {
1128
- if (route === "/api/overview") return json(res, 200, { ok: true, exit_code: 0, data: overview(ctx) });
1129
- if (route === "/api/learn") {
1130
- // The only endpoint with no CLI behind it: the onboarding topics are
1131
- // static content already shipped as a module, so spawning to read them
1132
- // would be ceremony with a cost.
1133
- const { SECTIONS } = require("../onboarding-content.js");
1134
- return json(res, 200, { ok: true, exit_code: 0, data: { sections: SECTIONS } });
1135
- }
1136
- // The mocked runs (v0.46.x). Same shape as /api/learn above and for the
1137
- // same reason: this is static content that ships inside this package, so
1138
- // spawning a subprocess to read files sitting next to this one would be
1139
- // ceremony with a cost. `orc mock-run` reads the identical module, so the
1140
- // terminal and the panel cannot disagree.
1141
- if (route === "/api/mockruns") {
1142
- return json(res, 200, { ok: true, exit_code: 0, data: require("../mockrun-catalog.js").catalogue() });
1143
- }
1144
- if (route === "/api/mockrun") {
1145
- const doc = require("../mockrun-catalog.js").get(String(q.slug || ""));
1146
- if (!doc) return json(res, 200, { ok: true, exit_code: 1, data: { slug: String(q.slug || ""), found: false } });
1147
- return json(res, 200, { ok: true, exit_code: 0, data: { ...doc, found: true } });
1148
- }
1149
- if (route === "/api/fs/list") {
1150
- return json(res, 200, { ok: true, exit_code: 0, data: fsList(q.path, ctx) });
1151
- }
1152
- if (route === "/api/experiment") {
1153
- return json(res, 200, {
1154
- ok: true,
1155
- exit_code: 0,
1156
- data: {
1157
- lanes: LANES,
1158
- project_root: ctx.projectRoot,
1159
- platform: process.platform,
1160
- // Fixture mode must never spawn a real terminal on a machine that has
1161
- // no project — the button says so instead of lying about it.
1162
- can_launch: !ctx.fixtures,
1163
- },
1164
- });
1165
- }
1166
- if (route === "/api/maintenance") {
1167
- const actions = Object.entries(MAINTENANCE).map(([id, m]) => ({
1168
- id,
1169
- label: m.label,
1170
- command: "orc " + m.apply.join(" "),
1171
- network: !!m.network,
1172
- names_files: !!m.names_files,
1173
- advanced: !!m.advanced,
1174
- restarts_ui: !!m.restarts_ui,
1175
- }));
1176
- return json(res, 200, { ok: true, exit_code: 0, data: { actions } });
1177
- }
1178
- if (route === "/api/maintenance/preview") {
1179
- const m = MAINTENANCE[String(q.action)];
1180
- if (!m) return json(res, 400, { error: "unknown action" });
1181
- const probe = readCli(m.preview, ctx);
1182
- return json(res, 200, {
1183
- ok: true,
1184
- exit_code: 0,
1185
- data: {
1186
- action: String(q.action),
1187
- label: m.label,
1188
- command: "orc " + m.apply.join(" "),
1189
- network: !!m.network,
1190
- names_files: !!m.names_files,
1191
- advanced: !!m.advanced,
1192
- // Said in the confirmation, not discovered afterwards. A panel that
1193
- // reloads itself without warning reads as a crash.
1194
- restarts_ui: !!m.restarts_ui,
1195
- preview_command: "orc " + m.preview.join(" "),
1196
- preview: probe.data,
1197
- // Only the UI can know a run is mid-flight; updating changes the
1198
- // skills that run would resume into.
1199
- waiting_runs: (readCli(["run", "list", "--limit", "200"], ctx).data || { runs: [] }).runs
1200
- .filter((r) => r.status === "waiting")
1201
- .map((r) => r.slug),
1202
- dirty_tree: m.network ? isDirtyTree(ctx) : false,
1203
- },
1204
- });
1205
- }
1206
- const build = READS[route];
1207
- if (!build) return json(res, 404, { error: "unknown endpoint " + route });
1208
- const out = readCli(build(q), ctx);
1209
- // v0.49.2 — a read that produced no parseable object still has to SAY why.
1210
- // The body already carried `stderr` and `stdout`; nothing named `error`, so
1211
- // the client fell through to "request failed (500)" and one corrupt ledger
1212
- // looked like a broken panel. The reason the CLI printed is what is shown.
1213
- return json(res, out.ok ? 200 : 500, out.ok ? out : { ...out, error: readFailReason(out) });
1214
- }
1215
-
1216
- if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
1217
-
1218
- let body;
1219
- try {
1220
- body = await readBody(req);
1221
- } catch (e) {
1222
- return json(res, 400, { error: e.message });
1223
- }
1224
-
1225
- // The handoff. It takes NO command from the browser: the lane id is looked up
1226
- // in the server's own catalog and is used only to echo back what to type. The
1227
- // process spawned is always a bare `claude` in the server's own projectRoot,
1228
- // so there is no path by which browser input reaches a shell.
1229
- if (route === "/api/experiment/launch") {
1230
- if (ctx.fixtures) return json(res, 400, { error: "fixture mode never launches anything real" });
1231
- const lane = body.lane ? LANES.find((l) => l.id === String(body.lane)) : null;
1232
- if (body.lane && !lane) return json(res, 400, { error: "unknown lane" });
1233
- const r = launchClaude(ctx);
1234
- if (!r.ok) return json(res, 500, { error: "could not open a terminal: " + r.error });
1235
- return json(res, 200, {
1236
- ok: true,
1237
- // What to type once it is open. The UI shows this; the server never runs it.
1238
- type_this: lane ? lane.cmd : null,
1239
- cwd: ctx.projectRoot,
1240
- });
1241
- }
1242
-
1243
- // v0.50.0 — THE CONNECTION TEST, and the one place this panel does something
1244
- // model-shaped. It is a DIAGNOSTIC in the same family as `orc doctor`: rung 1
1245
- // lists models and costs nothing, rung 2 sends a one-token completion and
1246
- // costs a fraction of a cent, and the CLI decides which — never this file.
1247
- //
1248
- // It is POST because it MUTATES: a green test writes `verified_at` onto the
1249
- // profile, and a red one on a never-verified profile REMOVES that profile
1250
- // (the CLI's own test-first-then-store lifecycle). A GET that did that would
1251
- // be reachable by a prefetch.
1252
- //
1253
- // A pasted key arrives in the BODY and leaves on the child's STDIN — line 1
1254
- // the key, an optional line 2 the passphrase that encrypts it. It is never in
1255
- // argv, never written here, and never echoed back: the response is the CLI's
1256
- // own `--json` object, which carries no credential by construction.
1257
- if (route === "/api/extra/ping") {
1258
- if (job && job.running) return json(res, 409, { error: "busy", job: jobView() });
1259
- const profile = String(body.profile || "");
1260
- if (!profile) return json(res, 400, { error: "missing argument" });
1261
- const argv = ["extra", "ping", profile];
1262
- // v0.51.0 — the PAID rung, opt-in and never a default. The panel quotes what
1263
- // it costs before the button; the CLI is what decides the rung and what it
1264
- // reports back.
1265
- if (body.live) argv.push("--live");
1266
- if (body.model) argv.push("--model", String(body.model));
1267
- let input;
1268
- if (body.key) {
1269
- // A NEW key: line 1 the key, an optional line 2 the passphrase that stores
1270
- // it after a green test.
1271
- argv.push("--key-stdin");
1272
- input = String(body.key) + chr10 + String(body.passphrase || "") + chr10;
1273
- } else if (body.passphrase) {
1274
- // A STORED key: the passphrase decrypts it into the CLI's memory for the
1275
- // probe. The two flags are mutually exclusive and the CLI refuses them
1276
- // together BY NAME, so this branch is an `else if` rather than a guess.
1277
- argv.push("--passphrase-stdin");
1278
- input = String(body.passphrase) + chr10;
1279
- }
1280
- const out = runCli(argv, ctx, { json: true, input });
1281
- clearCache();
1282
- // `ok` here is "did the CLI answer at all". Whether the CONNECTION worked is
1283
- // `data.ok` and the exit code, which are the CLI's answer and are passed
1284
- // through untouched — a failed probe is DATA, not a server error.
1285
- return json(res, out.ok ? 200 : 500, out.ok
1286
- ? { ok: true, exit_code: out.exit_code, data: out.data, command: out.command }
1287
- : { ...out, error: readFailReason(out) });
1288
- }
1289
-
1290
- // v0.51.0 — F5's answer, scoped to ONE model id. A model that is LISTED can
1291
- // still be DEAD upstream, so a dropdown is a list of what is OFFERED and never
1292
- // a list of what WORKS. This is POST because it spends money.
1293
- if (route === "/api/extra/models/test") {
1294
- if (job && job.running) return json(res, 409, { error: "busy", job: jobView() });
1295
- const profile = String(body.profile || "");
1296
- const model = String(body.model || "");
1297
- if (!profile || !model) return json(res, 400, { error: "missing argument" });
1298
- const argv = ["extra", "models", profile, "--test", model];
1299
- let input;
1300
- if (body.passphrase) {
1301
- argv.push("--passphrase-stdin");
1302
- input = String(body.passphrase) + chr10;
1303
- }
1304
- const out = runCli(argv, ctx, { json: true, input });
1305
- clearCache();
1306
- return json(res, out.ok ? 200 : 500, out.ok
1307
- ? { ok: true, exit_code: out.exit_code, data: out.data, command: out.command }
1308
- : { ...out, error: readFailReason(out) });
1309
- }
1310
-
1311
- // v0.52.0 — SAVING THE PASSPHRASE WITH A DEADLINE. The CLI tests it against
1312
- // the vault before it stores anything (test first, then store), validates the
1313
- // TTL against the same closed set the config key publishes, and answers with
1314
- // the DATE. This route composes nothing: it hands over a profile, a number of
1315
- // days, and a passphrase on stdin.
1316
- if (route === "/api/extra/session/save") {
1317
- if (job && job.running) return json(res, 409, { error: "busy", job: jobView() });
1318
- const profile = String(body.profile || "");
1319
- const ttl = String(body.ttl_days || "");
1320
- if (!profile || !ttl) return json(res, 400, { error: "missing argument" });
1321
- const out = runCli(["extra", "session", profile, "--save", "--ttl", ttl], ctx, {
1322
- json: true,
1323
- input: String(body.passphrase || "") + chr10,
1324
- });
1325
- clearCache();
1326
- return json(res, out.ok ? 200 : 500, out.ok
1327
- ? { ok: true, exit_code: out.exit_code, data: out.data, command: out.command }
1328
- : { ...out, error: readFailReason(out) });
1329
- }
1330
-
1331
- // v0.50.0 — proving a passphrase, which is the ONE action that clears the
1332
- // vault's countdown. It NEVER yields the key: `orc extra unlock` answers one
1333
- // question with a yes or a no, and its `attempt N of 10` message is the whole
1334
- // point of the feature, so it is passed back verbatim.
1335
- if (route === "/api/extra/unlock") {
1336
- if (job && job.running) return json(res, 409, { error: "busy", job: jobView() });
1337
- const profile = String(body.profile || "");
1338
- if (!profile) return json(res, 400, { error: "missing argument" });
1339
- const out = runCli(["extra", "unlock", profile], ctx, {
1340
- json: true,
1341
- input: String(body.passphrase || "") + chr10,
1342
- });
1343
- clearCache();
1344
- return json(res, out.ok ? 200 : 500, out.ok
1345
- ? { ok: true, exit_code: out.exit_code, data: out.data, command: out.command }
1346
- : { ...out, error: readFailReason(out) });
1347
- }
1348
-
1349
- // Hand the panel over to a fresh process on the SAME port and token, so the
1350
- // open tab only has to reload. POST-only like every other mutation, and it is
1351
- // a mutation: the process answering the next request is not this one.
1352
- //
1353
- // The CLIENT asks for this — never the job's own close handler. The job's
1354
- // output lives in this process's memory, so restarting the instant a command
1355
- // finished would destroy the record of what it did before anyone read it.
1356
- if (route === "/api/ui/restart") {
1357
- if (ctx.fixtures)
1358
- return json(res, 400, { ok: false, reason: "fixtures", error: "fixture mode serves canned data; there is nothing to restart into." });
1359
- if (job && job.running) return json(res, 409, { ok: false, reason: "busy", error: "a command is still running.", job: jobView() });
1360
- const out = typeof ctx.restart === "function" ? ctx.restart() : { ok: false, reason: "unsupported" };
1361
- // A failed handover is NOT fatal and never takes the running panel down:
1362
- // the old server keeps serving, and the client is told to do it by hand.
1363
- return json(res, out.ok ? 200 : 500, out);
1364
- }
1365
-
1366
- if (route === "/api/maintenance/apply") {
1367
- const m = MAINTENANCE[String(body.action)];
1368
- if (!m) return json(res, 400, { error: "unknown action" });
1369
- const started = startJob(m.apply, ctx, { restartUi: !!m.restarts_ui });
1370
- if (started.error) return json(res, 409, started);
1371
- return json(res, 200, { ok: true, ...started });
1372
- }
1373
-
1374
- const build = WRITES[route];
1375
- if (!build) return json(res, 404, { error: "unknown endpoint " + route });
1376
- if (job && job.running) return json(res, 409, { error: "busy", job: jobView() });
1377
- let argv;
1378
- try {
1379
- argv = build(body);
1380
- } catch (_) {
1381
- return json(res, 400, { error: "bad request body" });
1382
- }
1383
- if (argv.some((a) => a === "undefined" || a === "null" || a === ""))
1384
- return json(res, 400, { error: "missing argument" });
1385
- const out = runCli(argv, ctx);
1386
- clearCache();
1387
- // A write's exit code is a REAL failure signal (validators exit 1), unlike a
1388
- // read's — so it is reported as such, with the CLI's own message.
1389
- return json(res, 200, {
1390
- ok: out.exit_code === 0,
1391
- exit_code: out.exit_code,
1392
- command: out.command,
1393
- // Writes print human text, not JSON — that IS the confirmation to show.
1394
- output: (out.stdout + (out.stderr ? "\n" + out.stderr : "")).trim(),
1395
- });
1396
- }
1397
-
1398
- // `orc upgrade` replaces the package while your working tree may hold changes.
1399
- // Worth a warning before, not a surprise after.
1400
- function isDirtyTree(ctx) {
1401
- try {
1402
- const r = spawnSync("git", ["status", "--porcelain"], {
1403
- cwd: ctx.projectRoot,
1404
- encoding: "utf8",
1405
- windowsHide: true,
1406
- timeout: 5000,
1407
- });
1408
- return r.status === 0 && !!(r.stdout || "").trim();
1409
- } catch (_) {
1410
- return false;
1411
- }
1412
- }
1413
-
1414
- module.exports = { handleApi, clearCache, encodeBody, READS, WRITES, MAINTENANCE };
1
+ "use strict";
2
+ /**
3
+ * api.js — the /api router for `orc ui`.
4
+ *
5
+ * THE ONE ARCHITECTURAL RULE: this file never re-implements CLI logic. Every
6
+ * endpoint spawns `node bin/cli.js <cmd> --json` and forwards the parsed
7
+ * object. That makes UI/CLI drift structurally impossible — the UI *is* the
8
+ * CLI — and it means every write inherits the CLI's validators, the LEGACY_KEYS
9
+ * aliasing and the shadowing announcements for free, with zero duplicated
10
+ * logic.
11
+ *
12
+ * The alternative (requiring cli.js as a library) is off the table: cli.js ends
13
+ * with a bare IIFE, has no require.main guard, prints and process.exit()s
14
+ * directly, and is pinned by contract tokens with binFiles: ["bin/cli.js"].
15
+ *
16
+ * It also never RUNS a lane and never calls a model API. Since v0.43.4 there is
17
+ * exactly one deliberate exception to "never spawns claude": the Experiment
18
+ * panel can open a TERMINAL with `claude` in it and then forget about it (see
19
+ * `launchClaude`). No model output ever flows back through this server, no
20
+ * session is proxied, no API key is held — the panel is still not an AI client.
21
+ */
22
+
23
+ const { spawn, spawnSync } = require("child_process");
24
+ const fs = require("fs");
25
+ const os = require("os");
26
+ const path = require("path");
27
+ const zlib = require("zlib");
28
+ const fixtures = require("./fixtures/index.js");
29
+
30
+ const CLI = path.join(__dirname, "..", "cli.js");
31
+
32
+ // A single-user localhost panel re-reads the same command several times while
33
+ // one page renders. A short TTL collapses that without ever showing stale data
34
+ // across a user action: every mutation clears the cache outright.
35
+ const READ_TTL_MS = 2500;
36
+ const chr10 = String.fromCharCode(10);
37
+ const CLI_TIMEOUT_MS = 30_000;
38
+
39
+ const cache = new Map();
40
+
41
+ function cacheKey(argv) {
42
+ return argv.join("\u0000");
43
+ }
44
+
45
+ function clearCache() {
46
+ cache.clear();
47
+ }
48
+
49
+ // v1.4.0 — the board a statusline request is about. A value this server does
50
+ // not recognise is DROPPED rather than forwarded: the CLI would refuse it, and
51
+ // a query string is user input like any other.
52
+ function slBoard(q) {
53
+ const b = q && q.board ? String(q.board) : "";
54
+ return b === "subagent" ? ["--board", "subagent"] : [];
55
+ }
56
+
57
+ // ── running the CLI ─────────────────────────────────────────────────────────
58
+
59
+ // Several commands use a NON-ZERO exit as a normal answer, not a failure:
60
+ // `pattern status` (1 = absent, 2 = unknown key), `gotcha list` (1 = none),
61
+ // `wiki impact` (2 = delta, 3 = full), `pr stack status` (1 = not ready),
62
+ // `doctor` (1 = issues found), `resume` (1 = nothing waiting). So the exit code
63
+ // is DATA here, never an error condition — a run counts as failed only when it
64
+ // produced no parseable object.
65
+ // `input` (v0.50.0) is stdin for the child, and it exists for exactly one
66
+ // caller: the connection test, which takes a pasted API key on line 1 and an
67
+ // optional passphrase on line 2. It is a parameter rather than a second spawn
68
+ // helper because a secret must travel the SAME path everything else does —
69
+ // never argv (world-readable in a process list), never a temp file, never a log
70
+ // line. Nothing here ever echoes it back, and `command` below is built from
71
+ // argv alone.
72
+ function runCli(argv, ctx, { json = false, input = undefined } = {}) {
73
+ const args = [...argv];
74
+ if (json) args.push("--json");
75
+ // Always target the project explicitly: the server's cwd is not a reliable
76
+ // way to reach the same .claude the launching command resolved.
77
+ if (ctx.projectRoot) args.push("--dir", ctx.projectRoot);
78
+ // ORC_NO_UPDATE_CHECK exists here to protect the --json contract: most
79
+ // commands end with `maybeNudge()`, which prints an "update available" line to
80
+ // STDOUT and would sit beside the object this parses.
81
+ //
82
+ // `version` and `changelog` are the exceptions, and forcing the flag on them
83
+ // was a real bug: neither nudges, and for both the check IS the payload — so
84
+ // the panel asked whether an update existed with the check switched off and
85
+ // was told `check_disabled: true` forever. A blanket env var silenced the one
86
+ // command whose entire job is to answer that question.
87
+ const CHECKS_UPDATES = argv[0] === "version" || argv[0] === "changelog";
88
+ const env = { ...process.env, NO_COLOR: "1" };
89
+ if (!CHECKS_UPDATES) env.ORC_NO_UPDATE_CHECK = "1";
90
+ const r = spawnSync(process.execPath, [CLI, ...args], {
91
+ encoding: "utf8",
92
+ timeout: CLI_TIMEOUT_MS,
93
+ windowsHide: true,
94
+ env,
95
+ input,
96
+ });
97
+ const stdout = r.stdout || "";
98
+ let data = null;
99
+ try {
100
+ data = JSON.parse(stdout);
101
+ } catch (_) {}
102
+ return {
103
+ ok: data !== null,
104
+ exit_code: r.status === null ? -1 : r.status,
105
+ data,
106
+ stderr: (r.stderr || "").trim(),
107
+ stdout: data === null ? stdout.trim() : "",
108
+ command: "orc " + argv.join(" "),
109
+ };
110
+ }
111
+
112
+ // The one-line reason a read failed, in the CLI's own words. `crashed` is the
113
+ // envelope the CLI itself emits when a `--json` route throws (v0.49.2); anything
114
+ // else is a command that wrote nothing parseable at all.
115
+ function readFailReason(out) {
116
+ const first = (out.stderr || out.stdout || "")
117
+ .split(chr10)
118
+ .map((l) => l.trim())
119
+ .filter(Boolean)[0];
120
+ const tail = first ? " \u2014 " + first : "";
121
+ return `${out.command} produced no JSON (exit ${out.exit_code})${tail}`;
122
+ }
123
+
124
+ function readCli(argv, ctx) {
125
+ const key = cacheKey([ctx.projectRoot || "", ...argv]);
126
+ const hit = cache.get(key);
127
+ if (hit && Date.now() - hit.at < READ_TTL_MS) return hit.value;
128
+ const value = runCli(argv, ctx, { json: true });
129
+ cache.set(key, { at: Date.now(), value });
130
+ return value;
131
+ }
132
+
133
+ // ── jobs (the mutation half) ────────────────────────────────────────────────
134
+ // SINGLE-FLIGHT: one mutation at a time, process-wide. The client goes
135
+ // read-only while a job runs, so a second one is a bug, not a race to win.
136
+ // Output accumulates in memory and the client polls /api/job — which is what
137
+ // makes `orc update` and `orc upgrade` show their real output as it happens
138
+ // instead of a spinner and a verdict.
139
+
140
+ let job = null;
141
+ let jobSeq = 0;
142
+
143
+ function jobView() {
144
+ if (!job) return { id: null, running: false };
145
+ return {
146
+ id: job.id,
147
+ command: job.command,
148
+ running: job.running,
149
+ exit_code: job.exit_code,
150
+ output: job.output,
151
+ started_ms: job.started_ms,
152
+ // THE PANEL IS SERVING CODE THAT THIS JOB MAY HAVE JUST REPLACED. Reported
153
+ // only on a job that SUCCEEDED and whose action is declared as touching the
154
+ // install — a failed upgrade changed nothing, so restarting after one would
155
+ // be motion with no reason. The client acts on it; the server does not
156
+ // restart itself, so the job's output survives long enough to be read.
157
+ restart_pending: !!(job.restart_ui && !job.running && job.exit_code === 0),
158
+ };
159
+ }
160
+
161
+ function startJob(argv, ctx, opts) {
162
+ if (job && job.running) return { error: "busy", job: jobView() };
163
+ const args = [...argv];
164
+ if (ctx.projectRoot) args.push("--dir", ctx.projectRoot);
165
+ const id = ++jobSeq;
166
+ job = {
167
+ id,
168
+ command: "orc " + argv.join(" "),
169
+ running: true,
170
+ exit_code: null,
171
+ output: "",
172
+ started_ms: Date.now(),
173
+ restart_ui: !!(opts && opts.restartUi),
174
+ };
175
+ const child = spawn(process.execPath, [CLI, ...args], {
176
+ windowsHide: true,
177
+ env: { ...process.env, NO_COLOR: "1" },
178
+ });
179
+ const append = (buf) => {
180
+ job.output += buf.toString("utf8");
181
+ // A runaway job must not grow the server's heap without bound.
182
+ if (job.output.length > 400_000) job.output = job.output.slice(-400_000);
183
+ };
184
+ child.stdout.on("data", append);
185
+ child.stderr.on("data", append);
186
+ child.on("error", (e) => {
187
+ job.output += "\n" + String(e.message);
188
+ job.exit_code = -1;
189
+ job.running = false;
190
+ });
191
+ child.on("close", (code) => {
192
+ job.exit_code = code === null ? -1 : code;
193
+ job.running = false;
194
+ clearCache(); // every panel refetches against the new truth
195
+ });
196
+ return { job: jobView() };
197
+ }
198
+
199
+ // ── the endpoint table ──────────────────────────────────────────────────────
200
+ // READS map a route to CLI argv. WRITES are separate and POST-only — a GET can
201
+ // never mutate, so a prefetch, a bookmark or a browser retry is always safe.
202
+
203
+ const READS = {
204
+ "/api/version": () => ["version"],
205
+ "/api/changelog": () => ["changelog"],
206
+ "/api/where": () => ["where"],
207
+ "/api/doctor": () => ["doctor"],
208
+ "/api/config": () => ["config", "list"],
209
+ "/api/config/profiles": () => ["config", "profile"],
210
+ "/api/config/recommend": () => ["config", "recommend"],
211
+ // v1.0.0 W16 — the `orc lane` noun, rendered. All three are READS and all
212
+ // three are answers the CLI already computes in full: which lanes exist and
213
+ // how many keys each reads, which SHARED phases a lane runs and in what
214
+ // order, and the whole call catalogue. The panel draws them and decides
215
+ // nothing about them — the Flow-stepper rule, applied to the lane model.
216
+ // `lane phases` and `lane config` both exit 2 on an unknown lane, which is
217
+ // DATA here exactly like `pattern status` and `wiki impact` above.
218
+ "/api/lanes": () => ["lane", "list"],
219
+ "/api/lane/phases": (q) => ["lane", "phases", String(q.lane || "")],
220
+ "/api/lane/calls": (q) => (q.lane ? ["lane", "calls", String(q.lane)] : ["lane", "calls", "--all"]),
221
+ "/api/runs": (q) => ["run", "list", "--limit", String(Math.min(200, Number(q.limit) || 40))],
222
+ "/api/run": (q) => ["run", "show", String(q.slug || "")],
223
+ "/api/wiki": () => ["wiki", "status"],
224
+ // v1.8.0 — the code graph. A READ whose exit code is DATA (0 fresh · 1 none ·
225
+ // 2 drifted · 3 off), exactly like `wiki status` above.
226
+ "/api/graph": () => ["graph", "status"],
227
+ // v1.8.2 W4b — the gain meter. Two READS, both with exit codes that are DATA
228
+ // (0 rows · 1 no ledger or no rows · 3 off). `--measured` is its OWN route
229
+ // because it reads Claude Code's transcripts and takes longer; the panel
230
+ // asks for it on a button, never on load.
231
+ "/api/graph/gain": () => ["graph", "gain"],
232
+ "/api/graph/gain/measured": () => ["graph", "gain", "--measured"],
233
+ "/api/wiki/impact": () => ["wiki", "impact"],
234
+ // v0.46.0. Every one is a READ with an exit-code contract, so the exit code is
235
+ // DATA here exactly like `pattern status` and `wiki impact` above: pact 0/1/2/3,
236
+ // boundary 0/1/2/3, handoff 0/1, budget 0/1/2/3, aftermath 0/1/2/3,
237
+ // wiki plan 0/1/2/3, wiki debt 0/1/3, export --check 0/1.
238
+ "/api/wiki/plan": () => ["wiki", "plan"],
239
+ "/api/wiki/debt": () => ["wiki", "debt"],
240
+ "/api/wiki/usage": () => ["wiki", "usage"],
241
+ // v0.49.1 — the wiki's CONTENTS, not only its temperature. All three are
242
+ // READS whose exit code is DATA (docs 0/1/3, show 0/2/3, coverage 0/1), and
243
+ // `coverage` deliberately has no threshold: nothing branches on it, here or
244
+ // anywhere else. `--body` is opt-in and one artifact at a time — the
245
+ // /api/doc/section precedent.
246
+ "/api/wiki/docs": () => ["wiki", "docs"],
247
+ "/api/wiki/show": (q) => ["wiki", "show", String(q.doc || ""), ...(q.body ? ["--body"] : [])],
248
+ "/api/wiki/coverage": () => ["wiki", "coverage"],
249
+ // The pattern file is injected LITERALLY into every executor slice, and until
250
+ // now nothing would show you a line of it. Same 0/1/2 contract `pattern
251
+ // status` has had since v0.34.8.
252
+ "/api/pattern/show": (q) => ["pattern", "show", String(q.lang || ""), ...(q.body ? ["--body"] : [])],
253
+ "/api/gotcha/show": (q) => ["gotcha", "show", String(q.id || "")],
254
+ "/api/gotchas/archived": () => ["gotcha", "list", "--archived"],
255
+ // Preview-then-apply: A COUNT IS NOT CONSENT. The Apply button stays disabled
256
+ // until this has been fetched, and it names every entry eviction would touch.
257
+ "/api/gotcha/prune/preview": () => ["gotcha", "prune", "--dry-run"],
258
+ // v1.1.0 — the wait. Three READS, and every one of them is a state the panel
259
+ // renders and never derives: `usage check` 0/1/2 (and `unknown` is a state,
260
+ // not a failure), the lane table, and the run's block. The panel CANNOT start
261
+ // a wait — a wait lives in a Claude Code session, and `orc ui` never runs a
262
+ // lane. It configures the defaults, shows a wait, and cancels one.
263
+ "/api/usage": () => ["usage", "check"],
264
+ // v1.3.0 — the CLI Hook Interface. THE PANEL DRAWS THE CATALOGUE AND DERIVES
265
+ // NONE OF IT: the groups, the renderers, their option sets and the rendered
266
+ // sample per renderer all come from `statusline components --json`, and a
267
+ // test greps the panel for component ids, renderer names, glyph-set names,
268
+ // ramp names, colour tokens and state words. It must name none of them.
269
+ //
270
+ // `preview` is the one that earns the panel its place: it renders through the
271
+ // SAME engine the hook does, so what you see is what the bar will print.
272
+ // v1.4.0 — TWO BOARDS through one set of routes. `--board` is passed
273
+ // through verbatim; the CLI owns which boards exist and which components
274
+ // each may hold, and the panel — as everywhere else — decides none of it.
275
+ "/api/statusline/components": (q) => ["statusline", "components", ...slBoard(q)],
276
+ "/api/statusline/show": (q) => ["statusline", "show", ...slBoard(q)],
277
+ "/api/statusline/presets": (q) => ["statusline", "presets", ...slBoard(q)],
278
+ "/api/statusline/preview": (q) => {
279
+ const argv = ["statusline", "preview", ...slBoard(q)];
280
+ if (q.width) argv.push("--width", String(q.width));
281
+ if (q.state) argv.push("--state", String(q.state));
282
+ // v1.4.2 — a RENDER-ONLY override, so the panel can draw the same layout
283
+ // under each colour set and each symbol set. It writes nothing: the CLI
284
+ // applies these to a copy of the saved layout before it compiles.
285
+ if (q.theme) argv.push("--theme", String(q.theme));
286
+ if (q.glyphs) argv.push("--glyphs", String(q.glyphs));
287
+ return argv;
288
+ },
289
+ "/api/statusline/explain": (q) => ["statusline", "explain", String(q.at || "1:1"), ...slBoard(q)],
290
+ "/api/wait/lanes": () => ["wait", "lanes"],
291
+ "/api/wait/status": (q) => (q.slug ? ["wait", "status", String(q.slug)] : ["wait", "status"]),
292
+ "/api/pact": () => ["pact", "status"],
293
+ "/api/boundary": (q) => (q.path ? ["boundary", "status", String(q.path)] : ["boundary", "status"]),
294
+ "/api/handoff": () => ["handoff", "surfaces"],
295
+ "/api/budget/rates": () => ["budget", "rates"],
296
+ // A forecast takes a PLAN PATH. The browser sends a path the folder picker
297
+ // produced; the server passes it straight to the CLI and never opens the file
298
+ // itself — /api/fs/list stays a directory LISTER, and widening it to read a
299
+ // plan would be the one change that turns this panel into a file reader.
300
+ "/api/budget/forecast": (q) => ["budget", "forecast", String(q.plan || "")],
301
+ "/api/budget/actual": (q) => ["budget", "actual", String(q.slug || "")],
302
+ "/api/aftermath": (q) => (q.since ? ["aftermath", "status", "--since", String(q.since)] : ["aftermath", "status"]),
303
+ "/api/export": () => ["export", "--check"],
304
+ // v0.47.0 — /orc-challenge. Same shape: every one is a READ whose exit code is
305
+ // DATA (list 0/1/3, status 0/1/2/3, diff 0/1/2/3, lint 0/1/2), and the panel
306
+ // derives nothing from them — not the state word, not the iteration order, not
307
+ // the pass decision, not the expected revision path.
308
+ "/api/challenge": () => ["challenge", "list"],
309
+ "/api/challenge/one": (q) => ["challenge", "status", String(q.slug || "")],
310
+ "/api/challenge/show": (q) => [
311
+ "challenge",
312
+ "show",
313
+ String(q.slug || ""),
314
+ ...(q.iteration ? ["--iteration", String(q.iteration)] : []),
315
+ ],
316
+ "/api/challenge/diff": (q) => ["challenge", "diff", String(q.slug || "")],
317
+ // v0.49.1 — the council. `roles` is STATIC (it works with no cycle at all),
318
+ // and it is the ONE catalogue: the panel names no lens, no class and no
319
+ // disposition itself, exactly as it names no flow step. `council` is 0 set /
320
+ // 1 unset / 3 unknown, and UNSET is an ANSWER, not an error.
321
+ "/api/challenge/roles": (q) => ["challenge", "roles", ...(q.kind ? ["--kind", String(q.kind)] : [])],
322
+ "/api/challenge/council": (q) => ["challenge", "council", String(q.slug || "")],
323
+ "/api/challenge/lint": (q) => [
324
+ "challenge",
325
+ "lint",
326
+ String(q.path || ""),
327
+ ...(q.template ? ["--template", String(q.template)] : []),
328
+ ],
329
+ // v0.48.0 — /orc-doc. Same shape again: every one is a READ whose exit code is
330
+ // DATA (list 0, status 0/1/2, map 0/2, plan 0/1, lint 0/1/2), and the panel
331
+ // derives nothing from them — not the section order, not a line range, not a
332
+ // state word, not the batching, not a lint rule name. It draws what the CLI
333
+ // computed.
334
+ //
335
+ // `/api/doc/section` is the ONE route that returns any of the document's
336
+ // prose, it returns exactly ONE section, and only on an explicit Reveal click.
337
+ // The rule this lane lives by is that nothing HOLDS the document — not that
338
+ // the text is secret — and the panel renders it as DOM through `renderMd`,
339
+ // never as HTML.
340
+ "/api/doc": () => ["doc", "list"],
341
+ "/api/doc/one": (q) => ["doc", "status", String(q.slug || "")],
342
+ "/api/doc/show": (q) => ["doc", "show", String(q.slug || "")],
343
+ "/api/doc/section": (q) => ["doc", "show", String(q.slug || ""), "--section", String(q.section || "")],
344
+ "/api/doc/map": (q) => ["doc", "map", String(q.slug || "")],
345
+ "/api/doc/lint": (q) => [
346
+ "doc",
347
+ "lint",
348
+ String(q.slug || ""),
349
+ ...(q.target ? ["--target", String(q.target)] : []),
350
+ ],
351
+ "/api/doc/plan": (q) => ["doc", "plan", String(q.slug || ""), "--role", String(q.role || "write")],
352
+ "/api/doc/templates": () => ["doc", "templates"],
353
+ "/api/doc/targets": () => ["doc", "targets"],
354
+ // v0.48.1 — the score, the drift report and the memory surface. Every one is
355
+ // a subprocess of the real command: the panel decides nothing about the
356
+ // pipeline order, nothing about which drift classes exist, and nothing about
357
+ // which journal rows are the user's own words.
358
+ //
359
+ // There is deliberately NO route for `orc doc log`: the SKILL records a
360
+ // request, because the skill is what took one. A panel that could write a
361
+ // journal entry could write one nobody said.
362
+ // v0.49.0 — the SECTION FILES. This is the one read that works before a
363
+ // single compile has ever run, because the files ARE the progress.
364
+ "/api/doc/parts": (q) => ["doc", "parts", String(q.slug || "")],
365
+ "/api/doc/next": (q) => ["doc", "next", String(q.slug || "")],
366
+ "/api/doc/audit": (q) => ["doc", "audit", String(q.slug || "")],
367
+ "/api/doc/journal": (q) => ["doc", "journal", String(q.slug || "")],
368
+ "/api/doc/context": (q) => ["doc", "context", String(q.slug || "")],
369
+ "/api/doc/extra": (q) => ["doc", "extra", String(q.slug || "")],
370
+ // v0.49.2 — the project's own house rules, the frozen set of one document,
371
+ // the run map and the cost report. All four are READS; the panel decides
372
+ // nothing about a priority, an order, a wave shape or a number.
373
+ "/api/doc/rules": () => ["doc", "rules"],
374
+ "/api/doc/rules/one": (q) => ["doc", "rules", String(q.slug || "")],
375
+ // v1.7.0 — the anti-slop rule surface. Five READS. The panel decides nothing:
376
+ // not the precedence order, not a rule's tier, not the override count, and
377
+ // not which of the 65 rules the lint can actually prove.
378
+ //
379
+ // `lint` takes a path from the query string. It is user input like any other,
380
+ // so it is forwarded as ONE argv element and never through a shell — the same
381
+ // reason every other route here spawns an array rather than a string.
382
+ "/api/rules": () => ["rules"],
383
+ "/api/rules/user": () => ["rules", "user"],
384
+ "/api/rules/packs": () => ["rules", "packs"],
385
+ "/api/rules/credits": () => ["rules", "credits"],
386
+ "/api/rules/lint": (q) => ["rules", "lint", String(q.path || ".")],
387
+ // v1.7.0 — the wiki's one-doc-at-a-time probes. Both free, neither scans.
388
+ "/api/wiki/resolve": (q) => ["wiki", "resolve", String(q.topic || "")],
389
+ "/api/wiki/refs": () => ["wiki", "refs", "--check"],
390
+ "/api/doc/forecast": (q) => ["doc", "forecast", String(q.slug || "")],
391
+ "/api/doc/cost": (q) => ["doc", "cost", String(q.slug || "")],
392
+ // v0.50.0 — `orc extra`. Every one is a READ, and every one is a subprocess of
393
+ // the real command: the panel decides nothing about a provider, a model id, a
394
+ // verification state, a band or a price. It renders what the CLI computed.
395
+ //
396
+ // There is deliberately NO read that returns a credential. `orc extra list`
397
+ // and `orc extra show` go through `redactProfile`, which is an ALLOW-LIST, so
398
+ // a field added later that carries a secret has to be let through on purpose.
399
+ "/api/extra": () => ["extra", "list"],
400
+ "/api/extra/providers": () => ["extra", "providers"],
401
+ "/api/extra/show": (q) => ["extra", "show", String(q.profile || "")],
402
+ "/api/extra/models": (q) => ["extra", "models", String(q.profile || "")],
403
+ // v0.51.0 — the LOCAL TOOLS read, and the credential-route read. Both are the
404
+ // real commands, so the panel decides nothing about an install command, a
405
+ // platform, a version floor or which of three credential routes applies.
406
+ // `tools` exits 1 when no tool is ready, which is exit-code-as-DATA like
407
+ // `pattern status` — never an error.
408
+ "/api/extra/tools": () => ["extra", "tools"],
409
+ "/api/extra/keyhelp": (q) => ["extra", "keyhelp", String(q.profile || "")],
410
+ "/api/extra/route": () => ["extra", "route"],
411
+ // v0.55.0 — THE POSITIONS, the non-scored half of routing. It exits 1 when
412
+ // nothing routes, which is exit-code-as-DATA like `pattern status` — an empty
413
+ // result is an ANSWER and it still returns its whole object.
414
+ "/api/extra/role": () => ["extra", "role", "list"],
415
+ // v0.52.0 (D6) — WHICH LANE a band governs, computed through the same
416
+ // resolver every dispatch uses. The panel renders it and derives nothing:
417
+ // a band with no lane attached is not a routing decision.
418
+ "/api/extra/lanes": () => ["extra", "lanes"],
419
+ // `stats` exits 1 with a real object when no foreign dispatch has been traced
420
+ // yet, and `rates` exits 1 when a pair has no price — both are exit-code-as-
421
+ // DATA, like `pattern status` and `wiki impact`, never an error.
422
+ "/api/extra/stats": (q) => (q.since ? ["extra", "stats", "--since", String(q.since)] : ["extra", "stats"]),
423
+ "/api/extra/rates": () => ["extra", "rates"],
424
+ "/api/extra/doctor": () => ["extra", "doctor"],
425
+ // v0.54.0 — RECOVERY. Both are FREE reads (zero model tokens), which is why
426
+ // both are real buttons; `resume-slice` and the dispatch that follows it cost
427
+ // money and are copy-able commands instead. `reconcile` exits 0-4 as an
428
+ // exit-code-as-DATA contract like `pattern status`, so no state here is an
429
+ // error — including `in-flight`, which is a REFUSAL the panel must render as
430
+ // one rather than as a dead control.
431
+ "/api/extra/journal": () => ["extra", "journal", "list"],
432
+ // v1.0.0 W16 — the RUN DEMOTION, read. Exit 0 armed · 1 demoted · 2 unknown
433
+ // run, which is exit-code-as-DATA like every other gate command here. The
434
+ // run is optional: with none given the CLI reads the trace pointer, which is
435
+ // exactly what a panel wants — "the run that is open right now".
436
+ "/api/extra/demotion": (q) => ["extra", "demotion", ...(q.run ? [String(q.run)] : [])],
437
+ "/api/extra/reconcile": (q) => ["extra", "reconcile", String(q.task || "")],
438
+ "/api/extra/journal/prune/preview": () => ["extra", "journal", "prune", "--dry-run"],
439
+ // v1.5.0 — /orc-test, the lane that RUNS the test. THREE reads, and every one
440
+ // of them is a READ in the strict sense this lane needs: none re-scans the
441
+ // repository, none probes the target, and none writes the ledger. Opening a
442
+ // page must never be a measurement — a measurement nobody asked for is
443
+ // traffic nobody authorized.
444
+ //
445
+ // `orc test show` is the whole computed view (`--json is not a summary`), so
446
+ // the surface, the case matrix, the closed OWASP set, the runs and the
447
+ // findings all arrive in ONE object the panel renders and derives nothing
448
+ // from: not a state word, not a severity, not an OWASP id, not the tier.
449
+ // `status` 0 always, `show` 0 / 2 no run, `ui tools` 0 ready / 1 not ready /
450
+ // 2 the driver is `none` — and 2 there is a SETTING, not a missing tool.
451
+ "/api/test": () => ["test", "status"],
452
+ "/api/test/one": (q) => ["test", "show", String(q.slug || "")],
453
+ "/api/test/ui": () => ["test", "ui", "tools"],
454
+ "/api/patterns": () => ["pattern", "status"],
455
+ "/api/gotchas": () => ["gotcha", "list"],
456
+ "/api/stats": (q) => (q.since ? ["stats", "--since", String(q.since)] : ["stats"]),
457
+ "/api/diy": () => ["diy", "show"],
458
+ "/api/crosslink": () => ["crosslink", "list"],
459
+ "/api/crosslink/kinds": () => ["crosslink", "kinds"],
460
+ "/api/mocks": () => ["mock", "list"],
461
+ "/api/mock": (q) => ["mock", "show", String(q.slug || "")],
462
+ "/api/stack": (q) => (q.slug ? ["pr", "stack", "status", String(q.slug)] : ["pr", "stack", "status"]),
463
+ };
464
+
465
+ // v0.46.0 writes. THE LINE THIS PANEL DOES NOT CROSS: a button exists only for an
466
+ // action that costs NO model tokens. `orc pact check` runs the ledger's own cheap
467
+ // proofs (a test, a command, a grep the user wrote); `orc handoff set` edits one
468
+ // graded surface; `orc export` compiles files already on disk. Every one is
469
+ // deterministic and every one is a real CLI command.
470
+ //
471
+ // The conversational half of each lane — reconciling a promise, deciding a
472
+ // verdict, walking somebody through a change — costs model tokens, so the panel
473
+ // COPIES those commands and never runs them. A test greps this object to make
474
+ // sure no lane name ever appears inside it.
475
+ const WRITES = {
476
+ "/api/config/set": (b) => ["config", "set", String(b.key), String(b.value)],
477
+ "/api/config/reset": (b) => (b.key ? ["config", "reset", String(b.key)] : ["config", "reset"]),
478
+ "/api/config/profile": (b) => ["config", "profile", String(b.name)],
479
+ "/api/diy/set": (b) => ["diy", "set", String(b.key), String(b.value)],
480
+ "/api/diy/compile": () => ["diy", "compile"],
481
+ // The bootstrap the TTY composer offers as its first question, and the one
482
+ // piece of DIY the panel had no way to reach (v0.44.0). `--force` is what
483
+ // makes it an ANSWER rather than an error on an already-configured project:
484
+ // `orc diy init` refuses to overwrite without it. That is destructive, so the
485
+ // UI confirms with the preset's own diff and the exact command on screen.
486
+ // An empty name is the wizard's "full-lane defaults" — a real invocation,
487
+ // not a synthesised one.
488
+ "/api/diy/preset": (b) => {
489
+ const argv = ["diy", "init", "--force"];
490
+ if (b.name) argv.push("--preset", String(b.name));
491
+ return argv;
492
+ },
493
+ // v1.1.0 — the only two wait mutations this panel may make, and neither
494
+ // starts one. `unblock` restores a gate the user vetoed; `cancel` ends a wait
495
+ // already running. A BLOCK cannot be created here on purpose: it needs a
496
+ // reason typed in the moment, and a reason typed into a settings page days
497
+ // later is not the record that makes the risk demonstrably the user's.
498
+ // v1.3.0 — the layout's writers. Every one shells the same validator the CLI
499
+ // uses, so an illegal placement is refused BY NAME here exactly as it is
500
+ // there. The board makes the illegal drop impossible; this is the guarantee.
501
+ "/api/statusline/set": (b) => {
502
+ const argv = ["statusline", "set", String(b.line), String(b.pos)];
503
+ if (b.type) argv.push(String(b.type));
504
+ for (const [k, f] of [
505
+ ["render", "--render"], ["label", "--label"], ["color", "--color"],
506
+ ["label_color", "--label-color"], ["value_color", "--value-color"],
507
+ ["bg", "--bg"], ["ramp", "--ramp"], ["glyphs", "--glyphs"],
508
+ ["format", "--format"], ["case", "--case"], ["truncate", "--truncate"],
509
+ ["compact", "--compact"], ["prefix", "--prefix"], ["suffix", "--suffix"],
510
+ ["emphasis", "--emphasis"], ["hide_when", "--hide-when"],
511
+ ["width", "--width"], ["precision", "--precision"],
512
+ ["min_width", "--min-width"], ["min_cols", "--min-cols"],
513
+ ["max_cols", "--max-cols"], ["priority", "--priority"],
514
+ ]) {
515
+ if (b[k] !== undefined && b[k] !== null && b[k] !== "") argv.push(f, String(b[k]));
516
+ }
517
+ if (b.draw_empty) argv.push("--draw-empty");
518
+ return argv.concat(slBoard(b));
519
+ },
520
+ "/api/statusline/move": (b) => ["statusline", "move", String(b.from), String(b.to), ...slBoard(b)],
521
+ "/api/statusline/remove": (b) => ["statusline", "remove", String(b.at), ...slBoard(b)],
522
+ "/api/statusline/line": (b) => {
523
+ const argv = ["statusline", "line", String(b.line)];
524
+ if (b.separator !== undefined) argv.push("--separator", String(b.separator));
525
+ if (b.theme) argv.push("--theme", String(b.theme));
526
+ if (b.max_width !== undefined) argv.push("--max-width", String(b.max_width));
527
+ return argv.concat(slBoard(b));
528
+ },
529
+ // A preset REPLACES the layout, so the panel always confirms it and names
530
+ // the loss — the `orc diy init --force` rule.
531
+ // THE DOCUMENT-LEVEL SETTINGS. `line` is per-LINE and `doc` is the whole
532
+ // layout; the colour set is a document fact, and routing it through `line`
533
+ // wrote one third of the bar while the picker read the other value back.
534
+ "/api/statusline/doc": (b) => {
535
+ const argv = ["statusline", "doc"];
536
+ if (b.theme) argv.push("--theme", String(b.theme));
537
+ if (b.glyphs) argv.push("--glyphs", String(b.glyphs));
538
+ if (b.ansi) argv.push("--ansi", String(b.ansi));
539
+ if (b.align_columns !== undefined) argv.push("--align-columns", b.align_columns ? "on" : "off");
540
+ return argv.concat(slBoard(b));
541
+ },
542
+ "/api/statusline/apply": (b) => ["statusline", "apply", String(b.name), ...slBoard(b)],
543
+ // v1.3.0 W5. `group` wraps 2-4 as one object, `expand` is its inverse and is
544
+ // also how a composite becomes editable, `clone` is for two `config` chips on
545
+ // different keys — a normal thing to want.
546
+ "/api/statusline/group": (b) => ["statusline", "group", ...(b.refs || []).map(String), ...slBoard(b)],
547
+ "/api/statusline/expand": (b) => ["statusline", "expand", String(b.at), ...slBoard(b)],
548
+ "/api/statusline/clone": (b) => ["statusline", "clone", String(b.at), ...slBoard(b)],
549
+ "/api/statusline/reset": (b) => ["statusline", "reset", ...slBoard(b)],
550
+ "/api/statusline/compile": (b) => ["statusline", "compile", ...slBoard(b)],
551
+ "/api/wait/unblock": (b) => (b.slug ? ["wait", "unblock", String(b.slug)] : ["wait", "unblock"]),
552
+ "/api/wait/cancel": (b) => (b.slug ? ["wait", "cancel", String(b.slug)] : ["wait", "cancel"]),
553
+ "/api/wiki/sync": () => ["wiki", "sync"],
554
+ // v1.8.0 — FREE (parser only, no model), so it is a button, the `wiki sync` rule.
555
+ "/api/graph/update": () => ["graph", "update"],
556
+ "/api/wiki/usage/rebuild": () => ["wiki", "usage", "--rebuild"],
557
+ "/api/gotcha/prune": () => ["gotcha", "prune"],
558
+ "/api/pact/check": (b) => (b.id ? ["pact", "check", String(b.id)] : ["pact", "check"]),
559
+ "/api/pact/sync": () => ["pact", "sync"],
560
+ // The surface id, the key and the value all come from the browser — and all
561
+ // three are validated by the CLI, which refuses a RED surface, an unknown id,
562
+ // a key that does not already exist, and a write at all when handoff_write is
563
+ // false. There is no second idea of a safe edit anywhere in this panel.
564
+ "/api/handoff/set": (b) => ["handoff", "set", String(b.id), String(b.key), String(b.value)],
565
+ "/api/budget/calibrate": () => ["budget", "calibrate"],
566
+ // v0.47.0. All three are FREE and deterministic, so all three get a button.
567
+ // Running an ITERATION costs model tokens, so it is a copy-able command and
568
+ // there is deliberately no route for it here. `accept` and `rebut` both refuse
569
+ // without a reason — the CLI decides that, not the form.
570
+ "/api/challenge/accept": (b) => ["challenge", "accept", String(b.slug), String(b.id), String(b.reason || "")],
571
+ "/api/challenge/rebut": (b) => ["challenge", "rebut", String(b.slug), String(b.id), String(b.reason || "")],
572
+ "/api/challenge/report": (b) => ["challenge", "report", String(b.slug)],
573
+ // v0.49.1. Both are FREE and both REFUSE without a reason, so both are
574
+ // buttons. There is deliberately NO route for `council --set`: changing the
575
+ // roster mid-cycle is a decision with a recorded reason that the LANE takes in
576
+ // the conversation — the same reasoning that keeps `orc doc log` and
577
+ // `orc doc mode` off the panel. Adopting a premise needs a goals FILE, which
578
+ // the panel must not invent, so that one stays a copy-able command too.
579
+ "/api/challenge/premise": (b) => [
580
+ "challenge", "premise", String(b.slug), String(b.id), "--dismiss", "--reason", String(b.reason || ""),
581
+ ],
582
+ "/api/challenge/opportunity": (b) => [
583
+ "challenge", "opportunity", String(b.slug), String(b.id), b.take ? "--take" : "--drop", "--reason", String(b.reason || ""),
584
+ ],
585
+ // v0.48.0. `assemble` — now `compile` — is the ONE /orc-doc write that costs
586
+ // nothing: it concatenates section files that are already on disk, in an order
587
+ // the outline already fixed. Writing a section, checking one and editing one
588
+ // all cost model tokens, so they are copy-able commands and there is
589
+ // deliberately no route for any of them.
590
+ "/api/doc/assemble": (b) => ["doc", "assemble", String(b.slug)],
591
+ // v0.49.0. Both free, both non-destructive: `compile` rebuilds the artifact
592
+ // from disk, and `migrate` never deletes document.md and refuses what it
593
+ // cannot parse. `orc doc mode` deliberately has NO route — it is a USER
594
+ // decision the skill asks (the `orc doc log` precedent).
595
+ "/api/doc/compile": (b) => {
596
+ const argv = ["doc", "compile", String(b.slug)];
597
+ if (b.partial) argv.push("--partial");
598
+ return argv;
599
+ },
600
+ "/api/doc/migrate": (b) => ["doc", "migrate", String(b.slug)],
601
+ // v0.48.1. Shipping is a DECISION, so it is a write — and `--where` has no
602
+ // default here either, because the CLI refuses without it and the panel must
603
+ // never invent an argument the human path demands.
604
+ "/api/doc/ship": (b) => {
605
+ const argv = ["doc", "ship", String(b.slug), "--where", String(b.where || "")];
606
+ if (b.note) argv.push("--note", String(b.note));
607
+ if (b.force) argv.push("--force", "--reason", String(b.reason || ""));
608
+ return argv;
609
+ },
610
+ "/api/doc/unship": (b) => ["doc", "unship", String(b.slug), "--reason", String(b.reason || "")],
611
+ // v0.49.5 — the house-rule ledger is a PLAIN TEXT config, so the panel writes
612
+ // it the way a text config is written: the whole file, once, from one
613
+ // textarea. `set-all` is the only write route it needs — the per-priority
614
+ // `set`/`add`/`clear` commands stay a CLI convenience, and `--reset` still has
615
+ // no route because throwing away a project's standing rules is a CLI act.
616
+ //
617
+ // Every validator is still the CLI's. The panel has no second idea of what a
618
+ // house rule looks like, and the argv is a plain array, so a multi-line value
619
+ // needs no escaping and no temp file.
620
+ "/api/doc/rules/setAll": (b) => ["doc", "rules", "set-all", "--text", String(b.text || "")],
621
+ // v1.7.0 — the project's own anti-slop rules. ONE write route, for the same
622
+ // reason the doc ledger has one: it is a plain text config, so the panel
623
+ // writes the whole file from one textarea and the CLI is still the only
624
+ // writer and the only validator.
625
+ //
626
+ // There is deliberately NO route for the SHIPPED packs. They are read-only,
627
+ // `orc rules set --pack …` is refused by name, and a route that existed only
628
+ // to be refused would be a control that lies about what it can do.
629
+ // `--reset` also has no route: throwing away a project's standing rules is a
630
+ // CLI act.
631
+ "/api/rules/setAll": (b) => ["rules", "set-all", "--text", String(b.text || "")],
632
+ "/api/doc/rules/sync": (b) => ["doc", "rules", String(b.slug), "--sync"],
633
+ // v0.52.0 (D9) — per document, because a document's voice is the deliverable.
634
+ // The CLI owns the resolution order and the shadowing announcement; the panel
635
+ // renders both and decides neither.
636
+ "/api/doc/extra/set": (b) => ["doc", "extra", String(b.slug), "--set", String(b.mode)],
637
+ // v0.49.2. Closing a run is FREE, deterministic, reversible, and it DELETES
638
+ // NOTHING — `RESUME.md` is moved aside, not removed. The CLI refuses without a
639
+ // reason, so the form does not have to: there is one idea of a valid close and
640
+ // it lives in `bin/cli.js`.
641
+ "/api/run/close": (b) => ["run", "close", String(b.slug), "--reason", String(b.reason || "")],
642
+ "/api/run/reopen": (b) => ["run", "reopen", String(b.slug)],
643
+ // v0.50.0 — `orc extra`. Adding a connection writes a PROFILE and nothing
644
+ // else: no key, no route, no change to how anything builds. Removing one
645
+ // REFUSES without a reason — the same rule the promise ledger retires an
646
+ // invariant under — and names the route rows it drops, so the form does not
647
+ // have to: there is one idea of a valid removal and it lives in bin/cli.js.
648
+ //
649
+ // The routing table (W12). Both are STAGED in the panel and applied one at a
650
+ // time in staged order, so a refused row never aborts the rest — and the CLI
651
+ // is still what refuses an overlap, an unverified profile or a bad band spec,
652
+ // BY NAME. There is deliberately no `--key <value>` anywhere, because the CLI
653
+ // refuses it BY NAME — argv is world-readable. A pasted key travels on the
654
+ // connection test's STDIN and nowhere else.
655
+ "/api/extra/add": (b) => {
656
+ const argv = ["extra", "add", String(b.name), "--provider", String(b.provider), "--engine", String(b.engine)];
657
+ if (b.region && b.region !== "default") argv.push("--region", String(b.region));
658
+ if (b.base_url) argv.push("--base-url", String(b.base_url));
659
+ if (b.anthropic_base_url) argv.push("--anthropic-base-url", String(b.anthropic_base_url));
660
+ if (b.cli_bin) argv.push("--cli", String(b.cli_bin));
661
+ if (b.cli_agent) argv.push("--cli-agent", String(b.cli_agent));
662
+ // v0.52.0 — the THIRD credential source, checked FIRST. A local tool that
663
+ // already holds its own credential needs no key from ORC at all, and the
664
+ // panel forcing such a profile into the vault is what locked a run that had
665
+ // no business having a passphrase. ORC never writes another tool's
666
+ // credential store; `--tool-auth` is how that is said out loud.
667
+ if (b.tool_auth) argv.push("--tool-auth");
668
+ else if (b.vault) argv.push("--key-stdin");
669
+ else if (b.env_key) argv.push("--env-key", String(b.env_key));
670
+ return argv;
671
+ },
672
+ "/api/extra/remove": (b) => ["extra", "remove", String(b.name), "--reason", String(b.reason || "")],
673
+ // v0.52.0 — forgetting a saved passphrase carries no secret, so it is an
674
+ // ordinary argv write. SAVING one does, and has its own handler below: the
675
+ // passphrase travels on STDIN and `--passphrase <value>` is refused BY NAME in
676
+ // the CLI, so there is no argv path on either side.
677
+ "/api/extra/session/forget": (b) => ["extra", "session", String(b.profile), "--forget"],
678
+ "/api/extra/route/set": (b) => {
679
+ const argv = ["extra", "route", "set", String(b.band), String(b.target)];
680
+ if (b.small_model) argv.push("--small-model", String(b.small_model));
681
+ if (b.max_turns) argv.push("--max-turns", String(b.max_turns));
682
+ return argv;
683
+ },
684
+ "/api/extra/route/rm": (b) => ["extra", "route", "rm", String(b.band)],
685
+ // A slot is a POINT, not an interval, so there is no overlap to refuse and
686
+ // `set` on an occupied one REPLACES — which is why the panel confirms it and
687
+ // names what it replaces. A count is not consent.
688
+ "/api/extra/role/set": (b) => {
689
+ const argv = ["extra", "role", "set", String(b.slot), String(b.target)];
690
+ if (b.small_model) argv.push("--small-model", String(b.small_model));
691
+ if (b.max_turns) argv.push("--max-turns", String(b.max_turns));
692
+ return argv;
693
+ },
694
+ "/api/extra/role/rm": (b) => ["extra", "role", "rm", String(b.slot)],
695
+ // v0.51.0 — running the install in the USER'S OWN TERMINAL. It is a POST
696
+ // because it launches something; it writes no config, stores no state and
697
+ // never elevates. A launch that could not happen comes back exit 0 carrying
698
+ // the command to paste (the `openBrowser` rule), so there is no failure path
699
+ // that leaves the card without an answer.
700
+ "/api/extra/install": (b) => {
701
+ const argv = ["extra", "install", String(b.provider)];
702
+ if (b.manager) argv.push("--manager", String(b.manager));
703
+ return argv;
704
+ },
705
+ // A live model list is a FREE re-read of the provider's own catalogue, and a
706
+ // per-model test is the PAID rung scoped to one id — the only thing that tells
707
+ // a LISTED model from a WORKING one.
708
+ "/api/extra/models/refresh": (b) => ["extra", "models", String(b.profile), "--refresh"],
709
+ // Preview-then-apply, and the preview NAMES EVERY DIRECTORY — a count is not
710
+ // consent. Only a journal whose every attempt closed `done` 30+ days ago is
711
+ // ever a candidate, so this can never delete the record of a dispatch that
712
+ // never reported back.
713
+ "/api/extra/journal/prune": () => ["extra", "journal", "prune"],
714
+ // A PROMOTE IS A HUMAN ACTION AND A REASON IS REQUIRED (v1.0.0 W5). The CLI
715
+ // refuses without one — exit 2, `reason-required` — so the panel does not
716
+ // validate it a second time; it collects it and lets the CLI decide, which is
717
+ // the same contract every other write on this server keeps. There is
718
+ // deliberately NO demote route: demoting by hand is a diagnostic somebody
719
+ // reaches for at a terminal, and a button for it would invite muting a
720
+ // provider instead of fixing it.
721
+ "/api/extra/promote": (b) => ["extra", "promote", String(b.run || ""), "--reason", String(b.reason || "")],
722
+ "/api/crosslink/remove": (b) => ["crosslink", "remove", String(b.name)],
723
+ // The UI assembles no YAML. It hands the CLI the same arguments the
724
+ // interactive prompt collects, and every rejection the user sees is the
725
+ // CLI's own validator speaking — there is no second idea of a valid slug,
726
+ // a valid kind or a valid edge target anywhere in this panel.
727
+ // v1.5.0 — the THREE /orc-test mutations this panel may make, and NONE of
728
+ // them sends a request to the system under test. `orc test run` is where the
729
+ // traffic is, and it is a COPY-ABLE COMMAND here and never a button: a page
730
+ // that can start a scan against a live host is a page that can start one by
731
+ // accident.
732
+ //
733
+ // · `surface` is the FREE PASS — it reads the repository and the target's
734
+ // own spec document, and it is what every later step is derived from.
735
+ // · `env` is a HEALTH PROBE, the /orc-test equivalent of `orc extra ping`:
736
+ // it asks the target whether it is up. It never starts, stops or fixes
737
+ // anything, and on a REMOTE target it refuses BY NAME.
738
+ // · `report` RENDERS the ledger into REPORT.md and derives nothing.
739
+ //
740
+ // Every one costs zero model tokens, which is the line this panel does not
741
+ // cross. Nothing here dispatches an agent.
742
+ "/api/test/surface": (b) => ["test", "surface", String(b.slug)],
743
+ "/api/test/env": (b) => ["test", "env", String(b.slug)],
744
+ "/api/test/report": (b) => ["test", "report", String(b.slug)],
745
+ "/api/crosslink/add": (b) => {
746
+ const argv = ["crosslink", "add", String(b.name), String(b.repo_path), "--kinds", String(b.kinds)];
747
+ if (b.direction) argv.push("--direction", String(b.direction));
748
+ if (b.via) argv.push("--via", String(b.via));
749
+ if (b.target) argv.push("--target", String(b.target));
750
+ return argv;
751
+ },
752
+ };
753
+
754
+ // Maintenance: the safety-critical panel. Each action is a PAIR — a read-only
755
+ // preview and the apply that the preview is consent for. The apply route can
756
+ // never be reached without the UI having fetched the preview first, and the
757
+ // exact command is part of the preview payload so it is always visible in the
758
+ // confirmation, and always typeable by hand instead.
759
+ // `restarts_ui` — DOES THIS ACTION REPLACE WHAT THE PANEL IS SERVING?
760
+ //
761
+ // `orc upgrade` installs a new package over the one this process is running
762
+ // from, and `orc update` / `--prune` / `doctor --fix` rewrite the payload every
763
+ // panel reads. Node loaded bin/webui at require time and `STATIC` is a one-time
764
+ // walk at boot, so neither is visible until the server is replaced — which used
765
+ // to mean stop the server, re-run `orc ui`, open the new URL. The flag is
766
+ // DECLARED per action rather than inferred, because "this command changed the
767
+ // code under me" is not something a command's output can be read for.
768
+ //
769
+ // `update-global` is deliberately FALSE: it re-copies the payload into
770
+ // ~/.claude, which is not what this server is running and not what any panel
771
+ // here reads.
772
+ const MAINTENANCE = {
773
+ update: {
774
+ apply: ["update"],
775
+ restarts_ui: true,
776
+ label: "Re-copy this package's payload over the installed one",
777
+ // `orc doctor --json` already itemises exactly what an update would change
778
+ // (version skew, missing files, orphans) — a preview with no second engine.
779
+ preview: ["doctor"],
780
+ },
781
+ prune: {
782
+ apply: ["update", "--prune"],
783
+ restarts_ui: true,
784
+ label: "Update AND delete ORC-named orphans from a pre-manifest install",
785
+ preview: ["doctor"],
786
+ // A count is not consent for a deletion: the UI must name every file, and
787
+ // doctor's findings carry `paths` for exactly the two orphan findings.
788
+ names_files: true,
789
+ },
790
+ fix: {
791
+ apply: ["doctor", "--fix"],
792
+ restarts_ui: true,
793
+ label: "Apply every fix orc doctor found (= update + prune + settings re-merge)",
794
+ preview: ["doctor"],
795
+ },
796
+ upgrade: {
797
+ apply: ["upgrade"],
798
+ restarts_ui: true,
799
+ label: "Fetch the LATEST package from the network, then apply it",
800
+ preview: ["version"],
801
+ network: true,
802
+ },
803
+ // v0.46.0 — the portable export. It belongs on Maintenance by the v0.43.6 rule
804
+ // (a caution routes to the panel that can CLEAR it): `export-stale` is cleared
805
+ // by `orc export`, which is a CLI write this panel can run. Its preview is the
806
+ // `--check` report, which names WHICH source drifted — a count of stale sources
807
+ // would not be consent to overwrite a committed file.
808
+ export: {
809
+ apply: ["export"],
810
+ label: "Recompile AGENTS.md from the wiki, patterns, PACT.md and boundary cards",
811
+ preview: ["export", "--check"],
812
+ },
813
+ // ADVANCED (v0.44.0) — the one action on this panel that does not target the
814
+ // project. Every other route pins `--dir <projectRoot>`; `--global` outranks
815
+ // `--dir` in `resolveClaudeDir`, so this pair reaches ~/.claude and its
816
+ // preview reports on the same place it would write.
817
+ //
818
+ // It is here because a stale GLOBAL install is a real failure this panel
819
+ // already REPORTS (the persistent banner, and doctor's global-skew finding)
820
+ // and previously could not act on — the fix was "go type it in a terminal".
821
+ // It stays boxed off as advanced, and it is still the only global reach the
822
+ // panel has: config is never written globally, because config does not merge
823
+ // and a global write would silently outrank the file every panel here edits.
824
+ "update-global": {
825
+ apply: ["update", "--global"],
826
+ label: "Re-copy this package's payload over the GLOBAL install in ~/.claude",
827
+ preview: ["doctor", "--global"],
828
+ advanced: true,
829
+ },
830
+ };
831
+
832
+ // ── the Experiment panel ────────────────────────────────────────────────────
833
+ //
834
+ // THE BOUNDARY MOVES EXACTLY ONE STEP, AND NO FURTHER. `orc ui` still renders
835
+ // no model output, proxies no session and holds no API key — it does not become
836
+ // an AI client. What it gains is a HANDOFF: it can open a terminal, in this
837
+ // project, with `claude` running in it, and then forget about it. The spawned
838
+ // process is not a child this server manages, reads or reports on; it is the
839
+ // same detached-launch mechanism `openBrowser()` has always used.
840
+ //
841
+ // The lanes are a SERVER-SIDE catalog and the browser sends only an id. No
842
+ // string typed in a browser ever reaches a shell — the cwd is always the
843
+ // server's own projectRoot, and an unknown id is a 400. That constraint is the
844
+ // only reason a launch button is safe on a write surface at all.
845
+ const LANES = [
846
+ { id: "orc", cmd: "/orc", what: "Full pipeline: intake → plan → scored parallel waves → review → verify → ship." },
847
+ { id: "orc-quick", cmd: "/orc-quick", what: "Ask for anything. Look → ask once → do, and it always asks which agent." },
848
+ { id: "orc-mini", cmd: "/orc-mini", what: "One executor, smoke gate, ship. No full review or verify phase." },
849
+ { id: "orc-fast", cmd: "/orc-fast", what: "Fastest lane. Needs a fresh wiki AND a cached pattern, or it falls back." },
850
+ { id: "orc-ultra", cmd: "/orc-ultra", what: "Maximum rigor: an advisor plus three judgment gates. Cost accepted." },
851
+ { id: "orc-plan", cmd: "/orc-plan", what: "Turn a request or analyst spec into a grounded task plan. Plan only." },
852
+ { id: "orc-analyze", cmd: "/orc-analyze", what: "Turn a document or a vague requirement into code-grounded requirements." },
853
+ { id: "orc-wiki", cmd: "/orc-wiki", what: "Build or refresh the project wiki. Expensive; always asks first." },
854
+ { id: "orc-pattern", cmd: "/orc-pattern", what: "Learn this project's real code conventions and cache them per language." },
855
+ { id: "orc-verify", cmd: "/orc-verify", what: "Verify the git-modified changes in the working tree. Read-only." },
856
+ { id: "orc-learn", cmd: "/orc-learn", what: "Generate per-feature onboarding docs. Local and git-ignored." },
857
+ { id: "orc-retro", cmd: "/orc-retro", what: "Mine the behavior traces for calibration. Read-only, report-only." },
858
+ ];
859
+
860
+ // ── the folder picker ───────────────────────────────────────────────────────
861
+ //
862
+ // The THIRD endpoint with no CLI behind it (after /api/learn's shipped content
863
+ // and /api/experiment's lane catalog), and for the same reason: there is no
864
+ // `orc` command that lists directories, so there is nothing to shell. It exists
865
+ // because a crosslink repo path typed by hand is the one field in this panel
866
+ // where a typo is invisible until the edge silently resolves to nothing — the
867
+ // CLI then saves it as a PENDING edge and you find out much later.
868
+ //
869
+ // It is a DIRECTORY LISTER and nothing more, and the limits are the design:
870
+ // · directory names only — never a file list, never file contents, never a
871
+ // stat beyond "does .git / .claude/wiki exist here";
872
+ // · dotfolders are hidden (`.git`, `node_modules` and friends are noise here);
873
+ // · it reads, it never writes, and no path it is handed can reach a shell;
874
+ // · a path that cannot be read is an ANSWER (`error`), never a 500.
875
+ // Nothing is copied out of the folders it lists, so a wrong click costs a
876
+ // re-click. The browser is already loopback + token gated; this adds no reach
877
+ // beyond what the person at the keyboard already has.
878
+ const FS_LIST_MAX = 400;
879
+
880
+ function fsList(dir, ctx) {
881
+ const target = path.resolve(dir || ctx.projectRoot || os.homedir());
882
+ let entries;
883
+ try {
884
+ entries = fs.readdirSync(target, { withFileTypes: true });
885
+ } catch (e) {
886
+ return { path: target, error: String(e.code || e.message), dirs: [] };
887
+ }
888
+ const dirs = [];
889
+ for (const e of entries) {
890
+ if (dirs.length >= FS_LIST_MAX) break;
891
+ if (!e.isDirectory() || e.name.startsWith(".") || e.name === "node_modules") continue;
892
+ const full = path.join(target, e.name);
893
+ dirs.push({
894
+ name: e.name,
895
+ path: full,
896
+ // The two facts that decide whether a folder is worth linking at all.
897
+ // Both are a single existsSync — nothing inside either one is read.
898
+ is_repo: fs.existsSync(path.join(full, ".git")),
899
+ has_wiki: fs.existsSync(path.join(full, ".claude", "wiki")),
900
+ });
901
+ }
902
+ dirs.sort((a, b) => a.name.localeCompare(b.name));
903
+ const parent = path.dirname(target);
904
+ return {
905
+ path: target,
906
+ parent: parent === target ? null : parent, // null AT a filesystem root
907
+ sep: path.sep,
908
+ home: os.homedir(),
909
+ project_root: ctx.projectRoot || null,
910
+ is_project_root: !!ctx.projectRoot && path.resolve(ctx.projectRoot) === target,
911
+ // What the crosslink config actually stores. Computed here rather than in
912
+ // the browser because only the server knows the real path separator, and a
913
+ // Windows path assembled with "/" is the kind of thing that works until it
914
+ // does not.
915
+ relative: ctx.projectRoot ? path.relative(ctx.projectRoot, target).split(path.sep).join("/") || "." : null,
916
+ truncated: dirs.length >= FS_LIST_MAX,
917
+ dirs,
918
+ };
919
+ }
920
+
921
+ // Open a terminal running `claude` in the project root. Best-effort and never
922
+ // fatal — a failed launch is reported so the user can copy the command instead,
923
+ // which is why the command is always on screen anyway.
924
+ function launchClaude(ctx) {
925
+ const cwd = ctx.projectRoot;
926
+ let cmd, args;
927
+ if (process.platform === "win32") {
928
+ // `start` needs an empty title argument first, or a quoted path becomes it.
929
+ cmd = "cmd";
930
+ args = ["/c", "start", "", "cmd", "/k", "claude"];
931
+ } else if (process.platform === "darwin") {
932
+ cmd = "osascript";
933
+ args = ["-e", `tell application "Terminal" to do script "cd ${JSON.stringify(cwd).slice(1, -1)} && claude"`, "-e", 'tell application "Terminal" to activate'];
934
+ } else {
935
+ cmd = "x-terminal-emulator";
936
+ args = ["-e", "claude"];
937
+ }
938
+ try {
939
+ const child = spawn(cmd, args, { cwd, detached: true, stdio: "ignore", windowsHide: false });
940
+ child.unref();
941
+ return { ok: true };
942
+ } catch (e) {
943
+ return { ok: false, error: String(e && e.message) };
944
+ }
945
+ }
946
+
947
+ // ── request handling ────────────────────────────────────────────────────────
948
+
949
+ // A RESPONSE OVER 64 KiB IS A HAZARD ON WINDOWS LOOPBACK (v1.5.0).
950
+ //
951
+ // Measured, and NOT an ORC bug: a twenty-line plain `http.createServer` on this
952
+ // platform loses the tail of a response larger than the 64 KiB socket buffer
953
+ // roughly one time in six, after a handful of short-lived connections. The
954
+ // client receives ~65,077 of 69,177 bytes, the server's `finish` fires and
955
+ // `writableFinished` is true, and nineteen seconds later the client gets
956
+ // ECONNRESET with nothing in any log to say why. Under 60,000 bytes it never
957
+ // happened in any run.
958
+ //
959
+ // `/api/config` was 64,934 bytes — six hundred short of that line — so the
960
+ // Settings tab was one config key away from failing with no message, and it
961
+ // crossed on the release that added five. Two things answer it, and neither is
962
+ // a size limit on what this API may say (`--json is not a summary`):
963
+ //
964
+ // 1. DECLARE THE LENGTH. Without `content-length` Node falls back to chunked
965
+ // encoding, which is a worse shape for a body of known size and gives the
966
+ // client no way to tell a truncated read from a complete one.
967
+ // 2. COMPRESS IT WHEN THE CLIENT ASKS. Every browser sends
968
+ // `accept-encoding: gzip`, and the panel IS a browser — gzip takes that
969
+ // 69 KB answer to about 8 KB, far below the cliff. It is skipped for a
970
+ // small body, where the CPU is not worth it, and skipped entirely for a
971
+ // client that did not ask.
972
+ const GZIP_MIN_BYTES = 32 * 1024;
973
+ // `gzip` as a WHOLE token in the header's comma list, so `x-gzip-not-really`
974
+ // never matches. Written out rather than with a regex word boundary, which is
975
+ // not one beside a hyphen.
976
+ const GZIP_ACCEPTED = /(^|,)\s*gzip\s*(;|,|$)/;
977
+
978
+ // ONE implementation, and serve.js uses it for STATIC files too (v1.5.0). The
979
+ // 64 KiB cliff is a property of the SOCKET, not of the content type: it does not
980
+ // care whether the bytes are an API answer or a panel script. Leaving the static
981
+ // path uncompressed left the two largest files in the app — `extra.js` at 138 KB
982
+ // and `hookui.js` at 76 KB — riding the exact path this was written to fix. It
983
+ // showed up as an intermittent ECONNRESET in the asset-walk test, and it would
984
+ // show up in a real browser as a panel script that stops mid-function.
985
+ //
986
+ // Returns the bytes to send plus the headers that describe them. A caller that
987
+ // forgets to declare the length is the other half of the same bug, so the length
988
+ // is set HERE rather than left to each call site.
989
+ function encodeBody(raw, acceptEncoding) {
990
+ const headers = {};
991
+ let body = raw;
992
+ if (raw.length >= GZIP_MIN_BYTES && GZIP_ACCEPTED.test(String(acceptEncoding || ""))) {
993
+ try {
994
+ body = zlib.gzipSync(raw);
995
+ headers["content-encoding"] = "gzip";
996
+ // A cache keyed on the URL alone would hand a gzip body to a client that
997
+ // cannot read one. Nothing caches on loopback today; saying so costs one
998
+ // header and removes the whole class.
999
+ headers["vary"] = "accept-encoding";
1000
+ } catch (_) {
1001
+ body = raw;
1002
+ }
1003
+ }
1004
+ headers["content-length"] = String(body.length);
1005
+ return { body, headers };
1006
+ }
1007
+
1008
+ function json(res, status, obj) {
1009
+ const raw = Buffer.from(JSON.stringify(obj), "utf8");
1010
+ const { body, headers } = encodeBody(raw, res.req && res.req.headers && res.req.headers["accept-encoding"]);
1011
+ res.writeHead(
1012
+ status,
1013
+ Object.assign(
1014
+ {
1015
+ "content-type": "application/json; charset=utf-8",
1016
+ "cache-control": "no-store",
1017
+ // Belt and braces on top of the loopback + token checks in serve.js.
1018
+ "x-content-type-options": "nosniff",
1019
+ },
1020
+ headers
1021
+ )
1022
+ );
1023
+ res.end(body);
1024
+ }
1025
+
1026
+ function readBody(req) {
1027
+ return new Promise((resolve, reject) => {
1028
+ let raw = "";
1029
+ req.on("data", (c) => {
1030
+ raw += c;
1031
+ if (raw.length > 64_000) reject(new Error("body too large"));
1032
+ });
1033
+ req.on("end", () => {
1034
+ if (!raw) return resolve({});
1035
+ try {
1036
+ resolve(JSON.parse(raw));
1037
+ } catch (_) {
1038
+ reject(new Error("body is not JSON"));
1039
+ }
1040
+ });
1041
+ req.on("error", reject);
1042
+ });
1043
+ }
1044
+
1045
+ // The Overview panel needs four commands at once. Doing that in one request
1046
+ // keeps the first paint to a single round trip.
1047
+ function overview(ctx) {
1048
+ const doctor = readCli(["doctor"], ctx);
1049
+ const runs = readCli(["run", "list", "--limit", "200"], ctx);
1050
+ const waiting = runs.data ? runs.data.runs.filter((r) => r.status === "waiting") : [];
1051
+ return {
1052
+ where: readCli(["where"], ctx).data,
1053
+ doctor: doctor.data,
1054
+ wiki: readCli(["wiki", "status"], ctx).data,
1055
+ patterns: readCli(["pattern", "status"], ctx).data,
1056
+ runs_total: runs.data ? runs.data.total : 0,
1057
+ // Rows, not bare slugs (v0.49.2). The Overview card has an age column and
1058
+ // rendered it empty because the payload never carried the number — and the
1059
+ // "mark as done" button needs the slug beside a real timestamp to be worth
1060
+ // showing at all.
1061
+ waiting: waiting.map((r) => ({ slug: r.slug, updated_ms: r.updated_ms, lane: r.lane || null })),
1062
+ diy: readCli(["diy", "show"], ctx).data,
1063
+ // v0.46.0 chips. Each is the CLI's OWN answer — the panel repeats the state
1064
+ // words and never derives them. A chip with nothing to say still renders its
1065
+ // good state, so "healthy" and "not measured" never look the same.
1066
+ pact: readCli(["pact", "status"], ctx).data,
1067
+ boundary: readCli(["boundary", "status"], ctx).data,
1068
+ wiki_debt: readCli(["wiki", "debt"], ctx).data,
1069
+ // v0.54.0 — foreign dispatches that never reported back. Money spent and
1070
+ // work half-done that nothing will look at again unless somebody is told.
1071
+ // It is a FINDING, never a stop, and the Overview never resumes one.
1072
+ extra_journal: readCli(["extra", "journal", "list"], ctx).data,
1073
+ };
1074
+ }
1075
+
1076
+ async function handleApi(req, res, url, ctx) {
1077
+ const route = url.pathname;
1078
+ const q = Object.fromEntries(url.searchParams);
1079
+
1080
+ // Liveness. The page pings this every 15s; no ping from any client for the
1081
+ // grace window and the server exits, so a closed tab does not leave a write
1082
+ // surface holding a valid token.
1083
+ if (route === "/api/ping") {
1084
+ ctx.onHeartbeat();
1085
+ return json(res, 200, { ok: true, job: jobView() });
1086
+ }
1087
+
1088
+ // sendBeacon on beforeunload — a best-effort fast path to the same shutdown
1089
+ // the heartbeat timeout would reach a minute later.
1090
+ if (route === "/api/bye") {
1091
+ ctx.onBye();
1092
+ return json(res, 200, { ok: true });
1093
+ }
1094
+
1095
+ if (route === "/api/meta") {
1096
+ return json(res, 200, {
1097
+ project_root: ctx.projectRoot,
1098
+ fixtures: ctx.fixtures,
1099
+ version: ctx.version,
1100
+ port: ctx.port,
1101
+ idle_minutes: ctx.idleMinutes,
1102
+ started_ms: ctx.startedMs,
1103
+ });
1104
+ }
1105
+
1106
+ if (route === "/api/job") return json(res, 200, jobView());
1107
+
1108
+ // Fixture mode short-circuits every data route: canned JSON, no project, no
1109
+ // spawn. This is what makes the STALE chip and the unhealthy doctor panel
1110
+ // designable on a machine where everything is green (see the plan, §9).
1111
+ if (ctx.fixtures) {
1112
+ if (req.method !== "GET") {
1113
+ // Almost every mutation answers "nothing ran", which is the honest reply
1114
+ // in a mode that runs nothing. The ONE exception is the connection test:
1115
+ // its two outcomes are states the Extra panel is largely about, and a
1116
+ // state with no fixture is a state nobody has ever looked at. A canned
1117
+ // answer carries `data` and NOT the `fixture` flag, so the panel renders
1118
+ // the real result shape — the command string is what says it was canned.
1119
+ let body = {};
1120
+ try {
1121
+ body = await readBody(req);
1122
+ } catch (_) {}
1123
+ const canned = fixtures.post(route, body);
1124
+ if (canned)
1125
+ return json(res, 200, {
1126
+ ok: true,
1127
+ exit_code: canned.exit_code,
1128
+ data: canned.data,
1129
+ command: "(fixtures — nothing ran)",
1130
+ });
1131
+ return json(res, 200, { ok: true, fixture: true, command: "(fixtures — nothing ran)" });
1132
+ }
1133
+ const canned = fixtures.get(route, q);
1134
+ if (canned === undefined) return json(res, 404, { error: "no fixture for " + route });
1135
+ return json(res, 200, { ok: true, exit_code: 0, data: canned, fixture: true });
1136
+ }
1137
+
1138
+ if (req.method === "GET") {
1139
+ if (route === "/api/overview") return json(res, 200, { ok: true, exit_code: 0, data: overview(ctx) });
1140
+ if (route === "/api/learn") {
1141
+ // The only endpoint with no CLI behind it: the onboarding topics are
1142
+ // static content already shipped as a module, so spawning to read them
1143
+ // would be ceremony with a cost.
1144
+ const { SECTIONS } = require("../onboarding-content.js");
1145
+ return json(res, 200, { ok: true, exit_code: 0, data: { sections: SECTIONS } });
1146
+ }
1147
+ // The mocked runs (v0.46.x). Same shape as /api/learn above and for the
1148
+ // same reason: this is static content that ships inside this package, so
1149
+ // spawning a subprocess to read files sitting next to this one would be
1150
+ // ceremony with a cost. `orc mock-run` reads the identical module, so the
1151
+ // terminal and the panel cannot disagree.
1152
+ if (route === "/api/mockruns") {
1153
+ return json(res, 200, { ok: true, exit_code: 0, data: require("../mockrun-catalog.js").catalogue() });
1154
+ }
1155
+ if (route === "/api/mockrun") {
1156
+ const doc = require("../mockrun-catalog.js").get(String(q.slug || ""));
1157
+ if (!doc) return json(res, 200, { ok: true, exit_code: 1, data: { slug: String(q.slug || ""), found: false } });
1158
+ return json(res, 200, { ok: true, exit_code: 0, data: { ...doc, found: true } });
1159
+ }
1160
+ if (route === "/api/fs/list") {
1161
+ return json(res, 200, { ok: true, exit_code: 0, data: fsList(q.path, ctx) });
1162
+ }
1163
+ if (route === "/api/experiment") {
1164
+ return json(res, 200, {
1165
+ ok: true,
1166
+ exit_code: 0,
1167
+ data: {
1168
+ lanes: LANES,
1169
+ project_root: ctx.projectRoot,
1170
+ platform: process.platform,
1171
+ // Fixture mode must never spawn a real terminal on a machine that has
1172
+ // no project — the button says so instead of lying about it.
1173
+ can_launch: !ctx.fixtures,
1174
+ },
1175
+ });
1176
+ }
1177
+ if (route === "/api/maintenance") {
1178
+ const actions = Object.entries(MAINTENANCE).map(([id, m]) => ({
1179
+ id,
1180
+ label: m.label,
1181
+ command: "orc " + m.apply.join(" "),
1182
+ network: !!m.network,
1183
+ names_files: !!m.names_files,
1184
+ advanced: !!m.advanced,
1185
+ restarts_ui: !!m.restarts_ui,
1186
+ }));
1187
+ return json(res, 200, { ok: true, exit_code: 0, data: { actions } });
1188
+ }
1189
+ if (route === "/api/maintenance/preview") {
1190
+ const m = MAINTENANCE[String(q.action)];
1191
+ if (!m) return json(res, 400, { error: "unknown action" });
1192
+ const probe = readCli(m.preview, ctx);
1193
+ return json(res, 200, {
1194
+ ok: true,
1195
+ exit_code: 0,
1196
+ data: {
1197
+ action: String(q.action),
1198
+ label: m.label,
1199
+ command: "orc " + m.apply.join(" "),
1200
+ network: !!m.network,
1201
+ names_files: !!m.names_files,
1202
+ advanced: !!m.advanced,
1203
+ // Said in the confirmation, not discovered afterwards. A panel that
1204
+ // reloads itself without warning reads as a crash.
1205
+ restarts_ui: !!m.restarts_ui,
1206
+ preview_command: "orc " + m.preview.join(" "),
1207
+ preview: probe.data,
1208
+ // Only the UI can know a run is mid-flight; updating changes the
1209
+ // skills that run would resume into.
1210
+ waiting_runs: (readCli(["run", "list", "--limit", "200"], ctx).data || { runs: [] }).runs
1211
+ .filter((r) => r.status === "waiting")
1212
+ .map((r) => r.slug),
1213
+ dirty_tree: m.network ? isDirtyTree(ctx) : false,
1214
+ },
1215
+ });
1216
+ }
1217
+ const build = READS[route];
1218
+ if (!build) return json(res, 404, { error: "unknown endpoint " + route });
1219
+ const out = readCli(build(q), ctx);
1220
+ // v0.49.2 — a read that produced no parseable object still has to SAY why.
1221
+ // The body already carried `stderr` and `stdout`; nothing named `error`, so
1222
+ // the client fell through to "request failed (500)" and one corrupt ledger
1223
+ // looked like a broken panel. The reason the CLI printed is what is shown.
1224
+ return json(res, out.ok ? 200 : 500, out.ok ? out : { ...out, error: readFailReason(out) });
1225
+ }
1226
+
1227
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
1228
+
1229
+ let body;
1230
+ try {
1231
+ body = await readBody(req);
1232
+ } catch (e) {
1233
+ return json(res, 400, { error: e.message });
1234
+ }
1235
+
1236
+ // The handoff. It takes NO command from the browser: the lane id is looked up
1237
+ // in the server's own catalog and is used only to echo back what to type. The
1238
+ // process spawned is always a bare `claude` in the server's own projectRoot,
1239
+ // so there is no path by which browser input reaches a shell.
1240
+ if (route === "/api/experiment/launch") {
1241
+ if (ctx.fixtures) return json(res, 400, { error: "fixture mode never launches anything real" });
1242
+ const lane = body.lane ? LANES.find((l) => l.id === String(body.lane)) : null;
1243
+ if (body.lane && !lane) return json(res, 400, { error: "unknown lane" });
1244
+ const r = launchClaude(ctx);
1245
+ if (!r.ok) return json(res, 500, { error: "could not open a terminal: " + r.error });
1246
+ return json(res, 200, {
1247
+ ok: true,
1248
+ // What to type once it is open. The UI shows this; the server never runs it.
1249
+ type_this: lane ? lane.cmd : null,
1250
+ cwd: ctx.projectRoot,
1251
+ });
1252
+ }
1253
+
1254
+ // v0.50.0 — THE CONNECTION TEST, and the one place this panel does something
1255
+ // model-shaped. It is a DIAGNOSTIC in the same family as `orc doctor`: rung 1
1256
+ // lists models and costs nothing, rung 2 sends a one-token completion and
1257
+ // costs a fraction of a cent, and the CLI decides which — never this file.
1258
+ //
1259
+ // It is POST because it MUTATES: a green test writes `verified_at` onto the
1260
+ // profile, and a red one on a never-verified profile REMOVES that profile
1261
+ // (the CLI's own test-first-then-store lifecycle). A GET that did that would
1262
+ // be reachable by a prefetch.
1263
+ //
1264
+ // A pasted key arrives in the BODY and leaves on the child's STDIN — line 1
1265
+ // the key, an optional line 2 the passphrase that encrypts it. It is never in
1266
+ // argv, never written here, and never echoed back: the response is the CLI's
1267
+ // own `--json` object, which carries no credential by construction.
1268
+ if (route === "/api/extra/ping") {
1269
+ if (job && job.running) return json(res, 409, { error: "busy", job: jobView() });
1270
+ const profile = String(body.profile || "");
1271
+ if (!profile) return json(res, 400, { error: "missing argument" });
1272
+ const argv = ["extra", "ping", profile];
1273
+ // v0.51.0 — the PAID rung, opt-in and never a default. The panel quotes what
1274
+ // it costs before the button; the CLI is what decides the rung and what it
1275
+ // reports back.
1276
+ if (body.live) argv.push("--live");
1277
+ if (body.model) argv.push("--model", String(body.model));
1278
+ let input;
1279
+ if (body.key) {
1280
+ // A NEW key: line 1 the key, an optional line 2 the passphrase that stores
1281
+ // it after a green test.
1282
+ argv.push("--key-stdin");
1283
+ input = String(body.key) + chr10 + String(body.passphrase || "") + chr10;
1284
+ } else if (body.passphrase) {
1285
+ // A STORED key: the passphrase decrypts it into the CLI's memory for the
1286
+ // probe. The two flags are mutually exclusive and the CLI refuses them
1287
+ // together BY NAME, so this branch is an `else if` rather than a guess.
1288
+ argv.push("--passphrase-stdin");
1289
+ input = String(body.passphrase) + chr10;
1290
+ }
1291
+ const out = runCli(argv, ctx, { json: true, input });
1292
+ clearCache();
1293
+ // `ok` here is "did the CLI answer at all". Whether the CONNECTION worked is
1294
+ // `data.ok` and the exit code, which are the CLI's answer and are passed
1295
+ // through untouched — a failed probe is DATA, not a server error.
1296
+ return json(res, out.ok ? 200 : 500, out.ok
1297
+ ? { ok: true, exit_code: out.exit_code, data: out.data, command: out.command }
1298
+ : { ...out, error: readFailReason(out) });
1299
+ }
1300
+
1301
+ // v0.51.0 — F5's answer, scoped to ONE model id. A model that is LISTED can
1302
+ // still be DEAD upstream, so a dropdown is a list of what is OFFERED and never
1303
+ // a list of what WORKS. This is POST because it spends money.
1304
+ if (route === "/api/extra/models/test") {
1305
+ if (job && job.running) return json(res, 409, { error: "busy", job: jobView() });
1306
+ const profile = String(body.profile || "");
1307
+ const model = String(body.model || "");
1308
+ if (!profile || !model) return json(res, 400, { error: "missing argument" });
1309
+ const argv = ["extra", "models", profile, "--test", model];
1310
+ let input;
1311
+ if (body.passphrase) {
1312
+ argv.push("--passphrase-stdin");
1313
+ input = String(body.passphrase) + chr10;
1314
+ }
1315
+ const out = runCli(argv, ctx, { json: true, input });
1316
+ clearCache();
1317
+ return json(res, out.ok ? 200 : 500, out.ok
1318
+ ? { ok: true, exit_code: out.exit_code, data: out.data, command: out.command }
1319
+ : { ...out, error: readFailReason(out) });
1320
+ }
1321
+
1322
+ // v0.52.0 — SAVING THE PASSPHRASE WITH A DEADLINE. The CLI tests it against
1323
+ // the vault before it stores anything (test first, then store), validates the
1324
+ // TTL against the same closed set the config key publishes, and answers with
1325
+ // the DATE. This route composes nothing: it hands over a profile, a number of
1326
+ // days, and a passphrase on stdin.
1327
+ if (route === "/api/extra/session/save") {
1328
+ if (job && job.running) return json(res, 409, { error: "busy", job: jobView() });
1329
+ const profile = String(body.profile || "");
1330
+ const ttl = String(body.ttl_days || "");
1331
+ if (!profile || !ttl) return json(res, 400, { error: "missing argument" });
1332
+ const out = runCli(["extra", "session", profile, "--save", "--ttl", ttl], ctx, {
1333
+ json: true,
1334
+ input: String(body.passphrase || "") + chr10,
1335
+ });
1336
+ clearCache();
1337
+ return json(res, out.ok ? 200 : 500, out.ok
1338
+ ? { ok: true, exit_code: out.exit_code, data: out.data, command: out.command }
1339
+ : { ...out, error: readFailReason(out) });
1340
+ }
1341
+
1342
+ // v0.50.0 — proving a passphrase, which is the ONE action that clears the
1343
+ // vault's countdown. It NEVER yields the key: `orc extra unlock` answers one
1344
+ // question with a yes or a no, and its `attempt N of 10` message is the whole
1345
+ // point of the feature, so it is passed back verbatim.
1346
+ if (route === "/api/extra/unlock") {
1347
+ if (job && job.running) return json(res, 409, { error: "busy", job: jobView() });
1348
+ const profile = String(body.profile || "");
1349
+ if (!profile) return json(res, 400, { error: "missing argument" });
1350
+ const out = runCli(["extra", "unlock", profile], ctx, {
1351
+ json: true,
1352
+ input: String(body.passphrase || "") + chr10,
1353
+ });
1354
+ clearCache();
1355
+ return json(res, out.ok ? 200 : 500, out.ok
1356
+ ? { ok: true, exit_code: out.exit_code, data: out.data, command: out.command }
1357
+ : { ...out, error: readFailReason(out) });
1358
+ }
1359
+
1360
+ // Hand the panel over to a fresh process on the SAME port and token, so the
1361
+ // open tab only has to reload. POST-only like every other mutation, and it is
1362
+ // a mutation: the process answering the next request is not this one.
1363
+ //
1364
+ // The CLIENT asks for this — never the job's own close handler. The job's
1365
+ // output lives in this process's memory, so restarting the instant a command
1366
+ // finished would destroy the record of what it did before anyone read it.
1367
+ if (route === "/api/ui/restart") {
1368
+ if (ctx.fixtures)
1369
+ return json(res, 400, { ok: false, reason: "fixtures", error: "fixture mode serves canned data; there is nothing to restart into." });
1370
+ if (job && job.running) return json(res, 409, { ok: false, reason: "busy", error: "a command is still running.", job: jobView() });
1371
+ const out = typeof ctx.restart === "function" ? ctx.restart() : { ok: false, reason: "unsupported" };
1372
+ // A failed handover is NOT fatal and never takes the running panel down:
1373
+ // the old server keeps serving, and the client is told to do it by hand.
1374
+ return json(res, out.ok ? 200 : 500, out);
1375
+ }
1376
+
1377
+ if (route === "/api/maintenance/apply") {
1378
+ const m = MAINTENANCE[String(body.action)];
1379
+ if (!m) return json(res, 400, { error: "unknown action" });
1380
+ const started = startJob(m.apply, ctx, { restartUi: !!m.restarts_ui });
1381
+ if (started.error) return json(res, 409, started);
1382
+ return json(res, 200, { ok: true, ...started });
1383
+ }
1384
+
1385
+ const build = WRITES[route];
1386
+ if (!build) return json(res, 404, { error: "unknown endpoint " + route });
1387
+ if (job && job.running) return json(res, 409, { error: "busy", job: jobView() });
1388
+ let argv;
1389
+ try {
1390
+ argv = build(body);
1391
+ } catch (_) {
1392
+ return json(res, 400, { error: "bad request body" });
1393
+ }
1394
+ if (argv.some((a) => a === "undefined" || a === "null" || a === ""))
1395
+ return json(res, 400, { error: "missing argument" });
1396
+ const out = runCli(argv, ctx);
1397
+ clearCache();
1398
+ // A write's exit code is a REAL failure signal (validators exit 1), unlike a
1399
+ // read's — so it is reported as such, with the CLI's own message.
1400
+ return json(res, 200, {
1401
+ ok: out.exit_code === 0,
1402
+ exit_code: out.exit_code,
1403
+ command: out.command,
1404
+ // Writes print human text, not JSON — that IS the confirmation to show.
1405
+ output: (out.stdout + (out.stderr ? "\n" + out.stderr : "")).trim(),
1406
+ });
1407
+ }
1408
+
1409
+ // `orc upgrade` replaces the package while your working tree may hold changes.
1410
+ // Worth a warning before, not a surprise after.
1411
+ function isDirtyTree(ctx) {
1412
+ try {
1413
+ const r = spawnSync("git", ["status", "--porcelain"], {
1414
+ cwd: ctx.projectRoot,
1415
+ encoding: "utf8",
1416
+ windowsHide: true,
1417
+ timeout: 5000,
1418
+ });
1419
+ return r.status === 0 && !!(r.stdout || "").trim();
1420
+ } catch (_) {
1421
+ return false;
1422
+ }
1423
+ }
1424
+
1425
+ module.exports = { handleApi, clearCache, encodeBody, READS, WRITES, MAINTENANCE };