@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 +54 -4
- package/index.ts +207 -40
- package/log-source.ts +232 -0
- package/log-viewer.ts +116 -10
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -71,6 +71,47 @@ calls if another extension or preset re-enables `bash`, including shell
|
|
|
71
71
|
backgrounding (`… &`, `nohup`, `setsid`, `disown`). Set
|
|
72
72
|
`PI_BABYSIT_ALLOW_BASH=1` to retain direct `bash` explicitly.
|
|
73
73
|
|
|
74
|
+
## Remote processes over SSH
|
|
75
|
+
|
|
76
|
+
Install a `babysit` CLI with `--host` support on both machines (tested with
|
|
77
|
+
local 0.14.5 and remote 0.14.6), and configure non-interactive SSH access.
|
|
78
|
+
SSH aliases, keys, known hosts, and ProxyJump are handled by babysit/SSH;
|
|
79
|
+
pi-babysit does not install remote software or copy credentials.
|
|
80
|
+
|
|
81
|
+
```text
|
|
82
|
+
babysit_run { host: "yusuke-t4", cwd: "/home/yusuke/project", command: "bun test", foreground: true, returnLines: 30 }
|
|
83
|
+
babysit_run { host: "user@server", command: "python3 -i", name: "remote-repl", continueAfterStart: true }
|
|
84
|
+
babysit_check { id: "remote-repl", screen: true }
|
|
85
|
+
babysit_send { id: "remote-repl", text: "print(42)" }
|
|
86
|
+
babysit_kill { id: "remote-repl" }
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`host` omitted (or `"local"`) preserves local behavior. `cwd` is process-only:
|
|
90
|
+
local default is Pi's cwd; remote default is the remote login home, **never**
|
|
91
|
+
the local checkout. Remote commands use POSIX `sh`, with quoted cwd and Pi
|
|
92
|
+
metadata environment values; the local `PI_SESSION_FILE` and shell path are
|
|
93
|
+
not forwarded. Remote subagents and `retryOnWorkerDeath` are explicitly unsupported.
|
|
94
|
+
|
|
95
|
+
The returned Pi id persists a host and unique, Pi-namespaced remote worker id.
|
|
96
|
+
Check/send/wait/kill, PTY screens/keys, completion notifications, and mixed-host
|
|
97
|
+
waits use this mapping; manual remote sessions are never adopted or killed.
|
|
98
|
+
Resume the same Pi session to reconnect. Remote logs stay remote: results show
|
|
99
|
+
an executable `babysit --host … log -s …` locator instead of a local file path.
|
|
100
|
+
Remote output is a bounded tail (200 lines by default) or CLI `--grep` search
|
|
101
|
+
(no local line numbers). The widget shows host/state and a small tail; its
|
|
102
|
+
remote full-history viewer is unavailable and instead shows log/attach hints.
|
|
103
|
+
|
|
104
|
+
An SSH failure is **unknown status**, not process exit. Remote workers may
|
|
105
|
+
survive disconnects. An uncertain launch keeps its identity and reports the
|
|
106
|
+
tracked id: reconcile it with check/kill rather than blindly retrying. Attached
|
|
107
|
+
foreground interruption explicitly kills the remote worker and claims cleanup
|
|
108
|
+
only after terminal-state confirmation; unreachable cleanup is reported as
|
|
109
|
+
unverified. Detached waits do not own the worker. Pi quit attempts cleanup of
|
|
110
|
+
tracked workers, reporting unverified remote cleanup to stderr. Local GC retains
|
|
111
|
+
roots with unresolved remote identities; it never deletes/prunes remote logs.
|
|
112
|
+
After reconnecting, check or kill tracked workers to establish terminal status
|
|
113
|
+
before local retention can collect their metadata.
|
|
114
|
+
|
|
74
115
|
## Commands (human)
|
|
75
116
|
|
|
76
117
|
| Command | What it does |
|
|
@@ -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
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
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
|
-
|
|
349
|
-
return { session:
|
|
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)
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
2020
|
-
bsArgs.push("--",
|
|
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
|
|
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
|
-
|
|
2538
|
-
|
|
2539
|
-
log.
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
5285
|
-
|
|
5286
|
-
|
|
5287
|
-
const tail =
|
|
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
|
|
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.
|
|
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",
|