@yusukeshib/pi-babysit 0.6.16 → 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 +15 -4
- package/index.ts +27 -12
- package/log-source.ts +232 -0
- package/log-viewer.ts +154 -22
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -100,12 +100,23 @@ terminal, leaving room for the editor/status/footer. Agent logs separate
|
|
|
100
100
|
assistant text, tool calls, and results; process logs show terminal output.
|
|
101
101
|
When babysit provides `output.timestamps.jsonl`, process lines begin with a muted
|
|
102
102
|
local `[YYYY-MM-DD HH:mm:ss]` capture time, with wrapped text aligned beneath the
|
|
103
|
-
message.
|
|
103
|
+
message. Agent lines show the capture time of their block's first available event
|
|
104
|
+
in the same left column, with the assistant/tool/result/error label beside the
|
|
105
|
+
first content line; streamed updates and duplicate results keep that time. This
|
|
106
|
+
is the time babysit received the first byte of the event or line, not an
|
|
104
107
|
application event time. Narrow views place the date above the text. Old logs and
|
|
105
108
|
lines without capture metadata remain readable without invented timestamps.
|
|
106
|
-
Only the selected log is loaded
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
109
120
|
requires fullscreen mode; `/babysit view` opens the viewer using a keyboard
|
|
110
121
|
picker, and `/babysit` still provides a snapshot. The displayed `age` is total
|
|
111
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;
|
|
@@ -31,7 +33,7 @@ function sanitize(text: string, sgr = false): string {
|
|
|
31
33
|
(s) => (sgr && /^\x1b\[[\d;]*m$/.test(s) ? s : ""),
|
|
32
34
|
);
|
|
33
35
|
}
|
|
34
|
-
type Block = { kind: "assistant" | "tool" | "result" | "error"; text: string };
|
|
36
|
+
type Block = { kind: "assistant" | "tool" | "result" | "error"; text: string; timestamp?: number };
|
|
35
37
|
function contentText(content: unknown): string {
|
|
36
38
|
if (typeof content === "string") return content;
|
|
37
39
|
return Array.isArray(content)
|
|
@@ -41,15 +43,17 @@ function contentText(content: unknown): string {
|
|
|
41
43
|
.join("")
|
|
42
44
|
: "";
|
|
43
45
|
}
|
|
44
|
-
function agentBlocks(raw: string): Block[] {
|
|
46
|
+
function agentBlocks(raw: string, timestamps?: (number | undefined)[]): Block[] {
|
|
45
47
|
const blocks: Block[] = [];
|
|
46
48
|
let current: Block | undefined;
|
|
47
49
|
const toolResults = new Map<string, Block>();
|
|
48
|
-
const assistant = () =>
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
const assistant = (timestamp: number | undefined) => {
|
|
51
|
+
current ??= (blocks.push({ kind: "assistant", text: "", timestamp }), blocks[blocks.length - 1]);
|
|
52
|
+
current.timestamp ??= timestamp;
|
|
53
|
+
return current;
|
|
54
|
+
};
|
|
55
|
+
for (const [index, line] of raw.split(/\r\n|\r|\n/).entries()) {
|
|
56
|
+
const timestamp = timestamps?.[index];
|
|
53
57
|
let e: any;
|
|
54
58
|
try {
|
|
55
59
|
e = JSON.parse(line);
|
|
@@ -57,33 +61,36 @@ function agentBlocks(raw: string): Block[] {
|
|
|
57
61
|
continue;
|
|
58
62
|
}
|
|
59
63
|
if (!e || typeof e !== "object") continue;
|
|
64
|
+
if (e.type === "agent_start") { current = undefined; toolResults.clear(); }
|
|
60
65
|
if (e.type === "message_start" && e.message?.role === "assistant")
|
|
61
66
|
current = undefined;
|
|
62
67
|
if (["message_start", "message_update", "message_end"].includes(e.type)) {
|
|
63
68
|
if (e.message?.role === "assistant") {
|
|
64
69
|
const text = contentText(e.message.content);
|
|
65
|
-
const b = assistant();
|
|
70
|
+
const b = assistant(timestamp);
|
|
66
71
|
if (text) b.text = text; // cumulative/final snapshots replace streamed deltas
|
|
67
72
|
if (e.message.errorMessage)
|
|
68
|
-
blocks.push({ kind: "error", text: String(e.message.errorMessage) });
|
|
73
|
+
blocks.push({ kind: "error", text: String(e.message.errorMessage), timestamp });
|
|
69
74
|
if (e.type === "message_end") current = undefined;
|
|
70
75
|
} else if (
|
|
71
76
|
!e.message &&
|
|
72
77
|
e.assistantMessageEvent?.type === "text_delta" &&
|
|
73
78
|
typeof e.assistantMessageEvent.delta === "string"
|
|
74
79
|
) {
|
|
75
|
-
assistant().text += e.assistantMessageEvent.delta;
|
|
80
|
+
assistant(timestamp).text += e.assistantMessageEvent.delta;
|
|
76
81
|
} else if (e.type === "message_end" && e.message?.role === "toolResult") {
|
|
77
82
|
const id = e.message.toolCallId;
|
|
78
83
|
const text = contentText(e.message.content);
|
|
79
84
|
if (id && toolResults.has(id)) {
|
|
80
85
|
const b = toolResults.get(id)!;
|
|
81
86
|
b.text = text;
|
|
87
|
+
b.timestamp ??= timestamp;
|
|
82
88
|
b.kind = e.message.isError ? "error" : b.kind;
|
|
83
89
|
} else {
|
|
84
90
|
const b: Block = {
|
|
85
91
|
kind: e.message.isError ? "error" : "result",
|
|
86
92
|
text,
|
|
93
|
+
timestamp,
|
|
87
94
|
};
|
|
88
95
|
blocks.push(b);
|
|
89
96
|
if (id) toolResults.set(id, b);
|
|
@@ -93,10 +100,12 @@ function agentBlocks(raw: string): Block[] {
|
|
|
93
100
|
blocks.push({
|
|
94
101
|
kind: "tool",
|
|
95
102
|
text: `${e.toolName ?? "tool"} ${JSON.stringify(e.args ?? {})}`,
|
|
103
|
+
timestamp,
|
|
96
104
|
});
|
|
97
105
|
} else if (e.type === "tool_execution_end") {
|
|
98
106
|
const b: Block = {
|
|
99
107
|
kind: e.isError ? "error" : "result",
|
|
108
|
+
timestamp,
|
|
100
109
|
text:
|
|
101
110
|
contentText(e.result?.content) ||
|
|
102
111
|
String(e.error?.message ?? e.error ?? ""),
|
|
@@ -108,6 +117,7 @@ function agentBlocks(raw: string): Block[] {
|
|
|
108
117
|
} else if (e.type === "error")
|
|
109
118
|
blocks.push({
|
|
110
119
|
kind: "error",
|
|
120
|
+
timestamp,
|
|
111
121
|
text: String(
|
|
112
122
|
e.message ?? e.error?.message ?? e.error ?? "Unknown error",
|
|
113
123
|
),
|
|
@@ -130,6 +140,19 @@ export function createLogViewer(options: LogViewerOptions) {
|
|
|
130
140
|
description = "",
|
|
131
141
|
isAgent = false;
|
|
132
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
|
+
};
|
|
133
156
|
let cache: string[] | undefined,
|
|
134
157
|
cacheWidth = -1,
|
|
135
158
|
cacheTheme: Theme | undefined;
|
|
@@ -141,9 +164,9 @@ export function createLogViewer(options: LogViewerOptions) {
|
|
|
141
164
|
const theme = options.getTheme();
|
|
142
165
|
if (cache && cacheWidth === width && cacheTheme === theme) return cache;
|
|
143
166
|
const blocks = isAgent
|
|
144
|
-
? agentBlocks(raw)
|
|
167
|
+
? agentBlocks(raw, lineTimestamps)
|
|
145
168
|
: [{ kind: "result" as const, text: sanitize(raw, true) }];
|
|
146
|
-
const lines: string[] = description
|
|
169
|
+
const lines: string[] = description && (!source || pages[0]?.start === 0)
|
|
147
170
|
? [
|
|
148
171
|
theme.fg("muted", isAgent ? "[task]" : "[command]"),
|
|
149
172
|
...new Text(sanitize(description), 0, 0).render(width),
|
|
@@ -167,7 +190,25 @@ export function createLogViewer(options: LogViewerOptions) {
|
|
|
167
190
|
}
|
|
168
191
|
continue;
|
|
169
192
|
}
|
|
170
|
-
if (isAgent)
|
|
193
|
+
if (isAgent && b.timestamp !== undefined) {
|
|
194
|
+
const date = theme.fg("muted", dateLabel(b.timestamp));
|
|
195
|
+
const kind = theme.fg(b.kind === "error" ? "error" : b.kind === "assistant" ? "accent" : "toolTitle", `[${b.kind}]`);
|
|
196
|
+
const indent = visibleWidth(date) + 1;
|
|
197
|
+
const kindIndent = visibleWidth(kind) + 1;
|
|
198
|
+
if (width <= indent + kindIndent + 4) {
|
|
199
|
+
lines.push(...new Text(date, 0, 0).render(width));
|
|
200
|
+
lines.push(...new Text(`${kind} ${b.text}`, 0, 0).render(width));
|
|
201
|
+
} else {
|
|
202
|
+
const contentWidth = width - indent - kindIndent;
|
|
203
|
+
const rendered = b.kind === "assistant"
|
|
204
|
+
? new Markdown(b.text, 0, 0, getMarkdownTheme()).render(contentWidth)
|
|
205
|
+
: new Text(b.text, 0, 0).render(contentWidth);
|
|
206
|
+
lines.push(...rendered.map((line, row) =>
|
|
207
|
+
`${date} ${row === 0 ? kind + " " : " ".repeat(kindIndent)}${line}`));
|
|
208
|
+
}
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (isAgent) {
|
|
171
212
|
lines.push(
|
|
172
213
|
theme.fg(
|
|
173
214
|
b.kind === "error"
|
|
@@ -178,6 +219,7 @@ export function createLogViewer(options: LogViewerOptions) {
|
|
|
178
219
|
`[${b.kind}]`,
|
|
179
220
|
),
|
|
180
221
|
);
|
|
222
|
+
}
|
|
181
223
|
lines.push(
|
|
182
224
|
...(b.kind === "assistant"
|
|
183
225
|
? new Markdown(b.text, 0, 0, getMarkdownTheme()).render(width)
|
|
@@ -186,13 +228,7 @@ export function createLogViewer(options: LogViewerOptions) {
|
|
|
186
228
|
}
|
|
187
229
|
if (!blocks.some((block) => block.text.trim()))
|
|
188
230
|
lines.push(theme.fg("muted", "(no output available)"));
|
|
189
|
-
cache =
|
|
190
|
-
lines.length > 2000
|
|
191
|
-
? [
|
|
192
|
-
theme.fg("warning", "[Earlier log lines clipped]"),
|
|
193
|
-
...lines.slice(-1999),
|
|
194
|
-
]
|
|
195
|
-
: lines;
|
|
231
|
+
cache = lines;
|
|
196
232
|
cacheWidth = width;
|
|
197
233
|
cacheTheme = theme;
|
|
198
234
|
return cache;
|
|
@@ -236,7 +272,7 @@ export function createLogViewer(options: LogViewerOptions) {
|
|
|
236
272
|
const footer: Component = {
|
|
237
273
|
invalidate() {},
|
|
238
274
|
render: (width) => [
|
|
239
|
-
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)),
|
|
240
276
|
],
|
|
241
277
|
};
|
|
242
278
|
let geometry = {
|
|
@@ -255,8 +291,74 @@ export function createLogViewer(options: LogViewerOptions) {
|
|
|
255
291
|
width = Math.max(0, Math.floor(width));
|
|
256
292
|
const height = Math.max(0, Math.floor(options.getHeight()) - 2);
|
|
257
293
|
const contentWidth = Math.max(0, width - 1);
|
|
258
|
-
const
|
|
294
|
+
const renderWidth = Math.max(1, contentWidth);
|
|
295
|
+
let lines = body.render(renderWidth);
|
|
259
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
|
+
}
|
|
260
362
|
const visible = lines.slice(
|
|
261
363
|
scrollView.scrollTop,
|
|
262
364
|
scrollView.scrollTop + height,
|
|
@@ -344,7 +446,37 @@ export function createLogViewer(options: LogViewerOptions) {
|
|
|
344
446
|
invalidate() {
|
|
345
447
|
body.invalidate();
|
|
346
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
|
+
},
|
|
347
478
|
update(nextHeader: string, logText: string, agent: boolean, context = "", timestamps?: (number | undefined)[]) {
|
|
479
|
+
if (source) { source.dispose(); source = undefined; pages = []; }
|
|
348
480
|
header = nextHeader;
|
|
349
481
|
if (raw !== logText || isAgent !== agent || description !== context ||
|
|
350
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",
|