@yusukeshib/pi-babysit 0.6.19 → 0.6.21

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 +8 -3
  2. package/index.ts +225 -7
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -98,13 +98,18 @@ waits use this mapping; manual remote sessions are never adopted or killed.
98
98
  Resume the same Pi session to reconnect. Remote logs stay remote: results show
99
99
  an executable `babysit --host … log -s …` locator instead of a local file path.
100
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.
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.
103
107
 
104
108
  An SSH failure is **unknown status**, not process exit. Remote workers may
105
109
  survive disconnects. An uncertain launch keeps its identity and reports the
106
110
  tracked id: reconcile it with check/kill rather than blindly retrying. Attached
107
- foreground interruption explicitly kills the remote worker and claims cleanup
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
108
113
  only after terminal-state confirmation; unreachable cleanup is reported as
109
114
  unverified. Detached waits do not own the worker. Pi quit attempts cleanup of
110
115
  tracked workers, reporting unverified remote cleanup to stderr. Local GC retains
package/index.ts CHANGED
@@ -219,6 +219,8 @@ function bs(
219
219
  signal?: AbortSignal;
220
220
  env?: Record<string, string | undefined>;
221
221
  host?: string;
222
+ /** Return complete stdout even for remote log reads (log mirroring). */
223
+ unboundedStdout?: boolean;
222
224
  } = {},
223
225
  ): Promise<{ stdout: string; stderr: string; code: number }> {
224
226
  const sessionIndex = args.indexOf("-s");
@@ -240,34 +242,59 @@ function bs(
240
242
  else env[name] = value;
241
243
  }
242
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.
243
248
  const child = spawn(BABYSIT_BIN, args, {
244
249
  cwd: opts.cwd,
245
250
  env,
251
+ detached: true,
246
252
  });
247
253
  let stdout = "";
248
254
  let stderr = "";
249
- 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
+ };
250
270
  opts.signal?.addEventListener("abort", onAbort, { once: true });
271
+ const boundStdout = host && host !== "local" && !opts.unboundedStdout && (args.includes("log") || args.includes("screenshot"));
251
272
  child.stdout?.on("data", (d) => {
252
273
  stdout += d.toString();
253
274
  // 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");
275
+ if (boundStdout && Buffer.byteLength(stdout) > 32_000) stdout = Buffer.from(stdout).subarray(-32_000).toString("utf8");
255
276
  });
256
277
  child.stderr?.on("data", (d) => {
257
278
  stderr += d.toString();
258
279
  });
259
280
  child.on("error", (e) => {
260
- opts.signal?.removeEventListener("abort", onAbort);
261
281
  const installHint = babysitSpawnInstallHint(e);
262
282
  if (installHint) {
263
283
  babysitPreflightError = installHint;
264
284
  babysitPreflightCheckedAt = Date.now();
265
285
  }
266
- 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 });
267
295
  });
268
296
  child.on("close", (code) => {
269
- opts.signal?.removeEventListener("abort", onAbort);
270
- resolve({ stdout, stderr, code: code ?? 1 });
297
+ settle({ stdout, stderr, code: code ?? 1 });
271
298
  });
272
299
  });
273
300
  }
