@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.
Files changed (3) hide show
  1. package/README.md +48 -1
  2. package/index.ts +352 -33
  3. 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
- const onAbort = () => child.kill("SIGTERM");
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
- resolve({ stdout, stderr: installHint ?? stderr + String(e), code: 1 });
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
- opts.signal?.removeEventListener("abort", onAbort);
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 listSessions(): Promise<{ sessions: BsSession[]; error?: string }> {
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
- if (listed.error) return { session: null, error: listed.error };
350
- return { session: listed.sessions.find((session) => session.id === id) ?? null };
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) return;
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 (result.code !== 0 || validateKillResponse(result.stdout)) return;
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 logPath = (id: string) => path.join(ROOT, "sessions", id, "output.log");
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 output = (await bs(["log", "-s", id])).stdout.trimEnd();
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 tail = (await bs(["log", "-s", id, "--tail", String(lines)])).stdout.trimEnd();
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: string;
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
- if (reservedId) bsArgs.push("--id", reservedId);
2028
- bsArgs.push("--", SHELL, "-c", opts.command);
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
- cwd: ctx.cwd,
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 st = await statusOf(params.id);
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
- (await bs(["log", "-s", params.id, "--tail", String(nLines)])).stdout.trimEnd(),
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 st = await statusOf(params.id);
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 screen = running
5300
- ? (await bs(["screenshot", "-s", picked.id, "--trim"])).stdout.trimEnd()
5301
- : "";
5302
- const tail = (await bs(["log", "-s", picked.id, "--tail", "30"])).stdout.trimEnd();
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` : "") +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yusukeshib/pi-babysit",
3
- "version": "0.6.18",
3
+ "version": "0.6.20",
4
4
  "description": "Run any shell command and pi subagents under babysit, with context-safe captured output.",
5
5
  "keywords": [
6
6
  "pi-package",