@edgehero/pi-dispatch 4.3.0 → 5.0.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 (257) hide show
  1. package/.env.example +94 -4
  2. package/README.md +20 -7
  3. package/deploy/console.service +52 -0
  4. package/deploy/docker-compose.yml +3 -1
  5. package/deploy/pi-dispatch-valkey.container +4 -2
  6. package/deploy/sandbox-gateway.service +23 -0
  7. package/deploy/worker-env-wrapper.cmd +6 -0
  8. package/deploy/worker-env-wrapper.sh +10 -1
  9. package/package.json +117 -19
  10. package/src/agent-snapshot.mjs +49 -0
  11. package/src/answerer-runtime.mjs +194 -0
  12. package/src/answerer.mjs +178 -0
  13. package/src/approval-policy.mjs +76 -0
  14. package/src/approval-runtime.mjs +188 -0
  15. package/src/approval-session.mjs +137 -0
  16. package/src/approval-state.mjs +170 -0
  17. package/src/auth-cli.mjs +86 -0
  18. package/src/auth-connections.mjs +145 -0
  19. package/src/auth-policy.mjs +32 -0
  20. package/src/auth-store.mjs +97 -0
  21. package/src/azure-host.mjs +66 -4
  22. package/src/azure-prompt.mjs +5 -4
  23. package/src/backend-conformance.mjs +34 -2
  24. package/src/backend-local.mjs +103 -34
  25. package/src/backend-openshell.mjs +1540 -0
  26. package/src/backend-podman.mjs +63 -2
  27. package/src/backend-registry.mjs +61 -4
  28. package/src/backends.mjs +395 -6
  29. package/src/bullmq-internals.mjs +208 -0
  30. package/src/canary-cli.mjs +28 -0
  31. package/src/canary-lock.mjs +41 -0
  32. package/src/canary-runtime.mjs +237 -0
  33. package/src/canary.mjs +53 -0
  34. package/src/cancel-cli.mjs +47 -34
  35. package/src/cancel-state.mjs +213 -15
  36. package/src/capabilities.mjs +36 -1
  37. package/src/capacity-cli.mjs +28 -8
  38. package/src/capacity-records.mjs +75 -16
  39. package/src/capacity.mjs +65 -0
  40. package/src/chat-cli.mjs +288 -0
  41. package/src/chat-fork.mjs +547 -0
  42. package/src/chat-mode.mjs +156 -0
  43. package/src/chat-view.mjs +720 -0
  44. package/src/checkpoint-paths.mjs +113 -0
  45. package/src/cli.mjs +128 -41
  46. package/src/comment-tables.mjs +322 -0
  47. package/src/community-auth-adapters.mjs +87 -0
  48. package/src/community-auth-helper.mjs +76 -0
  49. package/src/community-auth-metadata.mjs +6 -0
  50. package/src/community-auth-output-pins.mjs +38 -0
  51. package/src/community-auth-patches.mjs +168 -0
  52. package/src/community-auth-pins.mjs +38 -0
  53. package/src/config.mjs +199 -30
  54. package/src/connection.mjs +83 -7
  55. package/src/console-answer.mjs +113 -0
  56. package/src/console-audit.mjs +604 -0
  57. package/src/console-bootstrap.mjs +159 -0
  58. package/src/console-client.mjs +848 -0
  59. package/src/console-probe.mjs +106 -0
  60. package/src/console-tokens.mjs +459 -0
  61. package/src/container-names.mjs +51 -0
  62. package/src/container-spec.mjs +20 -1
  63. package/src/cpu-reserve.mjs +4 -1
  64. package/src/defer-reasons.mjs +200 -0
  65. package/src/defer-write.mjs +208 -0
  66. package/src/deployment-venue.mjs +23 -6
  67. package/src/docker-run.mjs +11 -0
  68. package/src/doctor-bundles.mjs +130 -0
  69. package/src/doctor-model-proposals.mjs +109 -0
  70. package/src/doctor-openshell.mjs +238 -0
  71. package/src/doctor-results.mjs +602 -0
  72. package/src/doctor-spawner.mjs +215 -0
  73. package/src/doctor.mjs +1663 -198
  74. package/src/dollar-budget.mjs +212 -3
  75. package/src/endpoint-status.mjs +100 -0
  76. package/src/env-allowlist.mjs +103 -9
  77. package/src/env-file.mjs +6 -4
  78. package/src/file-expect.mjs +47 -0
  79. package/src/forgejo-auth.mjs +4 -0
  80. package/src/forgejo-host.mjs +127 -31
  81. package/src/forgejo-prompt.mjs +5 -4
  82. package/src/forges.mjs +73 -15
  83. package/src/fork-job.mjs +58 -0
  84. package/src/fork-policy.mjs +23 -0
  85. package/src/fork-store.mjs +338 -0
  86. package/src/get-token.mjs +1 -1
  87. package/src/git-dirty.mjs +119 -14
  88. package/src/git-hardening.mjs +85 -0
  89. package/src/github-host.mjs +31 -6
  90. package/src/github-prompt.mjs +7 -16
  91. package/src/gitlab-host.mjs +42 -3
  92. package/src/gitlab-prompt.mjs +5 -4
  93. package/src/held-document.mjs +23 -0
  94. package/src/held-replies.mjs +325 -0
  95. package/src/held-settle.mjs +266 -0
  96. package/src/hook-runs.mjs +188 -0
  97. package/src/host-budget.mjs +8 -5
  98. package/src/host-clone.mjs +72 -0
  99. package/src/host-loads.mjs +557 -0
  100. package/src/host-registry.mjs +66 -6
  101. package/src/host-telemetry.mjs +92 -0
  102. package/src/http-body.mjs +78 -0
  103. package/src/image-preflight.mjs +156 -13
  104. package/src/import-pi.mjs +60 -172
  105. package/src/inbox-relay.mjs +482 -0
  106. package/src/inbox-settle.mjs +196 -0
  107. package/src/index.mjs +740 -129
  108. package/src/init.mjs +35 -5
  109. package/src/job-data-version.mjs +75 -0
  110. package/src/job-id.mjs +30 -15
  111. package/src/job-layout.mjs +22 -0
  112. package/src/job-size.mjs +16 -0
  113. package/src/live-jobs.mjs +14 -6
  114. package/src/live-probes.mjs +6 -2
  115. package/src/live-transcript.mjs +872 -0
  116. package/src/loaded-record.mjs +609 -0
  117. package/src/mcp-config.mjs +1 -1
  118. package/src/mcp-preflight.mjs +36 -5
  119. package/src/model-attempts.mjs +84 -0
  120. package/src/model-catalog.mjs +83 -0
  121. package/src/model-cooldowns.mjs +22 -0
  122. package/src/model-failover-protocol.mjs +11 -0
  123. package/src/model-failover.mjs +134 -0
  124. package/src/model-observation.mjs +50 -0
  125. package/src/model-proposal-protocol.mjs +21 -0
  126. package/src/model-proposals.mjs +75 -0
  127. package/src/model-ref.mjs +67 -10
  128. package/src/model-roles-file.mjs +24 -0
  129. package/src/model-roles.mjs +73 -0
  130. package/src/model-status.mjs +34 -0
  131. package/src/models-json.mjs +4 -1
  132. package/src/money.mjs +1 -1
  133. package/src/oauth-openai.mjs +121 -0
  134. package/src/older-state.mjs +249 -0
  135. package/src/on-failure.mjs +17 -13
  136. package/src/open-browser.mjs +31 -2
  137. package/src/opener-cli.mjs +135 -0
  138. package/src/opener-protocol.mjs +27 -0
  139. package/src/opener-terminal.mjs +128 -0
  140. package/src/openshell-cli.mjs +155 -0
  141. package/src/openshell-policy.mjs +163 -0
  142. package/src/outbox.mjs +14 -0
  143. package/src/output-cap.mjs +1 -0
  144. package/src/package-canary-record.mjs +26 -0
  145. package/src/package-canary.mjs +135 -0
  146. package/src/package-names.mjs +9 -0
  147. package/src/package-stage.mjs +418 -0
  148. package/src/packages.mjs +24 -16
  149. package/src/parked.mjs +2 -1
  150. package/src/pause-windows.mjs +136 -17
  151. package/src/podman-stack.mjs +40 -14
  152. package/src/portfolio-snapshot.mjs +26 -7
  153. package/src/prepare-github.mjs +41 -77
  154. package/src/prepare-local.mjs +32 -2
  155. package/src/prepare.mjs +45 -11
  156. package/src/private-store-boundary.mjs +70 -0
  157. package/src/processor.mjs +1554 -167
  158. package/src/projects.mjs +23 -0
  159. package/src/prompt-fence.mjs +18 -0
  160. package/src/provider-key.mjs +7 -0
  161. package/src/provider-steering.mjs +1 -0
  162. package/src/question-context.mjs +35 -0
  163. package/src/question-group-runtime.mjs +91 -0
  164. package/src/question-groups.mjs +171 -0
  165. package/src/queue.mjs +296 -55
  166. package/src/reason-catalog.mjs +448 -0
  167. package/src/receiver-status.mjs +230 -0
  168. package/src/record-reason.mjs +37 -0
  169. package/src/recovery-queue.mjs +566 -0
  170. package/src/redact-secrets.mjs +219 -0
  171. package/src/reserve-estimate.mjs +101 -0
  172. package/src/reserved-env.mjs +36 -1
  173. package/src/retention.mjs +67 -0
  174. package/src/run-budget-ledger.mjs +283 -0
  175. package/src/run-checkpoint.mjs +627 -0
  176. package/src/run-container.mjs +749 -33
  177. package/src/run-history.mjs +791 -45
  178. package/src/run-mirror.mjs +75 -7
  179. package/src/run-progress.mjs +751 -0
  180. package/src/runtime-observations.mjs +3 -1
  181. package/src/runtime-settings.mjs +272 -37
  182. package/src/sandbox-cli.mjs +78 -2
  183. package/src/sandbox-deployment.mjs +51 -0
  184. package/src/sandbox-gateway-protocol.mjs +378 -0
  185. package/src/sandbox-hosts.mjs +100 -0
  186. package/src/sandbox-inventory.mjs +83 -0
  187. package/src/sandbox-policy.mjs +225 -0
  188. package/src/sandbox-prepare-requests.mjs +145 -0
  189. package/src/sandbox-prepare.mjs +285 -0
  190. package/src/sandbox-refs.mjs +70 -0
  191. package/src/sandbox-session-monitor.mjs +4 -0
  192. package/src/sandbox-session-watch.mjs +124 -0
  193. package/src/sandbox-sessions.mjs +161 -0
  194. package/src/sandbox-store.mjs +63 -6
  195. package/src/sandbox-workspace-guard.mjs +39 -0
  196. package/src/sandbox.mjs +27 -200
  197. package/src/scheduler-stall-guard.mjs +14 -8
  198. package/src/schedules.mjs +19 -6
  199. package/src/secret-bundles.mjs +245 -0
  200. package/src/secret-profiles.mjs +103 -3
  201. package/src/secrets.mjs +19 -9
  202. package/src/service-env.mjs +38 -5
  203. package/src/service.mjs +638 -106
  204. package/src/session-key.mjs +60 -0
  205. package/src/session-store.mjs +168 -10
  206. package/src/setup-contract.mjs +40 -0
  207. package/src/setup-egress.mjs +113 -0
  208. package/src/setup-host-entry.mjs +33 -0
  209. package/src/setup-host.mjs +242 -0
  210. package/src/setup-lock.mjs +41 -0
  211. package/src/size-records.mjs +15 -3
  212. package/src/start.mjs +1353 -135
  213. package/src/status-comment.mjs +415 -0
  214. package/src/stderr-transcript.mjs +318 -0
  215. package/src/steer-ack.mjs +122 -0
  216. package/src/steer-cli.mjs +213 -0
  217. package/src/steer-framing.mjs +185 -0
  218. package/src/steer-inbox.mjs +693 -0
  219. package/src/store-version.mjs +139 -0
  220. package/src/subscription-config.mjs +56 -0
  221. package/src/subscription-limits.mjs +3 -0
  222. package/src/subscription-quota.mjs +259 -0
  223. package/src/subscription-runtime.mjs +123 -0
  224. package/src/subscription-status.mjs +42 -0
  225. package/src/subscription-wait.mjs +11 -0
  226. package/src/subscriptions.mjs +7 -1
  227. package/src/thinking-level.mjs +19 -0
  228. package/src/touch-outbox.mjs +61 -0
  229. package/src/touch-state.mjs +133 -0
  230. package/src/triggers-file.mjs +299 -39
  231. package/src/triggers.mjs +782 -40
  232. package/src/trust-paths.mjs +171 -0
  233. package/src/up.mjs +398 -51
  234. package/src/updates-cli.mjs +61 -0
  235. package/src/updates-look.mjs +127 -0
  236. package/src/updates-reconcile.mjs +182 -0
  237. package/src/updates-subscription-adapters.mjs +8 -0
  238. package/src/updates.mjs +252 -0
  239. package/src/valkey-auth.mjs +32 -9
  240. package/src/valkey-endpoint.mjs +14 -4
  241. package/src/wait-for.mjs +1 -1
  242. package/src/wait-state.mjs +62 -3
  243. package/src/watch-board.mjs +38 -0
  244. package/src/watch-closer.mjs +1 -1
  245. package/src/watch-contract.mjs +102 -0
  246. package/src/watch-decision.mjs +78 -0
  247. package/src/watch-drainer.mjs +14 -0
  248. package/src/watch-issue-adapter.mjs +44 -0
  249. package/src/watch-issue.mjs +111 -0
  250. package/src/watch-record.mjs +10 -0
  251. package/src/watch-runtime.mjs +167 -0
  252. package/src/watch-store.mjs +183 -0
  253. package/src/watch-trace.mjs +34 -0
  254. package/src/workspace-checkpoint.mjs +1750 -0
  255. package/src/zero-touch-fixes.mjs +99 -0
  256. package/src/zero-touch.mjs +225 -0
  257. package/src/repeat-slot.mjs +0 -13
