@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,113 @@
1
+ /**
2
+ * Path rules for a workspace checkpoint (issue #645), shared by the SNAPSHOT walk and the RESTORE write.
3
+ *
4
+ * ONE MODULE FOR BOTH SIDES because the two must agree to the byte. A rule the snapshot applies and the
5
+ * restore does not is the tamper path: the store sits on the worker host, and a restore that trusts what a
6
+ * snapshot "would never have written" trusts a directory, not a rule. So the restore re-validates every
7
+ * path with these same functions, and a single disagreement refuses the whole restore.
8
+ *
9
+ * A LEAF that imports only node builtins, on `git-hardening.mjs`'s precedent: both the checkpointer and
10
+ * any future reader (doctor, an admin view) need it, and a leaf cannot grow an import cycle.
11
+ *
12
+ * Every function takes a `Buffer` or a `string`. The walk reads names as BUFFERS (`readdir` with
13
+ * `encoding: "buffer"`), because the default UTF-8 decode replaces an invalid byte with U+FFFD, and a name
14
+ * that decodes to something it is not is exactly the disagreement described above.
15
+ */
16
+
17
+ const MAX_SEGMENT_BYTES = 255;
18
+ export const MAX_PATH_BYTES = 4096;
19
+ export const MAX_DEPTH = 64;
20
+
21
+ const utf8 = new TextDecoder("utf-8", { fatal: true });
22
+
23
+ /**
24
+ * The codepoints HFS+ ignores when it compares names (git's `is_hfs_dotgit` list): ZWNJ/ZWJ and the
25
+ * directional marks, the bidi embeddings, the deprecated format characters and the BOM. `.g‌it` IS
26
+ * `.git` to a Mac's filesystem, so it must be `.git` to us too.
27
+ */
28
+ const HFS_IGNORABLE = /[‌-‏‪-‮-]/g;
29
+
30
+ function asBuffer(seg) {
31
+ return Buffer.isBuffer(seg) ? seg : Buffer.from(String(seg), "utf8");
32
+ }
33
+
34
+ /** Decode strictly, or `null` for bytes that are not UTF-8. */
35
+ function decode(buf) {
36
+ try {
37
+ return utf8.decode(buf);
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * True when `seg` names git's own directory on ANY filesystem a restore could land on, after the folding
45
+ * git itself applies under `core.protectHFS` and `core.protectNTFS`: case-insensitive, HFS ignorables
46
+ * removed, trailing dots and spaces stripped (NTFS drops them), and the 8.3 short name `git~1`.
47
+ *
48
+ * Over-inclusive on purpose in one place: everything from the first `:` is cut, so `.git::$INDEX_ALLOCATION`
49
+ * (an NTFS stream spelling of the directory) matches. A real file called `.git:notes` is then excluded too,
50
+ * which costs a checkpoint one oddly named file; the opposite mistake writes into a repository's config.
51
+ *
52
+ * Bytes that are not UTF-8 return false: no filesystem folds them to `.git`, and `validSegment` refuses
53
+ * them separately, so they are counted as a bad name rather than as a dotgit.
54
+ */
55
+ export function isDotGitSegment(seg) {
56
+ const s = decode(asBuffer(seg));
57
+ if (s === null) return false;
58
+ let folded = s.replace(HFS_IGNORABLE, "").toLowerCase();
59
+ const colon = folded.indexOf(":");
60
+ if (colon !== -1) folded = folded.slice(0, colon);
61
+ folded = folded.replace(/[. ]+$/, "");
62
+ return folded === ".git" || folded === "git~1";
63
+ }
64
+
65
+ /**
66
+ * One path component: non-empty, not `.`/`..`, no `/`, no NUL or other control byte (C0 and DEL), valid
67
+ * UTF-8, at most 255 bytes (the common NAME_MAX). Control bytes are refused rather than escaped because a
68
+ * name carrying a newline or an escape sequence would reach an operator's terminal through any listing,
69
+ * and nothing legitimate in a repository needs one.
70
+ */
71
+ export function validSegment(seg) {
72
+ const buf = asBuffer(seg);
73
+ if (buf.length === 0 || buf.length > MAX_SEGMENT_BYTES) return false;
74
+ if (buf.length === 1 && buf[0] === 0x2e) return false;
75
+ if (buf.length === 2 && buf[0] === 0x2e && buf[1] === 0x2e) return false;
76
+ for (const b of buf) {
77
+ if (b === 0x2f || b < 0x20 || b === 0x7f) return false;
78
+ }
79
+ return decode(buf) !== null;
80
+ }
81
+
82
+ /**
83
+ * A whole relative path: no leading `/`, every segment valid and none a dotgit, at most 4096 bytes and 64
84
+ * segments deep. The depth cap is the walk's own recursion bound, so a restore can never be handed a path
85
+ * the snapshot could not have produced.
86
+ */
87
+ export function validPath(p) {
88
+ const buf = asBuffer(p);
89
+ if (buf.length === 0 || buf.length > MAX_PATH_BYTES) return false;
90
+ if (buf[0] === 0x2f) return false;
91
+ const segments = [];
92
+ let start = 0;
93
+ for (let i = 0; i <= buf.length; i++) {
94
+ if (i === buf.length || buf[i] === 0x2f) {
95
+ segments.push(buf.subarray(start, i));
96
+ start = i + 1;
97
+ }
98
+ }
99
+ if (segments.length > MAX_DEPTH) return false;
100
+ return segments.every((seg) => validSegment(seg) && !isDotGitSegment(seg));
101
+ }
102
+
103
+ /**
104
+ * The key two paths collide under on a case-insensitive, normalising filesystem (APFS, HFS+, NTFS): NFC,
105
+ * lower case, HFS ignorables removed. Used only to DETECT a collision and refuse; it never names a file.
106
+ * Not the full Unicode case-folding table, which no filesystem uses either: each implements its own, and
107
+ * this one errs towards calling more pairs a collision, which refuses a restore rather than letting one
108
+ * file silently overwrite another.
109
+ */
110
+ export function foldCase(p) {
111
+ const s = Buffer.isBuffer(p) ? p.toString("utf8") : String(p);
112
+ return s.replace(HFS_IGNORABLE, "").normalize("NFC").toLowerCase();
113
+ }
package/src/cli.mjs CHANGED
@@ -1,13 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync, readFileSync } from "node:fs";
3
- import { join, resolve } from "node:path";
3
+ import { resolve } from "node:path";
4
4
  import { parseArgs } from "node:util";
5
5
  import { loadConfig } from "./config.mjs";
6
6
  import { EXIT_POLICY, installRejectionPrinter, installStdoutPipeGuard } from "./exit-code.mjs";
7
7
  import { isEntryModule } from "./entry.mjs";
8
8
  import { gitDirty, localRepoProblem } from "./git-dirty.mjs";
9
9
  import { imageRefProblem } from "./image-ref.mjs";
10
- import { resolveServiceEnv, serviceEnvFileOf, serviceEnvLoader } from "./service-env.mjs";
10
+ import { modelIdProblem, providerIdProblem } from "./model-ref.mjs";
11
+ import { cliDeploymentEnv } from "./service-env.mjs";
12
+
13
+ // Issue #804: `cliDeploymentEnv` lives in service-env.mjs, where `service restart --drain` reads it too (service.mjs
14
+ // must not import this entry module). Re-exported so every caller that took it from here keeps working.
15
+ export { cliDeploymentEnv };
11
16
 
12
17
  /** How long the kill switch waits on the host registry before acting on the shared queue alone. */
13
18
  const FLEET_READ_TIMEOUT_MS = 2_000;
@@ -15,13 +20,20 @@ const FLEET_READ_TIMEOUT_MS = 2_000;
15
20
  const USAGE = `pi-dispatch — run pi coding-agent flows on your own folders
16
21
 
17
22
  pi-dispatch init scaffold .env + triggers.json + pause-windows.json + pi-packages.json + subscriptions.json here
18
- pi-dispatch doctor [--fix] [--live]
23
+ pi-dispatch doctor [--json] [--fix] [--live] [--openshell] [--secrets] [--no-publish]
19
24
  preflight Docker, Valkey, the job image, and your provider key; --fix offers to run each fix (y/N per action);
20
- --live reads the backend declarations back off short-lived real containers (shown in docker ps while they run)
21
- pi-dispatch up [--yes] one consented pass: pull+tag the job image, start Valkey, init, doctor
25
+ --live reads the backend declarations back off short-lived real containers (shown in docker ps while they run);
26
+ --openshell says whether this host is ready for the openshell venue (run anyway once PI_BACKENDS names it);
27
+ --secrets resolves each project secret bundle here and prints its variable names, never values;
28
+ every run publishes its checks to Valkey for the console and the panel (labels with paths
29
+ and secret values redacted, kept 7 days), and --no-publish skips that
30
+ pi-dispatch up [--console] [--yes] [--adopt-volume] [--restart-proxy] [--refresh-proxy-rules]
31
+ one consented pass: pull+tag the job image, start Valkey, init, doctor
22
32
  pi-dispatch setup github mint GitHub App credentials in one browser click (App Manifest flow);
23
33
  every write shown first and individually consented — no --yes here
24
34
  (--webhook-url <URL> | --no-webhook) [--org <org>] [--name <appName>]
35
+ pi-dispatch auth <status|login CONNECTION|logout CONNECTION|usage CONNECTION|trial CONNECTION>
36
+ manage host-owned subscription authentication, see docs/subscription-auth.md
25
37
  pi-dispatch import-pi stage your host pi setup (models/skills/persona) into a global overlay
26
38
  [--no-extensions] [--with-packages] [--no-host-packages]
27
39
  [--packages-file <path>] [--from <agentDir>] [--to <overlayDir>]
@@ -37,15 +49,24 @@ const USAGE = `pi-dispatch — run pi coding-agent flows on your own folders
37
49
  pi-dispatch sandbox <jobId>
38
50
  re-open a finished run's sandbox as a shell — same image, same workspace,
39
51
  no credentials [--publish <port>[:<containerPort>]] [--pin]
52
+ pi-dispatch sandbox <id> --attach
53
+ return to the tracked shell on its original runtime
54
+ pi-dispatch sandbox --new <repo> --ref <branch|pr:N|sha> --as <trigger-index> [--pin]
55
+ prepare a kept workspace without an agent, container, reservation or run slot
56
+ pi-dispatch opener install --config <approved Fleet export.json>
57
+ register pi-dispatch-open for this account; terminal commands wait for Enter
40
58
  pi-dispatch sandbox --list
41
59
  what is still re-openable, for how long, and what is running now
60
+ pi-dispatch canary --role <role> --model <provider/model> --flow <flow> --cap-usd <amount>
61
+ replay up to three retained recent jobs in disposable workspaces with forge
62
+ access blocked, under one experiment cap [--count 1..10] [--json]
42
63
 
43
64
  pi-dispatch worker drain the queue (run this in another terminal, or as a service)
44
65
  pi-dispatch-receiver webhook receiver for forge triggers, its own bin (see docs/github.md)
45
- pi-dispatch service <render|install|uninstall|status|start|stop|restart> [--receiver] [--user|--system] [--force]
46
- run the worker (or --receiver) as an OS service — the deploy/ templates
66
+ pi-dispatch service <render|install|uninstall|status|start|stop|restart> [--receiver|--console] [--user|--system] [--force]
67
+ run the worker (or --receiver, or --console) as an OS service — the deploy/ templates
47
68
  rendered with this host's real paths, installed user-level;
48
- \`service restart --drain\` lets the in-flight job finish first
69
+ \`service restart --drain\` lets this host's running jobs finish first
49
70
  pi-dispatch egress render
50
71
  write model-endpoints.conf here from model-endpoints.json (in place), then print
51
72
  the command that reloads the egress proxy; it never reloads the proxy itself
@@ -58,6 +79,21 @@ const USAGE = `pi-dispatch — run pi coding-agent flows on your own folders
58
79
  pi-dispatch cancel <jobId> stop one job: a queued or held job is removed (the line says whether it had
59
80
  made attempts; cancel records nothing), a running one is aborted on whichever
60
81
  host owns it (its record says operator-cancel)
82
+ pi-dispatch steer <jobId> [--later] <text…|->
83
+ send a message to a running job whose trigger has run.steer: the agent reads it
84
+ after its current tool calls, or with --later once it would otherwise stop;
85
+ - reads the text from stdin. Prints the message id and its state, never the text
86
+ pi-dispatch steer --withdraw <jobId> <msgId>
87
+ take a message back: guaranteed while it is queued, best effort once written
88
+ pi-dispatch chat <run-id | issue-url> --fork-as-job [--entry <id>] [--position before|at] [--provider <id>] [--model <id>] [--thinking <level>] [--message <text>] [--yes]
89
+ pi-dispatch chat <run-id | issue-or-PR-url | session-key> [--fork] [--force] [--tools] [--pi <path>] [--console <addr>] [--yes]
90
+ open a run's chat here, through the operator console (PI_CONSOLE_TOKEN, else a prompt):
91
+ a running job is followed live and steered (Enter; /later <text>; /withdraw <id>;
92
+ Ctrl+C detaches), a parked one is answered, and a finished one (or a session key, or
93
+ --fork) opens in your own pi as a fork of its stored transcript, on the host that keeps
94
+ the sessions, with tools off unless --tools; the stored transcript is never changed.
95
+ A console that is not loopback is asked about first (the token goes over plain HTTP);
96
+ --yes answers that question
61
97
 
62
98
  Config comes from the environment (see .env.example); flags override it per run.
63
99
  Prefer being walked through all of this? The operator panel's /dispatch setup does every step
@@ -67,8 +103,14 @@ with a consent per action: pi install npm:@edgehero/pi-dispatch-admin`;
67
103
  // injects a collector instead of reassigning `process.stdout.write`. That matters because `node --test`
68
104
  // runs each file in a child process that serialises its own results over that same stdout, so a test
69
105
  // holding a replacement across an `await` swallows the runner's result frames (issue #266).
70
- export async function main(argv = process.argv.slice(2), env = process.env, { write = (chunk) => process.stdout.write(chunk), valkeyRefusal = valkeyRefusalAtStart, now = () => new Date() } = {}) {
106
+ export async function main(argv = process.argv.slice(2), env = process.env, { write = (chunk) => process.stdout.write(chunk), writeErr = (chunk) => process.stderr.write(chunk), valkeyRefusal = valkeyRefusalAtStart, now = () => new Date() } = {}) {
71
107
  const cmd = argv[0];
108
+ if (cmd === "auth") {
109
+ const { runAuth } = await import("./auth-cli.mjs");
110
+ const deployment = cliDeploymentEnv(env, ["PI_AUTH_CONNECTIONS_FILE", "PI_AUTH_STORE_DIR", "PI_WORKER_NAME", "PI_SETTINGS_FILE", "PI_GLOBAL_PI_DIR", "VALKEY_URL"]);
111
+ if (deployment.problem) { writeErr(`${deployment.problem}\n`); return 2; }
112
+ return runAuth(argv.slice(1), { env: deployment.env, write, writeErr, now: () => Number(now()) });
113
+ }
72
114
 
73
115
  if (cmd === "init") {
74
116
  const { runInit } = await import("./init.mjs");
@@ -76,11 +118,42 @@ export async function main(argv = process.argv.slice(2), env = process.env, { wr
76
118
  // the podman ladder.
77
119
  return runInit(process.cwd(), { env });
78
120
  }
121
+ if (cmd === "setup-host") {
122
+ if (argv.length !== 1) return 2;
123
+ const { runSetupHostCli } = await import("./setup-host.mjs");
124
+ return runSetupHostCli({ env });
125
+ }
79
126
 
80
127
  if (cmd === "doctor") {
81
128
  const { runDoctor } = await import("./doctor.mjs");
82
129
  // `fix` and `live` ride in the deps position (runDoctor(env, depsOrOpts)) — one options bag, no third arg.
83
- return runDoctor(env, { fix: argv.slice(1).includes("--fix"), live: argv.slice(1).includes("--live") });
130
+ // `--openshell` (issue #683): the openshell venue's lines, before PI_BACKENDS blesses it (issue #684 made it blessable).
131
+ // `--secrets` (issue #704): resolve each project secret bundle on this host, printing variable names and a count only.
132
+ // Publishing (issue #712) is ON unless `--no-publish`; `--publish` says it explicitly (the worker's own runs pass it),
133
+ // and the two together are refused rather than one silently winning.
134
+ const flags = argv.slice(1);
135
+ if (flags.includes("--publish") && flags.includes("--no-publish")) {
136
+ writeErr("pi-dispatch doctor: --publish and --no-publish contradict each other; pass one\n");
137
+ return 2;
138
+ }
139
+ // `--by-worker=<hours>` is the worker's own spawn saying so (doctor-spawner.mjs), with the interval it runs at, so
140
+ // the record names who ran it and carries the real interval. Not in the usage: an operator has no reason to type it,
141
+ // and one who does only mislabels their own run.
142
+ // Held to the worker's own range (1 to DOCTOR_INTERVAL_MAX_HOURS: a worker at 0 spawns no doctor at all), refused
143
+ // otherwise, as a mistyped flag is.
144
+ const byWorkerFlag = flags.find((f) => f.startsWith("--by-worker"));
145
+ let publishBy = null;
146
+ if (byWorkerFlag !== undefined) {
147
+ const { DOCTOR_INTERVAL_MAX_HOURS } = await import("./doctor-spawner.mjs");
148
+ const m = /^--by-worker=(\d{1,3})$/.exec(byWorkerFlag);
149
+ const hours = m ? Number(m[1]) : NaN;
150
+ if (!(hours >= 1 && hours <= DOCTOR_INTERVAL_MAX_HOURS)) {
151
+ writeErr(`pi-dispatch doctor: --by-worker takes the worker's doctor interval in hours, 1 to ${DOCTOR_INTERVAL_MAX_HOURS}\n`);
152
+ return 2;
153
+ }
154
+ publishBy = { worker: true, every: hours };
155
+ }
156
+ return runDoctor(env, { json: flags.includes("--json"), fix: flags.includes("--fix"), live: flags.includes("--live"), openshell: flags.includes("--openshell"), secrets: flags.includes("--secrets"), publish: !flags.includes("--no-publish"), publishBy });
84
157
  }
