plx-cli 0.0.0-stage → 0.5.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/README.md +60 -2
- package/bin/plx.mjs +15 -0
- package/package.json +28 -4
- package/src/cli.mjs +205 -0
- package/src/connect.mjs +325 -0
- package/src/detect.mjs +102 -0
- package/src/doctor-playbook.md +214 -0
- package/src/doctor-prompt.mjs +125 -0
- package/src/doctor.mjs +727 -0
- package/src/install.mjs +85 -0
- package/src/plxd.mjs +92 -0
- package/src/releases.mjs +133 -0
- package/src/run.mjs +141 -0
- package/src/scripts.mjs +304 -0
- package/src/signin.mjs +210 -0
- package/src/tailscale-setup.mjs +194 -0
- package/src/tailscale.mjs +162 -0
package/src/doctor.mjs
ADDED
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// `plx doctor`: checks Parallax on this computer, asking the running plxd itself, offers
|
|
3
|
+
// the fixes it knows, then hands the rest to the user's own coding agent (claude or codex) with
|
|
4
|
+
// a context file and the playbook in `doctor-playbook.md`, as T3 Code's `t3 triage` does. The
|
|
5
|
+
// agent asks what went wrong, investigates, and helps file an issue; the agent's own permission
|
|
6
|
+
// prompts gate what it runs.
|
|
7
|
+
|
|
8
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { existsSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, readSync, closeSync, statfsSync, statSync, writeFileSync } from "node:fs";
|
|
11
|
+
import net from "node:net";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { createInterface } from "node:readline/promises";
|
|
15
|
+
|
|
16
|
+
import { buildContext, buildLaunchPrompt, buildSeedPrompt, MARKS } from "./doctor-prompt.mjs";
|
|
17
|
+
import { fetchReleases, pickRelease } from "./releases.mjs";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {object} Fix A command that fixes a failed check.
|
|
21
|
+
* @property {string} title What it does, asked as a question.
|
|
22
|
+
* @property {string} file
|
|
23
|
+
* @property {string[]} args
|
|
24
|
+
* @property {string} [input] Piped to its stdin. Without it, the command runs in the terminal,
|
|
25
|
+
* for a sign-in that asks questions.
|
|
26
|
+
* @property {boolean} [quiet] Drop its stdout, such as `plxd attach`'s JSON-RPC.
|
|
27
|
+
* @property {Record<string, string>} [env] Added to the doctor's environment.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {object} Result
|
|
32
|
+
* @property {"ok" | "warn" | "fail" | "skip"} state `warn`: works, but worth a look. `skip`: the
|
|
33
|
+
* check couldn't tell.
|
|
34
|
+
* @property {string} detail
|
|
35
|
+
* @property {Fix} [fix]
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** @typedef {{ name: string, result: Result }} Checked */
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* An agent as `providers/list` reports it. Its env values may hold keys: `loginEnv` is only passed
|
|
42
|
+
* to the agent's own sign-in, as the app does, and none of them reaches the context file.
|
|
43
|
+
* @typedef {object} Provider
|
|
44
|
+
* @property {{ id: string, kind: string, name: string, enabled?: boolean, home?: string }} instance
|
|
45
|
+
* @property {boolean} [installed]
|
|
46
|
+
* @property {boolean} [signedIn]
|
|
47
|
+
* @property {string} [version]
|
|
48
|
+
* @property {string} [account]
|
|
49
|
+
* @property {string} [note]
|
|
50
|
+
* @property {string[]} [login]
|
|
51
|
+
* @property {{ name: string, value?: string }[]} [loginEnv]
|
|
52
|
+
* @property {string} [path] Where plxd found the agent's CLI.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What the running plxd answered over `plxd attach`. A method that failed or didn't answer is
|
|
57
|
+
* missing.
|
|
58
|
+
* @typedef {object} Answers
|
|
59
|
+
* @property {{ plxd: string, protocol: number }} [initialize]
|
|
60
|
+
* @property {{ os: string, arch: string, plxd: string }} [hostVersion]
|
|
61
|
+
* @property {{ uptimeSeconds: number, store: string, runningAgents: number, queues?: { store: { queued: number, maxWaitMicros: number } } }} [health]
|
|
62
|
+
* @property {{ providers: Provider[] }} [providers]
|
|
63
|
+
* @property {{ connect?: boolean, remote?: boolean, relay?: boolean, cleanWorktrees?: boolean }} [settings]
|
|
64
|
+
* @property {{ tailscale: string, port: number, listening: boolean, devices: { parallax?: boolean, online?: boolean }[] }} [connect]
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Everything the checks read, gathered once and again after a fix.
|
|
69
|
+
* @typedef {object} Facts
|
|
70
|
+
* @property {NodeJS.Platform} platform
|
|
71
|
+
* @property {string} dataDir
|
|
72
|
+
* @property {boolean} dataDirExists
|
|
73
|
+
* @property {string | undefined} plxd The plxd binary the doctor found.
|
|
74
|
+
* @property {string | undefined} plxdVersion
|
|
75
|
+
* @property {number | undefined} pid The running `serve`'s pid, from `plxd.lock`. Not on Windows.
|
|
76
|
+
* @property {boolean} running
|
|
77
|
+
* @property {string | undefined} runningExe The running `serve`'s binary, where the OS says.
|
|
78
|
+
* @property {string | undefined} runningExeVersion That binary's version now, which a self-update
|
|
79
|
+
* may have changed since `serve` started.
|
|
80
|
+
* @property {Answers | undefined} answers
|
|
81
|
+
* @property {{ stable?: string, nightly?: string, error?: string }} newest Release versions.
|
|
82
|
+
* @property {{ path: string, version: string } | undefined} git
|
|
83
|
+
* @property {number | undefined} freeBytes Free space where the data folder is.
|
|
84
|
+
* @property {number | undefined} worktrees How many run worktrees the data folder holds.
|
|
85
|
+
* @property {LogProblems | undefined} log
|
|
86
|
+
* @property {Record<string, string | undefined>} agentClis `claude` and `codex` on PATH.
|
|
87
|
+
*/
|
|
88
|
+
|
|
89
|
+
/** @typedef {{ errors: number, warnings: number, recent: string[] }} LogProblems */
|
|
90
|
+
|
|
91
|
+
const REPO = "https://github.com/ryan-stoffel/parallax";
|
|
92
|
+
|
|
93
|
+
/** The agent CLIs the doctor can hand off to, in the order it offers them. */
|
|
94
|
+
const AGENTS = [
|
|
95
|
+
{ id: "claude", label: "Claude Code" },
|
|
96
|
+
{ id: "codex", label: "Codex" },
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
const GiB = 1024 ** 3;
|
|
100
|
+
|
|
101
|
+
/** The variable that points each agent kind at an instance's own home, as the app's `HOME_VARS`. */
|
|
102
|
+
const HOME_VARS = /** @type {Record<string, string>} */ ({
|
|
103
|
+
claude: "CLAUDE_CONFIG_DIR",
|
|
104
|
+
codex: "CODEX_HOME",
|
|
105
|
+
antigravity: "GEMINI_HOME",
|
|
106
|
+
opencode: "OPENCODE_CONFIG_DIR",
|
|
107
|
+
hermes: "HERMES_HOME",
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The fix that signs in to `provider`, as the app's `providerSignInCommand` runs it: its `login`
|
|
112
|
+
* argv with its `loginEnv` and its home's variable set, so a second Codex signs in to its own
|
|
113
|
+
* `CODEX_HOME`. A program named like the CLI plxd found runs from where plxd found it.
|
|
114
|
+
* @param {Provider} provider
|
|
115
|
+
* @returns {Fix | undefined}
|
|
116
|
+
*/
|
|
117
|
+
export function signInFix(provider) {
|
|
118
|
+
const [program, ...args] = provider.login ?? [];
|
|
119
|
+
if (!program) return undefined;
|
|
120
|
+
const { kind, home, name } = provider.instance;
|
|
121
|
+
const homeVar = HOME_VARS[kind];
|
|
122
|
+
const env = Object.fromEntries((provider.loginEnv ?? []).map((v) => [v.name, v.value ?? ""]));
|
|
123
|
+
if (homeVar && home) env[homeVar] = home;
|
|
124
|
+
const own = provider.path && path.basename(provider.path).replace(/\.\w+$/, "") === program;
|
|
125
|
+
return { title: `Sign in to ${name} (${[program, ...args].join(" ")})`, file: own && provider.path ? provider.path : program, args, env };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Where plxd may be, in the desktop app's `LOCATE_PLXD` order: `~/.parallax-plxd`
|
|
130
|
+
* (`%USERPROFILE%\.parallax-plxd` on Windows), where plx and the app's SSH install put it;
|
|
131
|
+
* inside the app on macOS; `~/.local/bin` and the app's copy in `~/.local/share/parallax/app` on
|
|
132
|
+
* Linux (`command.ts`); and the app's `resources` folder under `%LOCALAPPDATA%\Programs` on
|
|
133
|
+
* Windows. PATH comes after these.
|
|
134
|
+
* @param {NodeJS.Platform} platform
|
|
135
|
+
* @param {string} home
|
|
136
|
+
* @param {NodeJS.ProcessEnv} env
|
|
137
|
+
*/
|
|
138
|
+
export function plxdCandidates(platform, home, env) {
|
|
139
|
+
if (platform === "darwin") {
|
|
140
|
+
return [
|
|
141
|
+
path.join(home, ".parallax-plxd", "plxd"),
|
|
142
|
+
...["/Applications", path.join(home, "Applications")].flatMap((dir) =>
|
|
143
|
+
["Parallax", "Parallax (Nightly)"].map((name) => path.join(dir, `${name}.app`, "Contents", "Resources", "plxd")),
|
|
144
|
+
),
|
|
145
|
+
];
|
|
146
|
+
}
|
|
147
|
+
if (platform !== "win32") {
|
|
148
|
+
const data = env.XDG_DATA_HOME || path.join(home, ".local", "share");
|
|
149
|
+
return [
|
|
150
|
+
path.join(home, ".parallax-plxd", "plxd"),
|
|
151
|
+
path.join(home, ".local", "bin", "plxd"),
|
|
152
|
+
path.join(data, "parallax", "app", "plxd"),
|
|
153
|
+
];
|
|
154
|
+
}
|
|
155
|
+
const own = path.join(home, ".parallax-plxd", "plxd.exe");
|
|
156
|
+
if (!env.LOCALAPPDATA) return [own];
|
|
157
|
+
const programs = path.join(env.LOCALAPPDATA, "Programs");
|
|
158
|
+
try {
|
|
159
|
+
return [own, ...readdirSync(programs).map((dir) => path.join(programs, dir, "resources", "plxd.exe"))];
|
|
160
|
+
} catch {
|
|
161
|
+
return [own];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* `name`'s path on PATH, trying PATHEXT's extensions on Windows, or undefined.
|
|
167
|
+
* @param {string} name
|
|
168
|
+
* @param {NodeJS.ProcessEnv} env
|
|
169
|
+
* @param {NodeJS.Platform} platform
|
|
170
|
+
*/
|
|
171
|
+
export function findOnPath(name, env, platform) {
|
|
172
|
+
const extensions = platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
|
|
173
|
+
for (const dir of (env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
|
|
174
|
+
for (const extension of extensions) {
|
|
175
|
+
const file = path.join(dir, name + extension);
|
|
176
|
+
if (isFile(file)) return file;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** @param {string} file */
|
|
183
|
+
const isFile = (file) => {
|
|
184
|
+
try {
|
|
185
|
+
return statSync(file).isFile();
|
|
186
|
+
} catch {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/** @param {string} dir */
|
|
192
|
+
const isDir = (dir) => {
|
|
193
|
+
try {
|
|
194
|
+
return statSync(dir).isDirectory();
|
|
195
|
+
} catch {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* plxd's data folder, as `DataDir::default_location` picks it: `PLXD_DATA_DIR`, else
|
|
202
|
+
* `~/.parallax`, unless that doesn't exist and the folder older versions used does.
|
|
203
|
+
* @param {NodeJS.Platform} platform
|
|
204
|
+
* @param {string} home
|
|
205
|
+
* @param {NodeJS.ProcessEnv} env
|
|
206
|
+
*/
|
|
207
|
+
export function resolveDataDir(platform, home, env) {
|
|
208
|
+
if (env.PLXD_DATA_DIR) return path.resolve(env.PLXD_DATA_DIR);
|
|
209
|
+
const fallback = path.join(home, ".parallax");
|
|
210
|
+
/** @type {string | undefined} */
|
|
211
|
+
let legacy;
|
|
212
|
+
if (platform === "darwin") legacy = path.join(home, "Library", "Application Support", "parallax");
|
|
213
|
+
else if (platform === "win32") legacy = env.LOCALAPPDATA && path.join(env.LOCALAPPDATA, "parallax");
|
|
214
|
+
else {
|
|
215
|
+
const data = env.XDG_DATA_HOME && path.isAbsolute(env.XDG_DATA_HOME) ? env.XDG_DATA_HOME : path.join(home, ".local", "share");
|
|
216
|
+
legacy = path.join(data, "parallax");
|
|
217
|
+
}
|
|
218
|
+
return !existsSync(fallback) && legacy && isDir(legacy) ? legacy : fallback;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The WARN and ERROR lines of plxd's log from the 24 hours before `now`. Lines start with an
|
|
223
|
+
* RFC 3339 time and the level, as `tracing`'s formatter writes them.
|
|
224
|
+
* @param {string} text The end of the log.
|
|
225
|
+
* @param {Date} now
|
|
226
|
+
* @returns {LogProblems}
|
|
227
|
+
*/
|
|
228
|
+
export function parseLogProblems(text, now) {
|
|
229
|
+
const since = now.getTime() - 24 * 3600 * 1000;
|
|
230
|
+
let errors = 0;
|
|
231
|
+
let warnings = 0;
|
|
232
|
+
/** @type {string[]} */
|
|
233
|
+
const recent = [];
|
|
234
|
+
for (const line of text.split("\n")) {
|
|
235
|
+
const match = /^(\S+)\s+(ERROR|WARN)\s/.exec(line);
|
|
236
|
+
if (!match?.[1] || !(Date.parse(match[1]) >= since)) continue;
|
|
237
|
+
if (match[2] === "ERROR") errors++;
|
|
238
|
+
else warnings++;
|
|
239
|
+
recent.push(line);
|
|
240
|
+
}
|
|
241
|
+
return { errors, warnings, recent: recent.slice(-40) };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The last `bytes` of `file`, from a line start. @param {string} file @param {number} bytes */
|
|
245
|
+
function readTail(file, bytes) {
|
|
246
|
+
const fd = openSync(file, "r");
|
|
247
|
+
try {
|
|
248
|
+
const size = statSync(file).size;
|
|
249
|
+
const length = Math.min(size, bytes);
|
|
250
|
+
const buffer = Buffer.alloc(length);
|
|
251
|
+
readSync(fd, buffer, 0, length, size - length);
|
|
252
|
+
const text = buffer.toString("utf8");
|
|
253
|
+
return length < size ? text.slice(text.indexOf("\n") + 1) : text;
|
|
254
|
+
} finally {
|
|
255
|
+
closeSync(fd);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Runs `file` and resolves to its exit code, stdout, and stderr. @param {string} file @param {string[]} args */
|
|
260
|
+
function capture(file, args) {
|
|
261
|
+
const run = spawnSync(file, args, { encoding: "utf8", timeout: 20_000, stdio: ["ignore", "pipe", "pipe"] });
|
|
262
|
+
return { code: run.status ?? 1, stdout: run.stdout ?? "", stderr: run.stderr ?? "" };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** `plxd --version`'s version, or undefined. @param {string} plxd */
|
|
266
|
+
const versionOf = (plxd) => capture(plxd, ["--version"]).stdout.trim().split(/\s+/)[1];
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The pid in `plxd.lock` if that process is alive. A `serve` holds the lock and writes its pid
|
|
270
|
+
* there; the file stays after it exits, and the pid may be reused, so `gather` also checks that
|
|
271
|
+
* the process runs plxd.
|
|
272
|
+
* @param {string} dataDir
|
|
273
|
+
*/
|
|
274
|
+
function lockPid(dataDir) {
|
|
275
|
+
let pid;
|
|
276
|
+
try {
|
|
277
|
+
pid = Number(readFileSync(path.join(dataDir, "plxd.lock"), "utf8").trim());
|
|
278
|
+
} catch {
|
|
279
|
+
return undefined;
|
|
280
|
+
}
|
|
281
|
+
if (!Number.isInteger(pid) || pid <= 0) return undefined;
|
|
282
|
+
try {
|
|
283
|
+
process.kill(pid, 0);
|
|
284
|
+
return pid;
|
|
285
|
+
} catch {
|
|
286
|
+
// Dead, or EPERM: another user's process, which can't be this user's plxd.
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The binary process `pid` runs: `/proc/<pid>/exe` on Linux, which ends in ` (deleted)` once a
|
|
293
|
+
* self-update replaced it, and `ps -o comm=` on macOS.
|
|
294
|
+
* @param {number} pid
|
|
295
|
+
*/
|
|
296
|
+
function exeOf(pid) {
|
|
297
|
+
if (process.platform === "linux") {
|
|
298
|
+
try {
|
|
299
|
+
return readlinkSync(`/proc/${pid}/exe`);
|
|
300
|
+
} catch {
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (process.platform !== "darwin") return undefined;
|
|
305
|
+
return capture("ps", ["-p", String(pid), "-o", "comm="]).stdout.trim() || undefined;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Whether Windows' plxd answers on its named pipe, `\\.\pipe\plxd-<hash>` (0023). @param {string} dataDir */
|
|
309
|
+
function probePipe(dataDir) {
|
|
310
|
+
const hash = createHash("sha256").update(dataDir).digest("hex").slice(0, 16);
|
|
311
|
+
return new Promise((resolve) => {
|
|
312
|
+
const socket = net.connect(`\\\\.\\pipe\\plxd-${hash}`);
|
|
313
|
+
socket.setTimeout(2000);
|
|
314
|
+
socket.once("connect", () => (socket.destroy(), resolve(true)));
|
|
315
|
+
socket.once("error", () => resolve(false));
|
|
316
|
+
socket.once("timeout", () => (socket.destroy(), resolve(false)));
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Asks the running plxd, over `plxd attach`, for its version, health, agents, settings, and
|
|
322
|
+
* Parallax Connect's state. `attach` starts
|
|
323
|
+
* plxd when none runs, so this is only called when one does. Resolves when every request is
|
|
324
|
+
* answered, or after `timeoutMs` with what was.
|
|
325
|
+
* @param {string} plxd
|
|
326
|
+
* @param {string} version plx's, for the `initialize` request.
|
|
327
|
+
* @param {number} timeoutMs
|
|
328
|
+
* @returns {Promise<Answers>}
|
|
329
|
+
*/
|
|
330
|
+
function askPlxd(plxd, version, timeoutMs = 15_000) {
|
|
331
|
+
/** @type {[keyof Answers, string, object][]} */
|
|
332
|
+
const requests = [
|
|
333
|
+
["initialize", "initialize", { protocol: { min: 1, max: 1 }, client: { name: "plx doctor", version }, capabilities: {} }],
|
|
334
|
+
["hostVersion", "host/version", {}],
|
|
335
|
+
["health", "host/health", {}],
|
|
336
|
+
["providers", "providers/list", {}],
|
|
337
|
+
["settings", "host/settings/get", {}],
|
|
338
|
+
["connect", "connect/devices", {}],
|
|
339
|
+
];
|
|
340
|
+
return new Promise((resolve) => {
|
|
341
|
+
/** @type {Record<string, unknown>} */
|
|
342
|
+
const answers = {};
|
|
343
|
+
const received = new Set();
|
|
344
|
+
let finished = false;
|
|
345
|
+
const child = spawn(plxd, ["attach"], { stdio: ["pipe", "pipe", "ignore"] });
|
|
346
|
+
const finish = () => {
|
|
347
|
+
if (finished) return;
|
|
348
|
+
finished = true;
|
|
349
|
+
clearTimeout(timer);
|
|
350
|
+
child.stdin?.end();
|
|
351
|
+
child.kill();
|
|
352
|
+
resolve(/** @type {Answers} */ (answers));
|
|
353
|
+
};
|
|
354
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
355
|
+
let pending = "";
|
|
356
|
+
child.stdout?.setEncoding("utf8");
|
|
357
|
+
child.stdout?.on("data", (/** @type {string} */ chunk) => {
|
|
358
|
+
const lines = (pending + chunk).split("\n");
|
|
359
|
+
pending = lines.pop() ?? "";
|
|
360
|
+
for (const line of lines) {
|
|
361
|
+
/** @type {{ id?: unknown, result?: unknown }} */
|
|
362
|
+
let message;
|
|
363
|
+
try {
|
|
364
|
+
message = JSON.parse(line);
|
|
365
|
+
} catch {
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
const request = typeof message.id === "number" ? requests[message.id - 1] : undefined;
|
|
369
|
+
if (!request || received.has(request[0])) continue;
|
|
370
|
+
// An error reply counts as answered, so a refused request doesn't wait out the timeout.
|
|
371
|
+
received.add(request[0]);
|
|
372
|
+
if (message.result !== undefined) answers[request[0]] = message.result;
|
|
373
|
+
}
|
|
374
|
+
if (received.size === requests.length) finish();
|
|
375
|
+
});
|
|
376
|
+
child.once("error", finish);
|
|
377
|
+
child.once("exit", finish);
|
|
378
|
+
child.stdin?.on("error", () => {});
|
|
379
|
+
requests.forEach(([, method, params], index) => child.stdin?.write(`${JSON.stringify({ jsonrpc: "2.0", id: index + 1, method, params })}\n`));
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Gathers the facts the checks read.
|
|
385
|
+
* @param {string} version plx's.
|
|
386
|
+
* @param {{ stable?: string, nightly?: string, error?: string }} [newest] The release versions
|
|
387
|
+
* from an earlier gather, so a recheck doesn't fetch them again.
|
|
388
|
+
* @returns {Promise<Facts>}
|
|
389
|
+
*/
|
|
390
|
+
async function gather(version, newest) {
|
|
391
|
+
const platform = process.platform;
|
|
392
|
+
const env = process.env;
|
|
393
|
+
const home = os.homedir();
|
|
394
|
+
const dataDir = resolveDataDir(platform, home, env);
|
|
395
|
+
const dataDirExists = isDir(dataDir);
|
|
396
|
+
const plxd = plxdCandidates(platform, home, env).find(isFile) ?? findOnPath("plxd", env, platform);
|
|
397
|
+
const plxdVersion = plxd && versionOf(plxd);
|
|
398
|
+
|
|
399
|
+
const lockedPid = platform === "win32" ? undefined : lockPid(dataDir);
|
|
400
|
+
const runningExe = lockedPid === undefined ? undefined : exeOf(lockedPid);
|
|
401
|
+
const exeOnDisk = runningExe?.replace(/ \(deleted\)$/, "");
|
|
402
|
+
// A stale lock's pid reused by another program isn't plxd. Where the OS can't say, trust it.
|
|
403
|
+
const pid = exeOnDisk === undefined || /^plxd(\.exe)?$/.test(path.basename(exeOnDisk)) ? lockedPid : undefined;
|
|
404
|
+
const running = platform === "win32" ? await probePipe(dataDir) : pid !== undefined;
|
|
405
|
+
const runningExeVersion = exeOnDisk && exeOnDisk !== plxd && isFile(exeOnDisk) ? versionOf(exeOnDisk) : exeOnDisk === plxd ? plxdVersion : undefined;
|
|
406
|
+
const answers = running && plxd ? await askPlxd(plxd, version) : undefined;
|
|
407
|
+
|
|
408
|
+
if (!newest) {
|
|
409
|
+
try {
|
|
410
|
+
const releases = await fetchReleases();
|
|
411
|
+
const tag = (/** @type {"stable" | "nightly"} */ channel) => {
|
|
412
|
+
try {
|
|
413
|
+
return pickRelease(releases, channel).tag_name.replace(/^v/, "");
|
|
414
|
+
} catch {
|
|
415
|
+
return undefined;
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
newest = { stable: tag("stable"), nightly: tag("nightly") };
|
|
419
|
+
} catch (error) {
|
|
420
|
+
newest = { error: message(error) };
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const gitPath = findOnPath("git", env, platform);
|
|
425
|
+
const git = gitPath ? { path: gitPath, version: capture(gitPath, ["--version"]).stdout.trim().replace(/^git version /, "") } : undefined;
|
|
426
|
+
|
|
427
|
+
let freeBytes;
|
|
428
|
+
try {
|
|
429
|
+
const stats = statfsSync(dataDirExists ? dataDir : home);
|
|
430
|
+
freeBytes = stats.bavail * stats.bsize;
|
|
431
|
+
} catch {
|
|
432
|
+
freeBytes = undefined;
|
|
433
|
+
}
|
|
434
|
+
let worktrees;
|
|
435
|
+
try {
|
|
436
|
+
worktrees = readdirSync(path.join(dataDir, "worktrees")).length;
|
|
437
|
+
} catch {
|
|
438
|
+
worktrees = undefined;
|
|
439
|
+
}
|
|
440
|
+
const logFile = path.join(dataDir, "logs", "plxd.log");
|
|
441
|
+
const log = isFile(logFile) ? parseLogProblems(readTail(logFile, 4 * 1024 * 1024), new Date()) : undefined;
|
|
442
|
+
|
|
443
|
+
const agentClis = Object.fromEntries(AGENTS.map(({ id }) => [id, findOnPath(id, env, platform)]));
|
|
444
|
+
|
|
445
|
+
return { platform, dataDir, dataDirExists, plxd, plxdVersion, pid, running, runningExe, runningExeVersion, answers, newest, git, freeBytes, worktrees, log, agentClis };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** `seconds` as `2 d 3 h`, `3 h 12 m`, or `12 m`. @param {number} seconds */
|
|
449
|
+
export function duration(seconds) {
|
|
450
|
+
const m = Math.floor(seconds / 60) % 60;
|
|
451
|
+
const h = Math.floor(seconds / 3600) % 24;
|
|
452
|
+
const d = Math.floor(seconds / 86400);
|
|
453
|
+
if (d > 0) return `${d} d ${h} h`;
|
|
454
|
+
if (h > 0) return `${h} h ${m} m`;
|
|
455
|
+
return `${m} m`;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** @param {number} bytes */
|
|
459
|
+
const gib = (bytes) => `${(bytes / GiB).toFixed(1)} GB`;
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* The checks' results, in the order they print. Pure, so a recheck after a fix is another call.
|
|
463
|
+
* @param {Facts} facts
|
|
464
|
+
* @returns {Checked[]}
|
|
465
|
+
*/
|
|
466
|
+
export function evaluate(facts) {
|
|
467
|
+
/** @type {Checked[]} */
|
|
468
|
+
const out = [];
|
|
469
|
+
const add = (/** @type {string} */ name, /** @type {Result} */ result) => out.push({ name, result });
|
|
470
|
+
const { plxd, answers } = facts;
|
|
471
|
+
|
|
472
|
+
add(
|
|
473
|
+
"plxd",
|
|
474
|
+
plxd
|
|
475
|
+
? { state: facts.plxdVersion ? "ok" : "fail", detail: facts.plxdVersion ? `${facts.plxdVersion} at ${plxd}` : `${plxd} --version printed no version` }
|
|
476
|
+
: { state: "fail", detail: `not found. Install Parallax from ${REPO}/releases, or run npx plx-cli setup.` },
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
/** @type {Fix | undefined} */
|
|
480
|
+
const start = plxd ? { title: "Start plxd", file: plxd, args: ["attach"], input: "", quiet: true } : undefined;
|
|
481
|
+
const pid = facts.pid === undefined ? "" : `pid ${facts.pid}, `;
|
|
482
|
+
if (!facts.running) add("Running", { state: "fail", detail: "plxd isn't running", fix: start });
|
|
483
|
+
else if (!answers?.initialize) add("Running", { state: "fail", detail: `${pid}but it didn't answer within 15 s` });
|
|
484
|
+
else {
|
|
485
|
+
const health = answers.health;
|
|
486
|
+
const busy = health ? `, ${health.runningAgents} agent${health.runningAgents === 1 ? "" : "s"} running` : "";
|
|
487
|
+
add("Running", { state: "ok", detail: `${pid}${answers.initialize.plxd}, up ${health ? duration(health.uptimeSeconds) : "?"}${busy}` });
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const runningVersion = answers?.initialize?.plxd;
|
|
491
|
+
if (facts.runningExe?.endsWith(" (deleted)") || (runningVersion && facts.runningExeVersion && runningVersion !== facts.runningExeVersion)) {
|
|
492
|
+
add("Restart", {
|
|
493
|
+
state: "fail",
|
|
494
|
+
detail: `${runningVersion ? `plxd ${runningVersion}` : "plxd"} is running, but its binary ${facts.runningExeVersion ? `is now ${facts.runningExeVersion}` : "was replaced"}. Quit and reopen Parallax to restart it.`,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const version = runningVersion ?? facts.plxdVersion;
|
|
499
|
+
if (version) {
|
|
500
|
+
const channel = version.endsWith("-nightly") ? "nightly" : "stable";
|
|
501
|
+
const newest = facts.newest[channel];
|
|
502
|
+
if (facts.newest.error) add("Up to date", { state: "skip", detail: `couldn't reach GitHub: ${facts.newest.error}` });
|
|
503
|
+
else if (!newest) add("Up to date", { state: "skip", detail: `no ${channel} release found` });
|
|
504
|
+
else if (newest === version) add("Up to date", { state: "ok", detail: `${version} is the newest ${channel}` });
|
|
505
|
+
else add("Up to date", { state: "warn", detail: `${version}; the newest ${channel} is ${newest}. Update from Parallax's menu.` });
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (answers?.health) {
|
|
509
|
+
const { store, queues } = answers.health;
|
|
510
|
+
const wait = queues ? queues.store.maxWaitMicros / 1e6 : 0;
|
|
511
|
+
if (store !== "ok") add("Project store", { state: "fail", detail: `plxd reports the store as ${store}` });
|
|
512
|
+
else if (wait > 5) add("Project store", { state: "warn", detail: `ok, but a write waited ${wait.toFixed(1)} s in the queue` });
|
|
513
|
+
else add("Project store", { state: "ok", detail: "ok" });
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (answers?.providers) {
|
|
517
|
+
for (const provider of answers.providers.providers.filter((p) => p.instance.enabled !== false)) {
|
|
518
|
+
const name = provider.instance.name;
|
|
519
|
+
const version = provider.version ? `${provider.version}, ` : "";
|
|
520
|
+
// plxd lists its default agents whether or not the user has any of them.
|
|
521
|
+
if (!provider.installed) add(name, { state: "skip", detail: "not installed" });
|
|
522
|
+
else if (provider.signedIn === false) {
|
|
523
|
+
add(name, {
|
|
524
|
+
state: "fail",
|
|
525
|
+
detail: `${version}isn't signed in`,
|
|
526
|
+
fix: signInFix(provider),
|
|
527
|
+
});
|
|
528
|
+
} else if (provider.note) add(name, { state: "warn", detail: `${version}${provider.note}` });
|
|
529
|
+
else add(name, { state: "ok", detail: `${version}${provider.signedIn ? `signed in${provider.account ? ` (${provider.account})` : ""}` : "ready"}` });
|
|
530
|
+
}
|
|
531
|
+
} else {
|
|
532
|
+
const found = AGENTS.flatMap(({ id }) => (facts.agentClis[id] ? [`${id} (${facts.agentClis[id]})`] : []));
|
|
533
|
+
add("Agent CLIs", found.length ? { state: "ok", detail: `on PATH: ${found.join(", ")}` } : { state: "fail", detail: "neither claude nor codex is on PATH. Install Claude Code: https://docs.claude.com/en/docs/claude-code/setup" });
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (answers?.settings?.connect && answers.connect) {
|
|
537
|
+
const { tailscale, port, listening, devices } = answers.connect;
|
|
538
|
+
const others = devices.filter((device) => device.parallax).length;
|
|
539
|
+
if (tailscale !== "running") add("Connect", { state: "fail", detail: `on, but Tailscale is ${tailscale === "missing" ? "not installed" : "not connected"}, so other computers can't reach this one` });
|
|
540
|
+
else if (!listening) add("Connect", { state: "fail", detail: `on, but plxd isn't listening on port ${port}. plxd's log says why.` });
|
|
541
|
+
else add("Connect", { state: "ok", detail: `listening on port ${port}; ${others} other computer${others === 1 ? "" : "s"} on Parallax` });
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
add("git", facts.git ? { state: "ok", detail: `${facts.git.version} at ${facts.git.path}` } : { state: "fail", detail: "isn't on PATH. Parallax runs agents in git worktrees." });
|
|
545
|
+
|
|
546
|
+
if (facts.freeBytes !== undefined) {
|
|
547
|
+
const worktrees = facts.worktrees === undefined ? "" : `; ${facts.worktrees} run worktrees in the data folder`;
|
|
548
|
+
const state = facts.freeBytes < 2 * GiB ? "fail" : facts.freeBytes < 10 * GiB ? "warn" : "ok";
|
|
549
|
+
add("Disk", { state, detail: `${gib(facts.freeBytes)} free${worktrees}` });
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (facts.log) {
|
|
553
|
+
const { errors, warnings, recent } = facts.log;
|
|
554
|
+
const counts = `${errors} error${errors === 1 ? "" : "s"} and ${warnings} warning${warnings === 1 ? "" : "s"} in the last 24 h`;
|
|
555
|
+
const lastError = recent.filter((text) => /^\S+\s+ERROR\s/.test(text)).at(-1)?.replace(/^\S+\s+ERROR\s+/, "");
|
|
556
|
+
add("plxd log", errors > 0 ? { state: "warn", detail: `${counts}. Latest error: ${lastError?.slice(0, 160)}` } : { state: "ok", detail: counts });
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return out;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** @param {unknown} error */
|
|
563
|
+
const message = (error) => (error instanceof Error ? error.message : String(error));
|
|
564
|
+
|
|
565
|
+
/** One check as a line, with its name in a column and the home folder as `~`. @param {Checked} checked */
|
|
566
|
+
const line = ({ name, result }) => `${MARKS[result.state]} ${name.padEnd(14)} ${result.detail.replaceAll(os.homedir(), "~")}`;
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Ignores Ctrl+C and Ctrl+\ while a child has the terminal: the terminal sends them to the doctor
|
|
570
|
+
* too, which would exit and leave the child without it. Returns the function that stops ignoring.
|
|
571
|
+
*/
|
|
572
|
+
function holdSignals() {
|
|
573
|
+
const ignore = () => {};
|
|
574
|
+
process.on("SIGINT", ignore);
|
|
575
|
+
process.on("SIGQUIT", ignore);
|
|
576
|
+
return () => {
|
|
577
|
+
process.off("SIGINT", ignore);
|
|
578
|
+
process.off("SIGQUIT", ignore);
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Spawns `file`. On Windows, an npm `.cmd` shim, or a bare name that may resolve to one, only
|
|
584
|
+
* runs through cmd.exe, which splits on spaces, so the file and each argument are quoted. No
|
|
585
|
+
* argument the doctor passes holds a `"`.
|
|
586
|
+
* @param {string} file
|
|
587
|
+
* @param {string[]} args
|
|
588
|
+
* @param {import("node:child_process").SpawnOptions} options
|
|
589
|
+
*/
|
|
590
|
+
function spawnCommand(file, args, options) {
|
|
591
|
+
const shell = process.platform === "win32" && (/\.(cmd|bat)$/i.test(file) || !/[\\/]/.test(file));
|
|
592
|
+
const quote = (/** @type {string} */ arg) => `"${arg}"`;
|
|
593
|
+
return shell ? spawn(quote(file), args.map(quote), { ...options, shell: true }) : spawn(file, args, options);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Runs `fix` and resolves when it exits. @param {Fix} fix */
|
|
597
|
+
function runFix(fix) {
|
|
598
|
+
return new Promise((resolve) => {
|
|
599
|
+
const stdin = fix.input === undefined ? "inherit" : "pipe";
|
|
600
|
+
// `exit`, not `close`: a `serve` that `attach` starts may hold its stdio open.
|
|
601
|
+
const child = spawnCommand(fix.file, fix.args, {
|
|
602
|
+
stdio: [stdin, fix.quiet ? "ignore" : "inherit", "inherit"],
|
|
603
|
+
env: { ...process.env, ...fix.env },
|
|
604
|
+
});
|
|
605
|
+
const release = holdSignals();
|
|
606
|
+
child.once("error", (error) => (release(), console.log(` ${message(error)}`), resolve(undefined)));
|
|
607
|
+
child.once("exit", () => (release(), resolve(undefined)));
|
|
608
|
+
if (fix.input !== undefined) child.stdin?.end(fix.input);
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** Asks a yes-or-no question, with `fallback` for an empty answer. @param {import("node:readline/promises").Interface} prompt @param {string} question @param {boolean} fallback */
|
|
613
|
+
async function ask(prompt, question, fallback) {
|
|
614
|
+
const answer = (await prompt.question(`${question} ${fallback ? "[Y/n]" : "[y/N]"} `)).trim().toLowerCase();
|
|
615
|
+
return answer === "" ? fallback : answer.startsWith("y");
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** Asks which agent to use. @param {import("node:readline/promises").Interface} prompt @param {typeof AGENTS} agents */
|
|
619
|
+
async function pickAgent(prompt, agents) {
|
|
620
|
+
const menu = agents.map((agent, index) => ` [${index + 1}] ${agent.label}`).join("\n");
|
|
621
|
+
for (;;) {
|
|
622
|
+
const answer = (await prompt.question(`Look into it with:\n${menu}\n> `)).trim().toLowerCase();
|
|
623
|
+
const picked = agents[Number(answer) - 1] ?? agents.find((agent) => agent.id === answer);
|
|
624
|
+
if (picked) return picked;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* The folder for this run's files: `doctor/<time>` in the data folder, beside `doctor/source`
|
|
630
|
+
* where the agent clones Parallax, or in the temp folder when there's no data folder. The doctor
|
|
631
|
+
* never creates the data folder: a new `~/.parallax` would hide a legacy one from plxd.
|
|
632
|
+
* @param {Facts} facts
|
|
633
|
+
* @param {Date} now
|
|
634
|
+
*/
|
|
635
|
+
function runFolder(facts, now) {
|
|
636
|
+
const base = path.join(facts.dataDirExists ? facts.dataDir : os.tmpdir(), facts.dataDirExists ? "doctor" : "parallax-doctor");
|
|
637
|
+
const dir = path.join(base, now.toISOString().replaceAll(":", "-").replace(".", "-"));
|
|
638
|
+
mkdirSync(dir, { recursive: true });
|
|
639
|
+
return { dir, sourceCache: path.join(base, "source") };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** How this command was run, for the agent to tell the user how to update it. */
|
|
643
|
+
const launchedAs = () => (process.env.npm_command === "exec" ? "npx plx-cli doctor" : `${process.argv[1] ?? "plx"} doctor`);
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* `plx doctor`. Prints each check. In a terminal, offers the fix for each failed one and
|
|
647
|
+
* checks again. Then writes the context and prompt files and hands them to claude or codex:
|
|
648
|
+
* asked first, unless `--agent` names one. Resolves to the agent's exit code when it ran, else 1
|
|
649
|
+
* when a check still fails.
|
|
650
|
+
* @param {{ agent?: string, model?: string }} options
|
|
651
|
+
*/
|
|
652
|
+
export async function doctor(options) {
|
|
653
|
+
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
654
|
+
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
655
|
+
const newPrompt = () => createInterface({ input: process.stdin, output: process.stdout });
|
|
656
|
+
let prompt = interactive ? newPrompt() : undefined;
|
|
657
|
+
try {
|
|
658
|
+
console.log(`Parallax doctor (plx ${pkg.version})\n`);
|
|
659
|
+
let facts = await gather(pkg.version);
|
|
660
|
+
let results = evaluate(facts);
|
|
661
|
+
for (const checked of results) console.log(line(checked));
|
|
662
|
+
|
|
663
|
+
const fixes = results.flatMap(({ result }) => (result.state === "fail" && result.fix ? [result.fix] : []));
|
|
664
|
+
if (fixes.length > 0 && prompt) {
|
|
665
|
+
console.log("");
|
|
666
|
+
let fixed = false;
|
|
667
|
+
for (const fix of fixes) {
|
|
668
|
+
if (!(await ask(prompt, `${fix.title}?`, true))) continue;
|
|
669
|
+
// A sign-in asks its own questions, so it gets the terminal to itself.
|
|
670
|
+
if (fix.input === undefined) prompt.close();
|
|
671
|
+
await runFix(fix);
|
|
672
|
+
if (fix.input === undefined) prompt = newPrompt();
|
|
673
|
+
fixed = true;
|
|
674
|
+
}
|
|
675
|
+
if (fixed) {
|
|
676
|
+
facts = await gather(pkg.version, facts.newest);
|
|
677
|
+
const before = new Map(results.map(({ name, result }) => [name, result.state]));
|
|
678
|
+
results = evaluate(facts);
|
|
679
|
+
console.log("\nAfter the fixes:");
|
|
680
|
+
for (const checked of results) if (before.get(checked.name) !== checked.result.state) console.log(line(checked));
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
const failed = results.some(({ result }) => result.state === "fail");
|
|
684
|
+
console.log(failed ? "\nSome checks failed." : "\nEvery check passed.");
|
|
685
|
+
|
|
686
|
+
const now = new Date();
|
|
687
|
+
const { dir, sourceCache } = runFolder(facts, now);
|
|
688
|
+
const contextFile = path.join(dir, "context.md");
|
|
689
|
+
const promptFile = path.join(dir, "prompt.md");
|
|
690
|
+
writeFileSync(contextFile, buildContext({ generatedAt: now.toISOString(), version: pkg.version, launchedAs: launchedAs(), facts, results, sourceCache }));
|
|
691
|
+
writeFileSync(promptFile, buildSeedPrompt(contextFile, readFileSync(new URL("./doctor-playbook.md", import.meta.url), "utf8")));
|
|
692
|
+
const paths = `The prompt and this computer's context are in:\n ${promptFile}\n ${contextFile}\nPaste the prompt file into any coding agent to look into a problem and file an issue.`;
|
|
693
|
+
|
|
694
|
+
const installed = AGENTS.filter(({ id }) => facts.agentClis[id]);
|
|
695
|
+
/** @type {(typeof AGENTS)[number] | undefined} */
|
|
696
|
+
let agent;
|
|
697
|
+
if (options.agent) {
|
|
698
|
+
agent = installed.find(({ id }) => id === options.agent);
|
|
699
|
+
if (!agent) {
|
|
700
|
+
console.log(`\n✗ ${options.agent} isn't on PATH.\n${paths}`);
|
|
701
|
+
return 1;
|
|
702
|
+
}
|
|
703
|
+
} else if (installed.length > 0 && prompt && (await ask(prompt, `\nOpen ${installed.length === 1 ? installed[0]?.label : "a coding agent"} to look into a problem and file an issue?`, failed))) {
|
|
704
|
+
agent = installed.length === 1 ? installed[0] : await pickAgent(prompt, installed);
|
|
705
|
+
}
|
|
706
|
+
prompt?.close();
|
|
707
|
+
if (!agent) {
|
|
708
|
+
console.log(`\n${installed.length === 0 ? "Neither claude nor codex is on PATH. " : ""}${paths}`);
|
|
709
|
+
return failed ? 1 : 0;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
console.log(`\nStarting ${agent.label}. It will ask what went wrong.\n`);
|
|
713
|
+
const cli = /** @type {string} */ (facts.agentClis[agent.id]);
|
|
714
|
+
// One line naming the prompt file: cmd.exe, which runs an npm `.cmd` shim, can't carry the
|
|
715
|
+
// multi-line playbook as an argument. It has no `"`, so quoting it for cmd.exe is safe.
|
|
716
|
+
const args = [...(options.model ? ["--model", options.model] : []), buildLaunchPrompt(promptFile)];
|
|
717
|
+
return await new Promise((resolve) => {
|
|
718
|
+
const child = spawnCommand(cli, args, { cwd: dir, stdio: "inherit" });
|
|
719
|
+
const release = holdSignals();
|
|
720
|
+
child.once("error", (error) => (release(), console.log(`✗ Couldn't start ${agent.label}: ${message(error)}\n${paths}`), resolve(1)));
|
|
721
|
+
// A signal has no exit code: report it as a failure, not success.
|
|
722
|
+
child.once("exit", (code, signal) => (release(), resolve(code ?? (signal === null ? 0 : 1))));
|
|
723
|
+
});
|
|
724
|
+
} finally {
|
|
725
|
+
prompt?.close();
|
|
726
|
+
}
|
|
727
|
+
}
|