@edgehero/pi-dispatch 4.3.1 → 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.
- package/.env.example +84 -3
- package/README.md +20 -7
- package/deploy/console.service +52 -0
- package/deploy/docker-compose.yml +3 -1
- package/deploy/pi-dispatch-valkey.container +4 -2
- package/deploy/sandbox-gateway.service +23 -0
- package/deploy/worker-env-wrapper.cmd +6 -0
- package/deploy/worker-env-wrapper.sh +10 -1
- package/package.json +117 -19
- package/src/agent-snapshot.mjs +49 -0
- package/src/answerer-runtime.mjs +194 -0
- package/src/answerer.mjs +178 -0
- package/src/approval-policy.mjs +76 -0
- package/src/approval-runtime.mjs +188 -0
- package/src/approval-session.mjs +137 -0
- package/src/approval-state.mjs +170 -0
- package/src/auth-cli.mjs +86 -0
- package/src/auth-connections.mjs +145 -0
- package/src/auth-policy.mjs +32 -0
- package/src/auth-store.mjs +97 -0
- package/src/azure-host.mjs +66 -4
- package/src/azure-prompt.mjs +5 -4
- package/src/backend-conformance.mjs +34 -2
- package/src/backend-local.mjs +103 -34
- package/src/backend-openshell.mjs +1540 -0
- package/src/backend-podman.mjs +63 -2
- package/src/backend-registry.mjs +61 -4
- package/src/backends.mjs +395 -6
- package/src/bullmq-internals.mjs +208 -0
- package/src/canary-cli.mjs +28 -0
- package/src/canary-lock.mjs +41 -0
- package/src/canary-runtime.mjs +237 -0
- package/src/canary.mjs +53 -0
- package/src/cancel-cli.mjs +47 -34
- package/src/cancel-state.mjs +213 -15
- package/src/capabilities.mjs +36 -1
- package/src/capacity-cli.mjs +28 -8
- package/src/capacity-records.mjs +75 -16
- package/src/capacity.mjs +65 -0
- package/src/chat-cli.mjs +288 -0
- package/src/chat-fork.mjs +547 -0
- package/src/chat-mode.mjs +156 -0
- package/src/chat-view.mjs +720 -0
- package/src/checkpoint-paths.mjs +113 -0
- package/src/cli.mjs +128 -41
- package/src/comment-tables.mjs +322 -0
- package/src/community-auth-adapters.mjs +87 -0
- package/src/community-auth-helper.mjs +76 -0
- package/src/community-auth-metadata.mjs +6 -0
- package/src/community-auth-output-pins.mjs +38 -0
- package/src/community-auth-patches.mjs +168 -0
- package/src/community-auth-pins.mjs +38 -0
- package/src/config.mjs +190 -29
- package/src/connection.mjs +83 -7
- package/src/console-answer.mjs +113 -0
- package/src/console-audit.mjs +604 -0
- package/src/console-bootstrap.mjs +159 -0
- package/src/console-client.mjs +848 -0
- package/src/console-probe.mjs +106 -0
- package/src/console-tokens.mjs +459 -0
- package/src/container-names.mjs +51 -0
- package/src/container-spec.mjs +20 -1
- package/src/cpu-reserve.mjs +4 -1
- package/src/defer-reasons.mjs +200 -0
- package/src/defer-write.mjs +208 -0
- package/src/deployment-venue.mjs +23 -6
- package/src/docker-run.mjs +11 -0
- package/src/doctor-bundles.mjs +130 -0
- package/src/doctor-model-proposals.mjs +109 -0
- package/src/doctor-openshell.mjs +238 -0
- package/src/doctor-results.mjs +602 -0
- package/src/doctor-spawner.mjs +215 -0
- package/src/doctor.mjs +1639 -196
- package/src/dollar-budget.mjs +212 -3
- package/src/endpoint-status.mjs +100 -0
- package/src/env-allowlist.mjs +96 -6
- package/src/env-file.mjs +6 -4
- package/src/file-expect.mjs +47 -0
- package/src/forgejo-host.mjs +42 -5
- package/src/forgejo-prompt.mjs +5 -4
- package/src/forges.mjs +33 -3
- package/src/fork-job.mjs +58 -0
- package/src/fork-policy.mjs +23 -0
- package/src/fork-store.mjs +338 -0
- package/src/get-token.mjs +1 -1
- package/src/git-dirty.mjs +119 -14
- package/src/git-hardening.mjs +85 -0
- package/src/github-host.mjs +31 -6
- package/src/github-prompt.mjs +7 -16
- package/src/gitlab-host.mjs +42 -3
- package/src/gitlab-prompt.mjs +5 -4
- package/src/held-document.mjs +23 -0
- package/src/held-replies.mjs +325 -0
- package/src/held-settle.mjs +266 -0
- package/src/hook-runs.mjs +188 -0
- package/src/host-budget.mjs +8 -5
- package/src/host-clone.mjs +72 -0
- package/src/host-loads.mjs +557 -0
- package/src/host-registry.mjs +66 -6
- package/src/host-telemetry.mjs +92 -0
- package/src/http-body.mjs +78 -0
- package/src/image-preflight.mjs +156 -13
- package/src/import-pi.mjs +60 -172
- package/src/inbox-relay.mjs +482 -0
- package/src/inbox-settle.mjs +196 -0
- package/src/index.mjs +734 -129
- package/src/init.mjs +35 -5
- package/src/job-data-version.mjs +75 -0
- package/src/job-id.mjs +30 -15
- package/src/job-layout.mjs +22 -0
- package/src/job-size.mjs +16 -0
- package/src/live-jobs.mjs +14 -6
- package/src/live-probes.mjs +6 -2
- package/src/live-transcript.mjs +872 -0
- package/src/loaded-record.mjs +609 -0
- package/src/mcp-config.mjs +1 -1
- package/src/mcp-preflight.mjs +36 -5
- package/src/model-attempts.mjs +84 -0
- package/src/model-catalog.mjs +83 -0
- package/src/model-cooldowns.mjs +22 -0
- package/src/model-failover-protocol.mjs +11 -0
- package/src/model-failover.mjs +134 -0
- package/src/model-observation.mjs +50 -0
- package/src/model-proposal-protocol.mjs +21 -0
- package/src/model-proposals.mjs +75 -0
- package/src/model-ref.mjs +67 -10
- package/src/model-roles-file.mjs +24 -0
- package/src/model-roles.mjs +73 -0
- package/src/model-status.mjs +34 -0
- package/src/models-json.mjs +4 -1
- package/src/money.mjs +1 -1
- package/src/oauth-openai.mjs +121 -0
- package/src/older-state.mjs +249 -0
- package/src/on-failure.mjs +17 -13
- package/src/open-browser.mjs +31 -2
- package/src/opener-cli.mjs +135 -0
- package/src/opener-protocol.mjs +27 -0
- package/src/opener-terminal.mjs +128 -0
- package/src/openshell-cli.mjs +155 -0
- package/src/openshell-policy.mjs +163 -0
- package/src/outbox.mjs +14 -0
- package/src/output-cap.mjs +1 -0
- package/src/package-canary-record.mjs +26 -0
- package/src/package-canary.mjs +135 -0
- package/src/package-names.mjs +9 -0
- package/src/package-stage.mjs +418 -0
- package/src/packages.mjs +24 -16
- package/src/parked.mjs +2 -1
- package/src/pause-windows.mjs +136 -17
- package/src/podman-stack.mjs +40 -14
- package/src/portfolio-snapshot.mjs +26 -7
- package/src/prepare-github.mjs +41 -77
- package/src/prepare-local.mjs +32 -2
- package/src/prepare.mjs +45 -11
- package/src/private-store-boundary.mjs +70 -0
- package/src/processor.mjs +1550 -173
- package/src/projects.mjs +23 -0
- package/src/prompt-fence.mjs +18 -0
- package/src/provider-key.mjs +7 -0
- package/src/provider-steering.mjs +1 -0
- package/src/question-context.mjs +35 -0
- package/src/question-group-runtime.mjs +91 -0
- package/src/question-groups.mjs +171 -0
- package/src/queue.mjs +264 -55
- package/src/reason-catalog.mjs +448 -0
- package/src/receiver-status.mjs +230 -0
- package/src/record-reason.mjs +37 -0
- package/src/recovery-queue.mjs +566 -0
- package/src/redact-secrets.mjs +219 -0
- package/src/reserve-estimate.mjs +101 -0
- package/src/reserved-env.mjs +36 -1
- package/src/retention.mjs +67 -0
- package/src/run-budget-ledger.mjs +283 -0
- package/src/run-checkpoint.mjs +627 -0
- package/src/run-container.mjs +749 -33
- package/src/run-history.mjs +791 -45
- package/src/run-mirror.mjs +75 -7
- package/src/run-progress.mjs +751 -0
- package/src/runtime-observations.mjs +3 -1
- package/src/runtime-settings.mjs +272 -37
- package/src/sandbox-cli.mjs +78 -2
- package/src/sandbox-deployment.mjs +51 -0
- package/src/sandbox-gateway-protocol.mjs +378 -0
- package/src/sandbox-hosts.mjs +100 -0
- package/src/sandbox-inventory.mjs +83 -0
- package/src/sandbox-policy.mjs +225 -0
- package/src/sandbox-prepare-requests.mjs +145 -0
- package/src/sandbox-prepare.mjs +285 -0
- package/src/sandbox-refs.mjs +70 -0
- package/src/sandbox-session-monitor.mjs +4 -0
- package/src/sandbox-session-watch.mjs +124 -0
- package/src/sandbox-sessions.mjs +161 -0
- package/src/sandbox-store.mjs +63 -6
- package/src/sandbox-workspace-guard.mjs +39 -0
- package/src/sandbox.mjs +27 -200
- package/src/scheduler-stall-guard.mjs +14 -8
- package/src/schedules.mjs +19 -6
- package/src/secret-bundles.mjs +245 -0
- package/src/secret-profiles.mjs +103 -3
- package/src/secrets.mjs +19 -9
- package/src/service-env.mjs +38 -5
- package/src/service.mjs +638 -106
- package/src/session-key.mjs +60 -0
- package/src/session-store.mjs +168 -10
- package/src/setup-contract.mjs +40 -0
- package/src/setup-egress.mjs +113 -0
- package/src/setup-host-entry.mjs +33 -0
- package/src/setup-host.mjs +242 -0
- package/src/setup-lock.mjs +41 -0
- package/src/size-records.mjs +15 -3
- package/src/start.mjs +1353 -135
- package/src/status-comment.mjs +415 -0
- package/src/stderr-transcript.mjs +318 -0
- package/src/steer-ack.mjs +122 -0
- package/src/steer-cli.mjs +213 -0
- package/src/steer-framing.mjs +185 -0
- package/src/steer-inbox.mjs +693 -0
- package/src/store-version.mjs +139 -0
- package/src/subscription-config.mjs +56 -0
- package/src/subscription-limits.mjs +3 -0
- package/src/subscription-quota.mjs +259 -0
- package/src/subscription-runtime.mjs +123 -0
- package/src/subscription-status.mjs +42 -0
- package/src/subscription-wait.mjs +11 -0
- package/src/subscriptions.mjs +7 -1
- package/src/thinking-level.mjs +19 -0
- package/src/touch-outbox.mjs +61 -0
- package/src/touch-state.mjs +133 -0
- package/src/triggers-file.mjs +299 -39
- package/src/triggers.mjs +780 -38
- package/src/trust-paths.mjs +171 -0
- package/src/up.mjs +398 -51
- package/src/updates-cli.mjs +61 -0
- package/src/updates-look.mjs +127 -0
- package/src/updates-reconcile.mjs +182 -0
- package/src/updates-subscription-adapters.mjs +8 -0
- package/src/updates.mjs +252 -0
- package/src/valkey-auth.mjs +32 -9
- package/src/valkey-endpoint.mjs +14 -4
- package/src/wait-for.mjs +1 -1
- package/src/wait-state.mjs +62 -3
- package/src/watch-board.mjs +38 -0
- package/src/watch-closer.mjs +1 -1
- package/src/watch-contract.mjs +102 -0
- package/src/watch-decision.mjs +78 -0
- package/src/watch-drainer.mjs +14 -0
- package/src/watch-issue-adapter.mjs +44 -0
- package/src/watch-issue.mjs +111 -0
- package/src/watch-record.mjs +10 -0
- package/src/watch-runtime.mjs +167 -0
- package/src/watch-store.mjs +183 -0
- package/src/watch-trace.mjs +34 -0
- package/src/workspace-checkpoint.mjs +1750 -0
- package/src/zero-touch-fixes.mjs +99 -0
- package/src/zero-touch.mjs +225 -0
- 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. `.git` 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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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`). `
|
|
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
|
|