85
158
 
86
159
  if (cmd === "up") {
@@ -104,10 +177,23 @@ export async function main(argv = process.argv.slice(2), env = process.env, { wr
104
177
  return runImportPi(argv.slice(1), { env });
105
178
  }
106
179
 
180
+ if (cmd === "updates") {
181
+ const { runUpdatesCli } = await import("./updates-cli.mjs");
182
+ return runUpdatesCli(argv.slice(1), { env });
183
+ }
184
+
107
185
  if (cmd === "sandbox") {
108
186
  const { runSandbox } = await import("./sandbox-cli.mjs");
109
187
  return runSandbox(argv.slice(1), { env });
110
188
  }
189
+ if (cmd === "opener") {
190
+ const { runOpener } = await import("./opener-cli.mjs");
191
+ return runOpener(argv.slice(1), { env });
192
+ }
193
+ if (cmd === "canary") {
194
+ const { runCanary } = await import("./canary-cli.mjs");
195
+ return runCanary(argv.slice(1), { env, write, deploymentEnv: cliDeploymentEnv });
196
+ }
111
197
 
112
198
  if (cmd === "worker") {
113
199
  const { startWorker } = await import("./start.mjs");
@@ -150,6 +236,15 @@ export async function main(argv = process.argv.slice(2), env = process.env, { wr
150
236
  const problem = imageRefProblem(values.image);
151
237
  if (problem) return fail(`--image ${problem.reason} (got ${JSON.stringify(values.image)})`);
152
238
  }
239
+ // The one model-id and provider-id rule (model-ref.mjs, issue #673), before anything is queued, so a local run's
240
+ // model is one the console's runs filter can find, as a trigger's, PI_MODEL's and the overlay's are. An empty
241
+ // flag is refused too, never read as absent: it used to be enqueued as "" and run on a model named "". The
242
+ // value is not echoed, as no other refusal of this rule echoes it.
243
+ for (const [flag, judge] of [["provider", providerIdProblem], ["model", modelIdProblem]]) {
244
+ if (values[flag] === undefined) continue;
245
+ const problem = judge(values[flag]);
246
+ if (problem) return fail(`--${flag} ${problem}. Nothing was queued.`);
247
+ }
153
248
 
154
249
  // The worker's folder rule, before anything is queued (issue #524): `.git` at the folder itself and a commit at
155
250
  // HEAD. A folder that fails it used to be queued anyway and refused at pickup as a generic `config-refused`.
@@ -257,6 +352,24 @@ export async function main(argv = process.argv.slice(2), env = process.env, { wr
257
352
  return runCancel(jobId, picked.urls[0], { write });
258
353
  }
259
354
 
355
+ if (cmd === "steer") {
356
+ // cancel's footing (issue #646): VALKEY_URL only, the kill switch's resolver, and a refusal on a shell/.env
357
+ // disagreement, since a job id belongs to one Valkey. parseArgs is strict, so a word of the message that looks
358
+ // like a flag is refused rather than silently dropped from the text; `--` or stdin is the way to send one.
359
+ const picked = await killSwitchUrls(argv.slice(1), env, { later: { type: "boolean", default: false }, withdraw: { type: "boolean", default: false } });
360
+ if (picked.error) return fail(/^Unknown option|^Option '/.test(picked.error) ? `${picked.error}\n A message word that starts with a dash needs -- before the text, or pass - and pipe the text on stdin. Nothing was sent` : picked.error);
361
+ if (picked.urls.length > 1) return fail(`${picked.disagreement}: a job lives in one of them. Say which: pi-dispatch steer ${picked.positionals[0] ?? "<jobId>"} --valkey-url <url> ...`);
362
+ const { runSteer } = await import("./steer-cli.mjs");
363
+ return runSteer({ positionals: picked.positionals, later: picked.values.later, withdraw: picked.values.withdraw }, picked.urls[0], { write });
364
+ }
365
+
366
+ if (cmd === "chat") {
367
+ // Through the operator console (issue #647): its cookie login, its run lookup and its actions, never Valkey, so a
368
+ // terminal behind an SSH tunnel needs nothing else. The fork mode reads this host's session store directly.
369
+ const { runChat } = await import("./chat-cli.mjs");
370
+ return runChat(argv.slice(1), { env, out: (line) => write(`${line}\n`), err: (line) => writeErr(`${line}\n`), deploymentEnv: cliDeploymentEnv });
371
+ }
372
+
260
373
  write(`${USAGE}\n`);
261
374
  return cmd && cmd !== "--help" && cmd !== "-h" ? 1 : 0; // asked-for help is success; a typo is not (the receiver's rule)
262
375
  }
@@ -264,13 +377,14 @@ export async function main(argv = process.argv.slice(2), env = process.env, { wr
264
377
  /**
265
378
  * The Valkey(s) a kill-switch verb acts on: the one rule of `killSwitchValkeyUrls` (valkey-endpoint.mjs), shared with the
266
379
  * panel. The verb's own flags are parsed with parseArgs over what follows the verb (PR #475's review, round 3: a hand
267
- * scan took `--valkey-url` for the job id in `cancel --valkey-url URL j1`). `{ urls, disagreement, positionals }` or
380
+ * scan took `--valkey-url` for the job id in `cancel --valkey-url URL j1`). `extraOptions` adds a verb's own parseArgs
381
+ * flags (steer's `--later` and `--withdraw`), returned in `values`. `{ urls, disagreement, positionals, values }` or
268
382
  * `{ error }`; a note (a URL that matches neither side) is written to stderr.
269
383
  */
270
- async function killSwitchUrls(args, env) {
384
+ async function killSwitchUrls(args, env, extraOptions = {}) {
271
385
  let parsed;
272
386
  try {
273
- parsed = parseArgs({ args, allowPositionals: true, options: { "valkey-url": { type: "string" } } });
387
+ parsed = parseArgs({ args, allowPositionals: true, options: { ...extraOptions, "valkey-url": { type: "string" } } });
274
388
  } catch (error) {
275
389
  return { error: error.message };
276
390
  }
@@ -278,7 +392,7 @@ async function killSwitchUrls(args, env) {
278
392
  const picked = killSwitchValkeyUrls({ env, flagUrl: parsed.values["valkey-url"] ?? null });
279
393
  if (picked.error) return picked;
280
394
  if (picked.note) process.stderr.write(`warning: ${picked.note}\n`);
281
- return { ...picked, positionals: parsed.positionals };
395
+ return { ...picked, positionals: parsed.positionals, values: parsed.values };
282
396
  }
283
397
 
284
398
  /**
@@ -368,33 +482,6 @@ async function runTrigger(values, positionals, env, { write, valkeyRefusal, now
368
482
  */
369
483
  export const TRIGGER_LOADER_ENV_KEYS = Object.freeze(["PI_TRIGGERS_FILE", "PI_WORKER_NAME", "PI_MAX_COST_USD"]);
370
484
 
371
- /**
372
- * Deployment keys for a CLI verb that acts for the deployment (review of PR #575): `keys` resolved by issue #471's rule
373
- * (`resolveServiceEnv`, service-env.mjs), the one doctor and `up` read the service's keys by. This shell's value where
374
- * it sets the key, else the `.env` in `cwd` from a line the service's loader reads as written. Where the two disagree,
375
- * or a line naming the key cannot be read as the loader reads it, or the file cannot be read and this shell sets none,
376
- * the answer is unknown and the verb refuses (`problem`), as `up` refuses on a PI_JOB_IMAGE disagreement: a job queued
377
- * on a queue no worker drains, or from a triggers file the worker does not read, is a silent no-op. Returns
378
- * `{ env }` (this shell's with the file's values filled in) or `{ problem }`.
379
- */
380
- export function cliDeploymentEnv(env, keys, { cwd = process.cwd(), platform = process.platform, readFile = (p) => readFileSync(p) } = {}) {
381
- const envPath = join(cwd, ".env");
382
- let file;
383
- try {
384
- file = serviceEnvFileOf(readFile(envPath), envPath, serviceEnvLoader(platform));
385
- } catch (error) {
386
- const unset = keys.filter((k) => typeof env[k] !== "string");
387
- if (error?.code !== "ENOENT" && unset.length > 0) return { problem: `${envPath} could not be read (${error?.code ?? "error"}), so ${unset.join(" and ")} cannot be told` };
388
- return { env };
389
- }
390
- const read = resolveServiceEnv({ env, file, keys });
391
- const [d] = read.disagreements;
392
- if (d) return { problem: `${d.key} is ${JSON.stringify(d.shell)} in this shell and ${JSON.stringify(d.file)} in ${envPath}: make them agree (the service runs the file's)` };
393
- const unread = [...read.unread.map((u) => u.key), ...read.hazardSkipped];
394
- if (unread.length > 0) return { problem: `${envPath} has a line for ${unread.join(" and ")} that the service's loader may read differently, so it cannot be told (pi-dispatch doctor names the line)` };
395
- return { env: read.env };
396
- }
397
-
398
485
  /** `workerHint`'s line when the queue cannot say, true whether or not a worker runs. */
399
486
  export const WORKER_HINT_UNKNOWN = "a worker picks it up; start one with `pi-dispatch worker` if none is running.";
400
487