@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.
- package/.env.example +94 -4
- 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 +199 -30
- 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 +1663 -198
- package/src/dollar-budget.mjs +212 -3
- package/src/endpoint-status.mjs +100 -0
- package/src/env-allowlist.mjs +103 -9
- package/src/env-file.mjs +6 -4
- package/src/file-expect.mjs +47 -0
- package/src/forgejo-auth.mjs +4 -0
- package/src/forgejo-host.mjs +127 -31
- package/src/forgejo-prompt.mjs +5 -4
- package/src/forges.mjs +73 -15
- 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 +740 -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 +1554 -167
- 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 +296 -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 +782 -40
- 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,547 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal chat's fork mode (issue #647, REQ-TERMINAL-CHAT): open a FINISHED run's conversation in the operator's
|
|
3
|
+
* own pi, on the host that holds the session store, to read it and go on talking to it locally.
|
|
4
|
+
*
|
|
5
|
+
* THE CANONICAL TRANSCRIPT IS NEVER WRITTEN. `pi --session <path>` would open the store's `current.jsonl` and append to
|
|
6
|
+
* it, a second writer beside the worker's promotion (no lock covers it, pi appends plainly), and the next job on that
|
|
7
|
+
* issue would resume from the operator's local chat. `pi --fork <path>` copies the file into pi's own sessions folder
|
|
8
|
+
* and records the source as the fork's `parentSession`; even that is not handed the canonical file. This module first
|
|
9
|
+
* copies it, with `O_NOFOLLOW` and a descriptor re-check, to `~/.pi-dispatch/chat/<key>/source-<ts>.jsonl` (0600, the
|
|
10
|
+
* directories 0700), and forks the COPY, so nothing pi writes or records ever names a path inside PI_SESSIONS_DIR.
|
|
11
|
+
* The source is opened read-only; its bytes and its mtime are unchanged (a test holds both).
|
|
12
|
+
*
|
|
13
|
+
* HOST-LOCAL BY DECISION. No console route serves a raw transcript: the stored one is UNREDACTED (it holds whatever the
|
|
14
|
+
* job saw, a secret a tool printed included), and the console's transcripts are the redacted live stream. So this mode
|
|
15
|
+
* needs the store's directory on this machine and reads it directly; a remote fork was rejected (DES-TERMINAL-CHAT).
|
|
16
|
+
*
|
|
17
|
+
* THE CHECKS, ALL FREE, IN ORDER (`planFork`), each refusal a sentence:
|
|
18
|
+
* 1. the record has a session at all (a forge issue or a cron trigger: a forge or `local` kind);
|
|
19
|
+
* 2. it is not a pull or merge request (their key needs the head ref, which no record carries);
|
|
20
|
+
* 3. its key derives (`sessionKeyOfRecord`, the one key rule: an issue number or a cron trigger id);
|
|
21
|
+
* 4. the key, derived or given, is 32 lowercase hex;
|
|
22
|
+
* 5. PI_SESSIONS_DIR is set (the deployment's, as the service reads it);
|
|
23
|
+
* 6. the key's directory exists and can be read;
|
|
24
|
+
* 7. it is a real directory, not a link or a file;
|
|
25
|
+
* 8. `current.jsonl` exists and can be read;
|
|
26
|
+
* 9. it is not a symbolic link;
|
|
27
|
+
* 10. it is a regular file;
|
|
28
|
+
* 11. it is not empty;
|
|
29
|
+
* 12. it is within `FORK_SOURCE_MAX_BYTES`;
|
|
30
|
+
* 13. the venue stamp is not the promotion's `(pending)` sentinel (a promotion is swapping the file right now);
|
|
31
|
+
* 14. there is a pi to run (`resolvePiBinary`'s refusal, carried in);
|
|
32
|
+
* 15. the `pi-version` stamp is readable, unless `--force`;
|
|
33
|
+
* 16. the host pi's version is readable, unless `--force`;
|
|
34
|
+
* 17. the stamp is not newer than the host pi, unless `--force` (an older stamp is only noted: pi reads older
|
|
35
|
+
* sessions);
|
|
36
|
+
* then a warning (not a refusal) when a newer run on the same target shares the session: the file then holds that run's
|
|
37
|
+
* conversation too, since the store keeps one transcript per issue.
|
|
38
|
+
*
|
|
39
|
+
* `runFork` then refuses, in order: no absolute home directory; a chat folder inside PI_SESSIONS_DIR (the store's
|
|
40
|
+
* reaper owns everything there); a `~/.pi-dispatch` or chat folder that is a link, not a directory, or not owned by
|
|
41
|
+
* this user (one owned by this user is made 0700); and a copy that fails (the source swapped since the check, grown
|
|
42
|
+
* past the cap, or a planted destination). It asks y/N naming the bytes and the provider and model the operator's pi
|
|
43
|
+
* will send them to (`forkModelOf`: when pi's settings name a default provider AND model, both are passed to pi as
|
|
44
|
+
* `--provider` and `--model`, so the prompt names exactly what pi uses; otherwise the prompt says pi restores the run's
|
|
45
|
+
* own model when it has a key for that provider, and else falls back to its default, which is what pi 1.2.0's
|
|
46
|
+
* `createAgentSession` does). Then it runs `<pi> --fork <copy> --no-tools` in the operator's current directory, with
|
|
47
|
+
* every `PI_CONSOLE_*` variable taken out of pi's environment (the console token is this command's, never pi's).
|
|
48
|
+
* Tools are off unless `--tools`: the conversation is agent-written text from a job that read untrusted input, and a
|
|
49
|
+
* forked agent with tools would act on the operator's machine (their directory is not the job's workspace either, which
|
|
50
|
+
* the command says).
|
|
51
|
+
*
|
|
52
|
+
* NOTHING UNREDACTED IS LEFT IN ~/.pi-dispatch/chat. A "no", Ctrl+C or SIGTERM/SIGHUP at the y/N removes the copy
|
|
53
|
+
* (the signal then ends the command); once pi exits the copy is removed too, because `pi --fork` has by then written
|
|
54
|
+
* its own copy into its sessions folder (`SessionManager.forkFrom` writes `<sessionDir>/<ISO>_<id>.jsonl` before the
|
|
55
|
+
* first prompt, pi 1.2.0). That copy is pi's, under the operator's own pi, and the command says it holds the same
|
|
56
|
+
* unredacted transcript.
|
|
57
|
+
*/
|
|
58
|
+
import * as nodeFs from "node:fs";
|
|
59
|
+
import { spawn as nodeSpawn, spawnSync as nodeSpawnSync } from "node:child_process";
|
|
60
|
+
import { homedir } from "node:os";
|
|
61
|
+
import { delimiter, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
62
|
+
import { fileURLToPath } from "node:url";
|
|
63
|
+
import { FORK_SESSION_KEY_PATTERN, SESSION_KEY_PATTERN, sessionKeyOfRecord } from "./session-key.mjs";
|
|
64
|
+
import { isForgeKind } from "./forges.mjs";
|
|
65
|
+
|
|
66
|
+
/** The largest transcript forked. The store's own default cap is 8 MiB; this is generous above any setting of it. */
|
|
67
|
+
export const FORK_SOURCE_MAX_BYTES = 64 * 1024 * 1024;
|
|
68
|
+
|
|
69
|
+
/** The session store's file names (`session-store.mjs`; a test reads that file and holds these equal to it). */
|
|
70
|
+
export const SESSION_FILE = "current.jsonl";
|
|
71
|
+
export const PI_VERSION_FILE = "pi-version";
|
|
72
|
+
export const VENUE_FILE = "venue";
|
|
73
|
+
export const VENUE_PENDING = "(pending)";
|
|
74
|
+
|
|
75
|
+
/** Every sidecar is a handful of bytes (`session-store.mjs`'s SIDECAR_MAX_BYTES). */
|
|
76
|
+
const SIDECAR_MAX_BYTES = 4096;
|
|
77
|
+
|
|
78
|
+
/** How far a transcript's mtime may sit after its run's end before it is read as a later run's promotion. */
|
|
79
|
+
const NEWER_PROMOTION_SKEW_MS = 5 * 60 * 1000;
|
|
80
|
+
|
|
81
|
+
const VERSION_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The environment a child (pi, `pi --version`) is given: this shell's, minus every `PI_CONSOLE_*` variable. The console
|
|
85
|
+
* token and address are this command's credentials; a forked agent (with `--tools`, able to run commands) or anything
|
|
86
|
+
* it loads must never read them.
|
|
87
|
+
*/
|
|
88
|
+
export function childEnv(env = process.env) {
|
|
89
|
+
const kept = {};
|
|
90
|
+
for (const [name, value] of Object.entries(env ?? {})) if (!/^PI_CONSOLE_/.test(name)) kept[name] = value;
|
|
91
|
+
return kept;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** What to run when no pi is found. */
|
|
95
|
+
export const PI_INSTALL_HINT = "install pi (npm install -g @earendil-works/pi-coding-agent) or pass --pi <path>";
|
|
96
|
+
|
|
97
|
+
/** -1, 0 or 1 for two exact versions. */
|
|
98
|
+
export function compareVersions(a, b) {
|
|
99
|
+
const x = a.split(".").map(Number);
|
|
100
|
+
const y = b.split(".").map(Number);
|
|
101
|
+
for (let i = 0; i < 3; i += 1) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A pull or merge request's record: a `pull_request` trigger, or a target spelled with `!` (GitLab, Azure). */
|
|
106
|
+
export function isPullRequestRecord(record) {
|
|
107
|
+
return record?.triggerType === "pull_request" || (typeof record?.target === "string" && /![0-9]+$/.test(record.target));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A small sidecar beside the transcript: its trimmed text, or null when it is not a regular, non-empty, small file. */
|
|
111
|
+
function readSidecar(fs, dir, name) {
|
|
112
|
+
try {
|
|
113
|
+
const st = fs.lstatSync(join(dir, name));
|
|
114
|
+
if (!st.isFile() || st.size === 0 || st.size > SIDECAR_MAX_BYTES) return null;
|
|
115
|
+
return String(fs.readFileSync(join(dir, name), "utf8")).trim();
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Whether `child` is `parent` or inside it, by path alone. */
|
|
122
|
+
function within(parent, child) {
|
|
123
|
+
const rel = relative(resolve(parent), resolve(child));
|
|
124
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The pi binary to run: `--pi` (it must be an executable file), else `pi` on PATH, else the
|
|
129
|
+
* `@earendil-works/pi-coding-agent` this package can resolve (its `bin.pi`), else `{refusal}` with the install hint.
|
|
130
|
+
* Returns `{path, from}`.
|
|
131
|
+
*/
|
|
132
|
+
export function resolvePiBinary({ flag = null, env = process.env, fs = nodeFs, resolvePackage = defaultResolvePackage, platform = process.platform } = {}) {
|
|
133
|
+
const executable = (p) => {
|
|
134
|
+
try {
|
|
135
|
+
const st = fs.statSync(p);
|
|
136
|
+
if (!st.isFile()) return false;
|
|
137
|
+
if (platform === "win32") return true;
|
|
138
|
+
fs.accessSync(p, nodeFs.constants.X_OK);
|
|
139
|
+
return true;
|
|
140
|
+
} catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
if (typeof flag === "string" && flag !== "") {
|
|
145
|
+
const p = resolve(flag);
|
|
146
|
+
return executable(p) ? { path: p, from: "--pi" } : { refusal: `--pi ${p} is not an executable file` };
|
|
147
|
+
}
|
|
148
|
+
const names = platform === "win32" ? ["pi.cmd", "pi.exe", "pi"] : ["pi"];
|
|
149
|
+
// env-internal PATH: the operator's own command search path, to find the pi they run themselves.
|
|
150
|
+
for (const dir of String(env.PATH ?? "").split(delimiter)) {
|
|
151
|
+
if (dir === "" || !isAbsolute(dir)) continue;
|
|
152
|
+
for (const name of names) if (executable(join(dir, name))) return { path: join(dir, name), from: "PATH" };
|
|
153
|
+
}
|
|
154
|
+
const installed = resolvePackage();
|
|
155
|
+
if (installed && executable(installed)) return { path: installed, from: "the installed @earendil-works/pi-coding-agent" };
|
|
156
|
+
return { refusal: `no pi to run: ${PI_INSTALL_HINT}` };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The installed pi-coding-agent's `bin.pi`, or null. pi exports no package.json, so its entry is walked up from. */
|
|
160
|
+
function defaultResolvePackage() {
|
|
161
|
+
try {
|
|
162
|
+
let dir = dirname(fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent")));
|
|
163
|
+
for (let i = 0; i < 6; i += 1) {
|
|
164
|
+
const pkg = join(dir, "package.json");
|
|
165
|
+
if (nodeFs.existsSync(pkg)) {
|
|
166
|
+
const json = JSON.parse(nodeFs.readFileSync(pkg, "utf8"));
|
|
167
|
+
if (json?.name === "@earendil-works/pi-coding-agent" && typeof json?.bin?.pi === "string") return join(dir, json.bin.pi);
|
|
168
|
+
}
|
|
169
|
+
dir = dirname(dir);
|
|
170
|
+
}
|
|
171
|
+
} catch {
|
|
172
|
+
// not installed beside this package
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The host pi's version from `<pi> --version` (an exact x.y.z on its first line), or null. */
|
|
178
|
+
export function piVersionOf(piPath, { spawnSync = nodeSpawnSync, env = process.env } = {}) {
|
|
179
|
+
try {
|
|
180
|
+
const got = spawnSync(piPath, ["--version"], { encoding: "utf8", timeout: 15_000, env: childEnv(env), stdio: ["ignore", "pipe", "ignore"] });
|
|
181
|
+
const first = String(got?.stdout ?? "").trim().split("\n")[0]?.trim() ?? "";
|
|
182
|
+
return got?.status === 0 && VERSION_RE.test(first) ? first : null;
|
|
183
|
+
} catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const MODEL_ID_RE = /^[A-Za-z0-9._@~/:+-]{1,128}$/;
|
|
189
|
+
const plainId = (v) => (typeof v === "string" && MODEL_ID_RE.test(v) ? v : null);
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The operator's pi defaults, `{provider, model}` (each a plain id or null): `defaultProvider` and `defaultModel` from
|
|
193
|
+
* `<agentDir>/settings.json` ONLY, the operator's own file. The current folder's `.pi/settings.json` is never read,
|
|
194
|
+
* because these become `--provider` and `--model`, which decide where the unredacted transcript is sent: a cloned
|
|
195
|
+
* repository could otherwise route it to a provider of its author's choosing. pi 1.2.0 itself applies project
|
|
196
|
+
* settings only in a folder the operator trusted (`SettingsManager.setProjectTrusted`); not reading them at all is the
|
|
197
|
+
* simpler rule and needs no copy of pi's trust store.
|
|
198
|
+
*/
|
|
199
|
+
export function hostDefaultsOf({ agentDir, fs = nodeFs }) {
|
|
200
|
+
const file = join(agentDir, "settings.json");
|
|
201
|
+
try {
|
|
202
|
+
const st = fs.lstatSync(file);
|
|
203
|
+
if (!st.isFile() || st.size > 1024 * 1024) return { provider: null, model: null };
|
|
204
|
+
const settings = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
205
|
+
return { provider: plainId(settings?.defaultProvider), model: plainId(settings?.defaultModel) };
|
|
206
|
+
} catch {
|
|
207
|
+
return { provider: null, model: null }; // unreadable settings name nothing
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The model a pi session last used, `{provider, modelId}` or null: the last `model_change` entry, or the last assistant
|
|
213
|
+
* message's `provider`/`model`, whichever comes later in the file. That is what pi 1.2.0's `getBranchSelection` reads to
|
|
214
|
+
* restore a model when a session is opened (for a linear transcript, the file's order is the branch's).
|
|
215
|
+
*/
|
|
216
|
+
export function sessionModelOf(file, { fs = nodeFs } = {}) {
|
|
217
|
+
let text;
|
|
218
|
+
try {
|
|
219
|
+
text = String(fs.readFileSync(file, "utf8"));
|
|
220
|
+
} catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
const lines = text.split("\n");
|
|
224
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
225
|
+
if (lines[i].trim() === "") continue;
|
|
226
|
+
let entry;
|
|
227
|
+
try {
|
|
228
|
+
entry = JSON.parse(lines[i]);
|
|
229
|
+
} catch {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const pair = entry?.type === "model_change" ? [entry.provider, entry.modelId] : entry?.type === "message" && entry.message?.role === "assistant" ? [entry.message.provider, entry.message.model] : null;
|
|
233
|
+
if (pair === null) continue;
|
|
234
|
+
const [provider, modelId] = pair.map(plainId);
|
|
235
|
+
return provider && modelId ? { provider, modelId } : null;
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* What the fork's consent names and what pi is told, so the two never disagree: `{args, phrase}`.
|
|
242
|
+
* - pi's settings name a default provider AND model: both are passed (`--provider P --model M`), and pi uses exactly
|
|
243
|
+
* them (an explicit model is never replaced by the session's);
|
|
244
|
+
* - else, the session names a model: pi restores it only when it knows the model and has a key for its provider, and
|
|
245
|
+
* otherwise falls back to its default; the phrase says both, since this command cannot see pi's keys;
|
|
246
|
+
* - else: pi's default provider, or a phrase saying pi picks.
|
|
247
|
+
*/
|
|
248
|
+
export function forkModelOf({ defaults = {}, session = null } = {}) {
|
|
249
|
+
if (defaults.provider && defaults.model) {
|
|
250
|
+
return { args: ["--provider", defaults.provider, "--model", defaults.model], phrase: `${defaults.provider} (model ${defaults.model}, your pi's default, passed to pi as --provider and --model)` };
|
|
251
|
+
}
|
|
252
|
+
const fallback = defaults.provider ? `your pi's default provider, ${defaults.provider}` : "whichever provider your pi falls back to";
|
|
253
|
+
if (session) {
|
|
254
|
+
return { args: [], phrase: `${session.provider} (the run's own model, ${session.provider}/${session.modelId}) if your pi knows that model and has a key for ${session.provider}, and otherwise ${fallback}` };
|
|
255
|
+
}
|
|
256
|
+
return { args: [], phrase: defaults.provider ? `${defaults.provider} (your pi's default provider)` : "whichever provider your pi is set to use (its agent dir's settings name no defaultProvider)" };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Judge a fork before anything is copied or run (the module comment's checks 1 to 8, in that order). Inputs: `record`
|
|
261
|
+
* (a run record, or null for a bare session key), `key` (a 32-hex key given directly), `env` (the deployment's
|
|
262
|
+
* `PI_SESSIONS_DIR`), `force`, `newerRunOnTarget` (the console's: the newer run's job id, or null), `pi` (`{path}` from `resolvePiBinary`, or
|
|
263
|
+
* `{refusal}`), `piVersion` (the host pi's, from `piVersionOf`). Returns `{plan}` or `{refusal}`.
|
|
264
|
+
*/
|
|
265
|
+
export function planFork({ record = null, key = null, env = {}, force = false, newerRunOnTarget = false, pi, piVersion = null, fs = nodeFs }) {
|
|
266
|
+
let k = key;
|
|
267
|
+
if (record !== null) {
|
|
268
|
+
const sessionKind = isForgeKind(record?.kind) || record?.kind === "local";
|
|
269
|
+
if (!sessionKind) return { refusal: "this run has no session to fork: only a forge issue's runs and a cron trigger's runs keep one" };
|
|
270
|
+
if (isPullRequestRecord(record)) return { refusal: "this run worked on a pull or merge request, whose session key needs the request's head branch, which no run record carries; fork it by its session key instead (pi-dispatch chat <32-hex key>)" };
|
|
271
|
+
k = sessionKeyOfRecord(record);
|
|
272
|
+
if (k === null) return { refusal: "this run has no session to fork: its record names no issue number or cron trigger id to derive a session key from" };
|
|
273
|
+
}
|
|
274
|
+
const forkKey = typeof k === "string" ? FORK_SESSION_KEY_PATTERN.exec(k) : null;
|
|
275
|
+
if (typeof k !== "string" || (!SESSION_KEY_PATTERN.test(k) && !forkKey)) return { refusal: "a session key is 32 lowercase hex characters (a managed fork also names its 32-hex lineage)" };
|
|
276
|
+
const sessionsDir = typeof env.PI_SESSIONS_DIR === "string" ? env.PI_SESSIONS_DIR.trim() : "";
|
|
277
|
+
if (sessionsDir === "") return { refusal: "PI_SESSIONS_DIR is not set for this deployment, so there is no session store on this host to fork from; run this on the host whose worker keeps the sessions, from its deployment folder" };
|
|
278
|
+
const dir = forkKey ? join(resolve(sessionsDir), forkKey[1], "forks", forkKey[2]) : join(resolve(sessionsDir), k);
|
|
279
|
+
let dst;
|
|
280
|
+
try {
|
|
281
|
+
if (forkKey) {
|
|
282
|
+
for (const parent of [join(resolve(sessionsDir), forkKey[1]), join(resolve(sessionsDir), forkKey[1], "forks")]) if (!fs.lstatSync(parent).isDirectory()) return { refusal: `${parent} is not a plain directory (a link or a file), so it is not read` };
|
|
283
|
+
}
|
|
284
|
+
dst = fs.lstatSync(dir);
|
|
285
|
+
} catch (e) {
|
|
286
|
+
if (e?.code === "ENOENT") return { refusal: `there is no session ${k} in ${resolve(sessionsDir)}: the run did not keep one (its trigger has no run.resume), it was reaped (PI_SESSIONS_TTL_DAYS), or the store is on another host` };
|
|
287
|
+
return { refusal: `the session folder ${dir} cannot be read (${e?.code ?? "error"})` };
|
|
288
|
+
}
|
|
289
|
+
if (!dst.isDirectory()) return { refusal: `${dir} is not a plain directory (a link or a file), so it is not read` };
|
|
290
|
+
const file = join(dir, SESSION_FILE);
|
|
291
|
+
let st;
|
|
292
|
+
try {
|
|
293
|
+
st = fs.lstatSync(file);
|
|
294
|
+
} catch (e) {
|
|
295
|
+
return { refusal: e?.code === "ENOENT" ? `session ${k} has no transcript (${SESSION_FILE}) yet` : `the transcript ${file} cannot be read (${e?.code ?? "error"})` };
|
|
296
|
+
}
|
|
297
|
+
if (st.isSymbolicLink()) return { refusal: `${file} is a symbolic link, so it is not read (the store never writes one)` };
|
|
298
|
+
if (!st.isFile()) return { refusal: `${file} is not a regular file, so it is not read` };
|
|
299
|
+
if (st.size === 0) return { refusal: `${file} is empty: there is no conversation to fork` };
|
|
300
|
+
if (st.size > FORK_SOURCE_MAX_BYTES) return { refusal: `${file} is ${st.size} bytes, over the ${FORK_SOURCE_MAX_BYTES}-byte limit for a fork` };
|
|
301
|
+
if (readSidecar(fs, dir, VENUE_FILE) === VENUE_PENDING) return { refusal: `a promotion is replacing session ${k}'s transcript right now; try again in a minute` };
|
|
302
|
+
if (pi?.refusal) return { refusal: pi.refusal };
|
|
303
|
+
const notes = [];
|
|
304
|
+
const stamp = readSidecar(fs, dir, PI_VERSION_FILE);
|
|
305
|
+
const stampOk = typeof stamp === "string" && VERSION_RE.test(stamp);
|
|
306
|
+
if (!stampOk) {
|
|
307
|
+
if (!force) return { refusal: `session ${k} has no readable pi-version stamp, so whether your pi can read it is unknown; pass --force to fork it anyway` };
|
|
308
|
+
notes.push("its pi-version stamp is unreadable (--force)");
|
|
309
|
+
} else if (piVersion === null) {
|
|
310
|
+
if (!force) return { refusal: `the version of ${pi.path} could not be read (pi --version), so whether it can read a session written by pi ${stamp} is unknown; pass --force to fork it anyway` };
|
|
311
|
+
notes.push(`written by pi ${stamp}; your pi's version could not be read (--force)`);
|
|
312
|
+
} else {
|
|
313
|
+
const cmp = compareVersions(stamp, piVersion);
|
|
314
|
+
if (cmp > 0 && !force) return { refusal: `session ${k} was written by pi ${stamp}, newer than your pi ${piVersion}; an older pi may misread it. Upgrade pi, or pass --force` };
|
|
315
|
+
if (cmp > 0) notes.push(`written by pi ${stamp}, newer than your pi ${piVersion} (--force)`);
|
|
316
|
+
else if (cmp < 0) notes.push(`written by pi ${stamp}; your pi ${piVersion} is newer, and pi reads older sessions`);
|
|
317
|
+
}
|
|
318
|
+
const warnings = [];
|
|
319
|
+
// The console's run says `finishedAt`; this host's run-history record (`buildRecord`) says `endedAt`.
|
|
320
|
+
const endedText = typeof record?.finishedAt === "string" ? record.finishedAt : typeof record?.endedAt === "string" ? record.endedAt : "";
|
|
321
|
+
const endedAt = Date.parse(endedText);
|
|
322
|
+
// The console names the newer run by its job id (`/api/run`'s `newerRunOnTarget`); a mtime after this run's end is
|
|
323
|
+
// the same fact read off the store, for a record the console was not asked about.
|
|
324
|
+
const newerId = typeof newerRunOnTarget === "string" && /^[A-Za-z0-9._:-]{1,200}$/.test(newerRunOnTarget) ? newerRunOnTarget : null;
|
|
325
|
+
if (newerId !== null || newerRunOnTarget === true || (Number.isFinite(endedAt) && st.mtimeMs > endedAt + NEWER_PROMOTION_SKEW_MS)) {
|
|
326
|
+
warnings.push(`a newer run on ${typeof record?.target === "string" ? record.target : "this target"}${newerId ? ` (${newerId})` : ""} shares this session: the transcript holds that run's conversation too, not only this run's`);
|
|
327
|
+
}
|
|
328
|
+
return { plan: { key: k, dir, file, bytes: st.size, dev: st.dev, ino: st.ino, stamp: stampOk ? stamp : null, piPath: pi.path, piVersion, notes, warnings, sessionsDir: resolve(sessionsDir) } };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Copy the transcript to the snapshot path: the source opened `O_RDONLY | O_NOFOLLOW` and its descriptor re-checked
|
|
333
|
+
* (a regular file, the same device and inode `planFork` judged, within the cap), the destination created
|
|
334
|
+
* `O_CREAT | O_EXCL | O_NOFOLLOW` at 0600 so nothing planted at the name is followed or overwritten. Returns the bytes
|
|
335
|
+
* copied; throws an Error with a sentence.
|
|
336
|
+
*/
|
|
337
|
+
export function copySnapshot({ plan, dest, fs = nodeFs }) {
|
|
338
|
+
const C = nodeFs.constants;
|
|
339
|
+
const src = fs.openSync(plan.file, C.O_RDONLY | (C.O_NOFOLLOW ?? 0));
|
|
340
|
+
let out = null;
|
|
341
|
+
try {
|
|
342
|
+
const st = fs.fstatSync(src);
|
|
343
|
+
if (!st.isFile() || st.dev !== plan.dev || st.ino !== plan.ino) throw new Error(`${plan.file} changed between the check and the copy; run the command again`);
|
|
344
|
+
if (st.size > FORK_SOURCE_MAX_BYTES) throw new Error(`${plan.file} grew past the ${FORK_SOURCE_MAX_BYTES}-byte limit`);
|
|
345
|
+
out = fs.openSync(dest, C.O_WRONLY | C.O_CREAT | C.O_EXCL | (C.O_NOFOLLOW ?? 0), 0o600);
|
|
346
|
+
const buf = Buffer.alloc(1024 * 1024);
|
|
347
|
+
let total = 0;
|
|
348
|
+
for (;;) {
|
|
349
|
+
const n = fs.readSync(src, buf, 0, buf.length, null);
|
|
350
|
+
if (n === 0) break;
|
|
351
|
+
total += n;
|
|
352
|
+
if (total > FORK_SOURCE_MAX_BYTES) throw new Error(`${plan.file} grew past the ${FORK_SOURCE_MAX_BYTES}-byte limit while it was copied`);
|
|
353
|
+
let off = 0;
|
|
354
|
+
while (off < n) off += fs.writeSync(out, buf, off, n - off);
|
|
355
|
+
}
|
|
356
|
+
return total;
|
|
357
|
+
} catch (e) {
|
|
358
|
+
if (out !== null) {
|
|
359
|
+
try {
|
|
360
|
+
fs.closeSync(out);
|
|
361
|
+
} catch {
|
|
362
|
+
// closing a failed copy
|
|
363
|
+
}
|
|
364
|
+
out = null;
|
|
365
|
+
try {
|
|
366
|
+
fs.unlinkSync(dest);
|
|
367
|
+
} catch {
|
|
368
|
+
// nothing to remove
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
throw e;
|
|
372
|
+
} finally {
|
|
373
|
+
if (out !== null) fs.closeSync(out);
|
|
374
|
+
fs.closeSync(src);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Create `dir` and its missing parents at 0700, and refuse one that is a link, not a directory, or owned by another
|
|
380
|
+
* user (who could read or swap what is written there); one of this user's with group or other bits is made 0700. `uid`
|
|
381
|
+
* is the seam (null where the platform has no uids).
|
|
382
|
+
*/
|
|
383
|
+
export function ensurePrivateDir(fs, dir, { uid = typeof process.getuid === "function" ? process.getuid() : null } = {}) {
|
|
384
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
385
|
+
const st = fs.lstatSync(dir);
|
|
386
|
+
if (!st.isDirectory()) throw new Error(`${dir} is not a plain directory`);
|
|
387
|
+
if (uid === null) return;
|
|
388
|
+
if (st.uid !== uid) throw new Error(`${dir} is owned by uid ${st.uid}, not by you (uid ${uid}), so nothing is written there`);
|
|
389
|
+
if ((st.mode & 0o077) !== 0) fs.chmodSync(dir, 0o700);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const EXIT_CODE_OF_SIGNAL = Object.freeze({ SIGINT: 130, SIGTERM: 143, SIGHUP: 129 });
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Run a planned fork: refuse when there is no terminal to ask on (`canAsk`, default `proc.stdin.isTTY`), so the
|
|
396
|
+
* unredacted copy is never written for a question that cannot be asked; copy the snapshot under
|
|
397
|
+
* `<home>/.pi-dispatch/chat/<key>/`, ask y/N (`ask(question)`, naming the
|
|
398
|
+
* bytes and the provider `forkModelOf` names), then spawn `<pi> --fork <snapshot>` (with `--provider`/`--model` when
|
|
399
|
+
* that names them, and `--no-tools` unless `tools`), stdio inherited, in `cwd`, with `childEnv(env)`. `out(line)`
|
|
400
|
+
* prints. Returns the exit code: pi's own, 1 for a refusal, 0 for a declined prompt, and 143 or 129 when a SIGTERM
|
|
401
|
+
* or SIGHUP was passed on to pi (the command was told to end, whatever pi then exited with).
|
|
402
|
+
*
|
|
403
|
+
* The snapshot never outlives the command: a "no" removes it; SIGINT, SIGTERM or SIGHUP from before the copy starts
|
|
404
|
+
* until the question is answered removes it and exits (130, 143, 129); once pi exits (or fails to start) it is
|
|
405
|
+
* removed, pi having written its own copy by then. Each removal also removes `<key>/` when that leaves it empty. The
|
|
406
|
+
* signal handlers go on BEFORE the copy: with no listener a signal takes the default action and kills the process
|
|
407
|
+
* mid-copy, leaving a partial unredacted file behind; with one, Node defers it to the handler. While pi runs, SIGTERM
|
|
408
|
+
* and SIGHUP are passed on to it and SIGINT is left to it (a Ctrl+C reaches pi from the terminal itself), so this
|
|
409
|
+
* process outlives pi and can clean up. `proc` is the seam (`on`, `removeListener`, `exit`, `stdin`).
|
|
410
|
+
*
|
|
411
|
+
* AN EMBEDDING HOST (the panel's `/dispatch chat`, issue #647 part 2) passes a `proc` whose `exit` does NOT end the
|
|
412
|
+
* process, because there it would end the operator's pi: a signal at the question then makes runFork return its exit
|
|
413
|
+
* code (130, 143 or 129) at once, with the copy removed and the line printed once, instead of asking or going on. Such a
|
|
414
|
+
* host's own signal handler can also end the process before these handlers run (they are added last), so
|
|
415
|
+
* `registerCleanup(remove)` is called once, before anything is copied, with the synchronous, idempotent removal of the
|
|
416
|
+
* copy and its emptied folder, for the host to run first (a prepended listener, `exit`).
|
|
417
|
+
*/
|
|
418
|
+
export async function runFork({ plan, ask, out, agentDir = null, cwd = process.cwd(), home = homedir(), tools = false, now = () => Date.now(), fs = nodeFs, spawn = nodeSpawn, env = process.env, proc = process, uid, canAsk = proc?.stdin?.isTTY === true, registerCleanup = null }) {
|
|
419
|
+
if (typeof home !== "string" || home === "" || !isAbsolute(home)) {
|
|
420
|
+
out("error: there is no home directory to keep the fork's copy in, so nothing was forked");
|
|
421
|
+
return 1;
|
|
422
|
+
}
|
|
423
|
+
const root = join(home, ".pi-dispatch", "chat");
|
|
424
|
+
if (within(plan.sessionsDir, root)) {
|
|
425
|
+
out(`error: the chat copies' folder ${root} is inside PI_SESSIONS_DIR (${plan.sessionsDir}), whose reaper owns everything there; move PI_SESSIONS_DIR to its own folder (the docs use ~/.pi-dispatch/sessions)`);
|
|
426
|
+
return 1;
|
|
427
|
+
}
|
|
428
|
+
if (!canAsk) {
|
|
429
|
+
out("error: there is no terminal to ask the y/N on (stdin is not a TTY), so nothing was copied or forked");
|
|
430
|
+
return 1;
|
|
431
|
+
}
|
|
432
|
+
const dir = join(root, plan.key);
|
|
433
|
+
const dest = join(dir, `source-${now()}.jsonl`);
|
|
434
|
+
const owner = uid === undefined ? {} : { uid };
|
|
435
|
+
// `ours` is true only while `dest` is a file this command made: copySnapshot removes its own failed copy, and a
|
|
436
|
+
// path someone else planted (EEXIST) is never ours to unlink.
|
|
437
|
+
let ours = false;
|
|
438
|
+
let dirChecked = false; // `<key>/` is removed only once it was checked as ours (ensurePrivateDir)
|
|
439
|
+
let removed = false;
|
|
440
|
+
const removeCopy = () => {
|
|
441
|
+
if (removed) return;
|
|
442
|
+
removed = true;
|
|
443
|
+
if (ours) {
|
|
444
|
+
try {
|
|
445
|
+
fs.unlinkSync(dest);
|
|
446
|
+
} catch {
|
|
447
|
+
// already gone
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (!dirChecked) return;
|
|
451
|
+
try {
|
|
452
|
+
fs.rmdirSync(dir); // only when empty: rmdir refuses a folder that still holds anything
|
|
453
|
+
} catch {
|
|
454
|
+
// not empty
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
registerCleanup?.(removeCopy);
|
|
458
|
+
const hook = (handlers) => {
|
|
459
|
+
for (const [signal, handler] of Object.entries(handlers)) proc.on(signal, handler);
|
|
460
|
+
return () => {
|
|
461
|
+
for (const [signal, handler] of Object.entries(handlers)) proc.removeListener(signal, handler);
|
|
462
|
+
};
|
|
463
|
+
};
|
|
464
|
+
const whileAsked = {};
|
|
465
|
+
let signalled = null; // the exit code of the first signal at the question, for a `proc.exit` that returns
|
|
466
|
+
for (const [signal, code] of Object.entries(EXIT_CODE_OF_SIGNAL)) {
|
|
467
|
+
whileAsked[signal] = () => {
|
|
468
|
+
if (signalled !== null) return;
|
|
469
|
+
signalled = code;
|
|
470
|
+
removeCopy();
|
|
471
|
+
out("not forked; the copy was removed");
|
|
472
|
+
proc.exit(code);
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
const unhookAsk = hook(whileAsked);
|
|
476
|
+
let bytes;
|
|
477
|
+
let model;
|
|
478
|
+
try {
|
|
479
|
+
ensurePrivateDir(fs, join(home, ".pi-dispatch"), owner);
|
|
480
|
+
ensurePrivateDir(fs, root, owner);
|
|
481
|
+
ensurePrivateDir(fs, dir, owner);
|
|
482
|
+
dirChecked = true;
|
|
483
|
+
ours = true;
|
|
484
|
+
bytes = copySnapshot({ plan, dest, fs });
|
|
485
|
+
model = forkModelOf({ defaults: agentDir ? hostDefaultsOf({ agentDir, fs }) : {}, session: sessionModelOf(dest, { fs }) });
|
|
486
|
+
} catch (e) {
|
|
487
|
+
if (bytes === undefined) ours = false;
|
|
488
|
+
unhookAsk();
|
|
489
|
+
removeCopy();
|
|
490
|
+
if (signalled !== null) return signalled;
|
|
491
|
+
out(`error: the transcript could not be copied (${e?.code ?? e?.message ?? "error"}), so nothing was forked`);
|
|
492
|
+
return 1;
|
|
493
|
+
}
|
|
494
|
+
if (signalled !== null) {
|
|
495
|
+
unhookAsk();
|
|
496
|
+
return signalled;
|
|
497
|
+
}
|
|
498
|
+
for (const n of plan.notes) out(`note: session ${plan.key} ${n}`);
|
|
499
|
+
for (const w of plan.warnings) out(`warning: ${w}`);
|
|
500
|
+
out(`The run's transcript is ${bytes} bytes, copied to ${dest} (removed again when this command ends).`);
|
|
501
|
+
out("It is UNREDACTED: it holds whatever the job saw, including any secret a tool printed.");
|
|
502
|
+
let yes;
|
|
503
|
+
try {
|
|
504
|
+
yes = await ask(`Open it in your pi? Every message you send goes, with this whole transcript, to ${model.phrase}. [y/N] `);
|
|
505
|
+
} finally {
|
|
506
|
+
unhookAsk();
|
|
507
|
+
}
|
|
508
|
+
if (signalled !== null) return signalled; // already removed and said, by the handler
|
|
509
|
+
if (!yes) {
|
|
510
|
+
removeCopy();
|
|
511
|
+
out("not forked; the copy was removed");
|
|
512
|
+
return 0;
|
|
513
|
+
}
|
|
514
|
+
out(`pi runs in ${cwd}: your current folder, NOT the job's workspace${tools ? "" : ", with its tools off (pass --tools to turn them on)"}.`);
|
|
515
|
+
out("pi writes the fork into its own sessions folder (that copy is as unredacted as this one, and stays yours to delete); the run's stored transcript is not changed, and nothing goes back to the forge.");
|
|
516
|
+
const args = ["--fork", dest, ...model.args, ...(tools ? [] : ["--no-tools"])];
|
|
517
|
+
return new Promise((resolveCode) => {
|
|
518
|
+
let unhookRun = () => {};
|
|
519
|
+
const end = (code) => {
|
|
520
|
+
unhookRun();
|
|
521
|
+
removeCopy();
|
|
522
|
+
resolveCode(code);
|
|
523
|
+
};
|
|
524
|
+
let child;
|
|
525
|
+
try {
|
|
526
|
+
child = spawn(plan.piPath, args, { cwd, stdio: "inherit", env: childEnv(env) });
|
|
527
|
+
} catch (e) {
|
|
528
|
+
out(`error: ${plan.piPath} could not be started (${e?.code ?? "error"})`);
|
|
529
|
+
return end(1);
|
|
530
|
+
}
|
|
531
|
+
let forwarded = null;
|
|
532
|
+
const pass = (signal) => () => {
|
|
533
|
+
forwarded ??= signal; // the first signal told to end the command decides its exit code
|
|
534
|
+
try {
|
|
535
|
+
child.kill?.(signal);
|
|
536
|
+
} catch {
|
|
537
|
+
// pi is already gone
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
unhookRun = hook({ SIGINT: () => {}, SIGTERM: pass("SIGTERM"), SIGHUP: pass("SIGHUP") });
|
|
541
|
+
child.on("error", (e) => {
|
|
542
|
+
out(`error: ${plan.piPath} could not be started (${e?.code ?? "error"})`);
|
|
543
|
+
end(1);
|
|
544
|
+
});
|
|
545
|
+
child.on("exit", (code, signal) => end(forwarded ? EXIT_CODE_OF_SIGNAL[forwarded] : Number.isInteger(code) ? code : signal ? 1 : 0));
|
|
546
|
+
});
|
|
547
|
+
}
|