@yusukeshib/pi-babysit 0.6.18 → 0.6.19
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 +43 -1
- package/index.ts +180 -28
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -71,6 +71,47 @@ calls if another extension or preset re-enables `bash`, including shell
|
|
|
71
71
|
backgrounding (`… &`, `nohup`, `setsid`, `disown`). Set
|
|
72
72
|
`PI_BABYSIT_ALLOW_BASH=1` to retain direct `bash` explicitly.
|
|
73
73
|
|
|
74
|
+
## Remote processes over SSH
|
|
75
|
+
|
|
76
|
+
Install a `babysit` CLI with `--host` support on both machines (tested with
|
|
77
|
+
local 0.14.5 and remote 0.14.6), and configure non-interactive SSH access.
|
|
78
|
+
SSH aliases, keys, known hosts, and ProxyJump are handled by babysit/SSH;
|
|
79
|
+
pi-babysit does not install remote software or copy credentials.
|
|
80
|
+
|
|
81
|
+
```text
|
|
82
|
+
babysit_run { host: "yusuke-t4", cwd: "/home/yusuke/project", command: "bun test", foreground: true, returnLines: 30 }
|
|
83
|
+
babysit_run { host: "user@server", command: "python3 -i", name: "remote-repl", continueAfterStart: true }
|
|
84
|
+
babysit_check { id: "remote-repl", screen: true }
|
|
85
|
+
babysit_send { id: "remote-repl", text: "print(42)" }
|
|
86
|
+
babysit_kill { id: "remote-repl" }
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`host` omitted (or `"local"`) preserves local behavior. `cwd` is process-only:
|
|
90
|
+
local default is Pi's cwd; remote default is the remote login home, **never**
|
|
91
|
+
the local checkout. Remote commands use POSIX `sh`, with quoted cwd and Pi
|
|
92
|
+
metadata environment values; the local `PI_SESSION_FILE` and shell path are
|
|
93
|
+
not forwarded. Remote subagents and `retryOnWorkerDeath` are explicitly unsupported.
|
|
94
|
+
|
|
95
|
+
The returned Pi id persists a host and unique, Pi-namespaced remote worker id.
|
|
96
|
+
Check/send/wait/kill, PTY screens/keys, completion notifications, and mixed-host
|
|
97
|
+
waits use this mapping; manual remote sessions are never adopted or killed.
|
|
98
|
+
Resume the same Pi session to reconnect. Remote logs stay remote: results show
|
|
99
|
+
an executable `babysit --host … log -s …` locator instead of a local file path.
|
|
100
|
+
Remote output is a bounded tail (200 lines by default) or CLI `--grep` search
|
|
101
|
+
(no local line numbers). The widget shows host/state and a small tail; its
|
|
102
|
+
remote full-history viewer is unavailable and instead shows log/attach hints.
|
|
103
|
+
|
|
104
|
+
An SSH failure is **unknown status**, not process exit. Remote workers may
|
|
105
|
+
survive disconnects. An uncertain launch keeps its identity and reports the
|
|
106
|
+
tracked id: reconcile it with check/kill rather than blindly retrying. Attached
|
|
107
|
+
foreground interruption explicitly kills the remote worker and claims cleanup
|
|
108
|
+
only after terminal-state confirmation; unreachable cleanup is reported as
|
|
109
|
+
unverified. Detached waits do not own the worker. Pi quit attempts cleanup of
|
|
110
|
+
tracked workers, reporting unverified remote cleanup to stderr. Local GC retains
|
|
111
|
+
roots with unresolved remote identities; it never deletes/prunes remote logs.
|
|
112
|
+
After reconnecting, check or kill tracked workers to establish terminal status
|
|
113
|
+
before local retention can collect their metadata.
|
|
114
|
+
|
|
74
115
|
## Commands (human)
|
|
75
116
|
|
|
76
117
|
| Command | What it does |
|
|
@@ -126,7 +167,8 @@ with Pi's tool-output toggle (`Ctrl+O` by default).
|
|
|
126
167
|
## Logs without context flooding
|
|
127
168
|
|
|
128
169
|
`babysit_run`, `babysit_wait`, and automatic completion notifications always
|
|
129
|
-
return lifecycle metadata and the absolute path to the complete `output.log
|
|
170
|
+
return lifecycle metadata and the absolute path to the complete local `output.log`
|
|
171
|
+
(or a remote CLI log locator for SSH processes).
|
|
130
172
|
Explicit run/wait results inline complete output up to 8 KB; unsolicited
|
|
131
173
|
completion notifications use a stricter 2 KB per-process output cap and an 8 KB
|
|
132
174
|
aggregate message cap. Process exits observed in one poll share one message and
|
package/index.ts
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
|
|
34
34
|
import { spawn } from "node:child_process";
|
|
35
35
|
import * as fs from "node:fs";
|
|
36
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
36
37
|
import * as os from "node:os";
|
|
37
38
|
import * as path from "node:path";
|
|
38
39
|
import { fileURLToPath } from "node:url";
|
|
@@ -186,6 +187,8 @@ interface BsSession {
|
|
|
186
187
|
note?: string | null;
|
|
187
188
|
output_bytes?: number;
|
|
188
189
|
screen_seq?: number | null;
|
|
190
|
+
host?: string;
|
|
191
|
+
transportError?: string;
|
|
189
192
|
}
|
|
190
193
|
|
|
191
194
|
// A worker whose PTY process is gone (alive:false) can still report state
|
|
@@ -215,8 +218,17 @@ function bs(
|
|
|
215
218
|
cwd?: string;
|
|
216
219
|
signal?: AbortSignal;
|
|
217
220
|
env?: Record<string, string | undefined>;
|
|
221
|
+
host?: string;
|
|
218
222
|
} = {},
|
|
219
223
|
): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
224
|
+
const sessionIndex = args.indexOf("-s");
|
|
225
|
+
const meta = sessionIndex >= 0 ? readMeta(args[sessionIndex + 1]) : null;
|
|
226
|
+
const host = opts.host ?? meta?.host;
|
|
227
|
+
if (host && host !== "local") {
|
|
228
|
+
args = [...args];
|
|
229
|
+
if (meta?.remoteId) args[sessionIndex + 1] = meta.remoteId;
|
|
230
|
+
args.unshift("--host", host);
|
|
231
|
+
}
|
|
220
232
|
return new Promise((resolve) => {
|
|
221
233
|
if (opts.signal?.aborted) {
|
|
222
234
|
resolve({ stdout: "", stderr: "aborted", code: 130 });
|
|
@@ -238,6 +250,8 @@ function bs(
|
|
|
238
250
|
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
239
251
|
child.stdout?.on("data", (d) => {
|
|
240
252
|
stdout += d.toString();
|
|
253
|
+
// Drain output but bound remote log/screen memory even for a huge single line.
|
|
254
|
+
if (host && host !== "local" && (args.includes("log") || args.includes("screenshot")) && Buffer.byteLength(stdout) > 32_000) stdout = Buffer.from(stdout).subarray(-32_000).toString("utf8");
|
|
241
255
|
});
|
|
242
256
|
child.stderr?.on("data", (d) => {
|
|
243
257
|
stderr += d.toString();
|
|
@@ -321,8 +335,8 @@ async function requireBabysit(): Promise<void> {
|
|
|
321
335
|
// Error-aware: `babysit list` failing is NOT the same as "no sessions" —
|
|
322
336
|
// callers that show state to the agent must surface the error instead of
|
|
323
337
|
// silently reporting an empty registry (which reads like lost sessions).
|
|
324
|
-
async function
|
|
325
|
-
const r = await bs(["list", "--json"]);
|
|
338
|
+
async function localSessions(host?: string): Promise<{ sessions: BsSession[]; error?: string }> {
|
|
339
|
+
const r = await bs(["list", "--json"], { host });
|
|
326
340
|
if (r.code !== 0) {
|
|
327
341
|
return {
|
|
328
342
|
sessions: [],
|
|
@@ -338,6 +352,28 @@ async function listSessions(): Promise<{ sessions: BsSession[]; error?: string }
|
|
|
338
352
|
}
|
|
339
353
|
}
|
|
340
354
|
|
|
355
|
+
async function listSessions(): Promise<{ sessions: BsSession[]; error?: string }> {
|
|
356
|
+
const local = await localSessions();
|
|
357
|
+
const known = remoteMetadata();
|
|
358
|
+
await Promise.all([...new Set(known.map(({ meta }) => meta.host!))].map(async (host) => {
|
|
359
|
+
const remote = await localSessions(host);
|
|
360
|
+
for (const { id, meta } of known.filter((entry) => entry.meta.host === host)) {
|
|
361
|
+
const found = remote.sessions.find((session) => session.id === meta.remoteId);
|
|
362
|
+
if (found && !remote.error) {
|
|
363
|
+
local.sessions.push({ ...found, id, host });
|
|
364
|
+
const terminalState = isConfirmedTerminalState(found.state) ? found.state : undefined;
|
|
365
|
+
if (meta.remoteTerminalState !== terminalState) {
|
|
366
|
+
writeMeta(id, { ...readMeta(id)!, remoteTerminalState: terminalState });
|
|
367
|
+
}
|
|
368
|
+
} else {
|
|
369
|
+
// Keep unresolved references active for polling/group delivery, never fabricate exit.
|
|
370
|
+
local.sessions.push({ id, host, state: "running", transportError: remote.error ?? "tracked remote session not found", note: `status unknown on ${host}: ${remote.error ?? "session not found"}` });
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}));
|
|
374
|
+
return local;
|
|
375
|
+
}
|
|
376
|
+
|
|
341
377
|
async function lookupStatus(
|
|
342
378
|
id: string,
|
|
343
379
|
): Promise<{ session: BsSession | null; error?: string }> {
|
|
@@ -346,8 +382,10 @@ async function lookupStatus(
|
|
|
346
382
|
// status-then-list pair (two CLI subprocesses for every status lookup).
|
|
347
383
|
try {
|
|
348
384
|
const listed = await listSessions();
|
|
349
|
-
|
|
350
|
-
return { session:
|
|
385
|
+
const session = listed.sessions.find((session) => session.id === id) ?? null;
|
|
386
|
+
if (session?.transportError) return { session: null, error: `${session.host}: ${session.transportError}` };
|
|
387
|
+
if (listed.error && !readMeta(id)?.host) return { session: null, error: listed.error };
|
|
388
|
+
return { session };
|
|
351
389
|
} catch (error) {
|
|
352
390
|
return { session: null, error: error instanceof Error ? error.message : String(error) };
|
|
353
391
|
}
|
|
@@ -409,10 +447,11 @@ async function terminateRunningSessions(): Promise<void> {
|
|
|
409
447
|
let listed: Awaited<ReturnType<typeof listSessions>>;
|
|
410
448
|
try {
|
|
411
449
|
listed = await listSessions();
|
|
412
|
-
} catch {
|
|
450
|
+
} catch (error) {
|
|
451
|
+
console.error(`Babysit shutdown cleanup unknown: ${error instanceof Error ? error.message : String(error)}`);
|
|
413
452
|
return;
|
|
414
453
|
}
|
|
415
|
-
if (listed.error)
|
|
454
|
+
if (listed.error) console.error(`Local babysit shutdown cleanup unknown: ${listed.error}`);
|
|
416
455
|
|
|
417
456
|
// A namespace may contain many independent commands and agents. Terminate
|
|
418
457
|
// them concurrently so quitting Pi does not wait for serial kill timeouts.
|
|
@@ -422,7 +461,13 @@ async function terminateRunningSessions(): Promise<void> {
|
|
|
422
461
|
.filter((session) => session.state === "running")
|
|
423
462
|
.map(async (session) => {
|
|
424
463
|
const result = await bs(["kill", "-s", session.id, "--json"]);
|
|
425
|
-
if (
|
|
464
|
+
if (session.host) {
|
|
465
|
+
const terminal = await awaitConfirmedTermination(session.id);
|
|
466
|
+
if (!terminal || !isConfirmedTerminalState(terminal.state)) {
|
|
467
|
+
console.error(`Remote cleanup unverified for ${session.id} on ${session.host}: ${result.stderr || "terminal state unavailable"}`);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
} else if (result.code !== 0 || validateKillResponse(result.stdout)) return;
|
|
426
471
|
// A later resume of this Pi session must not turn intentional shutdown
|
|
427
472
|
// cleanup into a process-completion notification.
|
|
428
473
|
suppressNotify(session.id, "kill");
|
|
@@ -445,6 +490,9 @@ export interface SubagentBudget {
|
|
|
445
490
|
}
|
|
446
491
|
|
|
447
492
|
interface Meta {
|
|
493
|
+
host?: string;
|
|
494
|
+
remoteId?: string;
|
|
495
|
+
remoteTerminalState?: string;
|
|
448
496
|
kind: "process" | "subagent";
|
|
449
497
|
// process
|
|
450
498
|
name?: string;
|
|
@@ -493,7 +541,21 @@ interface Meta {
|
|
|
493
541
|
}
|
|
494
542
|
|
|
495
543
|
const metaDir = () => path.join(ROOT, "meta");
|
|
496
|
-
const
|
|
544
|
+
const localLogPath = (id: string) => path.join(ROOT, "sessions", id, "output.log");
|
|
545
|
+
const logPath = (id: string) => {
|
|
546
|
+
const meta = readMeta(id);
|
|
547
|
+
return meta?.host ? `${shq(BABYSIT_BIN)} --host ${shq(meta.host)} log -s ${shq(meta.remoteId!)}` : localLogPath(id);
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
function remoteMetadata(): Array<{ id: string; meta: Meta }> {
|
|
551
|
+
try {
|
|
552
|
+
return fs.readdirSync(metaDir()).filter((file) => file.endsWith(".json")).flatMap((file) => {
|
|
553
|
+
const id = file.slice(0, -5);
|
|
554
|
+
const meta = readMeta(id);
|
|
555
|
+
return meta?.host && meta.remoteId ? [{ id, meta }] : [];
|
|
556
|
+
});
|
|
557
|
+
} catch { return []; }
|
|
558
|
+
}
|
|
497
559
|
|
|
498
560
|
function writeMeta(id: string, m: Meta): boolean {
|
|
499
561
|
const target = path.join(metaDir(), `${id}.json`);
|
|
@@ -630,6 +692,17 @@ function gcRootIsSafe(root: string): boolean {
|
|
|
630
692
|
}
|
|
631
693
|
}
|
|
632
694
|
|
|
695
|
+
// Remote PIDs are meaningless locally. Retain unresolved remote references;
|
|
696
|
+
// terminal observations are persisted by listSessions, never prune remote data.
|
|
697
|
+
try {
|
|
698
|
+
for (const file of fs.readdirSync(path.join(root, "meta"))) {
|
|
699
|
+
if (!file.endsWith(".json")) continue;
|
|
700
|
+
const meta = JSON.parse(fs.readFileSync(path.join(root, "meta", file), "utf8")) as Meta;
|
|
701
|
+
if (meta.host && (!meta.remoteTerminalState || !isConfirmedTerminalState(meta.remoteTerminalState))) return false;
|
|
702
|
+
}
|
|
703
|
+
} catch (error) {
|
|
704
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") return false;
|
|
705
|
+
}
|
|
633
706
|
const sessionsDir = path.join(root, "sessions");
|
|
634
707
|
let sessionDirs: fs.Dirent[];
|
|
635
708
|
try {
|
|
@@ -1124,6 +1197,10 @@ async function searchLog(
|
|
|
1124
1197
|
signal?: AbortSignal,
|
|
1125
1198
|
maxBytes = TAIL_MAX_BYTES,
|
|
1126
1199
|
): Promise<{ text: string; error?: string }> {
|
|
1200
|
+
if (readMeta(id)?.host) {
|
|
1201
|
+
const result = await bs(["log", "-s", id, "--grep", pattern, "--tail", String(maxLines)], { signal });
|
|
1202
|
+
return result.code === 0 ? { text: clip(result.stdout.trimEnd(), maxBytes) } : { text: "", error: result.stderr || result.stdout || "Remote log search failed" };
|
|
1203
|
+
}
|
|
1127
1204
|
const file = logPath(id);
|
|
1128
1205
|
if (!fs.existsSync(file)) return { text: "", error: `Log file is missing: ${file}` };
|
|
1129
1206
|
if (signal?.aborted) return { text: "", error: "Log search was interrupted." };
|
|
@@ -1196,6 +1273,12 @@ async function inlineOutput(
|
|
|
1196
1273
|
status: BsSession,
|
|
1197
1274
|
maxBytes = INLINE_OUTPUT_MAX_BYTES,
|
|
1198
1275
|
): Promise<string> {
|
|
1276
|
+
if (readMeta(id)?.host) {
|
|
1277
|
+
const result = await bs(["log", "-s", id, "--tail", "200"]);
|
|
1278
|
+
if (result.code !== 0) return `\nRemote output unavailable: ${clip(result.stderr || result.stdout, maxBytes)}`;
|
|
1279
|
+
const output = clip(result.stdout.trimEnd(), maxBytes);
|
|
1280
|
+
return output ? `\n\nRemote output (200 lines max):\n${output}` : "";
|
|
1281
|
+
}
|
|
1199
1282
|
let bytes = status.output_bytes;
|
|
1200
1283
|
if (bytes == null) {
|
|
1201
1284
|
try {
|
|
@@ -1208,7 +1291,9 @@ async function inlineOutput(
|
|
|
1208
1291
|
const size = Number.isFinite(bytes) ? `${bytes} bytes` : "size unavailable";
|
|
1209
1292
|
return `\nOutput omitted (${size}; inline limit ${maxBytes}).`;
|
|
1210
1293
|
}
|
|
1211
|
-
const
|
|
1294
|
+
const result = await bs(["log", "-s", id]);
|
|
1295
|
+
if (result.code !== 0) return `\nOutput unavailable: ${clip(result.stderr || result.stdout, maxBytes)}`;
|
|
1296
|
+
const output = result.stdout.trimEnd();
|
|
1212
1297
|
if (Buffer.byteLength(output) > maxBytes) {
|
|
1213
1298
|
return `\nOutput omitted (exceeds inline limit ${maxBytes} bytes).`;
|
|
1214
1299
|
}
|
|
@@ -1238,7 +1323,9 @@ async function selectedProcessOutput(
|
|
|
1238
1323
|
const body = result.text || `(no output matching /${selection.pattern}/)`;
|
|
1239
1324
|
return `\n\nSelected output /${selection.pattern}/:\n${clip(body, maxBytes)}`;
|
|
1240
1325
|
}
|
|
1241
|
-
const
|
|
1326
|
+
const result = await bs(["log", "-s", id, "--tail", String(lines)], { signal });
|
|
1327
|
+
if (result.code !== 0) return `\nOutput unavailable: ${clip(result.stderr || result.stdout, maxBytes)}`;
|
|
1328
|
+
const tail = result.stdout.trimEnd();
|
|
1242
1329
|
return tail ? `\n\nSelected tail (${lines} lines max):\n${clip(tail, maxBytes)}` : "";
|
|
1243
1330
|
}
|
|
1244
1331
|
|
|
@@ -1986,6 +2073,7 @@ const reservedSessionIds = new Set<string>();
|
|
|
1986
2073
|
async function reserveUniqueSessionId(name: string): Promise<string> {
|
|
1987
2074
|
const base = name.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "") || "proc";
|
|
1988
2075
|
const taken = new Set((await listSessions()).sessions.map((s) => s.id));
|
|
2076
|
+
for (const { id } of remoteMetadata()) taken.add(id);
|
|
1989
2077
|
for (const id of reservedSessionIds) taken.add(id);
|
|
1990
2078
|
let id = base;
|
|
1991
2079
|
for (let i = 2; taken.has(id); i++) id = `${base}-${i}`;
|
|
@@ -1996,7 +2084,8 @@ async function reserveUniqueSessionId(name: string): Promise<string> {
|
|
|
1996
2084
|
interface ProcOpts {
|
|
1997
2085
|
name?: string;
|
|
1998
2086
|
command: string;
|
|
1999
|
-
cwd
|
|
2087
|
+
cwd?: string;
|
|
2088
|
+
host?: string;
|
|
2000
2089
|
env?: Record<string, string | undefined>;
|
|
2001
2090
|
timeout?: string; // default: none — dev servers may run indefinitely
|
|
2002
2091
|
idleTimeout?: string;
|
|
@@ -2017,22 +2106,53 @@ export function processSessionEnvironment(
|
|
|
2017
2106
|
};
|
|
2018
2107
|
}
|
|
2019
2108
|
|
|
2109
|
+
// babysit session ids are limited to 64 ASCII characters. Names stay in local
|
|
2110
|
+
// metadata; a fixed namespace hash plus full UUID keeps remote ids at 52 bytes.
|
|
2111
|
+
export function remoteProcessId(namespace: string): string {
|
|
2112
|
+
return `pi-${createHash("sha256").update(namespace).digest("hex").slice(0, 12)}-${randomUUID()}`;
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
export function validateProcessHost(host?: string): void {
|
|
2116
|
+
if (host && host !== "local" && (!/^[A-Za-z0-9][A-Za-z0-9_.@:\[\]-]*$/.test(host) || host.startsWith("-"))) {
|
|
2117
|
+
throw new Error("Invalid host: use an SSH destination or config alias, not options or whitespace.");
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
export function remoteProcessCommand(command: string, cwd?: string, env: Record<string, string | undefined> = {}): string {
|
|
2122
|
+
const assignments = Object.entries(env).filter(([key, value]) => value !== undefined && key !== "PI_SESSION_FILE" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(key)).map(([key, value]) => `${key}=${shq(value!)}`);
|
|
2123
|
+
return `${cwd !== undefined ? `cd ${shq(cwd)} && ` : ""}exec env ${assignments.join(" ")} sh -c ${shq(command)}`;
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2020
2126
|
async function spawnProcess(opts: ProcOpts): Promise<{ id: string } | { error: string }> {
|
|
2127
|
+
validateProcessHost(opts.host);
|
|
2128
|
+
const remote = !!opts.host && opts.host !== "local";
|
|
2021
2129
|
const bsArgs = ["run", "-d", "--json", "--size", "120x40"];
|
|
2022
2130
|
if (!opts.pty) bsArgs.push("--no-tty");
|
|
2023
2131
|
if (opts.timeout && opts.timeout !== "none") bsArgs.push("--timeout", opts.timeout);
|
|
2024
2132
|
if (opts.idleTimeout && opts.idleTimeout !== "none")
|
|
2025
2133
|
bsArgs.push("--idle-timeout", opts.idleTimeout);
|
|
2026
|
-
const reservedId = opts.name ? await reserveUniqueSessionId(opts.name) : undefined;
|
|
2027
|
-
|
|
2028
|
-
bsArgs.push("--",
|
|
2134
|
+
const reservedId = remote ? await reserveUniqueSessionId(opts.name ?? "remote-proc") : opts.name ? await reserveUniqueSessionId(opts.name) : undefined;
|
|
2135
|
+
const remoteId = remote ? remoteProcessId(ROOT) : undefined;
|
|
2136
|
+
if (reservedId) bsArgs.push("--id", remoteId ?? reservedId);
|
|
2137
|
+
if (remote && !writeMeta(reservedId!, { kind: "process", host: opts.host, remoteId, name: opts.name ?? reservedId, command: opts.command, notificationGroup: opts.notificationGroup, notified: false, startedAt: Date.now() })) {
|
|
2138
|
+
reservedSessionIds.delete(reservedId!);
|
|
2139
|
+
return { error: "Cannot persist remote identity; process was not started." };
|
|
2140
|
+
}
|
|
2141
|
+
bsArgs.push("--", remote ? "sh" : SHELL, "-c", remote ? remoteProcessCommand(opts.command, opts.cwd, opts.env) : opts.command);
|
|
2029
2142
|
|
|
2030
2143
|
let r: Awaited<ReturnType<typeof bs>>;
|
|
2031
2144
|
try {
|
|
2032
|
-
r = await bs(bsArgs, { cwd: opts.cwd, env: opts.env });
|
|
2145
|
+
r = await bs(bsArgs, { cwd: remote ? undefined : opts.cwd, env: remote ? undefined : opts.env, host: opts.host });
|
|
2033
2146
|
} finally {
|
|
2034
2147
|
if (reservedId) reservedSessionIds.delete(reservedId);
|
|
2035
2148
|
}
|
|
2149
|
+
if (remote) {
|
|
2150
|
+
if (r.code !== 0) return { error: `Remote launch on ${opts.host} is unverified: ${r.stderr || r.stdout}. Tracked id: ${reservedId}; use check/kill to reconcile. Do not blindly retry.` };
|
|
2151
|
+
try {
|
|
2152
|
+
if (JSON.parse(r.stdout).id !== remoteId) throw new Error("unexpected remote id");
|
|
2153
|
+
} catch { return { error: `Remote launch response is unverified on ${opts.host}; tracked id: ${reservedId}. Use check/kill to reconcile; do not retry.` }; }
|
|
2154
|
+
return { id: reservedId! };
|
|
2155
|
+
}
|
|
2036
2156
|
if (r.code !== 0) {
|
|
2037
2157
|
return {
|
|
2038
2158
|
error:
|
|
@@ -2690,6 +2810,10 @@ function readTailLines(file: string, lines: number, maxBytes = 64_000): string[]
|
|
|
2690
2810
|
}
|
|
2691
2811
|
}
|
|
2692
2812
|
|
|
2813
|
+
const remoteTails = new Map<string, string[]>();
|
|
2814
|
+
// Running previews are not final. Only a successful post-terminal fetch is reusable.
|
|
2815
|
+
const remoteTerminalTails = new Set<string>();
|
|
2816
|
+
|
|
2693
2817
|
// Trailing lines to show for a session (sanitized, unprefixed).
|
|
2694
2818
|
// Process tails are read directly from the bounded end of output.log, avoiding
|
|
2695
2819
|
// one `babysit log` subprocess per visible process.
|
|
@@ -2700,6 +2824,7 @@ export function widgetTail(
|
|
|
2700
2824
|
lines = WIDGET_COLLAPSED_TAIL_LINES,
|
|
2701
2825
|
): string[] {
|
|
2702
2826
|
let raw: string[];
|
|
2827
|
+
if (readMeta(id)?.host) return remoteTails.get(id) ?? ["Remote output unavailable; use babysit_check."];
|
|
2703
2828
|
if (!isSub) {
|
|
2704
2829
|
raw = readTailLines(logPath(id), lines);
|
|
2705
2830
|
} else {
|
|
@@ -2729,6 +2854,8 @@ export function widgetTail(
|
|
|
2729
2854
|
// process interactively (detach with Ctrl-\ Ctrl-\). `/babysit` shows this as a
|
|
2730
2855
|
// hint alongside an inline snapshot instead of spawning a tmux window itself.
|
|
2731
2856
|
function attachCmd(id: string): string {
|
|
2857
|
+
const meta = readMeta(id);
|
|
2858
|
+
if (meta?.host) return `${shq(BABYSIT_BIN)} --host ${shq(meta.host)} attach -s ${shq(meta.remoteId!)}`;
|
|
2732
2859
|
return `BABYSIT_DIR=${shq(ROOT)} ${shq(BABYSIT_BIN)} attach -s ${shq(id)}`;
|
|
2733
2860
|
}
|
|
2734
2861
|
|
|
@@ -3526,6 +3653,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
3526
3653
|
const notices = prepared.flatMap((notice) => {
|
|
3527
3654
|
const current = readMeta(notice.id);
|
|
3528
3655
|
if (!shouldDeliverProcessCompletion(current)) return [];
|
|
3656
|
+
const latest = finalSessions.find((session) => session.id === notice.id);
|
|
3657
|
+
if (!latest || latest.transportError || latest.state === "running") return [];
|
|
3529
3658
|
if (!isNotificationGroupReady(current, finalSessions, readMeta)) return [];
|
|
3530
3659
|
metadataById.set(notice.id, current);
|
|
3531
3660
|
return [{ ...notice, command: current.command }];
|
|
@@ -3625,6 +3754,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
3625
3754
|
|
|
3626
3755
|
const theme = ctx.ui.theme;
|
|
3627
3756
|
const summaryLines = renderWidgetLines(procs, subs.length - idle, idle, theme);
|
|
3757
|
+
await Promise.all(ordered.filter((session) => session.host && (session.state === "running" || !remoteTerminalTails.has(session.id))).map(async (session) => {
|
|
3758
|
+
remoteTerminalTails.delete(session.id);
|
|
3759
|
+
const result = await bs(["log", "-s", session.id, "--tail", String(WIDGET_COLLAPSED_TAIL_LINES)]);
|
|
3760
|
+
remoteTails.set(session.id, result.code === 0 ? clip(result.stdout, TAIL_MAX_BYTES).trimEnd().split("\n").map(sanitizeTailLine) : [`Remote output unavailable on ${session.host}: ${clip(result.stderr, 200)}`]);
|
|
3761
|
+
if (result.code === 0 && !session.transportError && (isConfirmedTerminalState(session.state) || session.state === "dead")) remoteTerminalTails.add(session.id);
|
|
3762
|
+
}));
|
|
3628
3763
|
const tails = await Promise.all(
|
|
3629
3764
|
active.map((session) => {
|
|
3630
3765
|
const isSubagent = kindOf(session.id) === "subagent";
|
|
@@ -3647,11 +3782,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
3647
3782
|
...(meta?.launchCommand ? [theme.bold(escapeCommandForDisplay(readableLaunchCommand(meta.launchCommand)))] : []),
|
|
3648
3783
|
]
|
|
3649
3784
|
: meta?.command ? [theme.bold(escapeCommandForDisplay(meta.command))] : [];
|
|
3785
|
+
if (meta?.host) details.unshift(`host: ${meta.host}${session.transportError ? " (status unknown)" : ""} · ${attachCmd(id)}`);
|
|
3650
3786
|
if (session.state === "running") {
|
|
3651
3787
|
const state: WidgetSessionState = isSubagent && progressById.get(id)?.done ? "idle" : "running";
|
|
3652
3788
|
return {
|
|
3653
3789
|
id,
|
|
3654
|
-
header: renderWidgetSessionHeader(id, isSubagent ? "agent" : "process", state, elapsedOf(id), theme),
|
|
3790
|
+
header: renderWidgetSessionHeader(id, isSubagent ? "agent" : "process", state, elapsedOf(id), theme) + (session.transportError ? " · status unknown" : ""),
|
|
3655
3791
|
tail: activeTails.get(id) ?? [],
|
|
3656
3792
|
viewable: true,
|
|
3657
3793
|
active: true,
|
|
@@ -3689,6 +3825,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
3689
3825
|
requestRender: () => tui.requestRender(),
|
|
3690
3826
|
getTheme: () => ctx.ui.theme,
|
|
3691
3827
|
readLog: (id) => {
|
|
3828
|
+
if (readMeta(id)?.host) return { text: `Remote full-history viewer is unavailable.\nLog: ${logPath(id)}\nTake over: ${attachCmd(id)}\n\n${(remoteTails.get(id) ?? []).join("\n")}`, isAgent: false };
|
|
3692
3829
|
const isAgent = kindOf(id) === "subagent";
|
|
3693
3830
|
return { file: logPath(id), isAgent };
|
|
3694
3831
|
},
|
|
@@ -3821,6 +3958,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
3821
3958
|
}
|
|
3822
3959
|
taskProgressCache.clear();
|
|
3823
3960
|
searchLogCache.clear();
|
|
3961
|
+
remoteTails.clear();
|
|
3962
|
+
remoteTerminalTails.clear();
|
|
3824
3963
|
pollNeeded = true;
|
|
3825
3964
|
const retentionDays = Number(process.env.PI_BABYSIT_RETENTION_DAYS ?? "3");
|
|
3826
3965
|
if (
|
|
@@ -3961,6 +4100,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
3961
4100
|
return prepareBabysitRunArguments(args) as never;
|
|
3962
4101
|
},
|
|
3963
4102
|
parameters: Type.Object({
|
|
4103
|
+
host: Type.Optional(Type.String({ description: "Process SSH destination/config alias (default local). Remote subagents are unsupported." })),
|
|
4104
|
+
cwd: Type.Optional(Type.String({ description: "Process working directory. Remote default is login home; never inferred from local cwd." })),
|
|
3964
4105
|
command: Type.Optional(
|
|
3965
4106
|
Type.String({
|
|
3966
4107
|
description: "Shell command to run (process mode). Mutually exclusive with profile/task.",
|
|
@@ -4091,6 +4232,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
4091
4232
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4092
4233
|
await requireBabysit();
|
|
4093
4234
|
const isSubagent = params.profile === "subagent";
|
|
4235
|
+
validateProcessHost(params.host);
|
|
4236
|
+
if (isSubagent && (params.cwd !== undefined || (params.host && params.host !== "local"))) throw new Error("host/cwd are process-only options; remote subagents are not supported.");
|
|
4237
|
+
if (params.host && params.host !== "local" && params.retryOnWorkerDeath) throw new Error("Remote automatic launch retries are unsupported; reconcile the tracked session explicitly.");
|
|
4094
4238
|
if (isSubagent && !params.task) {
|
|
4095
4239
|
return {
|
|
4096
4240
|
content: [{ type: "text", text: "profile 'subagent' requires `task`." }],
|
|
@@ -4195,7 +4339,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
4195
4339
|
const spawnOpts: ProcOpts = {
|
|
4196
4340
|
name: params.name,
|
|
4197
4341
|
command: params.command as string,
|
|
4198
|
-
|
|
4342
|
+
host: params.host,
|
|
4343
|
+
cwd: params.cwd ?? (params.host && params.host !== "local" ? undefined : ctx.cwd),
|
|
4199
4344
|
timeout: params.timeout,
|
|
4200
4345
|
idleTimeout: params.idleTimeout,
|
|
4201
4346
|
pty: params.pty ?? true,
|
|
@@ -4571,7 +4716,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
4571
4716
|
: "";
|
|
4572
4717
|
const what = (kind === "subagent" ? meta?.task : meta?.command) ?? "";
|
|
4573
4718
|
const preview = what.length > 60 ? `${what.slice(0, 57)}…` : what;
|
|
4574
|
-
return `${s.id} [${kind}] ${s.state}${ec}${depth}${flag}${preview ? ` — ${preview}` : ""}`;
|
|
4719
|
+
return `${s.id} [${kind}${s.host ? ` host=${s.host}` : ""}] ${s.transportError ? "unknown" : s.state}${ec}${depth}${flag}${preview ? ` — ${preview}` : ""}`;
|
|
4575
4720
|
});
|
|
4576
4721
|
const hidden = sessions.length - selected.length;
|
|
4577
4722
|
const suffix = hidden > 0 ? `\n… ${hidden} session(s) hidden${revealHint}.` : "";
|
|
@@ -4581,7 +4726,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
4581
4726
|
};
|
|
4582
4727
|
}
|
|
4583
4728
|
|
|
4584
|
-
const
|
|
4729
|
+
const lookup = await lookupStatus(params.id);
|
|
4730
|
+
if (lookup.error) throw new Error(`Status unknown for ${params.id}: ${lookup.error}`);
|
|
4731
|
+
const st = lookup.session;
|
|
4585
4732
|
if (!st) {
|
|
4586
4733
|
return {
|
|
4587
4734
|
content: [{ type: "text", text: `No such session: ${params.id}` }],
|
|
@@ -4616,7 +4763,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
4616
4763
|
};
|
|
4617
4764
|
}
|
|
4618
4765
|
const kind = meta?.kind ?? "process";
|
|
4619
|
-
const header = `[${kind}] state=${st.state}\nlog: ${logPath(params.id)}`;
|
|
4766
|
+
const header = `[${kind}${meta?.host ? ` host=${meta.host}` : ""}] state=${st.state}\nlog: ${logPath(params.id)}`;
|
|
4620
4767
|
const body = result.text
|
|
4621
4768
|
? `--- latest matches /${params.pattern}/ ---\n${result.text}`
|
|
4622
4769
|
: `(no output matching /${params.pattern}/)`;
|
|
@@ -4629,7 +4776,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
4629
4776
|
// --- process ---
|
|
4630
4777
|
if (meta?.kind !== "subagent") {
|
|
4631
4778
|
const parts: string[] = [];
|
|
4632
|
-
let header = `[process] state=${st.state}`;
|
|
4779
|
+
let header = `[process${meta?.host ? ` host=${meta.host}` : ""}] state=${st.state}`;
|
|
4633
4780
|
if (st.state === "running") {
|
|
4634
4781
|
const el = elapsedOf(params.id);
|
|
4635
4782
|
if (el) header += ` elapsed=${el}`;
|
|
@@ -4641,10 +4788,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
4641
4788
|
parts.push(header);
|
|
4642
4789
|
if (params.screen) {
|
|
4643
4790
|
const sc = await bs(["screenshot", "-s", params.id, "--trim"]);
|
|
4791
|
+
if (sc.code !== 0) throw new Error(sc.stderr || sc.stdout || "Screenshot unavailable");
|
|
4644
4792
|
parts.push(`--- screen ---\n${clip(sc.stdout.trimEnd(), checkMaxBytes) || "(blank screen)"}`);
|
|
4645
4793
|
} else {
|
|
4794
|
+
const result = await bs(["log", "-s", params.id, "--tail", String(nLines)]);
|
|
4795
|
+
if (result.code !== 0) throw new Error(result.stderr || result.stdout || "Log unavailable");
|
|
4646
4796
|
const tail = clip(
|
|
4647
|
-
|
|
4797
|
+
result.stdout.trimEnd(),
|
|
4648
4798
|
checkMaxBytes,
|
|
4649
4799
|
);
|
|
4650
4800
|
parts.push(tail ? `--- recent output ---\n${tail}` : "(no output yet)");
|
|
@@ -4739,7 +4889,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
4739
4889
|
}),
|
|
4740
4890
|
async execute(_id, params) {
|
|
4741
4891
|
await requireBabysit();
|
|
4742
|
-
const
|
|
4892
|
+
const lookup = await lookupStatus(params.id);
|
|
4893
|
+
if (lookup.error) throw new Error(`Status unknown for ${params.id}: ${lookup.error}`);
|
|
4894
|
+
const st = lookup.session;
|
|
4743
4895
|
if (!st || st.state !== "running") {
|
|
4744
4896
|
return {
|
|
4745
4897
|
content: [
|
|
@@ -5296,12 +5448,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
5296
5448
|
// For a LIVE process show the CURRENT rendered screen (TUIs redraw in
|
|
5297
5449
|
// place, so the raw stream isn't representative); for a finished one
|
|
5298
5450
|
// the recorded tail is enough.
|
|
5299
|
-
const
|
|
5300
|
-
|
|
5301
|
-
|
|
5302
|
-
const tail =
|
|
5451
|
+
const screenResult = running ? await bs(["screenshot", "-s", picked.id, "--trim"]) : undefined;
|
|
5452
|
+
const screen = screenResult ? screenResult.code === 0 ? screenResult.stdout.trimEnd() : `Screen unavailable: ${screenResult.stderr || screenResult.stdout}` : "";
|
|
5453
|
+
const tailResult = await bs(["log", "-s", picked.id, "--tail", "30"]);
|
|
5454
|
+
const tail = tailResult.code === 0 ? tailResult.stdout.trimEnd() : `Output unavailable: ${tailResult.stderr || tailResult.stdout}`;
|
|
5303
5455
|
const title =
|
|
5304
|
-
`${picked.id} ${picked.state}${elapsedSuffix}` +
|
|
5456
|
+
`${picked.id}${meta?.host ? ` @${meta.host}` : ""} ${picked.transportError ? "unknown" : picked.state}${elapsedSuffix}` +
|
|
5305
5457
|
(picked.exit_code != null ? ` (exit=${picked.exit_code})` : "");
|
|
5306
5458
|
const body =
|
|
5307
5459
|
(meta?.command ? `\`${meta.command}\`\n\n` : "") +
|