@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.
- package/README.md +8 -3
- package/index.ts +225 -7
- 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
|
|
102
|
-
remote
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|