@yusukeshib/pi-babysit 0.6.14 → 0.6.16
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 +24 -14
- package/index.ts +166 -57
- package/log-viewer.ts +371 -0
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -76,6 +76,7 @@ backgrounding (`… &`, `nohup`, `setsid`, `disown`). Set
|
|
|
76
76
|
| Command | What it does |
|
|
77
77
|
| ------- | ------------ |
|
|
78
78
|
| `/babysit` | Arrow-key picker over all sessions. Renders an **inline snapshot** (no tmux): running **process** → current rendered screen + recent output + a copy-paste `babysit attach` take-over hint (detach `Ctrl-\ Ctrl-\`); running **subagent** → read-only progress (RPC stdin stays untouchable); finished → summary. Re-run `/babysit` to refresh |
|
|
79
|
+
| `/babysit view` | Keyboard picker to open a session in the widget log viewer (Pi TUI) |
|
|
79
80
|
| `/babysit gc [days]` | Preview and confirm deletion of old Pi-session roots (default 14 days). Active leases, live supervisor/child PIDs, unknown states, the current root, and recent roots are retained; old empty roots are eligible once their lease is gone. Deletion uses a GC lock and atomic rename |
|
|
80
81
|
|
|
81
82
|
A widget below the editor separates session **kind** from task **state** at a glance:
|
|
@@ -85,20 +86,29 @@ summary counts use `RUNNING` / `IDLE`, and every row is labeled
|
|
|
85
86
|
Click the header to toggle to `babysits(n all)`, which also shows finished and failed
|
|
86
87
|
sessions, and click again to return to running-only. Both modes show newest first,
|
|
87
88
|
ten per page; `< 1/4 >` shows the current/total page when needed. Click the
|
|
88
|
-
arrows to page.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
89
|
+
arrows to page. Rows occupy one line, previewing the command for processes and
|
|
90
|
+
the task for agents (with `…` when clipped). Click an AGENT or PROCESS row to
|
|
91
|
+
replace the list with a tall **log viewer in the same widget area**, not an
|
|
92
|
+
overlay or separate screen. `Esc` or the right-hand **Close** button restores
|
|
93
|
+
the list, preserving its mode and page. The viewer uses Pi's `ScrollView` for
|
|
94
|
+
scroll position and follow behavior, with a widget-local scrollbar styled using
|
|
95
|
+
Pi's scrollbar theme. Wheel, scrollbar drag/track clicks, and ↑/↓ navigate the
|
|
96
|
+
log without requiring any Pi core changes. Home/End and PageUp/PageDown remain
|
|
97
|
+
Pi's transcript shortcuts. At the bottom it follows new output; scrolling up
|
|
98
|
+
pauses following until you return to the bottom. The viewer resizes with the
|
|
99
|
+
terminal, leaving room for the editor/status/footer. Agent logs separate
|
|
100
|
+
assistant text, tool calls, and results; process logs show terminal output.
|
|
101
|
+
When babysit provides `output.timestamps.jsonl`, process lines begin with a muted
|
|
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
|
|
104
|
+
application event time. Narrow views place the date above the text. Old logs and
|
|
105
|
+
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
|
+
requires fullscreen mode; `/babysit view` opens the viewer using a keyboard
|
|
110
|
+
picker, and `/babysit` still provides a snapshot. The displayed `age` is total
|
|
111
|
+
session age, not time spent in the current state. Transcript details
|
|
102
112
|
from tool results, snapshots, and completion notifications collapse or expand
|
|
103
113
|
with Pi's tool-output toggle (`Ctrl+O` by default).
|
|
104
114
|
|
package/index.ts
CHANGED
|
@@ -43,7 +43,8 @@ import type {
|
|
|
43
43
|
ToolDefinition,
|
|
44
44
|
} from "@earendil-works/pi-coding-agent";
|
|
45
45
|
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
46
|
-
import { Box, Markdown, Text, truncateToWidth, visibleWidth,
|
|
46
|
+
import { Box, Markdown, Text, truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
47
|
+
import { createLogViewer } from "./log-viewer";
|
|
47
48
|
import { Type, type TSchema } from "typebox";
|
|
48
49
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
49
50
|
import { type AgentConfig, type AgentScope, discoverAgents } from "./agents";
|
|
@@ -2401,7 +2402,6 @@ function renderWidgetSessionHeader(
|
|
|
2401
2402
|
|
|
2402
2403
|
// How many trailing output lines to show per session in the widget.
|
|
2403
2404
|
const WIDGET_COLLAPSED_TAIL_LINES = 1;
|
|
2404
|
-
const WIDGET_EXPANDED_TAIL_LINES = 50;
|
|
2405
2405
|
const WIDGET_SESSION_PAGE_SIZE = 10;
|
|
2406
2406
|
|
|
2407
2407
|
export interface WidgetSessionListState {
|
|
@@ -2413,7 +2413,7 @@ export interface WidgetSessionDisplay {
|
|
|
2413
2413
|
id: string;
|
|
2414
2414
|
header: string;
|
|
2415
2415
|
tail: string[];
|
|
2416
|
-
|
|
2416
|
+
viewable: boolean;
|
|
2417
2417
|
active?: boolean;
|
|
2418
2418
|
details?: string[];
|
|
2419
2419
|
}
|
|
@@ -2426,14 +2426,12 @@ interface WidgetMouseEvent {
|
|
|
2426
2426
|
}
|
|
2427
2427
|
|
|
2428
2428
|
/**
|
|
2429
|
-
*
|
|
2430
|
-
* pi-tui mouse classes. Older hosts still render it normally; fullscreen hosts
|
|
2431
|
-
* dispatch component-local click coordinates to handleMouse.
|
|
2429
|
+
* Compact session list. Item presses open a viewer; rows never expand inline.
|
|
2432
2430
|
*/
|
|
2433
2431
|
export function createWidgetComponent(
|
|
2434
2432
|
summaryLines: string[],
|
|
2435
2433
|
sessions: WidgetSessionDisplay[],
|
|
2436
|
-
|
|
2434
|
+
onOpen: (id: string) => void,
|
|
2437
2435
|
listState?: WidgetSessionListState,
|
|
2438
2436
|
) {
|
|
2439
2437
|
type Target = { type: "session"; id: string } | { type: "sessions"; endX: number; prevX?: number; nextX?: number };
|
|
@@ -2448,31 +2446,10 @@ export function createWidgetComponent(
|
|
|
2448
2446
|
targetByLine.push(target);
|
|
2449
2447
|
};
|
|
2450
2448
|
const pushSession = (session: WidgetSessionDisplay) => {
|
|
2451
|
-
const
|
|
2452
|
-
const
|
|
2453
|
-
|
|
2454
|
-
);
|
|
2455
|
-
const target = session.expandable ? { type: "session" as const, id: session.id } : undefined;
|
|
2456
|
-
const details = session.details ?? [];
|
|
2457
|
-
const pushWrapped = (text: string) => {
|
|
2458
|
-
const indent = width > 6 ? " " : "";
|
|
2459
|
-
for (const line of wrapTextWithAnsi(text, Math.max(1, width - indent.length))) {
|
|
2460
|
-
push(`${indent}${line}`, target);
|
|
2461
|
-
}
|
|
2462
|
-
};
|
|
2463
|
-
const pushDetail = (detail: string) =>
|
|
2464
|
-
pushWrapped(detail.startsWith("task: ") ? detail.slice(6) : detail);
|
|
2465
|
-
if (!expanded && details.length) {
|
|
2466
|
-
const preview = details[0].startsWith("task: ") ? details[0].slice(6) : details[0];
|
|
2467
|
-
push(`${session.header} ${preview}`, target, "…");
|
|
2468
|
-
} else if (!expanded && tail.length === 1) {
|
|
2469
|
-
push(`${session.header} ${tail[0]}`, target);
|
|
2470
|
-
} else {
|
|
2471
|
-
push(session.header, target);
|
|
2472
|
-
for (const detail of details) pushDetail(detail);
|
|
2473
|
-
for (const line of tail) pushWrapped(line);
|
|
2474
|
-
if (expanded && tail.length === 0) push(" (no output available)", target);
|
|
2475
|
-
}
|
|
2449
|
+
const target = session.viewable ? { type: "session" as const, id: session.id } : undefined;
|
|
2450
|
+
const detail = session.details?.[0];
|
|
2451
|
+
const preview = detail?.startsWith("task: ") ? detail.slice(6) : detail ?? session.tail.at(-1);
|
|
2452
|
+
push(`${session.header}${preview ? ` ${preview}` : ""}`, target, "…");
|
|
2476
2453
|
};
|
|
2477
2454
|
|
|
2478
2455
|
for (const line of summaryLines.slice(0, -1)) push(line);
|
|
@@ -2515,11 +2492,7 @@ export function createWidgetComponent(
|
|
|
2515
2492
|
// Toggle on press so a terminal that omits release still works.
|
|
2516
2493
|
if (event.type === "press") {
|
|
2517
2494
|
if (target.type === "session") {
|
|
2518
|
-
|
|
2519
|
-
else {
|
|
2520
|
-
expandedSessionIds.clear();
|
|
2521
|
-
expandedSessionIds.add(target.id);
|
|
2522
|
-
}
|
|
2495
|
+
onOpen(target.id);
|
|
2523
2496
|
} else if (listState) {
|
|
2524
2497
|
const count = listState.mode === "running" ? sessions.filter((session) => session.active).length : sessions.length;
|
|
2525
2498
|
const pageCount = Math.max(1, Math.ceil(count / WIDGET_SESSION_PAGE_SIZE));
|
|
@@ -2544,6 +2517,124 @@ export function createWidgetComponent(
|
|
|
2544
2517
|
};
|
|
2545
2518
|
}
|
|
2546
2519
|
|
|
2520
|
+
export function createSessionWidget(options: {
|
|
2521
|
+
getHeight: () => number;
|
|
2522
|
+
requestRender: () => void;
|
|
2523
|
+
getTheme: () => Theme;
|
|
2524
|
+
readLog: (id: string) => { text: string; isAgent: boolean; clipped?: boolean; lineTimestamps?: (number | undefined)[] };
|
|
2525
|
+
}) {
|
|
2526
|
+
const listState: WidgetSessionListState = { mode: "running", page: 0 };
|
|
2527
|
+
let selectedId: string | undefined;
|
|
2528
|
+
let displays: WidgetSessionDisplay[] = [];
|
|
2529
|
+
let viewer: ReturnType<typeof createLogViewer> | undefined;
|
|
2530
|
+
let list = createWidgetComponent([], [], open, listState);
|
|
2531
|
+
function refreshViewer() {
|
|
2532
|
+
if (!selectedId || !viewer) return;
|
|
2533
|
+
const log = options.readLog(selectedId);
|
|
2534
|
+
const session = displays.find((session) => session.id === selectedId);
|
|
2535
|
+
const header = session?.header ?? selectedId;
|
|
2536
|
+
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
|
+
);
|
|
2544
|
+
}
|
|
2545
|
+
function close() {
|
|
2546
|
+
selectedId = undefined;
|
|
2547
|
+
viewer = undefined;
|
|
2548
|
+
options.requestRender();
|
|
2549
|
+
}
|
|
2550
|
+
function open(id: string) {
|
|
2551
|
+
selectedId = id;
|
|
2552
|
+
viewer = createLogViewer({ ...options, onClose: close });
|
|
2553
|
+
refreshViewer();
|
|
2554
|
+
options.requestRender();
|
|
2555
|
+
}
|
|
2556
|
+
return {
|
|
2557
|
+
open,
|
|
2558
|
+
update(summary: string[], sessions: WidgetSessionDisplay[]) {
|
|
2559
|
+
displays = sessions;
|
|
2560
|
+
list = createWidgetComponent(summary, sessions, open, listState);
|
|
2561
|
+
refreshViewer();
|
|
2562
|
+
options.requestRender();
|
|
2563
|
+
},
|
|
2564
|
+
render(width: number) { return (viewer ?? list).render(width); },
|
|
2565
|
+
handleMouse(event: TuiMouseEvent) {
|
|
2566
|
+
// Stock Pi widget hosts forward mouse input to this component, not
|
|
2567
|
+
// to a nested native layout tree. Keep the full wheel/drag event.
|
|
2568
|
+
return (viewer ?? list).handleMouse(event);
|
|
2569
|
+
},
|
|
2570
|
+
handleKey(data: string) { return viewer?.handleKey(data) ?? false; },
|
|
2571
|
+
invalidate() { list.invalidate(); viewer?.invalidate(); },
|
|
2572
|
+
};
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
/** Capture times are optional: old logs and incomplete sidecars remain readable. */
|
|
2576
|
+
function readLineTimestamps(file: string, data: Buffer, offset: number): (number | undefined)[] {
|
|
2577
|
+
const metadata = readViewerLog(path.join(path.dirname(file), "output.timestamps.jsonl")).text;
|
|
2578
|
+
const ranges: { start: number; end: number; time: number }[] = [];
|
|
2579
|
+
for (const line of metadata.split("\n")) {
|
|
2580
|
+
try {
|
|
2581
|
+
const record = JSON.parse(line);
|
|
2582
|
+
if (!Array.isArray(record) || record.length !== 3) continue;
|
|
2583
|
+
const [start, length, time] = record;
|
|
2584
|
+
if (!Number.isSafeInteger(start) || start < 0 ||
|
|
2585
|
+
!Number.isSafeInteger(length) || length <= 0 ||
|
|
2586
|
+
!Number.isSafeInteger(start + length) ||
|
|
2587
|
+
!Number.isSafeInteger(time) || time < 0 || time > 8640000000000000 ||
|
|
2588
|
+
start < (ranges.at(-1)?.end ?? 0)) continue;
|
|
2589
|
+
ranges.push({ start, end: start + length, time });
|
|
2590
|
+
} catch { /* A partial or malformed record has no trustworthy time. */ }
|
|
2591
|
+
}
|
|
2592
|
+
let range = 0;
|
|
2593
|
+
const timestamps: (number | undefined)[] = [];
|
|
2594
|
+
const addLine = (position: number) => {
|
|
2595
|
+
const absolute = offset + position;
|
|
2596
|
+
while (range < ranges.length && ranges[range].end <= absolute) range++;
|
|
2597
|
+
const entry = ranges[range];
|
|
2598
|
+
timestamps.push(position < data.length && entry && entry.start <= absolute ? entry.time : undefined);
|
|
2599
|
+
};
|
|
2600
|
+
addLine(0);
|
|
2601
|
+
for (let i = 0; i < data.length; i++) {
|
|
2602
|
+
if (data[i] !== 10 && data[i] !== 13) continue;
|
|
2603
|
+
if (data[i] === 13 && data[i + 1] === 10) i++;
|
|
2604
|
+
addLine(i + 1);
|
|
2605
|
+
}
|
|
2606
|
+
return timestamps;
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
/** Only the selected session is read; bounded snapshots never affect worker logs. */
|
|
2610
|
+
export function readViewerLog(file: string, withTimestamps = false): {
|
|
2611
|
+
text: string; clipped: boolean; lineTimestamps?: (number | undefined)[];
|
|
2612
|
+
} {
|
|
2613
|
+
try {
|
|
2614
|
+
const fd = fs.openSync(file, "r");
|
|
2615
|
+
try {
|
|
2616
|
+
const size = fs.fstatSync(fd).size;
|
|
2617
|
+
const start = Math.max(0, size - 1024 * 1024);
|
|
2618
|
+
const buffer = Buffer.alloc(size - start);
|
|
2619
|
+
const bytes = fs.readSync(fd, buffer, 0, buffer.length, start);
|
|
2620
|
+
let data = buffer.subarray(0, bytes);
|
|
2621
|
+
let offset = start;
|
|
2622
|
+
if (start > 0) {
|
|
2623
|
+
const skip = data.indexOf(10) + 1;
|
|
2624
|
+
data = data.subarray(skip);
|
|
2625
|
+
offset += skip;
|
|
2626
|
+
}
|
|
2627
|
+
const text = data.toString("utf8");
|
|
2628
|
+
return {
|
|
2629
|
+
text, clipped: start > 0,
|
|
2630
|
+
...(withTimestamps ? { lineTimestamps: readLineTimestamps(file, data, offset) } : {}),
|
|
2631
|
+
};
|
|
2632
|
+
} finally { fs.closeSync(fd); }
|
|
2633
|
+
} catch {
|
|
2634
|
+
return { text: "", clipped: false };
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2547
2638
|
// Strip ANSI/control escapes and clamp width so raw PTY output can't wrap or
|
|
2548
2639
|
// corrupt the widget area.
|
|
2549
2640
|
function sanitizeTailLine(s: string): string {
|
|
@@ -3498,8 +3589,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
3498
3589
|
}
|
|
3499
3590
|
}
|
|
3500
3591
|
|
|
3501
|
-
|
|
3502
|
-
|
|
3592
|
+
let sessionWidget: ReturnType<typeof createSessionWidget> | undefined;
|
|
3593
|
+
let unsubscribeWidgetInput: (() => void) | undefined;
|
|
3503
3594
|
|
|
3504
3595
|
const refreshWidget = async (ctx: ExtensionContext, snapshot?: BsSession[]) => {
|
|
3505
3596
|
if (!ctx.hasUI) return;
|
|
@@ -3516,10 +3607,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
3516
3607
|
}
|
|
3517
3608
|
}
|
|
3518
3609
|
const idle = subs.filter((session) => progressById.get(session.id)?.done).length;
|
|
3519
|
-
const sessionIds = new Set(all.map((session) => session.id));
|
|
3520
|
-
for (const id of expandedWidgetSessions) {
|
|
3521
|
-
if (!sessionIds.has(id)) expandedWidgetSessions.delete(id);
|
|
3522
|
-
}
|
|
3523
3610
|
|
|
3524
3611
|
const theme = ctx.ui.theme;
|
|
3525
3612
|
const summaryLines = renderWidgetLines(procs, subs.length - idle, idle, theme);
|
|
@@ -3530,7 +3617,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
3530
3617
|
session.id,
|
|
3531
3618
|
isSubagent,
|
|
3532
3619
|
isSubagent ? progressById.get(session.id) : undefined,
|
|
3533
|
-
|
|
3620
|
+
WIDGET_COLLAPSED_TAIL_LINES,
|
|
3534
3621
|
);
|
|
3535
3622
|
}),
|
|
3536
3623
|
);
|
|
@@ -3551,7 +3638,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
3551
3638
|
id,
|
|
3552
3639
|
header: renderWidgetSessionHeader(id, isSubagent ? "agent" : "process", state, elapsedOf(id), theme),
|
|
3553
3640
|
tail: activeTails.get(id) ?? [],
|
|
3554
|
-
|
|
3641
|
+
viewable: true,
|
|
3555
3642
|
active: true,
|
|
3556
3643
|
details,
|
|
3557
3644
|
};
|
|
@@ -3566,27 +3653,40 @@ export default function (pi: ExtensionAPI) {
|
|
|
3566
3653
|
theme,
|
|
3567
3654
|
),
|
|
3568
3655
|
details,
|
|
3569
|
-
// Only read/parse
|
|
3656
|
+
// Only read/parse visible sessions on demand.
|
|
3570
3657
|
get tail() {
|
|
3571
3658
|
try {
|
|
3572
|
-
return widgetTail(id, isSubagent, undefined,
|
|
3659
|
+
return widgetTail(id, isSubagent, undefined, WIDGET_COLLAPSED_TAIL_LINES);
|
|
3573
3660
|
} catch {
|
|
3574
3661
|
return [];
|
|
3575
3662
|
}
|
|
3576
3663
|
},
|
|
3577
|
-
|
|
3664
|
+
viewable: true,
|
|
3578
3665
|
};
|
|
3579
3666
|
});
|
|
3580
3667
|
|
|
3581
3668
|
if (ctx.mode === "tui") {
|
|
3582
|
-
|
|
3583
|
-
"pi-babysit",
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3669
|
+
if (!sessionWidget) {
|
|
3670
|
+
ctx.ui.setWidget("pi-babysit", (tui) => {
|
|
3671
|
+
sessionWidget = createSessionWidget({
|
|
3672
|
+
// Keep the viewer bounded while leaving room for editor/status/footer.
|
|
3673
|
+
getHeight: () => Math.max(3, tui.terminal.rows - 8),
|
|
3674
|
+
requestRender: () => tui.requestRender(),
|
|
3675
|
+
getTheme: () => ctx.ui.theme,
|
|
3676
|
+
readLog: (id) => {
|
|
3677
|
+
const isAgent = kindOf(id) === "subagent";
|
|
3678
|
+
return { ...readViewerLog(logPath(id), !isAgent), isAgent };
|
|
3679
|
+
},
|
|
3680
|
+
});
|
|
3681
|
+
return sessionWidget;
|
|
3682
|
+
}, { placement: "belowEditor" });
|
|
3683
|
+
unsubscribeWidgetInput = ctx.ui.onTerminalInput((data) =>
|
|
3684
|
+
sessionWidget?.handleKey(data) ? { consume: true } : undefined);
|
|
3685
|
+
}
|
|
3686
|
+
sessionWidget?.update(summaryLines, displays);
|
|
3587
3687
|
} else {
|
|
3588
3688
|
const activeIds = new Set(active.map((session) => session.id));
|
|
3589
|
-
const component = createWidgetComponent(summaryLines, displays.filter((session) => activeIds.has(session.id)),
|
|
3689
|
+
const component = createWidgetComponent(summaryLines, displays.filter((session) => activeIds.has(session.id)), () => {});
|
|
3590
3690
|
ctx.ui.setWidget("pi-babysit", component.render(10_000), {
|
|
3591
3691
|
placement: "belowEditor",
|
|
3592
3692
|
});
|
|
@@ -3768,6 +3868,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
3768
3868
|
});
|
|
3769
3869
|
|
|
3770
3870
|
pi.on("session_shutdown", async (event) => {
|
|
3871
|
+
unsubscribeWidgetInput?.();
|
|
3872
|
+
unsubscribeWidgetInput = undefined;
|
|
3873
|
+
sessionWidget = undefined;
|
|
3771
3874
|
if (pollTimer) clearInterval(pollTimer);
|
|
3772
3875
|
pollTimer = undefined;
|
|
3773
3876
|
// Pi also emits session_shutdown while hot-reloading and switching sessions.
|
|
@@ -5050,9 +5153,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
5050
5153
|
// output + a copy-paste `babysit attach` take-over hint; running subagent →
|
|
5051
5154
|
// read-only progress; finished → summary. Re-run to refresh.
|
|
5052
5155
|
pi.registerCommand("babysit", {
|
|
5053
|
-
description: "Pick a session to inspect
|
|
5156
|
+
description: "Pick a session to inspect; /babysit view opens its log viewer; /babysit gc [days] removes old terminal roots",
|
|
5054
5157
|
handler: async (args, ctx) => {
|
|
5055
|
-
const
|
|
5158
|
+
const view = args.trim() === "view";
|
|
5159
|
+
const command = view ? "" : args.trim();
|
|
5056
5160
|
if (command === "gc" || command.startsWith("gc ")) {
|
|
5057
5161
|
const daysText = command.slice(2).trim();
|
|
5058
5162
|
const days = daysText ? Number(daysText) : 14;
|
|
@@ -5087,7 +5191,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
5087
5191
|
return;
|
|
5088
5192
|
}
|
|
5089
5193
|
if (command) {
|
|
5090
|
-
ctx.ui.notify("Usage: /babysit or /babysit gc [days]", "error");
|
|
5194
|
+
ctx.ui.notify("Usage: /babysit, /babysit view, or /babysit gc [days]", "error");
|
|
5091
5195
|
return;
|
|
5092
5196
|
}
|
|
5093
5197
|
if (!(await babysitAvailable())) {
|
|
@@ -5124,6 +5228,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
5124
5228
|
if (!choice) return;
|
|
5125
5229
|
const picked = sessions[labels.indexOf(choice)];
|
|
5126
5230
|
if (!picked) return;
|
|
5231
|
+
if (view && ctx.mode === "tui") {
|
|
5232
|
+
await refreshWidget(ctx);
|
|
5233
|
+
sessionWidget?.open(picked.id);
|
|
5234
|
+
return;
|
|
5235
|
+
}
|
|
5127
5236
|
const kind = kindOf(picked.id);
|
|
5128
5237
|
const elapsed = picked.state === "running" ? elapsedOf(picked.id) : null;
|
|
5129
5238
|
const elapsedSuffix = elapsed ? ` ${elapsed}` : "";
|
package/log-viewer.ts
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
Key,
|
|
4
|
+
Markdown,
|
|
5
|
+
ScrollView,
|
|
6
|
+
Text,
|
|
7
|
+
matchesKey,
|
|
8
|
+
truncateToWidth,
|
|
9
|
+
visibleWidth,
|
|
10
|
+
type Component,
|
|
11
|
+
type TuiMouseEvent,
|
|
12
|
+
} from "@earendil-works/pi-tui";
|
|
13
|
+
|
|
14
|
+
export interface LogViewerOptions {
|
|
15
|
+
getHeight: () => number;
|
|
16
|
+
requestRender: () => void;
|
|
17
|
+
getTheme: () => Theme;
|
|
18
|
+
onClose: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Only numeric SGR escapes are allowed; never forward cursor, hyperlink or image controls. */
|
|
22
|
+
function sanitize(text: string, sgr = false): string {
|
|
23
|
+
return text
|
|
24
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\|$)/g, "")
|
|
25
|
+
.replace(/\x1b[P_^X][\s\S]*?(?:\x1b\\|$)/g, "")
|
|
26
|
+
.replace(/\r\n/g, "\n")
|
|
27
|
+
.replace(/\r/g, "\n")
|
|
28
|
+
.replace(/\t/g, " ")
|
|
29
|
+
.replace(
|
|
30
|
+
/\x1b\[[0-?]*[ -/]*[@-~]|\x1b[^\[]?|[\x00-\x08\x0b-\x1f\x7f-\x9f]/g,
|
|
31
|
+
(s) => (sgr && /^\x1b\[[\d;]*m$/.test(s) ? s : ""),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
type Block = { kind: "assistant" | "tool" | "result" | "error"; text: string };
|
|
35
|
+
function contentText(content: unknown): string {
|
|
36
|
+
if (typeof content === "string") return content;
|
|
37
|
+
return Array.isArray(content)
|
|
38
|
+
? content
|
|
39
|
+
.filter((c) => c?.type === "text")
|
|
40
|
+
.map((c) => c.text ?? "")
|
|
41
|
+
.join("")
|
|
42
|
+
: "";
|
|
43
|
+
}
|
|
44
|
+
function agentBlocks(raw: string): Block[] {
|
|
45
|
+
const blocks: Block[] = [];
|
|
46
|
+
let current: Block | undefined;
|
|
47
|
+
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")) {
|
|
53
|
+
let e: any;
|
|
54
|
+
try {
|
|
55
|
+
e = JSON.parse(line);
|
|
56
|
+
} catch {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (!e || typeof e !== "object") continue;
|
|
60
|
+
if (e.type === "message_start" && e.message?.role === "assistant")
|
|
61
|
+
current = undefined;
|
|
62
|
+
if (["message_start", "message_update", "message_end"].includes(e.type)) {
|
|
63
|
+
if (e.message?.role === "assistant") {
|
|
64
|
+
const text = contentText(e.message.content);
|
|
65
|
+
const b = assistant();
|
|
66
|
+
if (text) b.text = text; // cumulative/final snapshots replace streamed deltas
|
|
67
|
+
if (e.message.errorMessage)
|
|
68
|
+
blocks.push({ kind: "error", text: String(e.message.errorMessage) });
|
|
69
|
+
if (e.type === "message_end") current = undefined;
|
|
70
|
+
} else if (
|
|
71
|
+
!e.message &&
|
|
72
|
+
e.assistantMessageEvent?.type === "text_delta" &&
|
|
73
|
+
typeof e.assistantMessageEvent.delta === "string"
|
|
74
|
+
) {
|
|
75
|
+
assistant().text += e.assistantMessageEvent.delta;
|
|
76
|
+
} else if (e.type === "message_end" && e.message?.role === "toolResult") {
|
|
77
|
+
const id = e.message.toolCallId;
|
|
78
|
+
const text = contentText(e.message.content);
|
|
79
|
+
if (id && toolResults.has(id)) {
|
|
80
|
+
const b = toolResults.get(id)!;
|
|
81
|
+
b.text = text;
|
|
82
|
+
b.kind = e.message.isError ? "error" : b.kind;
|
|
83
|
+
} else {
|
|
84
|
+
const b: Block = {
|
|
85
|
+
kind: e.message.isError ? "error" : "result",
|
|
86
|
+
text,
|
|
87
|
+
};
|
|
88
|
+
blocks.push(b);
|
|
89
|
+
if (id) toolResults.set(id, b);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} else if (e.type === "tool_execution_start") {
|
|
93
|
+
blocks.push({
|
|
94
|
+
kind: "tool",
|
|
95
|
+
text: `${e.toolName ?? "tool"} ${JSON.stringify(e.args ?? {})}`,
|
|
96
|
+
});
|
|
97
|
+
} else if (e.type === "tool_execution_end") {
|
|
98
|
+
const b: Block = {
|
|
99
|
+
kind: e.isError ? "error" : "result",
|
|
100
|
+
text:
|
|
101
|
+
contentText(e.result?.content) ||
|
|
102
|
+
String(e.error?.message ?? e.error ?? ""),
|
|
103
|
+
};
|
|
104
|
+
if (b.text) {
|
|
105
|
+
blocks.push(b);
|
|
106
|
+
if (e.toolCallId) toolResults.set(e.toolCallId, b);
|
|
107
|
+
}
|
|
108
|
+
} else if (e.type === "error")
|
|
109
|
+
blocks.push({
|
|
110
|
+
kind: "error",
|
|
111
|
+
text: String(
|
|
112
|
+
e.message ?? e.error?.message ?? e.error ?? "Unknown error",
|
|
113
|
+
),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return blocks
|
|
117
|
+
.filter((b) => b.text)
|
|
118
|
+
.map((b) => ({ ...b, text: sanitize(b.text) }));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function dateLabel(timestamp: number): string {
|
|
122
|
+
const date = new Date(timestamp);
|
|
123
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
124
|
+
return `[${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}]`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function createLogViewer(options: LogViewerOptions) {
|
|
128
|
+
let header = "",
|
|
129
|
+
raw = "",
|
|
130
|
+
description = "",
|
|
131
|
+
isAgent = false;
|
|
132
|
+
let lineTimestamps: (number | undefined)[] | undefined;
|
|
133
|
+
let cache: string[] | undefined,
|
|
134
|
+
cacheWidth = -1,
|
|
135
|
+
cacheTheme: Theme | undefined;
|
|
136
|
+
const body: Component = {
|
|
137
|
+
invalidate() {
|
|
138
|
+
cache = undefined;
|
|
139
|
+
},
|
|
140
|
+
render(width) {
|
|
141
|
+
const theme = options.getTheme();
|
|
142
|
+
if (cache && cacheWidth === width && cacheTheme === theme) return cache;
|
|
143
|
+
const blocks = isAgent
|
|
144
|
+
? agentBlocks(raw)
|
|
145
|
+
: [{ kind: "result" as const, text: sanitize(raw, true) }];
|
|
146
|
+
const lines: string[] = description
|
|
147
|
+
? [
|
|
148
|
+
theme.fg("muted", isAgent ? "[task]" : "[command]"),
|
|
149
|
+
...new Text(sanitize(description), 0, 0).render(width),
|
|
150
|
+
"",
|
|
151
|
+
]
|
|
152
|
+
: [];
|
|
153
|
+
for (const b of blocks) {
|
|
154
|
+
if (!isAgent && lineTimestamps?.some((time) => time !== undefined)) {
|
|
155
|
+
for (const [index, text] of raw.split(/\r\n|\r|\n/).entries()) {
|
|
156
|
+
const time = lineTimestamps[index];
|
|
157
|
+
const label = time === undefined ? "" : dateLabel(time);
|
|
158
|
+
const indent = label ? visibleWidth(label) + 1 : 0;
|
|
159
|
+
if (indent && width <= indent + 4) {
|
|
160
|
+
lines.push(...new Text(theme.fg("muted", label), 0, 0).render(width));
|
|
161
|
+
lines.push(...new Text(sanitize(text, true), 0, 0).render(width));
|
|
162
|
+
} else {
|
|
163
|
+
const wrapped = new Text(sanitize(text, true), 0, 0).render(width - indent);
|
|
164
|
+
lines.push(...wrapped.map((line, row) =>
|
|
165
|
+
(row === 0 && label ? theme.fg("muted", label) + " " : " ".repeat(indent)) + line));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (isAgent)
|
|
171
|
+
lines.push(
|
|
172
|
+
theme.fg(
|
|
173
|
+
b.kind === "error"
|
|
174
|
+
? "error"
|
|
175
|
+
: b.kind === "assistant"
|
|
176
|
+
? "accent"
|
|
177
|
+
: "toolTitle",
|
|
178
|
+
`[${b.kind}]`,
|
|
179
|
+
),
|
|
180
|
+
);
|
|
181
|
+
lines.push(
|
|
182
|
+
...(b.kind === "assistant"
|
|
183
|
+
? new Markdown(b.text, 0, 0, getMarkdownTheme()).render(width)
|
|
184
|
+
: new Text(b.text, 0, 0).render(width)),
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
if (!blocks.some((block) => block.text.trim()))
|
|
188
|
+
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;
|
|
196
|
+
cacheWidth = width;
|
|
197
|
+
cacheTheme = theme;
|
|
198
|
+
return cache;
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
const scrollView = new ScrollView(body, {
|
|
202
|
+
follow: "end",
|
|
203
|
+
overscroll: "contain",
|
|
204
|
+
scrollbar: "always",
|
|
205
|
+
scrollbarTrackStyle: (s) => options.getTheme().fg("scrollbarTrack", s),
|
|
206
|
+
scrollbarThumbStyle: (s) => options.getTheme().fg("scrollbarThumb", s),
|
|
207
|
+
});
|
|
208
|
+
const heading = {
|
|
209
|
+
invalidate() {},
|
|
210
|
+
render(width: number) {
|
|
211
|
+
const close = truncateToWidth("[Close]", width);
|
|
212
|
+
const room = Math.max(0, width - visibleWidth(close) - 1);
|
|
213
|
+
const title = truncateToWidth(sanitize(header).replace(/\n/g, " "), room);
|
|
214
|
+
return [
|
|
215
|
+
options.getTheme().fg("accent", title) +
|
|
216
|
+
" ".repeat(
|
|
217
|
+
Math.max(0, width - visibleWidth(title) - visibleWidth(close)),
|
|
218
|
+
) +
|
|
219
|
+
options.getTheme().fg("muted", close),
|
|
220
|
+
];
|
|
221
|
+
},
|
|
222
|
+
handleMouse(e: TuiMouseEvent) {
|
|
223
|
+
if (
|
|
224
|
+
e.button !== "left" ||
|
|
225
|
+
e.x < Math.max(0, e.width - 7) ||
|
|
226
|
+
e.x >= e.width
|
|
227
|
+
)
|
|
228
|
+
return;
|
|
229
|
+
if (e.type === "press") {
|
|
230
|
+
options.onClose();
|
|
231
|
+
return { handled: true };
|
|
232
|
+
}
|
|
233
|
+
if (e.type === "click" || e.type === "release") return { handled: true };
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
const footer: Component = {
|
|
237
|
+
invalidate() {},
|
|
238
|
+
render: (width) => [
|
|
239
|
+
options.getTheme().fg("dim", truncateToWidth("↑↓ · Esc close", width)),
|
|
240
|
+
],
|
|
241
|
+
};
|
|
242
|
+
let geometry = {
|
|
243
|
+
width: 0,
|
|
244
|
+
height: 0,
|
|
245
|
+
thumbTop: 0,
|
|
246
|
+
thumbHeight: 0,
|
|
247
|
+
maxTop: 0,
|
|
248
|
+
};
|
|
249
|
+
let dragOffset: number | undefined;
|
|
250
|
+
// Stock widget Containers are opaque to native layout. Draw and route this
|
|
251
|
+
// viewport locally, retaining the public ScrollView's follow/scroll state.
|
|
252
|
+
return {
|
|
253
|
+
scrollView,
|
|
254
|
+
render(width: number) {
|
|
255
|
+
width = Math.max(0, Math.floor(width));
|
|
256
|
+
const height = Math.max(0, Math.floor(options.getHeight()) - 2);
|
|
257
|
+
const contentWidth = Math.max(0, width - 1);
|
|
258
|
+
const lines = body.render(Math.max(1, contentWidth));
|
|
259
|
+
scrollView.updateLayout(lines.length, height, options.requestRender);
|
|
260
|
+
const visible = lines.slice(
|
|
261
|
+
scrollView.scrollTop,
|
|
262
|
+
scrollView.scrollTop + height,
|
|
263
|
+
);
|
|
264
|
+
const thumbHeight =
|
|
265
|
+
height === 0
|
|
266
|
+
? 0
|
|
267
|
+
: Math.max(
|
|
268
|
+
1,
|
|
269
|
+
Math.min(
|
|
270
|
+
height,
|
|
271
|
+
Math.round((height * height) / Math.max(1, lines.length)),
|
|
272
|
+
),
|
|
273
|
+
);
|
|
274
|
+
const maxTop = Math.max(0, lines.length - height);
|
|
275
|
+
const thumbTop =
|
|
276
|
+
maxTop === 0
|
|
277
|
+
? 0
|
|
278
|
+
: Math.round(
|
|
279
|
+
(scrollView.scrollTop / maxTop) * (height - thumbHeight),
|
|
280
|
+
);
|
|
281
|
+
geometry = { width, height, thumbTop, thumbHeight, maxTop };
|
|
282
|
+
return [
|
|
283
|
+
...heading.render(width),
|
|
284
|
+
...Array.from({ length: height }, (_, row) => {
|
|
285
|
+
if (width === 0) return "";
|
|
286
|
+
const text = truncateToWidth(visible[row] ?? "", contentWidth);
|
|
287
|
+
const thumb = row >= thumbTop && row < thumbTop + thumbHeight;
|
|
288
|
+
return (
|
|
289
|
+
text +
|
|
290
|
+
" ".repeat(Math.max(0, contentWidth - visibleWidth(text))) +
|
|
291
|
+
options
|
|
292
|
+
.getTheme()
|
|
293
|
+
.fg(
|
|
294
|
+
thumb ? "scrollbarThumb" : "scrollbarTrack",
|
|
295
|
+
thumb ? (dragOffset === undefined ? "┃" : "█") : "│",
|
|
296
|
+
)
|
|
297
|
+
);
|
|
298
|
+
}),
|
|
299
|
+
...footer.render(width),
|
|
300
|
+
];
|
|
301
|
+
},
|
|
302
|
+
handleMouse(e: TuiMouseEvent) {
|
|
303
|
+
if (dragOffset !== undefined) {
|
|
304
|
+
if (e.type === "release") {
|
|
305
|
+
dragOffset = undefined;
|
|
306
|
+
return { handled: true, render: true };
|
|
307
|
+
}
|
|
308
|
+
if (e.type === "drag" || e.type === "move") {
|
|
309
|
+
const travel = geometry.height - geometry.thumbHeight;
|
|
310
|
+
const offset = Math.max(0, Math.min(travel, e.y - 1 - dragOffset));
|
|
311
|
+
scrollView.scrollTo(
|
|
312
|
+
travel > 0
|
|
313
|
+
? Math.round((offset / travel) * geometry.maxTop)
|
|
314
|
+
: scrollView.scrollTop,
|
|
315
|
+
);
|
|
316
|
+
return { handled: true, render: true };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (e.y === 0) return heading.handleMouse(e);
|
|
320
|
+
if (e.y < 1 || e.y > geometry.height || e.x < 0 || e.x >= geometry.width)
|
|
321
|
+
return;
|
|
322
|
+
if (e.type === "wheel") {
|
|
323
|
+
scrollView.scrollBy(e.wheelDelta ?? 0);
|
|
324
|
+
return { handled: true, render: true };
|
|
325
|
+
}
|
|
326
|
+
if (e.x === geometry.width - 1 && e.button === "left") {
|
|
327
|
+
if (e.type === "press") {
|
|
328
|
+
const row = e.y - 1;
|
|
329
|
+
if (
|
|
330
|
+
row >= geometry.thumbTop &&
|
|
331
|
+
row < geometry.thumbTop + geometry.thumbHeight
|
|
332
|
+
) {
|
|
333
|
+
dragOffset = row - geometry.thumbTop;
|
|
334
|
+
return { handled: true, capture: true, render: true };
|
|
335
|
+
}
|
|
336
|
+
scrollView.scrollBy(
|
|
337
|
+
row < geometry.thumbTop ? -geometry.height : geometry.height,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
if (["press", "click", "release"].includes(e.type))
|
|
341
|
+
return { handled: true, render: true };
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
invalidate() {
|
|
345
|
+
body.invalidate();
|
|
346
|
+
},
|
|
347
|
+
update(nextHeader: string, logText: string, agent: boolean, context = "", timestamps?: (number | undefined)[]) {
|
|
348
|
+
header = nextHeader;
|
|
349
|
+
if (raw !== logText || isAgent !== agent || description !== context ||
|
|
350
|
+
JSON.stringify(lineTimestamps) !== JSON.stringify(timestamps)) {
|
|
351
|
+
lineTimestamps = timestamps;
|
|
352
|
+
raw = logText;
|
|
353
|
+
isAgent = agent;
|
|
354
|
+
description = context;
|
|
355
|
+
body.invalidate();
|
|
356
|
+
}
|
|
357
|
+
options.requestRender();
|
|
358
|
+
},
|
|
359
|
+
handleKey(data: string): boolean {
|
|
360
|
+
if (matchesKey(data, Key.escape)) {
|
|
361
|
+
options.onClose();
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
if (matchesKey(data, Key.up)) scrollView.scrollBy(-1);
|
|
365
|
+
else if (matchesKey(data, Key.down)) scrollView.scrollBy(1);
|
|
366
|
+
else return false;
|
|
367
|
+
options.requestRender();
|
|
368
|
+
return true;
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yusukeshib/pi-babysit",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.16",
|
|
4
4
|
"description": "Run any shell command and pi subagents under babysit, with context-safe captured output.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"type": "module",
|
|
21
21
|
"files": [
|
|
22
22
|
"index.ts",
|
|
23
|
+
"log-viewer.ts",
|
|
23
24
|
"agents.ts",
|
|
24
25
|
"self-reap.ts",
|
|
25
26
|
"format-stream.mjs",
|
|
@@ -39,8 +40,8 @@
|
|
|
39
40
|
},
|
|
40
41
|
"peerDependencies": {
|
|
41
42
|
"@earendil-works/pi-ai": "*",
|
|
42
|
-
"@earendil-works/pi-coding-agent": "
|
|
43
|
-
"@earendil-works/pi-tui": "
|
|
43
|
+
"@earendil-works/pi-coding-agent": ">=1.0.0",
|
|
44
|
+
"@earendil-works/pi-tui": ">=1.0.0",
|
|
44
45
|
"typebox": "*"
|
|
45
46
|
}
|
|
46
47
|
}
|