@yusukeshib/pi-babysit 0.6.18 → 0.6.20
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 +48 -1
- package/index.ts +352 -33
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -71,6 +71,52 @@ 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. Opening
|
|
102
|
+
a remote session in the widget viewer shows its full history: while the viewer
|
|
103
|
+
is open, pi-babysit incrementally mirrors the remote raw log (`log --since
|
|
104
|
+
--raw --json`) into the local session root and pages it like a local log; a
|
|
105
|
+
replaced or truncated remote log rebuilds the mirror, and a finished log stops
|
|
106
|
+
syncing once its final output has been read.
|
|
107
|
+
|
|
108
|
+
An SSH failure is **unknown status**, not process exit. Remote workers may
|
|
109
|
+
survive disconnects. An uncertain launch keeps its identity and reports the
|
|
110
|
+
tracked id: reconcile it with check/kill rather than blindly retrying. Attached
|
|
111
|
+
foreground interruption stops the local SSH transport immediately (the CLI runs
|
|
112
|
+
in its own process group), then explicitly kills the remote worker and claims cleanup
|
|
113
|
+
only after terminal-state confirmation; unreachable cleanup is reported as
|
|
114
|
+
unverified. Detached waits do not own the worker. Pi quit attempts cleanup of
|
|
115
|
+
tracked workers, reporting unverified remote cleanup to stderr. Local GC retains
|
|
116
|
+
roots with unresolved remote identities; it never deletes/prunes remote logs.
|
|
117
|
+
After reconnecting, check or kill tracked workers to establish terminal status
|
|
118
|
+
before local retention can collect their metadata.
|
|
119
|
+
|
|
74
120
|
## Commands (human)
|
|
75
121
|
|
|
76
122
|
| Command | What it does |
|
|
@@ -126,7 +172,8 @@ with Pi's tool-output toggle (`Ctrl+O` by default).
|
|
|
126
172
|
## Logs without context flooding
|
|
127
173
|
|
|
128
174
|
`babysit_run`, `babysit_wait`, and automatic completion notifications always
|
|
129
|
-
return lifecycle metadata and the absolute path to the complete `output.log
|
|
175
|
+
return lifecycle metadata and the absolute path to the complete local `output.log`
|
|
176
|
+
(or a remote CLI log locator for SSH processes).
|
|
130
177
|
Explicit run/wait results inline complete output up to 8 KB; unsolicited
|
|
131
178
|
completion notifications use a stricter 2 KB per-process output cap and an 8 KB
|
|
132
179
|
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,19 @@ function bs(
|
|
|
215
218
|
cwd?: string;
|
|
216
219
|
signal?: AbortSignal;
|
|
217
220
|
env?: Record<string, string | undefined>;
|
|
221
|
+
host?: string;
|
|
222
|
+
/** Return complete stdout even for remote log reads (log mirroring). */
|
|
223
|
+
unboundedStdout?: boolean;
|
|
218
224
|
} = {},
|
|
219
225
|
): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
226
|
+
const sessionIndex = args.indexOf("-s");
|
|
227
|
+
const meta = sessionIndex >= 0 ? readMeta(args[sessionIndex + 1]) : null;
|
|
228
|
+
const host = opts.host ?? meta?.host;
|
|
229
|
+
if (host && host !== "local") {
|
|
230
|
+
args = [...args];
|
|
231
|
+
if (meta?.remoteId) args[sessionIndex + 1] = meta.remoteId;
|
|
232
|
+
args.unshift("--host", host);
|
|
233
|
+
}
|
|
220
234
|
return new Promise((resolve) => {
|
|
221
235
|
if (opts.signal?.aborted) {
|
|
222
236
|
resolve({ stdout: "", stderr: "aborted", code: 130 });
|
|
@@ -228,32 +242,59 @@ function bs(
|
|
|
228
242
|
else env[name] = value;
|
|
229
243
|
}
|
|
230
244
|
env.BABYSIT_DIR = ROOT;
|
|
245
|
+
// Own a process group: remote commands run `ssh` as a grandchild that
|
|
246
|
+
// shares our pipes, so signalling only the CLI would leave the transport
|
|
247
|
+
// alive and `close` (and therefore cancellation) blocked on it.
|
|
231
248
|
const child = spawn(BABYSIT_BIN, args, {
|
|
232
249
|
cwd: opts.cwd,
|
|
233
250
|
env,
|
|
251
|
+
detached: true,
|
|
234
252
|
});
|
|
235
253
|
let stdout = "";
|
|
236
254
|
let stderr = "";
|
|
237
|
-
|
|
255
|
+
let settled = false;
|
|
256
|
+
const settle = (result: { stdout: string; stderr: string; code: number }) => {
|
|
257
|
+
if (settled) return;
|
|
258
|
+
settled = true;
|
|
259
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
260
|
+
resolve(result);
|
|
261
|
+
};
|
|
262
|
+
const onAbort = () => {
|
|
263
|
+
try {
|
|
264
|
+
if (child.pid) process.kill(-child.pid, "SIGTERM");
|
|
265
|
+
else child.kill("SIGTERM");
|
|
266
|
+
} catch {
|
|
267
|
+
child.kill("SIGTERM");
|
|
268
|
+
}
|
|
269
|
+
};
|
|
238
270
|
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
271
|
+
const boundStdout = host && host !== "local" && !opts.unboundedStdout && (args.includes("log") || args.includes("screenshot"));
|
|
239
272
|
child.stdout?.on("data", (d) => {
|
|
240
273
|
stdout += d.toString();
|
|
274
|
+
// Drain output but bound remote log/screen memory even for a huge single line.
|
|
275
|
+
if (boundStdout && Buffer.byteLength(stdout) > 32_000) stdout = Buffer.from(stdout).subarray(-32_000).toString("utf8");
|
|
241
276
|
});
|
|
242
277
|
child.stderr?.on("data", (d) => {
|
|
243
278
|
stderr += d.toString();
|
|
244
279
|
});
|
|
245
280
|
child.on("error", (e) => {
|
|
246
|
-
opts.signal?.removeEventListener("abort", onAbort);
|
|
247
281
|
const installHint = babysitSpawnInstallHint(e);
|
|
248
282
|
if (installHint) {
|
|
249
283
|
babysitPreflightError = installHint;
|
|
250
284
|
babysitPreflightCheckedAt = Date.now();
|
|
251
285
|
}
|
|
252
|
-
|
|
286
|
+
settle({ stdout, stderr: installHint ?? stderr + String(e), code: 1 });
|
|
287
|
+
});
|
|
288
|
+
child.on("exit", (code) => {
|
|
289
|
+
// After cancellation, a descendant that escaped the process group must
|
|
290
|
+
// not keep the caller blocked on inherited pipes.
|
|
291
|
+
if (!opts.signal?.aborted) return;
|
|
292
|
+
child.stdout?.destroy();
|
|
293
|
+
child.stderr?.destroy();
|
|
294
|
+
settle({ stdout, stderr: stderr || "aborted", code: code ?? 130 });
|
|
253
295
|
});
|
|
254
296
|
child.on("close", (code) => {
|
|
255
|
-
|
|
256
|
-
resolve({ stdout, stderr, code: code ?? 1 });
|
|
297
|
+
settle({ stdout, stderr, code: code ?? 1 });
|
|
257
298
|
});
|
|
258
299
|
});
|
|
259
300
|
}
|
|
@@ -321,8 +362,8 @@ async function requireBabysit(): Promise<void> {
|
|
|
321
362
|
// Error-aware: `babysit list` failing is NOT the same as "no sessions" —
|
|
322
363
|
// callers that show state to the agent must surface the error instead of
|
|
323
364
|
// silently reporting an empty registry (which reads like lost sessions).
|
|
324
|
-
async function
|
|
325
|
-
const r = await bs(["list", "--json"]);
|
|
365
|
+
async function localSessions(host?: string): Promise<{ sessions: BsSession[]; error?: string }> {
|
|
366
|
+
const r = await bs(["list", "--json"], { host });
|
|
326
367
|
if (r.code !== 0) {
|
|
327
368
|
return {
|
|
328
369
|
sessions: [],
|
|
@@ -338,6 +379,28 @@ async function listSessions(): Promise<{ sessions: BsSession[]; error?: string }
|
|
|
338
379
|
}
|
|
339
380
|
}
|
|
340
381
|
|
|
382
|
+
async function listSessions(): Promise<{ sessions: BsSession[]; error?: string }> {
|
|
383
|
+
const local = await localSessions();
|
|
384
|
+
const known = remoteMetadata();
|
|
385
|
+
await Promise.all([...new Set(known.map(({ meta }) => meta.host!))].map(async (host) => {
|
|
386
|
+
const remote = await localSessions(host);
|
|
387
|
+
for (const { id, meta } of known.filter((entry) => entry.meta.host === host)) {
|
|
388
|
+
const found = remote.sessions.find((session) => session.id === meta.remoteId);
|
|
389
|
+
if (found && !remote.error) {
|
|
390
|
+
local.sessions.push({ ...found, id, host });
|
|
391
|
+
const terminalState = isConfirmedTerminalState(found.state) ? found.state : undefined;
|
|
392
|
+
if (meta.remoteTerminalState !== terminalState) {
|
|
393
|
+
writeMeta(id, { ...readMeta(id)!, remoteTerminalState: terminalState });
|
|
394
|
+
}
|
|
395
|
+
} else {
|
|
396
|
+
// Keep unresolved references active for polling/group delivery, never fabricate exit.
|
|
397
|
+
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"}` });
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}));
|
|
401
|
+
return local;
|
|
402
|
+
}
|
|
403
|
+
|
|
341
404
|
async function lookupStatus(
|
|
342
405
|
id: string,
|
|
343
406
|
): Promise<{ session: BsSession | null; error?: string }> {
|
|
@@ -346,8 +409,10 @@ async function lookupStatus(
|
|
|
346
409
|
// status-then-list pair (two CLI subprocesses for every status lookup).
|
|
347
410
|
try {
|
|
348
411
|
const listed = await listSessions();
|
|
349
|
-
|
|
350
|
-
return { session:
|
|
412
|
+
const session = listed.sessions.find((session) => session.id === id) ?? null;
|
|
413
|
+
if (session?.transportError) return { session: null, error: `${session.host}: ${session.transportError}` };
|
|
414
|
+
if (listed.error && !readMeta(id)?.host) return { session: null, error: listed.error };
|
|
415
|
+
return { session };
|
|
351
416
|
} catch (error) {
|
|
352
417
|
return { session: null, error: error instanceof Error ? error.message : String(error) };
|
|
353
418
|
}
|
|
@@ -409,10 +474,11 @@ async function terminateRunningSessions(): Promise<void> {
|
|
|
409
474
|
let listed: Awaited<ReturnType<typeof listSessions>>;
|
|
410
475
|
try {
|
|
411
476
|
listed = await listSessions();
|
|
412
|
-
} catch {
|
|
477
|
+
} catch (error) {
|
|
478
|
+
console.error(`Babysit shutdown cleanup unknown: ${error instanceof Error ? error.message : String(error)}`);
|
|
413
479
|
return;
|
|
414
480
|
}
|
|
415
|
-
if (listed.error)
|
|
481
|
+
if (listed.error) console.error(`Local babysit shutdown cleanup unknown: ${listed.error}`);
|
|
416
482
|
|
|
417
483
|
// A namespace may contain many independent commands and agents. Terminate
|
|
418
484
|
// them concurrently so quitting Pi does not wait for serial kill timeouts.
|
|
@@ -422,7 +488,13 @@ async function terminateRunningSessions(): Promise<void> {
|
|
|
422
488
|
.filter((session) => session.state === "running")
|
|
423
489
|
.map(async (session) => {
|
|
424
490
|
const result = await bs(["kill", "-s", session.id, "--json"]);
|
|
425
|
-
if (
|
|
491
|
+
if (session.host) {
|
|
492
|
+
const terminal = await awaitConfirmedTermination(session.id);
|
|
493
|
+
if (!terminal || !isConfirmedTerminalState(terminal.state)) {
|
|
494
|
+
console.error(`Remote cleanup unverified for ${session.id} on ${session.host}: ${result.stderr || "terminal state unavailable"}`);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
} else if (result.code !== 0 || validateKillResponse(result.stdout)) return;
|
|
426
498
|
// A later resume of this Pi session must not turn intentional shutdown
|
|
427
499
|
// cleanup into a process-completion notification.
|
|
428
500
|
suppressNotify(session.id, "kill");
|
|
@@ -445,6 +517,9 @@ export interface SubagentBudget {
|
|
|
445
517
|
}
|
|
446
518
|
|
|
447
519
|
interface Meta {
|
|
520
|
+
host?: string;
|
|
521
|
+
remoteId?: string;
|
|
522
|
+
remoteTerminalState?: string;
|
|
448
523
|
kind: "process" | "subagent";
|
|
449
524
|
// process
|
|
450
525
|
name?: string;
|
|
@@ -493,7 +568,21 @@ interface Meta {
|
|
|
493
568
|
}
|
|
494
569
|
|
|
495
570
|
const metaDir = () => path.join(ROOT, "meta");
|
|
496
|
-
const
|
|
571
|
+
const localLogPath = (id: string) => path.join(ROOT, "sessions", id, "output.log");
|
|
572
|
+
const logPath = (id: string) => {
|
|
573
|
+
const meta = readMeta(id);
|
|
574
|
+
return meta?.host ? `${shq(BABYSIT_BIN)} --host ${shq(meta.host)} log -s ${shq(meta.remoteId!)}` : localLogPath(id);
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
function remoteMetadata(): Array<{ id: string; meta: Meta }> {
|
|
578
|
+
try {
|
|
579
|
+
return fs.readdirSync(metaDir()).filter((file) => file.endsWith(".json")).flatMap((file) => {
|
|
580
|
+
const id = file.slice(0, -5);
|
|
581
|
+
const meta = readMeta(id);
|
|
582
|
+
return meta?.host && meta.remoteId ? [{ id, meta }] : [];
|
|
583
|
+
});
|
|
584
|
+
} catch { return []; }
|
|
585
|
+
}
|
|
497
586
|
|
|
498
587
|
function writeMeta(id: string, m: Meta): boolean {
|
|
499
588
|
const target = path.join(metaDir(), `${id}.json`);
|
|
@@ -630,6 +719,17 @@ function gcRootIsSafe(root: string): boolean {
|
|
|
630
719
|
}
|
|
631
720
|
}
|
|
632
721
|
|
|
722
|
+
// Remote PIDs are meaningless locally. Retain unresolved remote references;
|
|
723
|
+
// terminal observations are persisted by listSessions, never prune remote data.
|
|
724
|
+
try {
|
|
725
|
+
for (const file of fs.readdirSync(path.join(root, "meta"))) {
|
|
726
|
+
if (!file.endsWith(".json")) continue;
|
|
727
|
+
const meta = JSON.parse(fs.readFileSync(path.join(root, "meta", file), "utf8")) as Meta;
|
|
728
|
+
if (meta.host && (!meta.remoteTerminalState || !isConfirmedTerminalState(meta.remoteTerminalState))) return false;
|
|
729
|
+
}
|
|
730
|
+
} catch (error) {
|
|
731
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") return false;
|
|
732
|
+
}
|
|
633
733
|
const sessionsDir = path.join(root, "sessions");
|
|
634
734
|
let sessionDirs: fs.Dirent[];
|
|
635
735
|
try {
|
|
@@ -1124,6 +1224,10 @@ async function searchLog(
|
|
|
1124
1224
|
signal?: AbortSignal,
|
|
1125
1225
|
maxBytes = TAIL_MAX_BYTES,
|
|
1126
1226
|
): Promise<{ text: string; error?: string }> {
|
|
1227
|
+
if (readMeta(id)?.host) {
|
|
1228
|
+
const result = await bs(["log", "-s", id, "--grep", pattern, "--tail", String(maxLines)], { signal });
|
|
1229
|
+
return result.code === 0 ? { text: clip(result.stdout.trimEnd(), maxBytes) } : { text: "", error: result.stderr || result.stdout || "Remote log search failed" };
|
|
1230
|
+
}
|
|
1127
1231
|
const file = logPath(id);
|
|
1128
1232
|
if (!fs.existsSync(file)) return { text: "", error: `Log file is missing: ${file}` };
|
|
1129
1233
|
if (signal?.aborted) return { text: "", error: "Log search was interrupted." };
|
|
@@ -1196,6 +1300,12 @@ async function inlineOutput(
|
|
|
1196
1300
|
status: BsSession,
|
|
1197
1301
|
maxBytes = INLINE_OUTPUT_MAX_BYTES,
|
|
1198
1302
|
): Promise<string> {
|
|
1303
|
+
if (readMeta(id)?.host) {
|
|
1304
|
+
const result = await bs(["log", "-s", id, "--tail", "200"]);
|
|
1305
|
+
if (result.code !== 0) return `\nRemote output unavailable: ${clip(result.stderr || result.stdout, maxBytes)}`;
|
|
1306
|
+
const output = clip(result.stdout.trimEnd(), maxBytes);
|
|
1307
|
+
return output ? `\n\nRemote output (200 lines max):\n${output}` : "";
|
|
1308
|
+
}
|
|
1199
1309
|
let bytes = status.output_bytes;
|
|
1200
1310
|
if (bytes == null) {
|
|
1201
1311
|
try {
|
|
@@ -1208,7 +1318,9 @@ async function inlineOutput(
|
|
|
1208
1318
|
const size = Number.isFinite(bytes) ? `${bytes} bytes` : "size unavailable";
|
|
1209
1319
|
return `\nOutput omitted (${size}; inline limit ${maxBytes}).`;
|
|
1210
1320
|
}
|
|
1211
|
-
const
|
|
1321
|
+
const result = await bs(["log", "-s", id]);
|
|
1322
|
+
if (result.code !== 0) return `\nOutput unavailable: ${clip(result.stderr || result.stdout, maxBytes)}`;
|
|
1323
|
+
const output = result.stdout.trimEnd();
|
|
1212
1324
|
if (Buffer.byteLength(output) > maxBytes) {
|
|
1213
1325
|
return `\nOutput omitted (exceeds inline limit ${maxBytes} bytes).`;
|
|
1214
1326
|
}
|
|
@@ -1238,7 +1350,9 @@ async function selectedProcessOutput(
|
|
|
1238
1350
|
const body = result.text || `(no output matching /${selection.pattern}/)`;
|
|
1239
1351
|
return `\n\nSelected output /${selection.pattern}/:\n${clip(body, maxBytes)}`;
|
|
1240
1352
|
}
|
|
1241
|
-
const
|
|
1353
|
+
const result = await bs(["log", "-s", id, "--tail", String(lines)], { signal });
|
|
1354
|
+
if (result.code !== 0) return `\nOutput unavailable: ${clip(result.stderr || result.stdout, maxBytes)}`;
|
|
1355
|
+
const tail = result.stdout.trimEnd();
|
|
1242
1356
|
return tail ? `\n\nSelected tail (${lines} lines max):\n${clip(tail, maxBytes)}` : "";
|
|
1243
1357
|
}
|
|
1244
1358
|
|
|
@@ -1986,6 +2100,7 @@ const reservedSessionIds = new Set<string>();
|
|
|
1986
2100
|
async function reserveUniqueSessionId(name: string): Promise<string> {
|
|
1987
2101
|
const base = name.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "") || "proc";
|
|
1988
2102
|
const taken = new Set((await listSessions()).sessions.map((s) => s.id));
|
|
2103
|
+
for (const { id } of remoteMetadata()) taken.add(id);
|
|
1989
2104
|
for (const id of reservedSessionIds) taken.add(id);
|
|
1990
2105
|
let id = base;
|
|
1991
2106
|
for (let i = 2; taken.has(id); i++) id = `${base}-${i}`;
|
|
@@ -1996,7 +2111,8 @@ async function reserveUniqueSessionId(name: string): Promise<string> {
|
|
|
1996
2111
|
interface ProcOpts {
|
|
1997
2112
|
name?: string;
|
|
1998
2113
|
command: string;
|
|
1999
|
-
cwd
|
|
2114
|
+
cwd?: string;
|
|
2115
|
+
host?: string;
|
|
2000
2116
|
env?: Record<string, string | undefined>;
|
|
2001
2117
|
timeout?: string; // default: none — dev servers may run indefinitely
|
|
2002
2118
|
idleTimeout?: string;
|
|
@@ -2017,22 +2133,53 @@ export function processSessionEnvironment(
|
|
|
2017
2133
|
};
|
|
2018
2134
|
}
|
|
2019
2135
|
|
|
2136
|
+
// babysit session ids are limited to 64 ASCII characters. Names stay in local
|
|
2137
|
+
// metadata; a fixed namespace hash plus full UUID keeps remote ids at 52 bytes.
|
|
2138
|
+
export function remoteProcessId(namespace: string): string {
|
|
2139
|
+
return `pi-${createHash("sha256").update(namespace).digest("hex").slice(0, 12)}-${randomUUID()}`;
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
export function validateProcessHost(host?: string): void {
|
|
2143
|
+
if (host && host !== "local" && (!/^[A-Za-z0-9][A-Za-z0-9_.@:\[\]-]*$/.test(host) || host.startsWith("-"))) {
|
|
2144
|
+
throw new Error("Invalid host: use an SSH destination or config alias, not options or whitespace.");
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
export function remoteProcessCommand(command: string, cwd?: string, env: Record<string, string | undefined> = {}): string {
|
|
2149
|
+
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!)}`);
|
|
2150
|
+
return `${cwd !== undefined ? `cd ${shq(cwd)} && ` : ""}exec env ${assignments.join(" ")} sh -c ${shq(command)}`;
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2020
2153
|
async function spawnProcess(opts: ProcOpts): Promise<{ id: string } | { error: string }> {
|
|
2154
|
+
validateProcessHost(opts.host);
|
|
2155
|
+
const remote = !!opts.host && opts.host !== "local";
|
|
2021
2156
|
const bsArgs = ["run", "-d", "--json", "--size", "120x40"];
|
|
2022
2157
|
if (!opts.pty) bsArgs.push("--no-tty");
|
|
2023
2158
|
if (opts.timeout && opts.timeout !== "none") bsArgs.push("--timeout", opts.timeout);
|
|
2024
2159
|
if (opts.idleTimeout && opts.idleTimeout !== "none")
|
|
2025
2160
|
bsArgs.push("--idle-timeout", opts.idleTimeout);
|
|
2026
|
-
const reservedId = opts.name ? await reserveUniqueSessionId(opts.name) : undefined;
|
|
2027
|
-
|
|
2028
|
-
bsArgs.push("--",
|
|
2161
|
+
const reservedId = remote ? await reserveUniqueSessionId(opts.name ?? "remote-proc") : opts.name ? await reserveUniqueSessionId(opts.name) : undefined;
|
|
2162
|
+
const remoteId = remote ? remoteProcessId(ROOT) : undefined;
|
|
2163
|
+
if (reservedId) bsArgs.push("--id", remoteId ?? reservedId);
|
|
2164
|
+
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() })) {
|
|
2165
|
+
reservedSessionIds.delete(reservedId!);
|
|
2166
|
+
return { error: "Cannot persist remote identity; process was not started." };
|
|
2167
|
+
}
|
|
2168
|
+
bsArgs.push("--", remote ? "sh" : SHELL, "-c", remote ? remoteProcessCommand(opts.command, opts.cwd, opts.env) : opts.command);
|
|
2029
2169
|
|
|
2030
2170
|
let r: Awaited<ReturnType<typeof bs>>;
|
|
2031
2171
|
try {
|
|
2032
|
-
r = await bs(bsArgs, { cwd: opts.cwd, env: opts.env });
|
|
2172
|
+
r = await bs(bsArgs, { cwd: remote ? undefined : opts.cwd, env: remote ? undefined : opts.env, host: opts.host });
|
|
2033
2173
|
} finally {
|
|
2034
2174
|
if (reservedId) reservedSessionIds.delete(reservedId);
|
|
2035
2175
|
}
|
|
2176
|
+
if (remote) {
|
|
2177
|
+
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.` };
|
|
2178
|
+
try {
|
|
2179
|
+
if (JSON.parse(r.stdout).id !== remoteId) throw new Error("unexpected remote id");
|
|
2180
|
+
} catch { return { error: `Remote launch response is unverified on ${opts.host}; tracked id: ${reservedId}. Use check/kill to reconcile; do not retry.` }; }
|
|
2181
|
+
return { id: reservedId! };
|
|
2182
|
+
}
|
|
2036
2183
|
if (r.code !== 0) {
|
|
2037
2184
|
return {
|
|
2038
2185
|
error:
|
|
@@ -2570,6 +2717,12 @@ export function createSessionWidget(options: {
|
|
|
2570
2717
|
}
|
|
2571
2718
|
return {
|
|
2572
2719
|
open,
|
|
2720
|
+
/** Re-read the open viewer's log source (e.g. after a remote mirror sync). */
|
|
2721
|
+
refresh() {
|
|
2722
|
+
if (!selectedId) return;
|
|
2723
|
+
refreshViewer();
|
|
2724
|
+
options.requestRender();
|
|
2725
|
+
},
|
|
2573
2726
|
update(summary: string[], sessions: WidgetSessionDisplay[]) {
|
|
2574
2727
|
displays = sessions;
|
|
2575
2728
|
list = createWidgetComponent(summary, sessions, open, listState);
|
|
@@ -2690,6 +2843,143 @@ function readTailLines(file: string, lines: number, maxBytes = 64_000): string[]
|
|
|
2690
2843
|
}
|
|
2691
2844
|
}
|
|
2692
2845
|
|
|
2846
|
+
const remoteTails = new Map<string, string[]>();
|
|
2847
|
+
// Running previews are not final. Only a successful post-terminal fetch is reusable.
|
|
2848
|
+
const remoteTerminalTails = new Set<string>();
|
|
2849
|
+
|
|
2850
|
+
// Remote logs stay on the remote host; the widget viewer reads a local,
|
|
2851
|
+
// append-only mirror built from incremental `log --since --raw --json` reads.
|
|
2852
|
+
// The cursor records the remote raw-byte offset and the local mirror size it
|
|
2853
|
+
// corresponds to, so an interrupted append is truncated instead of duplicated.
|
|
2854
|
+
type RemoteLogCursor = { remoteOffset: number; localBytes: number; complete: boolean };
|
|
2855
|
+
type RemoteLogSyncState = { inFlight?: Promise<boolean>; lastSyncAt: number; lastChanged: boolean; error?: string };
|
|
2856
|
+
const remoteLogSyncs = new Map<string, RemoteLogSyncState>();
|
|
2857
|
+
const REMOTE_LOG_SYNC_INTERVAL_MS = 1000;
|
|
2858
|
+
|
|
2859
|
+
export function remoteLogMirrorPath(id: string): string {
|
|
2860
|
+
return path.join(ROOT, "remote-logs", id, "output.log");
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
function remoteLogCursorPath(id: string): string {
|
|
2864
|
+
return path.join(path.dirname(remoteLogMirrorPath(id)), "cursor.json");
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
function readRemoteLogCursor(id: string): RemoteLogCursor {
|
|
2868
|
+
const file = remoteLogMirrorPath(id);
|
|
2869
|
+
let cursor: RemoteLogCursor = { remoteOffset: 0, localBytes: 0, complete: false };
|
|
2870
|
+
try {
|
|
2871
|
+
const parsed = JSON.parse(fs.readFileSync(remoteLogCursorPath(id), "utf8"));
|
|
2872
|
+
if (Number.isSafeInteger(parsed.remoteOffset) && Number.isSafeInteger(parsed.localBytes) && parsed.remoteOffset >= 0 && parsed.localBytes >= 0) {
|
|
2873
|
+
cursor = { remoteOffset: parsed.remoteOffset, localBytes: parsed.localBytes, complete: parsed.complete === true };
|
|
2874
|
+
}
|
|
2875
|
+
} catch {}
|
|
2876
|
+
let size = 0;
|
|
2877
|
+
try {
|
|
2878
|
+
size = fs.statSync(file).size;
|
|
2879
|
+
} catch {}
|
|
2880
|
+
if (size < cursor.localBytes) return { remoteOffset: 0, localBytes: 0, complete: false };
|
|
2881
|
+
if (size > cursor.localBytes) fs.truncateSync(file, cursor.localBytes);
|
|
2882
|
+
return cursor;
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
function writeRemoteLogCursor(id: string, cursor: RemoteLogCursor): void {
|
|
2886
|
+
const target = remoteLogCursorPath(id);
|
|
2887
|
+
const temp = `${target}.${process.pid}.tmp`;
|
|
2888
|
+
fs.writeFileSync(temp, JSON.stringify(cursor));
|
|
2889
|
+
fs.renameSync(temp, target);
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
/** Fetch new remote output into the local mirror. Resolves true when it grew or completed. */
|
|
2893
|
+
export async function syncRemoteLog(id: string): Promise<boolean> {
|
|
2894
|
+
const state = remoteLogSyncs.get(id) ?? { lastSyncAt: 0, lastChanged: false };
|
|
2895
|
+
remoteLogSyncs.set(id, state);
|
|
2896
|
+
if (state.inFlight) return state.inFlight;
|
|
2897
|
+
state.inFlight = (async () => {
|
|
2898
|
+
const file = remoteLogMirrorPath(id);
|
|
2899
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
2900
|
+
if (!fs.existsSync(file)) fs.writeFileSync(file, "");
|
|
2901
|
+
const cursor = readRemoteLogCursor(id);
|
|
2902
|
+
if (cursor.complete) return false;
|
|
2903
|
+
const result = await bs(["log", "-s", id, "--since", String(cursor.remoteOffset), "--raw", "--json"], { unboundedStdout: true });
|
|
2904
|
+
if (result.code !== 0) {
|
|
2905
|
+
const error = clip(result.stderr.trim() || result.stdout.trim() || `babysit log exited ${result.code}`, 500);
|
|
2906
|
+
const changed = state.error !== error;
|
|
2907
|
+
state.error = error;
|
|
2908
|
+
return changed;
|
|
2909
|
+
}
|
|
2910
|
+
let response: { text?: unknown; offset?: unknown; done?: unknown };
|
|
2911
|
+
try {
|
|
2912
|
+
response = JSON.parse(result.stdout);
|
|
2913
|
+
} catch {
|
|
2914
|
+
state.error = `invalid remote log response: ${clip(result.stdout, 200)}`;
|
|
2915
|
+
return true;
|
|
2916
|
+
}
|
|
2917
|
+
if (typeof response.text !== "string" || !Number.isSafeInteger(response.offset) || (response.offset as number) < 0) {
|
|
2918
|
+
state.error = `invalid remote log response: ${clip(result.stdout, 200)}`;
|
|
2919
|
+
return true;
|
|
2920
|
+
}
|
|
2921
|
+
if ((response.offset as number) < cursor.remoteOffset) {
|
|
2922
|
+
// The remote log was replaced or truncated (e.g. restart); rebuild the mirror.
|
|
2923
|
+
fs.truncateSync(file, 0);
|
|
2924
|
+
writeRemoteLogCursor(id, { remoteOffset: 0, localBytes: 0, complete: false });
|
|
2925
|
+
return true;
|
|
2926
|
+
}
|
|
2927
|
+
const hadError = state.error !== undefined;
|
|
2928
|
+
state.error = undefined;
|
|
2929
|
+
const bytes = Buffer.from(response.text, "utf8");
|
|
2930
|
+
if (bytes.length > 0) fs.appendFileSync(file, bytes);
|
|
2931
|
+
const advanced = (response.offset as number) > cursor.remoteOffset || bytes.length > 0;
|
|
2932
|
+
// `done` is reported before the worker's final flush, so completion
|
|
2933
|
+
// requires a terminal read that returned nothing new.
|
|
2934
|
+
const complete = response.done === true && !advanced;
|
|
2935
|
+
writeRemoteLogCursor(id, { remoteOffset: response.offset as number, localBytes: cursor.localBytes + bytes.length, complete });
|
|
2936
|
+
return advanced || complete || hadError;
|
|
2937
|
+
})().then((changed) => {
|
|
2938
|
+
state.lastChanged = changed;
|
|
2939
|
+
return changed;
|
|
2940
|
+
}, (error) => {
|
|
2941
|
+
state.error = error instanceof Error ? error.message : String(error);
|
|
2942
|
+
state.lastChanged = true;
|
|
2943
|
+
return true;
|
|
2944
|
+
}).finally(() => {
|
|
2945
|
+
state.lastSyncAt = Date.now();
|
|
2946
|
+
state.inFlight = undefined;
|
|
2947
|
+
});
|
|
2948
|
+
return state.inFlight;
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2951
|
+
/**
|
|
2952
|
+
* Schedule a background mirror sync for an open viewer. Catch-up continues
|
|
2953
|
+
* immediately while output keeps arriving; otherwise reads are throttled.
|
|
2954
|
+
*/
|
|
2955
|
+
function scheduleRemoteLogSync(id: string, onChange: () => void): void {
|
|
2956
|
+
const state = remoteLogSyncs.get(id);
|
|
2957
|
+
if (state?.inFlight) return;
|
|
2958
|
+
if (state && !state.lastChanged && Date.now() - state.lastSyncAt < REMOTE_LOG_SYNC_INTERVAL_MS) return;
|
|
2959
|
+
if (state && !state.error && readRemoteLogCursor(id).complete) return;
|
|
2960
|
+
// Always re-render: the first read may replace a loading placeholder even
|
|
2961
|
+
// when the remote log is still empty. Re-entry is throttled above.
|
|
2962
|
+
void syncRemoteLog(id).then(() => onChange());
|
|
2963
|
+
}
|
|
2964
|
+
|
|
2965
|
+
function remoteLogViewerSource(id: string, onChange: () => void): { text?: string; file?: string; isAgent: boolean } {
|
|
2966
|
+
const file = remoteLogMirrorPath(id);
|
|
2967
|
+
scheduleRemoteLogSync(id, onChange);
|
|
2968
|
+
const error = remoteLogSyncs.get(id)?.error;
|
|
2969
|
+
let size = 0;
|
|
2970
|
+
try {
|
|
2971
|
+
size = fs.statSync(file).size;
|
|
2972
|
+
} catch {}
|
|
2973
|
+
if (size > 0) return { file, isAgent: false };
|
|
2974
|
+
const meta = readMeta(id);
|
|
2975
|
+
return {
|
|
2976
|
+
text: error
|
|
2977
|
+
? `Remote output unavailable on ${meta?.host}: ${error}\nLog: ${logPath(id)}\nTake over: ${attachCmd(id)}`
|
|
2978
|
+
: remoteLogSyncs.get(id)?.inFlight ? `Loading remote output from ${meta?.host}…` : "",
|
|
2979
|
+
isAgent: false,
|
|
2980
|
+
};
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2693
2983
|
// Trailing lines to show for a session (sanitized, unprefixed).
|
|
2694
2984
|
// Process tails are read directly from the bounded end of output.log, avoiding
|
|
2695
2985
|
// one `babysit log` subprocess per visible process.
|
|
@@ -2700,6 +2990,7 @@ export function widgetTail(
|
|
|
2700
2990
|
lines = WIDGET_COLLAPSED_TAIL_LINES,
|
|
2701
2991
|
): string[] {
|
|
2702
2992
|
let raw: string[];
|
|
2993
|
+
if (readMeta(id)?.host) return remoteTails.get(id) ?? ["Remote output unavailable; use babysit_check."];
|
|
2703
2994
|
if (!isSub) {
|
|
2704
2995
|
raw = readTailLines(logPath(id), lines);
|
|
2705
2996
|
} else {
|
|
@@ -2729,6 +3020,8 @@ export function widgetTail(
|
|
|
2729
3020
|
// process interactively (detach with Ctrl-\ Ctrl-\). `/babysit` shows this as a
|
|
2730
3021
|
// hint alongside an inline snapshot instead of spawning a tmux window itself.
|
|
2731
3022
|
function attachCmd(id: string): string {
|
|
3023
|
+
const meta = readMeta(id);
|
|
3024
|
+
if (meta?.host) return `${shq(BABYSIT_BIN)} --host ${shq(meta.host)} attach -s ${shq(meta.remoteId!)}`;
|
|
2732
3025
|
return `BABYSIT_DIR=${shq(ROOT)} ${shq(BABYSIT_BIN)} attach -s ${shq(id)}`;
|
|
2733
3026
|
}
|
|
2734
3027
|
|
|
@@ -3526,6 +3819,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
3526
3819
|
const notices = prepared.flatMap((notice) => {
|
|
3527
3820
|
const current = readMeta(notice.id);
|
|
3528
3821
|
if (!shouldDeliverProcessCompletion(current)) return [];
|
|
3822
|
+
const latest = finalSessions.find((session) => session.id === notice.id);
|
|
3823
|
+
if (!latest || latest.transportError || latest.state === "running") return [];
|
|
3529
3824
|
if (!isNotificationGroupReady(current, finalSessions, readMeta)) return [];
|
|
3530
3825
|
metadataById.set(notice.id, current);
|
|
3531
3826
|
return [{ ...notice, command: current.command }];
|
|
@@ -3625,6 +3920,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
3625
3920
|
|
|
3626
3921
|
const theme = ctx.ui.theme;
|
|
3627
3922
|
const summaryLines = renderWidgetLines(procs, subs.length - idle, idle, theme);
|
|
3923
|
+
await Promise.all(ordered.filter((session) => session.host && (session.state === "running" || !remoteTerminalTails.has(session.id))).map(async (session) => {
|
|
3924
|
+
remoteTerminalTails.delete(session.id);
|
|
3925
|
+
const result = await bs(["log", "-s", session.id, "--tail", String(WIDGET_COLLAPSED_TAIL_LINES)]);
|
|
3926
|
+
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)}`]);
|
|
3927
|
+
if (result.code === 0 && !session.transportError && (isConfirmedTerminalState(session.state) || session.state === "dead")) remoteTerminalTails.add(session.id);
|
|
3928
|
+
}));
|
|
3628
3929
|
const tails = await Promise.all(
|
|
3629
3930
|
active.map((session) => {
|
|
3630
3931
|
const isSubagent = kindOf(session.id) === "subagent";
|
|
@@ -3647,11 +3948,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
3647
3948
|
...(meta?.launchCommand ? [theme.bold(escapeCommandForDisplay(readableLaunchCommand(meta.launchCommand)))] : []),
|
|
3648
3949
|
]
|
|
3649
3950
|
: meta?.command ? [theme.bold(escapeCommandForDisplay(meta.command))] : [];
|
|
3951
|
+
if (meta?.host) details.unshift(`host: ${meta.host}${session.transportError ? " (status unknown)" : ""} · ${attachCmd(id)}`);
|
|
3650
3952
|
if (session.state === "running") {
|
|
3651
3953
|
const state: WidgetSessionState = isSubagent && progressById.get(id)?.done ? "idle" : "running";
|
|
3652
3954
|
return {
|
|
3653
3955
|
id,
|
|
3654
|
-
header: renderWidgetSessionHeader(id, isSubagent ? "agent" : "process", state, elapsedOf(id), theme),
|
|
3956
|
+
header: renderWidgetSessionHeader(id, isSubagent ? "agent" : "process", state, elapsedOf(id), theme) + (session.transportError ? " · status unknown" : ""),
|
|
3655
3957
|
tail: activeTails.get(id) ?? [],
|
|
3656
3958
|
viewable: true,
|
|
3657
3959
|
active: true,
|
|
@@ -3689,6 +3991,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
3689
3991
|
requestRender: () => tui.requestRender(),
|
|
3690
3992
|
getTheme: () => ctx.ui.theme,
|
|
3691
3993
|
readLog: (id) => {
|
|
3994
|
+
if (readMeta(id)?.host) return remoteLogViewerSource(id, () => sessionWidget?.refresh());
|
|
3692
3995
|
const isAgent = kindOf(id) === "subagent";
|
|
3693
3996
|
return { file: logPath(id), isAgent };
|
|
3694
3997
|
},
|
|
@@ -3821,6 +4124,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
3821
4124
|
}
|
|
3822
4125
|
taskProgressCache.clear();
|
|
3823
4126
|
searchLogCache.clear();
|
|
4127
|
+
remoteTails.clear();
|
|
4128
|
+
remoteTerminalTails.clear();
|
|
4129
|
+
remoteLogSyncs.clear();
|
|
3824
4130
|
pollNeeded = true;
|
|
3825
4131
|
const retentionDays = Number(process.env.PI_BABYSIT_RETENTION_DAYS ?? "3");
|
|
3826
4132
|
if (
|
|
@@ -3961,6 +4267,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
3961
4267
|
return prepareBabysitRunArguments(args) as never;
|
|
3962
4268
|
},
|
|
3963
4269
|
parameters: Type.Object({
|
|
4270
|
+
host: Type.Optional(Type.String({ description: "Process SSH destination/config alias (default local). Remote subagents are unsupported." })),
|
|
4271
|
+
cwd: Type.Optional(Type.String({ description: "Process working directory. Remote default is login home; never inferred from local cwd." })),
|
|
3964
4272
|
command: Type.Optional(
|
|
3965
4273
|
Type.String({
|
|
3966
4274
|
description: "Shell command to run (process mode). Mutually exclusive with profile/task.",
|
|
@@ -4091,6 +4399,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
4091
4399
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4092
4400
|
await requireBabysit();
|
|
4093
4401
|
const isSubagent = params.profile === "subagent";
|
|
4402
|
+
validateProcessHost(params.host);
|
|
4403
|
+
if (isSubagent && (params.cwd !== undefined || (params.host && params.host !== "local"))) throw new Error("host/cwd are process-only options; remote subagents are not supported.");
|
|
4404
|
+
if (params.host && params.host !== "local" && params.retryOnWorkerDeath) throw new Error("Remote automatic launch retries are unsupported; reconcile the tracked session explicitly.");
|
|
4094
4405
|
if (isSubagent && !params.task) {
|
|
4095
4406
|
return {
|
|
4096
4407
|
content: [{ type: "text", text: "profile 'subagent' requires `task`." }],
|
|
@@ -4195,7 +4506,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
4195
4506
|
const spawnOpts: ProcOpts = {
|
|
4196
4507
|
name: params.name,
|
|
4197
4508
|
command: params.command as string,
|
|
4198
|
-
|
|
4509
|
+
host: params.host,
|
|
4510
|
+
cwd: params.cwd ?? (params.host && params.host !== "local" ? undefined : ctx.cwd),
|
|
4199
4511
|
timeout: params.timeout,
|
|
4200
4512
|
idleTimeout: params.idleTimeout,
|
|
4201
4513
|
pty: params.pty ?? true,
|
|
@@ -4571,7 +4883,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
4571
4883
|
: "";
|
|
4572
4884
|
const what = (kind === "subagent" ? meta?.task : meta?.command) ?? "";
|
|
4573
4885
|
const preview = what.length > 60 ? `${what.slice(0, 57)}…` : what;
|
|
4574
|
-
return `${s.id} [${kind}] ${s.state}${ec}${depth}${flag}${preview ? ` — ${preview}` : ""}`;
|
|
4886
|
+
return `${s.id} [${kind}${s.host ? ` host=${s.host}` : ""}] ${s.transportError ? "unknown" : s.state}${ec}${depth}${flag}${preview ? ` — ${preview}` : ""}`;
|
|
4575
4887
|
});
|
|
4576
4888
|
const hidden = sessions.length - selected.length;
|
|
4577
4889
|
const suffix = hidden > 0 ? `\n… ${hidden} session(s) hidden${revealHint}.` : "";
|
|
@@ -4581,7 +4893,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
4581
4893
|
};
|
|
4582
4894
|
}
|
|
4583
4895
|
|
|
4584
|
-
const
|
|
4896
|
+
const lookup = await lookupStatus(params.id);
|
|
4897
|
+
if (lookup.error) throw new Error(`Status unknown for ${params.id}: ${lookup.error}`);
|
|
4898
|
+
const st = lookup.session;
|
|
4585
4899
|
if (!st) {
|
|
4586
4900
|
return {
|
|
4587
4901
|
content: [{ type: "text", text: `No such session: ${params.id}` }],
|
|
@@ -4616,7 +4930,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
4616
4930
|
};
|
|
4617
4931
|
}
|
|
4618
4932
|
const kind = meta?.kind ?? "process";
|
|
4619
|
-
const header = `[${kind}] state=${st.state}\nlog: ${logPath(params.id)}`;
|
|
4933
|
+
const header = `[${kind}${meta?.host ? ` host=${meta.host}` : ""}] state=${st.state}\nlog: ${logPath(params.id)}`;
|
|
4620
4934
|
const body = result.text
|
|
4621
4935
|
? `--- latest matches /${params.pattern}/ ---\n${result.text}`
|
|
4622
4936
|
: `(no output matching /${params.pattern}/)`;
|
|
@@ -4629,7 +4943,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
4629
4943
|
// --- process ---
|
|
4630
4944
|
if (meta?.kind !== "subagent") {
|
|
4631
4945
|
const parts: string[] = [];
|
|
4632
|
-
let header = `[process] state=${st.state}`;
|
|
4946
|
+
let header = `[process${meta?.host ? ` host=${meta.host}` : ""}] state=${st.state}`;
|
|
4633
4947
|
if (st.state === "running") {
|
|
4634
4948
|
const el = elapsedOf(params.id);
|
|
4635
4949
|
if (el) header += ` elapsed=${el}`;
|
|
@@ -4641,10 +4955,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
4641
4955
|
parts.push(header);
|
|
4642
4956
|
if (params.screen) {
|
|
4643
4957
|
const sc = await bs(["screenshot", "-s", params.id, "--trim"]);
|
|
4958
|
+
if (sc.code !== 0) throw new Error(sc.stderr || sc.stdout || "Screenshot unavailable");
|
|
4644
4959
|
parts.push(`--- screen ---\n${clip(sc.stdout.trimEnd(), checkMaxBytes) || "(blank screen)"}`);
|
|
4645
4960
|
} else {
|
|
4961
|
+
const result = await bs(["log", "-s", params.id, "--tail", String(nLines)]);
|
|
4962
|
+
if (result.code !== 0) throw new Error(result.stderr || result.stdout || "Log unavailable");
|
|
4646
4963
|
const tail = clip(
|
|
4647
|
-
|
|
4964
|
+
result.stdout.trimEnd(),
|
|
4648
4965
|
checkMaxBytes,
|
|
4649
4966
|
);
|
|
4650
4967
|
parts.push(tail ? `--- recent output ---\n${tail}` : "(no output yet)");
|
|
@@ -4739,7 +5056,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
4739
5056
|
}),
|
|
4740
5057
|
async execute(_id, params) {
|
|
4741
5058
|
await requireBabysit();
|
|
4742
|
-
const
|
|
5059
|
+
const lookup = await lookupStatus(params.id);
|
|
5060
|
+
if (lookup.error) throw new Error(`Status unknown for ${params.id}: ${lookup.error}`);
|
|
5061
|
+
const st = lookup.session;
|
|
4743
5062
|
if (!st || st.state !== "running") {
|
|
4744
5063
|
return {
|
|
4745
5064
|
content: [
|
|
@@ -5296,12 +5615,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
5296
5615
|
// For a LIVE process show the CURRENT rendered screen (TUIs redraw in
|
|
5297
5616
|
// place, so the raw stream isn't representative); for a finished one
|
|
5298
5617
|
// the recorded tail is enough.
|
|
5299
|
-
const
|
|
5300
|
-
|
|
5301
|
-
|
|
5302
|
-
const tail =
|
|
5618
|
+
const screenResult = running ? await bs(["screenshot", "-s", picked.id, "--trim"]) : undefined;
|
|
5619
|
+
const screen = screenResult ? screenResult.code === 0 ? screenResult.stdout.trimEnd() : `Screen unavailable: ${screenResult.stderr || screenResult.stdout}` : "";
|
|
5620
|
+
const tailResult = await bs(["log", "-s", picked.id, "--tail", "30"]);
|
|
5621
|
+
const tail = tailResult.code === 0 ? tailResult.stdout.trimEnd() : `Output unavailable: ${tailResult.stderr || tailResult.stdout}`;
|
|
5303
5622
|
const title =
|
|
5304
|
-
`${picked.id} ${picked.state}${elapsedSuffix}` +
|
|
5623
|
+
`${picked.id}${meta?.host ? ` @${meta.host}` : ""} ${picked.transportError ? "unknown" : picked.state}${elapsedSuffix}` +
|
|
5305
5624
|
(picked.exit_code != null ? ` (exit=${picked.exit_code})` : "");
|
|
5306
5625
|
const body =
|
|
5307
5626
|
(meta?.command ? `\`${meta.command}\`\n\n` : "") +
|