@yusukeshib/pi-babysit 0.6.17 → 0.6.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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 |
@@ -106,9 +147,17 @@ first content line; streamed updates and duplicate results keep that time. This
106
147
  is the time babysit received the first byte of the event or line, not an
107
148
  application event time. Narrow views place the date above the text. Old logs and
108
149
  lines without capture metadata remain readable without invented timestamps.
109
- Only the selected log is loaded, bounded to the latest 1 MiB / 2,000 display
110
- lines with an omission notice; the full log file is unchanged. Empty logs show
111
- a placeholder. Works in the released Pi 1.0 widget host. Mouse interaction
150
+ Only the selected log is loaded: the viewer starts at the tail and lazily reads
151
+ older/newer pages as you scroll to the loaded window's edges. The scrollbar
152
+ represents that window, not the entire file; the footer shows its byte range.
153
+ Normally a small sliding window of roughly 64 KiB pages is retained. Page limits
154
+ are soft for oversized records and individual assistant-response/tool-result
155
+ groups (streaming snapshots and duplicate results stay together), and for pages
156
+ intersecting the viewport. Agent tasks are split at assistant-message boundaries,
157
+ not loaded as a whole; older logs lacking those events can form larger groups.
158
+ All saved history remains reachable, with no log records discarded or modified.
159
+ Capture-time sidecars are sought by byte range rather than read in full. Empty
160
+ logs show a placeholder. Works in the released Pi 1.0 widget host. Mouse interaction
112
161
  requires fullscreen mode; `/babysit view` opens the viewer using a keyboard
113
162
  picker, and `/babysit` still provides a snapshot. The displayed `age` is total
114
163
  session age, not time spent in the current state. Transcript details
@@ -118,7 +167,8 @@ with Pi's tool-output toggle (`Ctrl+O` by default).
118
167
  ## Logs without context flooding
119
168
 
120
169
  `babysit_run`, `babysit_wait`, and automatic completion notifications always
121
- 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).
122
172
  Explicit run/wait results inline complete output up to 8 KB; unsolicited
123
173
  completion notifications use a stricter 2 KB per-process output cap and an 8 KB
