@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 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. This is the time babysit received the first byte of the line, not an
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, bounded to the latest 1 MiB / 2,000 display
107
- lines with an omission notice; the full log file is unchanged. Empty logs show
108
- a placeholder. Works in the released Pi 1.0 widget host. Mouse interaction
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 = 0;
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: string; isAgent: boolean; clipped?: boolean; lineTimestamps?: (number | undefined)[] };
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
- viewer.update(
2538
- `${log.clipped ? "[older log omitted: 1 MiB limit] " : ""}${header}`,
2539
- log.text,
2540
- log.isAgent,
2541
- detail.startsWith("task: ") ? detail.slice(6) : detail,
2542
- log.lineTimestamps,
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 { ...readViewerLog(logPath(id), !isAgent), isAgent };
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
- (current ??=
50
- (blocks.push({ kind: "assistant", text: "" }),
51
- blocks[blocks.length - 1]));
52
- for (const line of raw.split("\n")) {
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 lines = body.render(Math.max(1, contentWidth));
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.16",
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",