@azure-id/orc 1.4.0 → 1.4.1

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