124
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";
@@ -45,6 +46,7 @@ import type {
45
46
  import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
46
47
  import { Box, Markdown, Text, truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
47
48
  import { createLogViewer } from "./log-viewer";
49
+ import { FileLogSource } from "./log-source";
48
50
  import { Type, type TSchema } from "typebox";
49
51
  import { StringEnum } from "@earendil-works/pi-ai";
50
52
  import { type AgentConfig, type AgentScope, discoverAgents } from "./agents";
@@ -185,6 +187,8 @@ interface BsSession {
185
187
  note?: string | null;
186
188
  output_bytes?: number;
187
189
  screen_seq?: number | null;
190
+ host?: string;
191
+ transportError?: string;
188
192
  }
189
193
 
190
194
  // A worker whose PTY process is gone (alive:false) can still report state
@@ -214,8 +218,17 @@ function bs(
214
218
  cwd?: string;
215
219
  signal?: AbortSignal;
216
220
  env?: Record<string, string | undefined>;
221
+ host?: string;
217
222
  } = {},
218
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
+ }
219
232
  return new Promise((resolve) => {
220
233
  if (opts.signal?.aborted) {
221
234
  resolve({ stdout: "", stderr: "aborted", code: 130 });
@@ -237,6 +250,8 @@ function bs(
237
250
  opts.signal?.addEventListener("abort", onAbort, { once: true });
238
251
  child.stdout?.on("data", (d) => {
239
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");
240
255
  });
241
256
  child.stderr?.on("data", (d) => {
242
257
  stderr += d.toString();
@@ -320,8 +335,8 @@ async function requireBabysit(): Promise<void> {
320
335
  // Error-aware: `babysit list` failing is NOT the same as "no sessions" —
321
336
  // callers that show state to the agent must surface the error instead of
322
337
  // silently reporting an empty registry (which reads like lost sessions).
323
- async function listSessions(): Promise<{ sessions: BsSession[]; error?: string }> {
324
- 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 });
325
340
  if (r.code !== 0) {
326
341
  return {
327
342
  sessions: [],
@@ -337,6 +352,28 @@ async function listSessions(): Promise<{ sessions: BsSession[]; error?: string }
337
352
  }
338
353
  }
339
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
+
340
377
  async function lookupStatus(
341
378
  id: string,
342
379
  ): Promise<{ session: BsSession | null; error?: string }> {
@@ -345,8 +382,10 @@ async function lookupStatus(
345
382
  // status-then-list pair (two CLI subprocesses for every status lookup).
346
383
  try {
347
384
  const listed = await listSessions();
348
- if (listed.error) return { session: null, error: listed.error };
349
- 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 };
350
389
  } catch (error) {
351
390
  return { session: null, error: error instanceof Error ? error.message : String(error) };
352
391
  }
@@ -408,10 +447,11 @@ async function terminateRunningSessions(): Promise<void> {
408
447
  let listed: Awaited<ReturnType<typeof listSessions>>;
409
448
  try {
410
449
  listed = await listSessions();
411
- } catch {
450
+ } catch (error) {
451
+ console.error(`Babysit shutdown cleanup unknown: ${error instanceof Error ? error.message : String(error)}`);
412
452
  return;
413
453
  }
414
- if (listed.error) return;
454
+ if (listed.error) console.error(`Local babysit shutdown cleanup unknown: ${listed.error}`);
415
455
 
416
456
  // A namespace may contain many independent commands and agents. Terminate
417
457
  // them concurrently so quitting Pi does not wait for serial kill timeouts.
@@ -421,7 +461,13 @@ async function terminateRunningSessions(): Promise<void> {
421
461
  .filter((session) => session.state === "running")
422
462
  .map(async (session) => {
423
463
  const result = await bs(["kill", "-s", session.id, "--json"]);
424
- 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;
425
471
  // A later resume of this Pi session must not turn intentional shutdown
426
472
  // cleanup into a process-completion notification.
427
473
  suppressNotify(session.id, "kill");
@@ -444,6 +490,9 @@ export interface SubagentBudget {
444
490
  }
445
491
 
446
492
  interface Meta {
493
+ host?: string;
494
+ remoteId?: string;
495
+ remoteTerminalState?: string;
447
496
  kind: "process" | "subagent";
448
497
  // process
449
498
  name?: string;
@@ -492,7 +541,21 @@ interface Meta {
492
541
  }
493
542
 
494
543
  const metaDir = () => path.join(ROOT, "meta");
495
- 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
+ }
496
559
 
497
560
  function writeMeta(id: string, m: Meta): boolean {
498
561
  const target = path.join(metaDir(), `${id}.json`);
@@ -574,9 +637,16 @@ function markAutomaticGc(now = new Date()): void {
574
637
  }
575
638
  }
576
639
 
577
- function scanTreeStats(root: string): { bytes: number; newestMtimeMs: number } {
640
+ function scanTreeStats(root: string, rootMtimeMs?: number): { bytes: number; newestMtimeMs: number } {
578
641
  let bytes = 0;
579
- let newestMtimeMs = 0;
642
+ let newestMtimeMs: number;
643
+ try {
644
+ // The collector's own lock file updates the root mtime; use the
645
+ // pre-lock age when rescanning, but still include fresh descendants.
646
+ newestMtimeMs = rootMtimeMs ?? fs.lstatSync(root).mtimeMs;
647
+ } catch {
648
+ return { bytes: 0, newestMtimeMs: Infinity };
649
+ }
580
650
  const pending = [root];
581
651
  while (pending.length > 0) {
582
652
  const current = pending.pop() as string;
@@ -622,6 +692,17 @@ function gcRootIsSafe(root: string): boolean {
622
692
  }
623
693
  }
624
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
+ }
625
706
  const sessionsDir = path.join(root, "sessions");
626
707
  let sessionDirs: fs.Dirent[];
627
708
  try {
@@ -748,7 +829,7 @@ export function gcBabysitRoots(options: {
748
829
  result.skippedLive.push(entry.name);
749
830
  continue;
750
831
  }
751
- const refreshedStats = scanTreeStats(root);
832
+ const refreshedStats = scanTreeStats(root, stats.newestMtimeMs);
752
833
  if (now - refreshedStats.newestMtimeMs < options.olderThanMs) continue;
753
834
  tombstone = path.join(
754
835
  options.rootBase,
@@ -1116,6 +1197,10 @@ async function searchLog(
1116
1197
  signal?: AbortSignal,
1117
1198
  maxBytes = TAIL_MAX_BYTES,
1118
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
+ }
1119
1204
  const file = logPath(id);
1120
1205
  if (!fs.existsSync(file)) return { text: "", error: `Log file is missing: ${file}` };
1121
1206
  if (signal?.aborted) return { text: "", error: "Log search was interrupted." };
@@ -1188,6 +1273,12 @@ async function inlineOutput(
1188
1273
  status: BsSession,
1189
1274
  maxBytes = INLINE_OUTPUT_MAX_BYTES,
1190
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
+ }
1191
1282
  let bytes = status.output_bytes;
1192
1283
  if (bytes == null) {
1193
1284
  try {
@@ -1200,7 +1291,9 @@ async function inlineOutput(
1200
1291
  const size = Number.isFinite(bytes) ? `${bytes} bytes` : "size unavailable";
1201
1292
  return `\nOutput omitted (${size}; inline limit ${maxBytes}).`;
1202
1293
  }
1203
- 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();
1204
1297
  if (Buffer.byteLength(output) > maxBytes) {
1205
1298
  return `\nOutput omitted (exceeds inline limit ${maxBytes} bytes).`;
1206
1299
  }
@@ -1230,7 +1323,9 @@ async function selectedProcessOutput(
1230
1323
  const body = result.text || `(no output matching /${selection.pattern}/)`;
1231
1324
  return `\n\nSelected output /${selection.pattern}/:\n${clip(body, maxBytes)}`;
1232
1325
  }
1233
- 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();
1234
1329
  return tail ? `\n\nSelected tail (${lines} lines max):\n${clip(tail, maxBytes)}` : "";
1235
1330
  }
1236
1331
 
@@ -1978,6 +2073,7 @@ const reservedSessionIds = new Set<string>();
1978
2073
  async function reserveUniqueSessionId(name: string): Promise<string> {
1979
2074
  const base = name.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "") || "proc";
1980
2075
  const taken = new Set((await listSessions()).sessions.map((s) => s.id));
2076
+ for (const { id } of remoteMetadata()) taken.add(id);
1981
2077
  for (const id of reservedSessionIds) taken.add(id);
1982
2078
  let id = base;
1983
2079
  for (let i = 2; taken.has(id); i++) id = `${base}-${i}`;
@@ -1988,7 +2084,8 @@ async function reserveUniqueSessionId(name: string): Promise<string> {
1988
2084
  interface ProcOpts {
1989
2085
  name?: string;
1990
2086
  command: string;
1991
- cwd: string;
2087
+ cwd?: string;
2088
+ host?: string;
1992
2089
  env?: Record<string, string | undefined>;
1993
2090
  timeout?: string; // default: none — dev servers may run indefinitely
1994
2091
  idleTimeout?: string;
@@ -2009,22 +2106,53 @@ export function processSessionEnvironment(
2009
2106
  };
2010
2107
  }
2011
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
+
2012
2126
  async function spawnProcess(opts: ProcOpts): Promise<{ id: string } | { error: string }> {
2127
+ validateProcessHost(opts.host);
2128
+ const remote = !!opts.host && opts.host !== "local";
2013
2129
  const bsArgs = ["run", "-d", "--json", "--size", "120x40"];
2014
2130
  if (!opts.pty) bsArgs.push("--no-tty");
2015
2131
  if (opts.timeout && opts.timeout !== "none") bsArgs.push("--timeout", opts.timeout);
2016
2132
  if (opts.idleTimeout && opts.idleTimeout !== "none")
2017
2133
  bsArgs.push("--idle-timeout", opts.idleTimeout);
2018
- const reservedId = opts.name ? await reserveUniqueSessionId(opts.name) : undefined;
2019
- if (reservedId) bsArgs.push("--id", reservedId);
2020
- 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);
2021
2142
 
2022
2143
  let r: Awaited<ReturnType<typeof bs>>;
2023
2144
  try {
2024
- 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 });
2025
2146
  } finally {
2026
2147
  if (reservedId) reservedSessionIds.delete(reservedId);
2027
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
+ }
2028
2156
  if (r.code !== 0) {
2029
2157
  return {
2030
2158
  error:
@@ -2521,12 +2649,13 @@ export function createSessionWidget(options: {
2521
2649
  getHeight: () => number;
2522
2650
  requestRender: () => void;
2523
2651
  getTheme: () => Theme;
2524
- readLog: (id: string) => { text: string; isAgent: boolean; clipped?: boolean; lineTimestamps?: (number | undefined)[] };
2652
+ readLog: (id: string) => { text?: string; file?: string; isAgent: boolean; clipped?: boolean; lineTimestamps?: (number | undefined)[] };
2525
2653
  }) {
2526
2654
  const listState: WidgetSessionListState = { mode: "running", page: 0 };
2527
2655
  let selectedId: string | undefined;
2528
2656
  let displays: WidgetSessionDisplay[] = [];
2529
2657
  let viewer: ReturnType<typeof createLogViewer> | undefined;
2658
+ let source: FileLogSource | undefined;
2530
2659
  let list = createWidgetComponent([], [], open, listState);
2531
2660
  function refreshViewer() {
2532
2661
  if (!selectedId || !viewer) return;
@@ -2534,20 +2663,26 @@ export function createSessionWidget(options: {
2534
2663
  const session = displays.find((session) => session.id === selectedId);
2535
2664
  const header = session?.header ?? selectedId;
2536
2665
  const detail = session?.details?.[0] ?? "";
2537
- viewer.update(
2538
- `${log.clipped ? "[older log omitted: 1 MiB limit] " : ""}${header}`,
2539
- log.text,
2540
- log.isAgent,
2541
- detail.startsWith("task: ") ? detail.slice(6) : detail,
2542
- log.lineTimestamps,
2543
- );
2666
+ const context = detail.startsWith("task: ") ? detail.slice(6) : detail;
2667
+ if (log.file) {
2668
+ if (!source || source.file !== log.file || source.isAgent !== log.isAgent)
2669
+ source = new FileLogSource(log.file, log.isAgent);
2670
+ viewer.updateSource(header, source, context);
2671
+ } else {
2672
+ viewer.update(header, log.text ?? "", log.isAgent, context, log.lineTimestamps);
2673
+ source = undefined;
2674
+ }
2544
2675
  }
2545
2676
  function close() {
2546
2677
  selectedId = undefined;
2678
+ viewer?.dispose();
2547
2679
  viewer = undefined;
2680
+ source = undefined;
2548
2681
  options.requestRender();
2549
2682
  }
2550
2683
  function open(id: string) {
2684
+ viewer?.dispose();
2685
+ source = undefined;
2551
2686
  selectedId = id;
2552
2687
  viewer = createLogViewer({ ...options, onClose: close });
2553
2688
  refreshViewer();
@@ -2675,6 +2810,10 @@ function readTailLines(file: string, lines: number, maxBytes = 64_000): string[]
2675
2810
  }
2676
2811
  }
2677
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
+
2678
2817
  // Trailing lines to show for a session (sanitized, unprefixed).
2679
2818
  // Process tails are read directly from the bounded end of output.log, avoiding
2680
2819
  // one `babysit log` subprocess per visible process.
@@ -2685,6 +2824,7 @@ export function widgetTail(
2685
2824
  lines = WIDGET_COLLAPSED_TAIL_LINES,
2686
2825
  ): string[] {
2687
2826
  let raw: string[];
2827
+ if (readMeta(id)?.host) return remoteTails.get(id) ?? ["Remote output unavailable; use babysit_check."];
2688
2828
  if (!isSub) {
2689
2829
  raw = readTailLines(logPath(id), lines);
2690
2830
  } else {
@@ -2714,6 +2854,8 @@ export function widgetTail(
2714
2854
  // process interactively (detach with Ctrl-\ Ctrl-\). `/babysit` shows this as a
2715
2855
  // hint alongside an inline snapshot instead of spawning a tmux window itself.
2716
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!)}`;
2717
2859
  return `BABYSIT_DIR=${shq(ROOT)} ${shq(BABYSIT_BIN)} attach -s ${shq(id)}`;
2718
2860
  }
2719
2861
 
@@ -3511,6 +3653,8 @@ export default function (pi: ExtensionAPI) {
3511
3653
  const notices = prepared.flatMap((notice) => {
3512
3654
  const current = readMeta(notice.id);
3513
3655
  if (!shouldDeliverProcessCompletion(current)) return [];
3656
+ const latest = finalSessions.find((session) => session.id === notice.id);
3657
+ if (!latest || latest.transportError || latest.state === "running") return [];
3514
3658
  if (!isNotificationGroupReady(current, finalSessions, readMeta)) return [];
3515
3659
  metadataById.set(notice.id, current);
3516
3660
  return [{ ...notice, command: current.command }];
@@ -3610,6 +3754,12 @@ export default function (pi: ExtensionAPI) {
3610
3754
 
3611
3755
  const theme = ctx.ui.theme;
3612
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
+ }));
3613
3763
  const tails = await Promise.all(
3614
3764
  active.map((session) => {
3615
3765
  const isSubagent = kindOf(session.id) === "subagent";
@@ -3632,11 +3782,12 @@ export default function (pi: ExtensionAPI) {
3632
3782
  ...(meta?.launchCommand ? [theme.bold(escapeCommandForDisplay(readableLaunchCommand(meta.launchCommand)))] : []),
3633
3783
  ]
3634
3784
  : meta?.command ? [theme.bold(escapeCommandForDisplay(meta.command))] : [];
3785
+ if (meta?.host) details.unshift(`host: ${meta.host}${session.transportError ? " (status unknown)" : ""} · ${attachCmd(id)}`);
3635
3786
  if (session.state === "running") {
3636
3787
  const state: WidgetSessionState = isSubagent && progressById.get(id)?.done ? "idle" : "running";
3637
3788
  return {
3638
3789
  id,
3639
- 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" : ""),
3640
3791
  tail: activeTails.get(id) ?? [],
3641
3792
  viewable: true,
3642
3793
  active: true,
@@ -3674,8 +3825,9 @@ export default function (pi: ExtensionAPI) {
3674
3825
  requestRender: () => tui.requestRender(),
3675
3826
  getTheme: () => ctx.ui.theme,
3676
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 };
3677
3829
  const isAgent = kindOf(id) === "subagent";
3678
- return { ...readViewerLog(logPath(id), true), isAgent };
3830
+ return { file: logPath(id), isAgent };
3679
3831
  },
3680
3832
  });
3681
3833
  return sessionWidget;
@@ -3806,6 +3958,8 @@ export default function (pi: ExtensionAPI) {
3806
3958
  }
3807
3959
  taskProgressCache.clear();
3808
3960
  searchLogCache.clear();
3961
+ remoteTails.clear();
3962
+ remoteTerminalTails.clear();
3809
3963
  pollNeeded = true;
3810
3964
  const retentionDays = Number(process.env.PI_BABYSIT_RETENTION_DAYS ?? "3");
3811
3965
  if (
@@ -3946,6 +4100,8 @@ export default function (pi: ExtensionAPI) {
3946
4100
  return prepareBabysitRunArguments(args) as never;
3947
4101
  },
3948
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." })),
3949
4105
  command: Type.Optional(
3950
4106
  Type.String({
3951
4107
  description: "Shell command to run (process mode). Mutually exclusive with profile/task.",
@@ -4076,6 +4232,9 @@ export default function (pi: ExtensionAPI) {
4076
4232
  async execute(_id, params, _signal, _onUpdate, ctx) {
4077
4233
  await requireBabysit();
4078
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.");
4079
4238
  if (isSubagent && !params.task) {
4080
4239
  return {
4081
4240
  content: [{ type: "text", text: "profile 'subagent' requires `task`." }],
@@ -4180,7 +4339,8 @@ export default function (pi: ExtensionAPI) {
4180
4339
  const spawnOpts: ProcOpts = {
4181
4340
  name: params.name,
4182
4341
  command: params.command as string,
4183
- cwd: ctx.cwd,
4342
+ host: params.host,
4343
+ cwd: params.cwd ?? (params.host && params.host !== "local" ? undefined : ctx.cwd),
4184
4344
  timeout: params.timeout,
4185
4345
  idleTimeout: params.idleTimeout,
4186
4346
  pty: params.pty ?? true,
@@ -4556,7 +4716,7 @@ export default function (pi: ExtensionAPI) {
4556
4716
  : "";
4557
4717
  const what = (kind === "subagent" ? meta?.task : meta?.command) ?? "";
4558
4718
  const preview = what.length > 60 ? `${what.slice(0, 57)}…` : what;
4559
- 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}` : ""}`;
4560
4720
  });
4561
4721
  const hidden = sessions.length - selected.length;
4562
4722
  const suffix = hidden > 0 ? `\n… ${hidden} session(s) hidden${revealHint}.` : "";
@@ -4566,7 +4726,9 @@ export default function (pi: ExtensionAPI) {
4566
4726
  };
4567
4727
  }
4568
4728
 
4569
- 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;
4570
4732
  if (!st) {
4571
4733
  return {
4572
4734
  content: [{ type: "text", text: `No such session: ${params.id}` }],
@@ -4601,7 +4763,7 @@ export default function (pi: ExtensionAPI) {
4601
4763
  };
4602
4764
  }
4603
4765
  const kind = meta?.kind ?? "process";
4604
- 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)}`;
4605
4767
  const body = result.text
4606
4768
  ? `--- latest matches /${params.pattern}/ ---\n${result.text}`
4607
4769
  : `(no output matching /${params.pattern}/)`;
@@ -4614,7 +4776,7 @@ export default function (pi: ExtensionAPI) {
4614
4776
  // --- process ---
4615
4777
  if (meta?.kind !== "subagent") {
4616
4778
  const parts: string[] = [];
4617
- let header = `[process] state=${st.state}`;
4779
+ let header = `[process${meta?.host ? ` host=${meta.host}` : ""}] state=${st.state}`;
4618
4780
  if (st.state === "running") {
4619
4781
  const el = elapsedOf(params.id);
4620
4782
  if (el) header += ` elapsed=${el}`;
@@ -4626,10 +4788,13 @@ export default function (pi: ExtensionAPI) {
4626
4788
  parts.push(header);
4627
4789
  if (params.screen) {
4628
4790
  const sc = await bs(["screenshot", "-s", params.id, "--trim"]);
4791
+ if (sc.code !== 0) throw new Error(sc.stderr || sc.stdout || "Screenshot unavailable");
4629
4792
  parts.push(`--- screen ---\n${clip(sc.stdout.trimEnd(), checkMaxBytes) || "(blank screen)"}`);
4630
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");
4631
4796
  const tail = clip(
4632
- (await bs(["log", "-s", params.id, "--tail", String(nLines)])).stdout.trimEnd(),
4797
+ result.stdout.trimEnd(),
4633
4798
  checkMaxBytes,
4634
4799
  );
4635
4800
  parts.push(tail ? `--- recent output ---\n${tail}` : "(no output yet)");
@@ -4724,7 +4889,9 @@ export default function (pi: ExtensionAPI) {
4724
4889
  }),
4725
4890
  async execute(_id, params) {
4726
4891
  await requireBabysit();
4727
- 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;
4728
4895
  if (!st || st.state !== "running") {
4729
4896
  return {
4730
4897
  content: [
@@ -5281,12 +5448,12 @@ export default function (pi: ExtensionAPI) {
5281
5448
  // For a LIVE process show the CURRENT rendered screen (TUIs redraw in
5282
5449
  // place, so the raw stream isn't representative); for a finished one
5283
5450
  // the recorded tail is enough.
5284
- const screen = running
5285
- ? (await bs(["screenshot", "-s", picked.id, "--trim"])).stdout.trimEnd()
5286
- : "";
5287
- 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}`;
5288
5455
  const title =
5289
- `${picked.id} ${picked.state}${elapsedSuffix}` +
5456
+ `${picked.id}${meta?.host ? ` @${meta.host}` : ""} ${picked.transportError ? "unknown" : picked.state}${elapsedSuffix}` +
5290
5457
  (picked.exit_code != null ? ` (exit=${picked.exit_code})` : "");
5291
5458
  const body =
5292
5459
  (meta?.command ? `\`${meta.command}\`\n\n` : "") +
package/log-source.ts ADDED
@@ -0,0 +1,232 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ export interface LogPage {
5
+ start: number;
6
+ end: number;
7
+ text: string;
8
+ lineTimestamps: (number | undefined)[];
9
+ }
10
+
11
+ /** Byte cursors, not an index: only the selected sliding window is retained. */
12
+ export class FileLogSource {
13
+ readonly pageBytes: number;
14
+ size = 0;
15
+ metadataVersion = "";
16
+ private identity = "";
17
+ private modified = 0;
18
+ constructor(readonly file: string, readonly isAgent = false, pageBytes = 64 * 1024) {
19
+ this.pageBytes = Math.max(1, pageBytes);
20
+ }
21
+ /** A changed identity or shrinking file invalidates every cursor. */
22
+ refresh(): boolean {
23
+ try {
24
+ const stat = fs.statSync(path.join(path.dirname(this.file), "output.timestamps.jsonl"));
25
+ this.metadataVersion = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`;
26
+ } catch { this.metadataVersion = ""; }
27
+ try {
28
+ const stat = fs.statSync(this.file);
29
+ const identity = `${stat.dev}:${stat.ino}`;
30
+ const reset = identity !== this.identity || stat.size < this.size ||
31
+ (stat.size === this.size && stat.mtimeMs !== this.modified);
32
+ this.identity = identity;
33
+ this.modified = stat.mtimeMs;
34
+ this.size = stat.size;
35
+ return reset;
36
+ } catch {
37
+ const reset = this.identity !== "";
38
+ this.identity = "";
39
+ this.size = 0;
40
+ return reset;
41
+ }
42
+ }
43
+ private bytes(fd: number, start: number, end: number): Buffer {
44
+ const data = Buffer.alloc(Math.max(0, end - start));
45
+ let read = 0;
46
+ while (read < data.length) {
47
+ const n = fs.readSync(fd, data, read, data.length - read, start + read);
48
+ if (!n) break;
49
+ read += n;
50
+ }
51
+ return data.subarray(0, read);
52
+ }
53
+ /** Find record boundaries in fixed-size reads. Keep assistant snapshots and
54
+ * subsequent tool results together, but never require a whole agent task:
55
+ * each new assistant message is another safe page boundary. Limits are soft
56
+ * for a single response/result group or oversized individual record. */
57
+ private boundary(fd: number, position: number, direction: -1 | 1): number {
58
+ const chunk = 8192;
59
+ let cursor = Math.max(0, Math.min(position, this.size));
60
+ if (!this.isAgent) {
61
+ while (direction < 0 ? cursor > 0 : cursor < this.size) {
62
+ const start = direction < 0 ? Math.max(0, cursor - chunk) : cursor;
63
+ const end = direction < 0 ? cursor : Math.min(this.size, cursor + chunk);
64
+ const data = this.bytes(fd, start, Math.min(this.size, end + 1));
65
+ for (let i = direction < 0 ? end - start - 1 : 0;
66
+ direction < 0 ? i >= 0 : i < end - start; i += direction) {
67
+ if (data[i] !== 10 && data[i] !== 13) continue;
68
+ const boundary = start + i + (data[i] === 13 && data[i + 1] === 10 ? 2 : 1);
69
+ if (direction < 0 ? boundary <= position : boundary >= position) return boundary;
70
+ }
71
+ cursor = direction < 0 ? start : end;
72
+ }
73
+ return direction < 0 ? 0 : this.size;
74
+ }
75
+ // Scan records in non-overlapping chunks. Carry only the record crossing
76
+ // the chunk edge, so normal pages do not reread 8 KiB for every event.
77
+ cursor = this.recordBoundary(fd, position, direction < 0 ? 1 : -1);
78
+ let parts: Buffer[] = [];
79
+ let recordStart = cursor;
80
+ const isStart = (start: number, segments: Buffer[]) => {
81
+ if (!(direction < 0 ? start <= position : start >= position)) return false;
82
+ try {
83
+ const event = JSON.parse(Buffer.concat(segments).toString("utf8"));
84
+ return event?.type === "agent_start" ||
85
+ (event?.type === "message_start" && event.message?.role === "assistant");
86
+ } catch { return false; }
87
+ };
88
+ while (direction < 0 ? cursor > 0 : cursor < this.size) {
89
+ const start = direction < 0 ? Math.max(0, cursor - chunk) : cursor;
90
+ const end = direction < 0 ? cursor : Math.min(this.size, cursor + chunk);
91
+ const data = this.bytes(fd, start, Math.min(this.size, end + 1));
92
+ let edge = direction < 0 ? end - start : 0;
93
+ for (let i = direction < 0 ? end - start - 1 : 0;
94
+ direction < 0 ? i >= 0 : i < end - start; i += direction) {
95
+ if (data[i] !== 10 && data[i] !== 13) continue;
96
+ if (data[i] === 13 && data[i + 1] === 10) continue;
97
+ if (direction < 0) {
98
+ const segments = [data.subarray(i + 1, edge), ...parts];
99
+ if (isStart(start + i + 1, segments)) return start + i + 1;
100
+ parts = []; edge = i + 1;
101
+ } else {
102
+ parts.push(data.subarray(edge, i + 1));
103
+ if (isStart(recordStart, parts)) return recordStart;
104
+ parts = []; edge = i + 1; recordStart = start + edge;
105
+ }
106
+ }
107
+ if (direction < 0) parts.unshift(data.subarray(0, edge));
108
+ else parts.push(data.subarray(edge, end - start));
109
+ cursor = direction < 0 ? start : end;
110
+ }
111
+ if (isStart(direction < 0 ? 0 : recordStart, parts)) return direction < 0 ? 0 : recordStart;
112
+ return direction < 0 ? 0 : this.size;
113
+ }
114
+ private recordBoundary(fd: number, position: number, direction: -1 | 1): number {
115
+ // Same byte scanner, without interpreting agent events.
116
+ const processSource = new FileLogSource(this.file, false, this.pageBytes);
117
+ processSource.size = this.size;
118
+ return processSource.boundary(fd, position, direction);
119
+ }
120
+ private empty(): LogPage { return { start: 0, end: 0, text: "", lineTimestamps: [] }; }
121
+ private page(fd: number, start: number, end: number): LogPage {
122
+ const data = this.bytes(fd, start, end);
123
+ return { start, end: start + data.length, text: data.toString("utf8"), lineTimestamps: this.timestamps(data, start) };
124
+ }
125
+ before(end = this.size): LogPage {
126
+ if (!this.size) return this.empty();
127
+ let fd: number | undefined;
128
+ try {
129
+ fd = fs.openSync(this.file, "r");
130
+ return this.page(fd, this.boundary(fd, Math.max(0, end - this.pageBytes), -1), end);
131
+ } catch { this.dispose(); return this.empty(); }
132
+ finally { if (fd !== undefined) fs.closeSync(fd); }
133
+ }
134
+ after(start: number): LogPage {
135
+ if (!this.size) return this.empty();
136
+ let fd: number | undefined;
137
+ try {
138
+ fd = fs.openSync(this.file, "r");
139
+ return this.page(fd, start, this.boundary(fd, Math.min(this.size, start + this.pageBytes), 1));
140
+ } catch { this.dispose(); return this.empty(); }
141
+ finally { if (fd !== undefined) fs.closeSync(fd); }
142
+ }
143
+ /** Sorted sidecar byte ranges permit a binary seek without a persistent index.
144
+ * Capture metadata is optional; malformed records have no trusted timestamp. */
145
+ private timestamps(data: Buffer, offset: number): (number | undefined)[] {
146
+ const times: (number | undefined)[] = [];
147
+ const positions = [offset];
148
+ for (let i = 0; i < data.length; i++) {
149
+ if (data[i] !== 10 && data[i] !== 13) continue;
150
+ if (data[i] === 13 && data[i + 1] === 10) i++;
151
+ positions.push(offset + i + 1);
152
+ }
153
+ let fd: number | undefined;
154
+ try {
155
+ fd = fs.openSync(path.join(path.dirname(this.file), "output.timestamps.jsonl"), "r");
156
+ const size = fs.fstatSync(fd).size;
157
+ const record = (at: number) => {
158
+ let start = at;
159
+ if (at > 0) {
160
+ while (start > 0) {
161
+ const begin = Math.max(0, start - 1024);
162
+ const b = this.bytes(fd!, begin, start);
163
+ const newline = b.lastIndexOf(10);
164
+ if (newline >= 0) { start = begin + newline + 1; break; }
165
+ start = begin;
166
+ }
167
+ }
168
+ let end = start;
169
+ const chunks: Buffer[] = [];
170
+ while (end < size) {
171
+ const b = this.bytes(fd!, end, Math.min(size, end + 1024));
172
+ if (!b.length) break;
173
+ const newline = b.indexOf(10);
174
+ const n = newline < 0 ? b.length : newline + 1;
175
+ chunks.push(b.subarray(0, n)); end += n;
176
+ if (newline >= 0) break;
177
+ }
178
+ let value: number[] | undefined;
179
+ try {
180
+ const r = JSON.parse(Buffer.concat(chunks).toString());
181
+ if (Array.isArray(r) && r.length === 3 && r.every(Number.isSafeInteger) && r[0] >= 0 && r[1] > 0 && Number.isSafeInteger(r[0] + r[1]) && r[2] >= 0 && r[2] <= 8640000000000000) value = r;
182
+ } catch { /* Optional malformed metadata. */ }
183
+ return { start, end, value };
184
+ };
185
+ let low = 0, high = size;
186
+ while (low < high) {
187
+ const r = record(Math.floor((low + high) / 2));
188
+ // An invalid seek pivot gives no ordering evidence. Do not fall back
189
+ // to a full sidecar scan; metadata is optional, raw history is not.
190
+ if (!r.value) return positions.map(() => undefined);
191
+ if (r.value[0] + r.value[1] <= offset) low = Math.max(low + 1, r.end);
192
+ else high = r.start;
193
+ }
194
+ let position = 0, cursor = low;
195
+ let pending: Buffer[] = [];
196
+ let previousEnd = 0;
197
+ while (cursor < size && position < positions.length) {
198
+ let chunk = this.bytes(fd, cursor, Math.min(size, cursor + 8192));
199
+ if (!chunk.length) break;
200
+ cursor += chunk.length;
201
+ if (cursor === size && chunk.at(-1) !== 10) chunk = Buffer.concat([chunk, Buffer.from("\n")]);
202
+ let edge = 0;
203
+ for (let i = 0; i < chunk.length && position < positions.length; i++) {
204
+ if (chunk[i] !== 10) continue;
205
+ pending.push(chunk.subarray(edge, i)); edge = i + 1;
206
+ const text = Buffer.concat(pending).toString(); pending = [];
207
+ let value: number[];
208
+ try {
209
+ value = JSON.parse(text);
210
+ if (!Array.isArray(value) || value.length !== 3 || !value.every(Number.isSafeInteger) ||
211
+ value[0] < previousEnd || value[1] <= 0 || !Number.isSafeInteger(value[0] + value[1]) ||
212
+ value[2] < 0 || value[2] > 8640000000000000) continue;
213
+ } catch { continue; }
214
+ const [start, length, time] = value;
215
+ previousEnd = start + length;
216
+ while (position < positions.length && positions[position] < start) times[position++] = undefined;
217
+ while (position < positions.length && positions[position] < start + length) {
218
+ times[position] = positions[position] < offset + data.length ? time : undefined;
219
+ position++;
220
+ }
221
+ }
222
+ pending.push(chunk.subarray(edge));
223
+ }
224
+ } catch { /* Missing sidecars are normal for old logs. */ }
225
+ finally { if (fd !== undefined) fs.closeSync(fd); }
226
+ return positions.map((_, i) => times[i]);
227
+ }
228
+ retime(page: LogPage): LogPage {
229
+ return { ...page, lineTimestamps: this.timestamps(Buffer.from(page.text), page.start) };
230
+ }
231
+ dispose() { this.identity = ""; this.size = 0; this.metadataVersion = ""; }
232
+ }
package/log-viewer.ts CHANGED
@@ -11,6 +11,8 @@ import {
11
11
  type TuiMouseEvent,
12
12
  } from "@earendil-works/pi-tui";
13
13
 
14
+ import { FileLogSource, type LogPage } from "./log-source";
15
+
14
16
  export interface LogViewerOptions {
15
17
  getHeight: () => number;
16
18
  requestRender: () => void;
@@ -59,6 +61,7 @@ function agentBlocks(raw: string, timestamps?: (number | undefined)[]): Block[]
59
61
  continue;
60
62
  }
61
63
  if (!e || typeof e !== "object") continue;
64
+ if (e.type === "agent_start") { current = undefined; toolResults.clear(); }
62
65
  if (e.type === "message_start" && e.message?.role === "assistant")
63
66
  current = undefined;
64
67
  if (["message_start", "message_update", "message_end"].includes(e.type)) {
@@ -137,6 +140,19 @@ export function createLogViewer(options: LogViewerOptions) {
137
140
  description = "",
138
141
  isAgent = false;
139
142
  let lineTimestamps: (number | undefined)[] | undefined;
143
+ let source: FileLogSource | undefined;
144
+ let pages: LogPage[] = [];
145
+ let sourceSize = 0;
146
+ let tailDirty = false;
147
+ let metadataVersion = "";
148
+ let adjacentPageStart: number | undefined;
149
+ const setPages = () => {
150
+ raw = pages.map((page) => page.text).join("");
151
+ lineTimestamps = pages.flatMap((page, i) =>
152
+ i < pages.length - 1 && /[\r\n]$/.test(page.text)
153
+ ? page.lineTimestamps.slice(0, -1) : page.lineTimestamps);
154
+ body.invalidate();
155
+ };
140
156
  let cache: string[] | undefined,
141
157
  cacheWidth = -1,
142
158
  cacheTheme: Theme | undefined;
@@ -150,7 +166,7 @@ export function createLogViewer(options: LogViewerOptions) {
150
166
  const blocks = isAgent
151
167
  ? agentBlocks(raw, lineTimestamps)
152
168
  : [{ kind: "result" as const, text: sanitize(raw, true) }];
153
- const lines: string[] = description
169
+ const lines: string[] = description && (!source || pages[0]?.start === 0)
154
170
  ? [
155
171
  theme.fg("muted", isAgent ? "[task]" : "[command]"),
156
172
  ...new Text(sanitize(description), 0, 0).render(width),
@@ -212,13 +228,7 @@ export function createLogViewer(options: LogViewerOptions) {
212
228
  }
213
229
  if (!blocks.some((block) => block.text.trim()))
214
230
  lines.push(theme.fg("muted", "(no output available)"));
215
- cache =
216
- lines.length > 2000
217
- ? [
218
- theme.fg("warning", "[Earlier log lines clipped]"),
219
- ...lines.slice(-1999),
220
- ]
221
- : lines;
231
+ cache = lines;
222
232
  cacheWidth = width;
223
233
  cacheTheme = theme;
224
234
  return cache;
@@ -262,7 +272,7 @@ export function createLogViewer(options: LogViewerOptions) {
262
272
  const footer: Component = {
263
273
  invalidate() {},
264
274
  render: (width) => [
265
- options.getTheme().fg("dim", truncateToWidth("↑↓ · Esc close", width)),
275
+ options.getTheme().fg("dim", truncateToWidth(source ? `↑↓ · bytes ${pages[0]?.start ?? 0}–${pages.at(-1)?.end ?? 0}/${source.size} (window) · Esc close` : "↑↓ · Esc close", width)),
266
276
  ],
267
277
  };
268
278
  let geometry = {
@@ -281,8 +291,74 @@ export function createLogViewer(options: LogViewerOptions) {
281
291
  width = Math.max(0, Math.floor(width));
282
292
  const height = Math.max(0, Math.floor(options.getHeight()) - 2);
283
293
  const contentWidth = Math.max(0, width - 1);
284
- const lines = body.render(Math.max(1, contentWidth));
294
+ const renderWidth = Math.max(1, contentWidth);
295
+ let lines = body.render(renderWidth);
285
296
  scrollView.updateLayout(lines.length, height, options.requestRender);
297
+ const followAtEof = source && pages.at(-1)?.end === source.size && scrollView.isFollowingEnd;
298
+ if (source && tailDirty && scrollView.scrollTop + height >= lines.length) {
299
+ const anchor = scrollView.scrollTop;
300
+ pages[pages.length - 1] = source.after(pages.at(-1)!.start);
301
+ tailDirty = false; setPages(); lines = body.render(renderWidth);
302
+ scrollView.updateLayout(lines.length, height, options.requestRender);
303
+ scrollView.scrollTo(anchor, { disableFollow: true });
304
+ }
305
+ if (source && pages.length && height > 0) {
306
+ const top = scrollView.scrollTop;
307
+ const first = pages[0], last = pages[pages.length - 1];
308
+ if (top === 0 && first.start > 0) {
309
+ const page = source.before(first.start);
310
+ if (page.start < first.start) {
311
+ adjacentPageStart = page.start;
312
+ pages.unshift(page); setPages();
313
+ const anchor = top + body.render(renderWidth).length - lines.length;
314
+ lines = body.render(renderWidth);
315
+ scrollView.updateLayout(lines.length, height, options.requestRender);
316
+ if (followAtEof) scrollView.scrollToEnd();
317
+ else scrollView.scrollTo(anchor, { disableFollow: true });
318
+ }
319
+ } else if (top + height >= lines.length && last.end < source.size) {
320
+ const page = source.after(last.end);
321
+ if (page.end > last.end) {
322
+ adjacentPageStart = page.start;
323
+ pages.push(page); setPages();
324
+ const anchor = top;
325
+ lines = body.render(renderWidth);
326
+ scrollView.updateLayout(lines.length, height, options.requestRender);
327
+ scrollView.scrollTo(anchor, { disableFollow: true });
328
+ }
329
+ }
330
+ }
331
+ // A resize may make formerly visible pages evictable. Keep only the
332
+ // small window plus pages that actually intersect the viewport.
333
+ while (source && pages.length > 3) {
334
+ const top = scrollView.scrollTop;
335
+ const first = pages.shift()!; setPages();
336
+ let next = body.render(renderWidth);
337
+ const delta = lines.length - next.length;
338
+ if (top >= delta && first.start !== adjacentPageStart) {
339
+ lines = next;
340
+ scrollView.updateLayout(lines.length, height, options.requestRender);
341
+ if (followAtEof) scrollView.scrollToEnd();
342
+ else scrollView.scrollTo(top - delta, { disableFollow: true });
343
+ continue;
344
+ }
345
+ pages.unshift(first);
346
+ const last = pages.pop()!; setPages(); next = body.render(renderWidth);
347
+ if (top + height <= next.length && last.start !== adjacentPageStart) {
348
+ lines = next;
349
+ scrollView.updateLayout(lines.length, height, options.requestRender);
350
+ if (followAtEof) scrollView.scrollToEnd();
351
+ else scrollView.scrollTo(top, { disableFollow: true });
352
+ continue;
353
+ }
354
+ pages.push(last); setPages(); lines = body.render(renderWidth);
355
+ break;
356
+ }
357
+ if (source && source.size === 0 && raw) {
358
+ pages = [source.before()]; tailDirty = false; setPages();
359
+ lines = body.render(renderWidth);
360
+ scrollView.updateLayout(lines.length, height, options.requestRender);
361
+ }
286
362
  const visible = lines.slice(
287
363
  scrollView.scrollTop,
288
364
  scrollView.scrollTop + height,
@@ -370,7 +446,37 @@ export function createLogViewer(options: LogViewerOptions) {
370
446
  invalidate() {
371
447
  body.invalidate();
372
448
  },
449
+ dispose() { source?.dispose(); source = undefined; pages = []; raw = ""; body.invalidate(); },
450
+ updateSource(nextHeader: string, nextSource: FileLogSource, context = "") {
451
+ header = nextHeader;
452
+ const changed = source !== nextSource;
453
+ if (changed) { source?.dispose(); pages = []; }
454
+ source = nextSource;
455
+ const reset = source.refresh();
456
+ isAgent = source.isAgent;
457
+ if (description !== context) body.invalidate();
458
+ description = context;
459
+ if (changed || reset || !pages.length) {
460
+ adjacentPageStart = undefined;
461
+ tailDirty = false;
462
+ pages = [source.before()]; setPages(); scrollView.scrollToEnd();
463
+ } else if (source.size !== sourceSize) {
464
+ if (scrollView.isFollowingEnd && pages.at(-1)?.end === sourceSize) {
465
+ tailDirty = false;
466
+ pages = [source.before()]; setPages(); scrollView.scrollToEnd();
467
+ } else if (pages.at(-1)?.end === sourceSize) {
468
+ tailDirty = true;
469
+ }
470
+ }
471
+ if (metadataVersion !== source.metadataVersion && !changed && !reset) {
472
+ pages = pages.map(page => source!.retime(page)); setPages();
473
+ }
474
+ metadataVersion = source.metadataVersion;
475
+ sourceSize = source.size;
476
+ options.requestRender();
477
+ },
373
478
  update(nextHeader: string, logText: string, agent: boolean, context = "", timestamps?: (number | undefined)[]) {
479
+ if (source) { source.dispose(); source = undefined; pages = []; }
374
480
  header = nextHeader;
375
481
  if (raw !== logText || isAgent !== agent || description !== context ||
376
482
  JSON.stringify(lineTimestamps) !== JSON.stringify(timestamps)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yusukeshib/pi-babysit",
3
- "version": "0.6.17",
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",
@@ -21,6 +21,7 @@
21
21
  "files": [
22
22
  "index.ts",
23
23
  "log-viewer.ts",
24
+ "log-source.ts",
24
25
  "agents.ts",
25
26
  "self-reap.ts",
26
27
  "format-stream.mjs",