@@ -0,0 +1,159 @@
1
+ /** A terminal-only composition of existing bootstrap, token, service and login-link commands (#774). */
2
+ import * as nodeFs from "node:fs";
3
+ import { execFile } from "node:child_process";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import { resolveConsoleEntry, findOnPath, consoleTokensPathFor, installedUnitPaths, planServiceSetup, runService } from "./service.mjs";
7
+ import { addToken, readTokensFile, TOKEN_NAME_RE, tokenNames } from "./console-tokens.mjs";
8
+ import { makeConsoleAudit } from "./console-audit.mjs";
9
+ import { deploymentServiceEnv } from "./service-env.mjs";
10
+ import { logsDirPath } from "./config.mjs";
11
+ import { probeConsole, consoleProbeTarget } from "./console-probe.mjs";
12
+ import { resolveSetupWorkerEntry, setupCachePath, trustedSetupFile } from "./setup-host-entry.mjs";
13
+
14
+ /**
15
+ * What the service manager's probe may print. `launchctl print gui/<uid>` describes the whole login session: 141 KiB on one real Mac,
16
+ * and more on a busier one. execFile kills a child that passes its bound and reports a string code, so the small bound below read a
17
+ * manager that answered as one that did not, and `up --console` refused the README's install on that Mac. Only this probe's exit code
18
+ * is read, so it gets a bound for what it may print, and every other command keeps the small one. REJECTED: reading the overflow as an
19
+ * answer, since the kill means no exit status was ever seen.
20
+ */
21
+ export const MANAGER_PROBE_MAX_BUFFER = 16 * 1024 * 1024;
22
+
23
+ export function boundedCommand(bin, args, { maxBuffer = 65536, ...options } = {}) {
24
+ return new Promise((done) => {
25
+ try { execFile(bin, args, { ...options, timeout: 15_000, maxBuffer, killSignal: "SIGKILL" }, (error, stdout) => done({ code: error ? typeof error.code === "number" ? error.code : null : 0, stdout: String(stdout ?? "") })); }
26
+ catch { done({ code: null, stdout: "" }); }
27
+ });
28
+ }
29
+ const shellWord = (value) => /^[A-Za-z0-9_./+-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
30
+
31
+ /** Inspect the installed peer's metadata without importing pi or accepting npm's floating peer resolution. */
32
+ export function verifyConsolePeer(consoleEntry, { fs = nodeFs, uid = process.geteuid?.(), expected = JSON.parse(nodeFs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).dependencies["@earendil-works/pi-ai"] } = {}) {
33
+ let parent = dirname(dirname(consoleEntry));
34
+ for (;;) {
35
+ const path = join(parent, "node_modules", "@earendil-works", "pi-coding-agent", "package.json");
36
+ try {
37
+ if (!trustedSetupFile(path, { uid, stat: fs.statSync, realpath: fs.realpathSync })) {
38
+ if (!fs.existsSync(path)) throw Object.assign(new Error(), { code: "ENOENT" });
39
+ return false;
40
+ }
41
+ const pkg = JSON.parse(fs.readFileSync(path, "utf8"));
42
+ const bin = pkg.bin?.pi;
43
+ return pkg.name === "@earendil-works/pi-coding-agent" && pkg.version === expected && bin === "dist/bundle/cli.js" && trustedSetupFile(join(dirname(path), bin), { uid, stat: fs.statSync, realpath: fs.realpathSync }) !== null && Object.entries(pkg.dependencies ?? {}).filter(([name]) => name.startsWith("@earendil-works/")).every(([, version]) => version === expected);
44
+ } catch (e) { if (e?.code !== "ENOENT" && e?.code !== "ENOTDIR") return false; }
45
+ if (parent === dirname(parent)) return false;
46
+ parent = dirname(parent);
47
+ }
48
+ }
49
+
50
+ export async function runConsoleUp(argv, deps = {}) {
51
+ const fs = deps.fs ?? nodeFs;
52
+ const env = deps.env ?? process.env;
53
+ const cwd = deps.cwd ?? process.cwd();
54
+ const platform = deps.platform ?? process.platform;
55
+ const uid = deps.euid ?? process.geteuid?.();
56
+ const home = deps.home ?? homedir();
57
+ const out = deps.out ?? ((text) => process.stdout.write(text));
58
+ const prompt = deps.prompt ?? (await import("./up.mjs")).defaultPrompt;
59
+ const run = deps.consoleCommand ?? boundedCommand;
60
+ const refuse = (text) => { out(`up --console: ${text}\n`); return 2; };
61
+ const worker = (deps.resolveSetupEntry ?? resolveSetupWorkerEntry)();
62
+ // env-internal PATH: find the installed admin binary; never a browser-supplied program search path.
63
+ const consoleEntry = (deps.resolveConsole ?? (() => resolveConsoleEntry({ findBin: (name) => findOnPath(name, env.PATH), realpath: fs.realpathSync })))();
64
+ // Every determinate bootstrap refusal comes before init, image pulls, tokens or unit writes.
65
+ if (!consoleEntry) return refuse("the admin package's built console is missing. Install exact matching @edgehero/pi-dispatch and @edgehero/pi-dispatch-admin versions durably in this deployment, then run its installed pi-dispatch up --console.");
66
+ if (!worker.entry || setupCachePath(consoleEntry)) return refuse("service entries must come from durable installed packages, never an npm execution cache. Install both packages into the deployment and use node_modules/.bin/pi-dispatch up --console.");
67
+ try {
68
+ const metadata = join(dirname(consoleEntry), "..", "package.json");
69
+ if (!trustedSetupFile(consoleEntry, { uid, stat: fs.statSync, realpath: fs.realpathSync }) || !trustedSetupFile(metadata, { uid, stat: fs.statSync, realpath: fs.realpathSync })) return refuse("the built admin entry and its installation directories must belong to this account or root and be writable by them alone.");
70
+ const pkg = JSON.parse(fs.readFileSync(metadata, "utf8"));
71
+ if (pkg.name !== "@edgehero/pi-dispatch-admin" || pkg.version !== worker.version || !fs.statSync(consoleEntry).isFile()) return refuse("the worker and built admin package must have the same exact version.");
72
+ } catch { return refuse("the admin package's built entry or package metadata could not be validated."); }
73
+ if (!(deps.verifyConsolePeer ?? verifyConsolePeer)(consoleEntry, { fs, uid })) return refuse("the installed admin's pi peer is missing, unsafe or differs from the worker's exact pin. Install the matching pinned pi-coding-agent package before bootstrap.");
74
+ if (!["linux", "darwin"].includes(platform) || (platform === "darwin" && uid === 0)) return refuse("browser host setup needs a Linux user systemd manager or your macOS LaunchAgent session; use terminal setup on this platform.");
75
+ let dir;
76
+ try {
77
+ dir = fs.realpathSync(cwd);
78
+ const st = fs.statSync(dir);
79
+ if (st.uid !== uid || (st.mode & 0o022)) return refuse("the deployment directory must belong to this account and be writable by this account alone.");
80
+ } catch { return refuse("the deployment directory could not be validated."); }
81
+ const declaredConsoleKeys = [...new Set(["PI_CONSOLE_LISTEN", "PI_CONSOLE_TOKENS_FILE", "PI_LOGS_DIR", ...Object.keys(env).filter((key) => key.startsWith("PI_CONSOLE_"))])];
82
+ const read = deploymentServiceEnv({ env: {}, dir, keys: declaredConsoleKeys, fs, uid, platform });
83
+ if (read.untrusted || read.unreadable && read.unreadable !== "ENOENT" || read.unread?.length || read.hazardSkipped?.length) return refuse("the deployment environment is unsafe or ambiguous; correct it before console bootstrap.");
84
+ if (declaredConsoleKeys.some((key) => typeof env[key] === "string" && env[key] !== read.env[key])) return refuse("console settings in this shell differ from the deployment service's environment. Unset the shell overrides before bootstrap.");
85
+ const target = consoleProbeTarget(read.env.PI_CONSOLE_LISTEN);
86
+ if (target.unknown) return refuse("the console needs a concrete local port or private socket so its one-time setup link can be reached.");
87
+ if (target.kind === "unix") {
88
+ try { const parent = fs.lstatSync(dirname(target.path)); if (!parent.isDirectory() || parent.isSymbolicLink() || parent.uid !== uid || (parent.mode & 0o077)) return refuse("the console socket's parent must be a private directory owned by this account."); }
89
+ catch { return refuse("the console socket's private parent directory must exist before bootstrap."); }
90
+ }
91
+ const tokensPath = (deps.consoleTokensPath ?? consoleTokensPathFor)(dir, platform);
92
+ let existing = null;
93
+ try { if (fs.existsSync(tokensPath)) { const got = (deps.readTokens ?? readTokensFile)({ path: tokensPath, fs, uid }); if (got.refusal) return refuse("the console tokens file is unsafe or invalid; correct it before bootstrap."); existing = got.file; } }
94
+ catch { return refuse("the console tokens file could not be inspected."); }
95
+ const units = installedUnitPaths(platform, home).filter((row) => row.which === "console" && fs.existsSync(row.path));
96
+ let consolePlan;
97
+ const serviceDeps = { ...deps, cwd: dir, env, platform, euid: uid, home, fs, resolveConsole: () => consoleEntry, setupConsoleGrant: true };
98
+ for (const unit of units) {
99
+ try {
100
+ if (unit.scope !== "user") return refuse("a console unit already exists without this exact setup grant or for another deployment. Review it and reinstall explicitly with service install --console --host-setup --force.");
101
+ consolePlan = await (deps.consoleServicePlan ?? planServiceSetup)(["start", "--console"], serviceDeps);
102
+ if (!consolePlan || consolePlan.refusal || typeof consolePlan !== "object") return refuse("the existing console unit or loaded manager command differs from this exact setup grant. Review it and reinstall explicitly with service install --console --host-setup --force.");
103
+ } catch { return refuse("the existing console service could not be validated."); }
104
+ }
105
+ const manager = await run(platform === "linux" ? "systemctl" : "launchctl", platform === "linux" ? ["--user", "show-environment"] : ["print", `gui/${uid}`], { cwd: dir, env, maxBuffer: MANAGER_PROBE_MAX_BUFFER });
106
+ if (manager.code !== 0) return refuse("the account's user service manager did not answer. Start it before console bootstrap.");
107
+ const operators = existing?.tokens.filter((entry) => entry.role === "operator") ?? [];
108
+ let name = operators.length === 1 ? operators[0].name : null;
109
+ if (operators.length > 1) {
110
+ out(`Operator entries: ${operators.map((row) => row.name).join(", ")}\n`);
111
+ name = String(await prompt("Name the operator entry for this setup link: ")).trim();
112
+ if (!operators.some((row) => row.name === name)) return refuse("no existing operator was selected; nothing was changed.");
113
+ }
114
+ if (operators.length === 0) {
115
+ name = argv.includes("--yes") ? "operator" : String(await prompt("Name the new operator entry [operator]: ")).trim() || "operator";
116
+ // Every name the file holds, an entry kept for a role this build does not know included (issue #761).
117
+ if (!TOKEN_NAME_RE.test(name) || tokenNames(existing).includes(name)) return refuse("choose an unused valid operator name; nothing was changed.");
118
+ }
119
+ out("Console bootstrap will grant this account's console bounded local host setup (--host-setup). It starts the console service; the worker is started only by a separate confirmed action.\n");
120
+ if (!argv.includes("--yes") && !/^y(es)?$/i.test(String(await prompt("Proceed with console bootstrap? [y/N] ")).trim())) { out("Console bootstrap declined; nothing was changed.\n"); return 0; }
121
+ const upCode = await deps.runUpFn(argv.filter((arg) => arg !== "--console"), { ...deps, cwd: dir });
122
+ if (!fs.existsSync(join(dir, ".env"))) return refuse("up left no deployment .env, so no token or console unit was made; finish pi-dispatch up first.");
123
+ const logRead = deploymentServiceEnv({ env: {}, dir, keys: ["PI_LOGS_DIR"], fs, uid, platform });
124
+ const audit = deps.audit ?? makeConsoleAudit({ logsDir: resolve(dir, logsDirPath(logRead.env)), fs, now: deps.now ?? Date.now });
125
+ if (operators.length === 0) {
126
+ try { audit.append({ actor: { name: "shell", role: "shell", session: null }, action: "token-added", target: name, outcome: "ok" }); }
127
+ catch { return refuse("the audit log could not be written, so no operator token was created."); }
128
+ const made = (deps.addToken ?? addToken)({ path: tokensPath, name, role: "operator", fs, uid, now: deps.now ?? Date.now });
129
+ if (made.invalid) {
130
+ try { audit.append({ actor: { name: "shell", role: "shell", session: null }, action: "token-added", target: name, outcome: "failed", reason: "token-write-refused" }); } catch { /* The failed write is still reported by the command. */ }
131
+ return refuse("the operator entry could not be created; inspect the token file and use pi-dispatch-console token add in the host terminal.");
132
+ }
133
+ delete made.token;
134
+ // Deliberately retain the hash only; recovering another link needs the account's CLI, not this plaintext.
135
+ out(`Created operator entry ${name}; its durable plaintext was discarded.\n`);
136
+ } else out(`Using existing operator entry ${name}.\n`);
137
+ const service = deps.runServiceFn ?? runService;
138
+ const serviceRow = { actor: { name: "shell", role: "shell", session: null }, action: "write", kind: units.length ? "setup-console-start" : "setup-console-install", target: "console", outcome: "ok" };
139
+ try { audit.append(serviceRow); } catch { return refuse("the audit log could not be written, so the console service was not changed; the operator entry remains."); }
140
+ const code = await service([units.length ? "start" : "install", "--console", ...(!units.length ? ["--host-setup"] : [])], { ...serviceDeps, ...(consolePlan ? { setupExpected: consolePlan } : {}) });
141
+ const recovery = `pi-dispatch-console link --deployment ${shellWord(dir)} --name ${name}${target.kind === "unix" ? " --port 4790" : ""} --next /setup`;
142
+ if (code !== 0) {
143
+ try { audit.append({ ...serviceRow, outcome: "failed", reason: "console-start-refused" }); } catch { /* Never repeat an uncertain service action for its audit row. */ }
144
+ return refuse(`the operator entry remains, but the console service did not start. Fix it with pi-dispatch service install --console --host-setup; then ${recovery}`);
145
+ }
146
+ const now = deps.now ?? Date.now;
147
+ const wait = deps.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
148
+ const probe = deps.probeConsole ?? probeConsole;
149
+ const until = now() + (deps.consoleWaitMs ?? 15_000);
150
+ let ready = false;
151
+ do { if ((await probe(read.env)).state === "running") { ready = true; break; } if (now() >= until) break; await wait(250); } while (true);
152
+ if (!ready) return refuse(`the console did not answer within the startup bound. Its token/unit remain. Once it answers, run ${recovery}`);
153
+ const link = await run(process.execPath, [consoleEntry, "link", "--deployment", dir, "--name", name, ...(target.kind === "unix" ? ["--port", "4790"] : []), "--next", "/setup"], { cwd: dir, env });
154
+ if (link.code !== 0 || !/^http:\/\/[^\s]+\/login\/code#[A-Za-z0-9_-]{43}&next=\/setup\s*$/.test(link.stdout.trim())) return refuse(`the console runs but no setup link was produced. Run ${recovery}`);
155
+ if (target.kind === "unix") out(`Reach this private socket through your tunnel first: ssh -L 4790:${shellWord(target.path)} <this host>\n`);
156
+ out(`${link.stdout.trim()}\nOpen it within 60 seconds; it works once. To make another link: ${recovery}\nTo replace the durable token: pi-dispatch-console token rotate --deployment ${shellWord(dir)} --name ${name}\n`);
157
+ if (upCode !== 0) out("Doctor reports remaining setup. Continue on /setup.\n");
158
+ return 0;
159
+ }