@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.
Files changed (3) hide show
  1. package/README.md +43 -1
  2. package/index.ts +180 -28
  3. 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 listSessions(): Promise<{ sessions: BsSession[]; error?: string }> {
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
- if (listed.error) return { session: null, error: listed.error };
350
- return { session: listed.sessions.find((session) => session.id === id) ?? null };
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) return;
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 (result.code !== 0 || validateKillResponse(result.stdout)) return;
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 logPath = (id: string) => path.join(ROOT, "sessions", id, "output.log");
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 output = (await bs(["log", "-s", id])).stdout.trimEnd();
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 tail = (await bs(["log", "-s", id, "--tail", String(lines)])).stdout.trimEnd();
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: string;
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
- if (reservedId) bsArgs.push("--id", reservedId);
2028
- bsArgs.push("--", SHELL, "-c", opts.command);
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
- cwd: ctx.cwd,
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 st = await statusOf(params.id);
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
- (await bs(["log", "-s", params.id, "--tail", String(nLines)])).stdout.trimEnd(),
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 st = await statusOf(params.id);
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 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();
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` : "") +
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.19",
4
4
  "description": "Run any shell command and pi subagents under babysit, with context-safe captured output.",
5
5
  "keywords": [
6
6
  "pi-package",