@yusukeshib/pi-babysit 0.6.17 → 0.6.18
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 +11 -3
- package/index.ts +27 -12
- package/log-source.ts +232 -0
- package/log-viewer.ts +116 -10
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -106,9 +106,17 @@ first content line; streamed updates and duplicate results keep that time. This
|
|
|
106
106
|
is the time babysit received the first byte of the event or line, not an
|
|
107
107
|
application event time. Narrow views place the date above the text. Old logs and
|
|
108
108
|
lines without capture metadata remain readable without invented timestamps.
|
|
109
|
-
Only the selected log is loaded
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
Only the selected log is loaded: the viewer starts at the tail and lazily reads
|
|
110
|
+
older/newer pages as you scroll to the loaded window's edges. The scrollbar
|
|
111
|
+
represents that window, not the entire file; the footer shows its byte range.
|
|
112
|
+
Normally a small sliding window of roughly 64 KiB pages is retained. Page limits
|
|
113
|
+
are soft for oversized records and individual assistant-response/tool-result
|
|
114
|
+
groups (streaming snapshots and duplicate results stay together), and for pages
|
|
115
|
+
intersecting the viewport. Agent tasks are split at assistant-message boundaries,
|
|
116
|
+
not loaded as a whole; older logs lacking those events can form larger groups.
|
|
117
|
+
All saved history remains reachable, with no log records discarded or modified.
|
|
118
|
+
Capture-time sidecars are sought by byte range rather than read in full. Empty
|
|
119
|
+
logs show a placeholder. Works in the released Pi 1.0 widget host. Mouse interaction
|
|
112
120
|
requires fullscreen mode; `/babysit view` opens the viewer using a keyboard
|
|
113
121
|
picker, and `/babysit` still provides a snapshot. The displayed `age` is total
|
|
114
122
|
session age, not time spent in the current state. Transcript details
|
package/index.ts
CHANGED
|
@@ -45,6 +45,7 @@ import type {
|
|
|
45
45
|
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
46
46
|
import { Box, Markdown, Text, truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
47
47
|
import { createLogViewer } from "./log-viewer";
|
|
48
|
+
import { FileLogSource } from "./log-source";
|
|
48
49
|
import { Type, type TSchema } from "typebox";
|
|
49
50
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
50
51
|
import { type AgentConfig, type AgentScope, discoverAgents } from "./agents";
|
|
@@ -574,9 +575,16 @@ function markAutomaticGc(now = new Date()): void {
|
|
|
574
575
|
}
|
|
575
576
|
}
|
|
576
577
|
|
|
577
|
-
function scanTreeStats(root: string): { bytes: number; newestMtimeMs: number } {
|
|
578
|
+
function scanTreeStats(root: string, rootMtimeMs?: number): { bytes: number; newestMtimeMs: number } {
|
|
578
579
|
let bytes = 0;
|
|
579
|
-
let newestMtimeMs
|
|
580
|
+
let newestMtimeMs: number;
|
|
581
|
+
try {
|
|
582
|
+
// The collector's own lock file updates the root mtime; use the
|
|
583
|
+
// pre-lock age when rescanning, but still include fresh descendants.
|
|
584
|
+
newestMtimeMs = rootMtimeMs ?? fs.lstatSync(root).mtimeMs;
|
|
585
|
+
} catch {
|
|
586
|
+
return { bytes: 0, newestMtimeMs: Infinity };
|
|
587
|
+
}
|
|
580
588
|
const pending = [root];
|
|
581
589
|
while (pending.length > 0) {
|
|
582
590
|
const current = pending.pop() as string;
|
|
@@ -748,7 +756,7 @@ export function gcBabysitRoots(options: {
|
|
|
748
756
|
result.skippedLive.push(entry.name);
|
|
749
757
|
continue;
|
|
750
758
|
}
|
|
751
|
-
const refreshedStats = scanTreeStats(root);
|
|
759
|
+
const refreshedStats = scanTreeStats(root, stats.newestMtimeMs);
|
|
752
760
|
if (now - refreshedStats.newestMtimeMs < options.olderThanMs) continue;
|
|
753
761
|
tombstone = path.join(
|
|
754
762
|
options.rootBase,
|
|
@@ -2521,12 +2529,13 @@ export function createSessionWidget(options: {
|
|
|
2521
2529
|
getHeight: () => number;
|
|
2522
2530
|
requestRender: () => void;
|
|
2523
2531
|
getTheme: () => Theme;
|
|
2524
|
-
readLog: (id: string) => { text
|
|
2532
|
+
readLog: (id: string) => { text?: string; file?: string; isAgent: boolean; clipped?: boolean; lineTimestamps?: (number | undefined)[] };
|
|
2525
2533
|
}) {
|
|
2526
2534
|
const listState: WidgetSessionListState = { mode: "running", page: 0 };
|
|
2527
2535
|
let selectedId: string | undefined;
|
|
2528
2536
|
let displays: WidgetSessionDisplay[] = [];
|
|
2529
2537
|
let viewer: ReturnType<typeof createLogViewer> | undefined;
|
|
2538
|
+
let source: FileLogSource | undefined;
|
|
2530
2539
|
let list = createWidgetComponent([], [], open, listState);
|
|
2531
2540
|
function refreshViewer() {
|
|
2532
2541
|
if (!selectedId || !viewer) return;
|
|
@@ -2534,20 +2543,26 @@ export function createSessionWidget(options: {
|
|
|
2534
2543
|
const session = displays.find((session) => session.id === selectedId);
|
|
2535
2544
|
const header = session?.header ?? selectedId;
|
|
2536
2545
|
const detail = session?.details?.[0] ?? "";
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
log.
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2546
|
+
const context = detail.startsWith("task: ") ? detail.slice(6) : detail;
|
|
2547
|
+
if (log.file) {
|
|
2548
|
+
if (!source || source.file !== log.file || source.isAgent !== log.isAgent)
|
|
2549
|
+
source = new FileLogSource(log.file, log.isAgent);
|
|
2550
|
+
viewer.updateSource(header, source, context);
|
|
2551
|
+
} else {
|
|
2552
|
+
viewer.update(header, log.text ?? "", log.isAgent, context, log.lineTimestamps);
|
|
2553
|
+
source = undefined;
|
|
2554
|
+
}
|
|
2544
2555
|
}
|
|
2545
2556
|
function close() {
|
|
2546
2557
|
selectedId = undefined;
|
|
2558
|
+
viewer?.dispose();
|
|
2547
2559
|
viewer = undefined;
|
|
2560
|
+
source = undefined;
|
|
2548
2561
|
options.requestRender();
|
|
2549
2562
|
}
|
|
2550
2563
|
function open(id: string) {
|
|
2564
|
+
viewer?.dispose();
|
|
2565
|
+
source = undefined;
|
|
2551
2566
|
selectedId = id;
|
|
2552
2567
|
viewer = createLogViewer({ ...options, onClose: close });
|
|
2553
2568
|
refreshViewer();
|
|
@@ -3675,7 +3690,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
3675
3690
|
getTheme: () => ctx.ui.theme,
|
|
3676
3691
|
readLog: (id) => {
|
|
3677
3692
|
const isAgent = kindOf(id) === "subagent";
|
|
3678
|
-
return {
|
|
3693
|
+
return { file: logPath(id), isAgent };
|
|
3679
3694
|
},
|
|
3680
3695
|
});
|
|
3681
3696
|
return sessionWidget;
|
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.18",
|
|
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",
|