@azure-id/orc 0.56.1 → 1.1.0

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