@@ -2690,6 +2717,12 @@ export function createSessionWidget(options: {
2690
2717
  }
2691
2718
  return {
2692
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
+ },
2693
2726
  update(summary: string[], sessions: WidgetSessionDisplay[]) {
2694
2727
  displays = sessions;
2695
2728
  list = createWidgetComponent(summary, sessions, open, listState);
@@ -2814,6 +2847,189 @@ const remoteTails = new Map<string, string[]>();
2814
2847
  // Running previews are not final. Only a successful post-terminal fetch is reusable.
2815
2848
  const remoteTerminalTails = new Set<string>();
2816
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; timestampBytes: 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
+ // Hosts whose babysit predates `log --timestamps`; their mirrors have no dates.
2859
+ const remoteLogTimestampUnsupported = new Set<string>();
2860
+
2861
+ export function remoteLogMirrorPath(id: string): string {
2862
+ return path.join(ROOT, "remote-logs", id, "output.log");
2863
+ }
2864
+
2865
+ function remoteLogTimestampsPath(id: string): string {
2866
+ return path.join(path.dirname(remoteLogMirrorPath(id)), "output.timestamps.jsonl");
2867
+ }
2868
+
2869
+ function remoteLogCursorPath(id: string): string {
2870
+ return path.join(path.dirname(remoteLogMirrorPath(id)), "cursor.json");
2871
+ }
2872
+
2873
+ function readRemoteLogCursor(id: string): RemoteLogCursor {
2874
+ const file = remoteLogMirrorPath(id);
2875
+ const sidecar = remoteLogTimestampsPath(id);
2876
+ const empty: RemoteLogCursor = { remoteOffset: 0, localBytes: 0, timestampBytes: 0, complete: false };
2877
+ let cursor = empty;
2878
+ try {
2879
+ const parsed = JSON.parse(fs.readFileSync(remoteLogCursorPath(id), "utf8"));
2880
+ const valid = (value: unknown) => Number.isSafeInteger(value) && (value as number) >= 0;
2881
+ if (valid(parsed.remoteOffset) && valid(parsed.localBytes)) {
2882
+ cursor = { remoteOffset: parsed.remoteOffset, localBytes: parsed.localBytes, timestampBytes: valid(parsed.timestampBytes) ? parsed.timestampBytes : 0, complete: parsed.complete === true };
2883
+ }
2884
+ } catch {}
2885
+ const sizeOf = (target: string) => {
2886
+ try {
2887
+ return fs.statSync(target).size;
2888
+ } catch {
2889
+ return 0;
2890
+ }
2891
+ };
2892
+ const size = sizeOf(file);
2893
+ const sidecarSize = sizeOf(sidecar);
2894
+ if (size < cursor.localBytes || sidecarSize < cursor.timestampBytes) {
2895
+ // Committed data is missing; restart the mirror from scratch.
2896
+ if (size > 0) fs.truncateSync(file, 0);
2897
+ if (sidecarSize > 0) fs.truncateSync(sidecar, 0);
2898
+ return empty;
2899
+ }
2900
+ // Drop an append that was interrupted before its cursor was committed.
2901
+ if (size > cursor.localBytes) fs.truncateSync(file, cursor.localBytes);
2902
+ if (sidecarSize > cursor.timestampBytes) fs.truncateSync(sidecar, cursor.timestampBytes);
2903
+ return cursor;
2904
+ }
2905
+
2906
+ function writeRemoteLogCursor(id: string, cursor: RemoteLogCursor): void {
2907
+ const target = remoteLogCursorPath(id);
2908
+ const temp = `${target}.${process.pid}.tmp`;
2909
+ fs.writeFileSync(temp, JSON.stringify(cursor));
2910
+ fs.renameSync(temp, target);
2911
+ }
2912
+
2913
+ /** Fetch new remote output into the local mirror. Resolves true when it grew or completed. */
2914
+ export async function syncRemoteLog(id: string): Promise<boolean> {
2915
+ const state = remoteLogSyncs.get(id) ?? { lastSyncAt: 0, lastChanged: false };
2916
+ remoteLogSyncs.set(id, state);
2917
+ if (state.inFlight) return state.inFlight;
2918
+ state.inFlight = (async () => {
2919
+ const file = remoteLogMirrorPath(id);
2920
+ fs.mkdirSync(path.dirname(file), { recursive: true });
2921
+ if (!fs.existsSync(file)) fs.writeFileSync(file, "");
2922
+ const cursor = readRemoteLogCursor(id);
2923
+ if (cursor.complete) return false;
2924
+ const host = readMeta(id)?.host ?? "";
2925
+ const readArgs = ["log", "-s", id, "--since", String(cursor.remoteOffset), "--raw", "--json"];
2926
+ let withTimestamps = !remoteLogTimestampUnsupported.has(host);
2927
+ let result = await bs(withTimestamps ? [...readArgs, "--timestamps"] : readArgs, { unboundedStdout: true });
2928
+ if (withTimestamps && result.code !== 0 && /--timestamps/.test(result.stderr)) {
2929
+ // Older remote babysit: keep mirroring text, without capture dates.
2930
+ remoteLogTimestampUnsupported.add(host);
2931
+ withTimestamps = false;
2932
+ result = await bs(readArgs, { unboundedStdout: true });
2933
+ }
2934
+ if (result.code !== 0) {
2935
+ const error = clip(result.stderr.trim() || result.stdout.trim() || `babysit log exited ${result.code}`, 500);
2936
+ const changed = state.error !== error;
2937
+ state.error = error;
2938
+ return changed;
2939
+ }
2940
+ let response: { text?: unknown; offset?: unknown; done?: unknown; timestamps?: unknown };
2941
+ try {
2942
+ response = JSON.parse(result.stdout);
2943
+ } catch {
2944
+ state.error = `invalid remote log response: ${clip(result.stdout, 200)}`;
2945
+ return true;
2946
+ }
2947
+ if (typeof response.text !== "string" || !Number.isSafeInteger(response.offset) || (response.offset as number) < 0) {
2948
+ state.error = `invalid remote log response: ${clip(result.stdout, 200)}`;
2949
+ return true;
2950
+ }
2951
+ if ((response.offset as number) < cursor.remoteOffset) {
2952
+ // The remote log was replaced or truncated (e.g. restart); rebuild the mirror.
2953
+ fs.truncateSync(file, 0);
2954
+ fs.rmSync(remoteLogTimestampsPath(id), { force: true });
2955
+ writeRemoteLogCursor(id, { remoteOffset: 0, localBytes: 0, timestampBytes: 0, complete: false });
2956
+ return true;
2957
+ }
2958
+ const hadError = state.error !== undefined;
2959
+ state.error = undefined;
2960
+ const bytes = Buffer.from(response.text, "utf8");
2961
+ if (bytes.length > 0) fs.appendFileSync(file, bytes);
2962
+ const remoteStart = cursor.remoteOffset;
2963
+ const remoteEnd = response.offset as number;
2964
+ let timestampBytes = cursor.timestampBytes;
2965
+ // Remote rows use remote raw offsets. They map 1:1 onto the mirror only
2966
+ // when the chunk arrived byte-for-byte; a lossy UTF-8 replacement shifts
2967
+ // offsets, so that chunk is left undated rather than mis-dated.
2968
+ if (withTimestamps && Array.isArray(response.timestamps) && bytes.length === remoteEnd - remoteStart) {
2969
+ const rows = response.timestamps.flatMap((row) => {
2970
+ if (!Array.isArray(row) || row.length !== 3 || !row.every((value) => Number.isSafeInteger(value) && value >= 0)) return [];
2971
+ const [start, length, time] = row as number[];
2972
+ const begin = Math.max(start, remoteStart);
2973
+ const end = Math.min(start + length, remoteEnd);
2974
+ return end > begin ? [`[${cursor.localBytes + begin - remoteStart},${end - begin},${time}]\n`] : [];
2975
+ }).join("");
2976
+ if (rows) {
2977
+ fs.appendFileSync(remoteLogTimestampsPath(id), rows);
2978
+ timestampBytes += Buffer.byteLength(rows);
2979
+ }
2980
+ }
2981
+ const advanced = (response.offset as number) > cursor.remoteOffset || bytes.length > 0;
2982
+ // `done` is reported before the worker's final flush, so completion
2983
+ // requires a terminal read that returned nothing new.
2984
+ const complete = response.done === true && !advanced;
2985
+ writeRemoteLogCursor(id, { remoteOffset: remoteEnd, localBytes: cursor.localBytes + bytes.length, timestampBytes, complete });
2986
+ return advanced || complete || hadError;
2987
+ })().then((changed) => {
2988
+ state.lastChanged = changed;
2989
+ return changed;
2990
+ }, (error) => {
2991
+ state.error = error instanceof Error ? error.message : String(error);
2992
+ state.lastChanged = true;
2993
+ return true;
2994
+ }).finally(() => {
2995
+ state.lastSyncAt = Date.now();
2996
+ state.inFlight = undefined;
2997
+ });
2998
+ return state.inFlight;
2999
+ }
3000
+
3001
+ /**
3002
+ * Schedule a background mirror sync for an open viewer. Catch-up continues
3003
+ * immediately while output keeps arriving; otherwise reads are throttled.
3004
+ */
3005
+ function scheduleRemoteLogSync(id: string, onChange: () => void): void {
3006
+ const state = remoteLogSyncs.get(id);
3007
+ if (state?.inFlight) return;
3008
+ if (state && !state.lastChanged && Date.now() - state.lastSyncAt < REMOTE_LOG_SYNC_INTERVAL_MS) return;
3009
+ if (state && !state.error && readRemoteLogCursor(id).complete) return;
3010
+ // Always re-render: the first read may replace a loading placeholder even
3011
+ // when the remote log is still empty. Re-entry is throttled above.
3012
+ void syncRemoteLog(id).then(() => onChange());
3013
+ }
3014
+
3015
+ function remoteLogViewerSource(id: string, onChange: () => void): { text?: string; file?: string; isAgent: boolean } {
3016
+ const file = remoteLogMirrorPath(id);
3017
+ scheduleRemoteLogSync(id, onChange);
3018
+ const error = remoteLogSyncs.get(id)?.error;
3019
+ let size = 0;
3020
+ try {
3021
+ size = fs.statSync(file).size;
3022
+ } catch {}
3023
+ if (size > 0) return { file, isAgent: false };
3024
+ const meta = readMeta(id);
3025
+ return {
3026
+ text: error
3027
+ ? `Remote output unavailable on ${meta?.host}: ${error}\nLog: ${logPath(id)}\nTake over: ${attachCmd(id)}`
3028
+ : remoteLogSyncs.get(id)?.inFlight ? `Loading remote output from ${meta?.host}…` : "",
3029
+ isAgent: false,
3030
+ };
3031
+ }
3032
+
2817
3033
  // Trailing lines to show for a session (sanitized, unprefixed).
2818
3034
  // Process tails are read directly from the bounded end of output.log, avoiding
2819
3035
  // one `babysit log` subprocess per visible process.
@@ -3825,7 +4041,7 @@ export default function (pi: ExtensionAPI) {
3825
4041
  requestRender: () => tui.requestRender(),
3826
4042
  getTheme: () => ctx.ui.theme,
3827
4043
  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 };
4044
+ if (readMeta(id)?.host) return remoteLogViewerSource(id, () => sessionWidget?.refresh());
3829
4045
  const isAgent = kindOf(id) === "subagent";
3830
4046
  return { file: logPath(id), isAgent };
3831
4047
  },
@@ -3960,6 +4176,8 @@ export default function (pi: ExtensionAPI) {
3960
4176
  searchLogCache.clear();
3961
4177
  remoteTails.clear();
3962
4178
  remoteTerminalTails.clear();
4179
+ remoteLogSyncs.clear();
4180
+ remoteLogTimestampUnsupported.clear();
3963
4181
  pollNeeded = true;
3964
4182
  const retentionDays = Number(process.env.PI_BABYSIT_RETENTION_DAYS ?? "3");
3965
4183
  if (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yusukeshib/pi-babysit",
3
- "version": "0.6.19",
3
+ "version": "0.6.21",
4
4
  "description": "Run any shell command and pi subagents under babysit, with context-safe captured output.",
5
5
  "keywords": [
6
6
  "pi-package",