@yusukeshib/pi-babysit 0.6.13 → 0.6.15

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.
Files changed (4) hide show
  1. package/README.md +26 -16
  2. package/index.ts +139 -61
  3. package/log-viewer.ts +346 -0
  4. 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,24 @@ 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. Collapsed rows occupy one line, previewing the command for
89
- processes and the task for agents (with `…` when clipped). Click a row for the
90
- complete wrapped command or task; agent rows also show their RPC worker launch
91
- argv (bold, readable text rather than a copy-paste shell command), and up to 50
92
- output lines. Agent output retains recent RPC text, tool calls, and tool results
93
- in chronological order instead of showing only the latest message. Blank lines
94
- and surrounding whitespace are preserved. Expanded output lines wrap without a
95
- 100-character cutoff.
96
- Opening another row closes the previous
97
- one. A session with no available output shows an explicit placeholder when
98
- expanded. Mouse clicks
99
- require fullscreen mode; `/babysit` works in regular mode. The displayed `age`
100
- is total session age, not time spent
101
- in the current state. Transcript details
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
+ Only the selected log is loaded, bounded to the latest 1 MiB / 2,000 display
102
+ lines with an omission notice; the full log file is unchanged. Empty logs show
103
+ a placeholder. Works in the released Pi 1.0 widget host. Mouse interaction
104
+ requires fullscreen mode; `/babysit view` opens the viewer using a keyboard
105
+ picker, and `/babysit` still provides a snapshot. The displayed `age` is total
106
+ session age, not time spent in the current state. Transcript details
102
107
  from tool results, snapshots, and completion notifications collapse or expand
103
108
  with Pi's tool-output toggle (`Ctrl+O` by default).
104
109
 
@@ -210,8 +215,13 @@ because blindly rerunning an arbitrary command can duplicate side effects.
210
215
  incrementally, which keeps follow-up tasks efficient.
211
216
 
212
217
  Subagents load `self-reap.ts`, which exits an idle finished subagent after a
213
- grace window (`PI_BABYSIT_REAP_AFTER`, default 120s) using the same parked-turn
214
- rule, so a subagent waiting on a long build is never false-killed. Give bounded
218
+ grace window using the same parked-turn rule, so a subagent waiting on a long
219
+ build is never false-killed. Callers can set `reapAfter: "5m"` on
220
+ `babysit_run { profile: "subagent", task, reapAfter: "5m" }`. Omission uses
221
+ `PI_BABYSIT_REAP_AFTER`, then `PI_SUBAGENT_REAP_AFTER`, then 120s.
222
+ `reapAfter: "none"` (also `"off"` or `"0"`) disables idle reaping; the absolute
223
+ `timeout` still applies. Follow-up tasks cancel the idle timer. This differs
224
+ from `idleTimeout`, which kills based on lack of output, even during work. Give bounded
215
225
  recon/review tasks at least one cost, turn, tool-call, or token budget; omit
216
226
  budgets only for intentionally open-ended work. Optional task budgets are
217
227
  observed by the parent poller. `maxUsageTokens` counts cumulative input, output,
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, wrapTextWithAnsi } from "@earendil-works/pi-tui";
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";
@@ -464,6 +465,7 @@ interface Meta {
464
465
  // subagent
465
466
  task?: string;
466
467
  launchCommand?: string;
468
+ reapAfter?: string;
467
469
  promptOffset?: number;
468
470
  model?: string;
469
471
  tools?: string[];
@@ -1065,9 +1067,11 @@ const SUBAGENT_BUDGET_GRACE_MS =
1065
1067
  parseDurMs(process.env.PI_BABYSIT_BUDGET_GRACE ?? "90s") ?? 90_000;
1066
1068
  const SUBAGENT_REAP_AFTER =
1067
1069
  process.env.PI_BABYSIT_REAP_AFTER ?? process.env.PI_SUBAGENT_REAP_AFTER ?? "120s";
1068
- const SUBAGENT_REUSE_HINT = ["0", "off", "none"].includes(SUBAGENT_REAP_AFTER)
1069
- ? "Session remains available until its absolute timeout."
1070
- : `Session remains available for follow-ups during the ${SUBAGENT_REAP_AFTER} idle grace.`;
1070
+ export function subagentReuseHint(reapAfter = SUBAGENT_REAP_AFTER): string {
1071
+ return ["0", "off", "none"].includes(reapAfter)
1072
+ ? "Session remains available until its absolute timeout."
1073
+ : `Session remains available for follow-ups during the ${reapAfter} idle grace.`;
1074
+ }
1071
1075
 
1072
1076
  export function clip(s: string, maxBytes = TAIL_MAX_BYTES): string {
1073
1077
  if (maxBytes <= 0) return "";
@@ -2101,6 +2105,7 @@ interface SubagentOpts {
2101
2105
  depth: number;
2102
2106
  maxDepth: number;
2103
2107
  budget?: SubagentBudget;
2108
+ reapAfter?: string;
2104
2109
  // Idle-timeout is OFF by default: an RPC-mode pi is silent while it works,
2105
2110
  // so idle detection would false-kill a busy subagent. The absolute timeout
2106
2111
  // is the safety valve instead.
@@ -2111,6 +2116,11 @@ interface SubagentOpts {
2111
2116
  async function spawnSubagent(
2112
2117
  opts: SubagentOpts,
2113
2118
  ): Promise<{ id: string; model?: string } | { error: string }> {
2119
+ const reapAfter = opts.reapAfter?.trim() ?? SUBAGENT_REAP_AFTER;
2120
+ if (opts.reapAfter !== undefined && !["0", "off", "none"].includes(reapAfter) &&
2121
+ (!/^\d+(ms|s|m|h)?$/.test(reapAfter) || !Number.isFinite(parseDurMs(reapAfter)))) {
2122
+ return { error: "Invalid reapAfter: use a duration such as 30s or 5m, or none/off/0 to disable." };
2123
+ }
2114
2124
  // Long-lived RPC worker: the task is NOT passed as argv — it is injected
2115
2125
  // below as an RPC `prompt` command, whose response we validate so spawn
2116
2126
  // failures (bad model, missing API key) are loud instead of a silent exit=1.
@@ -2189,6 +2199,7 @@ async function spawnSubagent(
2189
2199
  r = await bs(bsArgs, {
2190
2200
  cwd: opts.cwd,
2191
2201
  env: {
2202
+ PI_BABYSIT_REAP_AFTER: reapAfter,
2192
2203
  [SUBAGENT_DEPTH_ENV]: String(opts.depth),
2193
2204
  [SUBAGENT_MAX_DEPTH_ENV]: String(opts.maxDepth),
2194
2205
  },
@@ -2221,6 +2232,7 @@ async function spawnSubagent(
2221
2232
  depth: opts.depth,
2222
2233
  maxDepth: opts.maxDepth,
2223
2234
  budget: opts.budget,
2235
+ reapAfter,
2224
2236
  });
2225
2237
 
2226
2238
  // Wait for pi to boot (first JSON event in the log), then inject the task.
@@ -2314,6 +2326,7 @@ async function spawnSubagent(
2314
2326
  depth: opts.depth,
2315
2327
  maxDepth: opts.maxDepth,
2316
2328
  budget: opts.budget,
2329
+ reapAfter,
2317
2330
  startedAt: Date.now(),
2318
2331
  });
2319
2332
  return { id, model: resolvedModel };
@@ -2389,7 +2402,6 @@ function renderWidgetSessionHeader(
2389
2402
 
2390
2403
  // How many trailing output lines to show per session in the widget.
2391
2404
  const WIDGET_COLLAPSED_TAIL_LINES = 1;
2392
- const WIDGET_EXPANDED_TAIL_LINES = 50;
2393
2405
  const WIDGET_SESSION_PAGE_SIZE = 10;
2394
2406
 
2395
2407
  export interface WidgetSessionListState {
@@ -2401,7 +2413,7 @@ export interface WidgetSessionDisplay {
2401
2413
  id: string;
2402
2414
  header: string;
2403
2415
  tail: string[];
2404
- expandable: boolean;
2416
+ viewable: boolean;
2405
2417
  active?: boolean;
2406
2418
  details?: string[];
2407
2419
  }
@@ -2414,14 +2426,12 @@ interface WidgetMouseEvent {
2414
2426
  }
2415
2427
 
2416
2428
  /**
2417
- * Build the live widget as a mouse-aware component without depending on newer
2418
- * pi-tui mouse classes. Older hosts still render it normally; fullscreen hosts
2419
- * dispatch component-local click coordinates to handleMouse.
2429
+ * Compact session list. Item presses open a viewer; rows never expand inline.
2420
2430
  */
2421
2431
  export function createWidgetComponent(
2422
2432
  summaryLines: string[],
2423
2433
  sessions: WidgetSessionDisplay[],
2424
- expandedSessionIds: Set<string>,
2434
+ onOpen: (id: string) => void,
2425
2435
  listState?: WidgetSessionListState,
2426
2436
  ) {
2427
2437
  type Target = { type: "session"; id: string } | { type: "sessions"; endX: number; prevX?: number; nextX?: number };
@@ -2436,31 +2446,10 @@ export function createWidgetComponent(
2436
2446
  targetByLine.push(target);
2437
2447
  };
2438
2448
  const pushSession = (session: WidgetSessionDisplay) => {
2439
- const expanded = session.expandable && expandedSessionIds.has(session.id);
2440
- const tail = session.tail.slice(
2441
- expanded ? -WIDGET_EXPANDED_TAIL_LINES : -WIDGET_COLLAPSED_TAIL_LINES,
2442
- );
2443
- const target = session.expandable ? { type: "session" as const, id: session.id } : undefined;
2444
- const details = session.details ?? [];
2445
- const pushWrapped = (text: string) => {
2446
- const indent = width > 6 ? " " : "";
2447
- for (const line of wrapTextWithAnsi(text, Math.max(1, width - indent.length))) {
2448
- push(`${indent}${line}`, target);
2449
- }
2450
- };
2451
- const pushDetail = (detail: string) =>
2452
- pushWrapped(detail.startsWith("task: ") ? detail.slice(6) : detail);
2453
- if (!expanded && details.length) {
2454
- const preview = details[0].startsWith("task: ") ? details[0].slice(6) : details[0];
2455
- push(`${session.header} ${preview}`, target, "…");
2456
- } else if (!expanded && tail.length === 1) {
2457
- push(`${session.header} ${tail[0]}`, target);
2458
- } else {
2459
- push(session.header, target);
2460
- for (const detail of details) pushDetail(detail);
2461
- for (const line of tail) pushWrapped(line);
2462
- if (expanded && tail.length === 0) push(" (no output available)", target);
2463
- }
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, "…");
2464
2453
  };
2465
2454
 
2466
2455
  for (const line of summaryLines.slice(0, -1)) push(line);
@@ -2503,11 +2492,7 @@ export function createWidgetComponent(
2503
2492
  // Toggle on press so a terminal that omits release still works.
2504
2493
  if (event.type === "press") {
2505
2494
  if (target.type === "session") {
2506
- if (expandedSessionIds.has(target.id)) expandedSessionIds.clear();
2507
- else {
2508
- expandedSessionIds.clear();
2509
- expandedSessionIds.add(target.id);
2510
- }
2495
+ onOpen(target.id);
2511
2496
  } else if (listState) {
2512
2497
  const count = listState.mode === "running" ? sessions.filter((session) => session.active).length : sessions.length;
2513
2498
  const pageCount = Math.max(1, Math.ceil(count / WIDGET_SESSION_PAGE_SIZE));
@@ -2532,6 +2517,78 @@ export function createWidgetComponent(
2532
2517
  };
2533
2518
  }
2534
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 };
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
+ );
2543
+ }
2544
+ function close() {
2545
+ selectedId = undefined;
2546
+ viewer = undefined;
2547
+ options.requestRender();
2548
+ }
2549
+ function open(id: string) {
2550
+ selectedId = id;
2551
+ viewer = createLogViewer({ ...options, onClose: close });
2552
+ refreshViewer();
2553
+ options.requestRender();
2554
+ }
2555
+ return {
2556
+ open,
2557
+ update(summary: string[], sessions: WidgetSessionDisplay[]) {
2558
+ displays = sessions;
2559
+ list = createWidgetComponent(summary, sessions, open, listState);
2560
+ refreshViewer();
2561
+ options.requestRender();
2562
+ },
2563
+ render(width: number) { return (viewer ?? list).render(width); },
2564
+ handleMouse(event: TuiMouseEvent) {
2565
+ // Stock Pi widget hosts forward mouse input to this component, not
2566
+ // to a nested native layout tree. Keep the full wheel/drag event.
2567
+ return (viewer ?? list).handleMouse(event);
2568
+ },
2569
+ handleKey(data: string) { return viewer?.handleKey(data) ?? false; },
2570
+ invalidate() { list.invalidate(); viewer?.invalidate(); },
2571
+ };
2572
+ }
2573
+
2574
+ /** Only the selected session is read; bounded snapshots never affect worker logs. */
2575
+ export function readViewerLog(file: string): { text: string; clipped: boolean } {
2576
+ try {
2577
+ const fd = fs.openSync(file, "r");
2578
+ try {
2579
+ const size = fs.fstatSync(fd).size;
2580
+ const start = Math.max(0, size - 1024 * 1024);
2581
+ const buffer = Buffer.alloc(size - start);
2582
+ const bytes = fs.readSync(fd, buffer, 0, buffer.length, start);
2583
+ let text = buffer.subarray(0, bytes).toString("utf8");
2584
+ if (start > 0) text = text.slice(text.indexOf("\n") + 1);
2585
+ return { text, clipped: start > 0 };
2586
+ } finally { fs.closeSync(fd); }
2587
+ } catch {
2588
+ return { text: "", clipped: false };
2589
+ }
2590
+ }
2591
+
2535
2592
  // Strip ANSI/control escapes and clamp width so raw PTY output can't wrap or
2536
2593
  // corrupt the widget area.
2537
2594
  function sanitizeTailLine(s: string): string {
@@ -2804,7 +2861,7 @@ async function waitForTask(
2804
2861
  ok: completed.ok,
2805
2862
  text:
2806
2863
  `Subagent ${id} finished its task (${stats}).\n` +
2807
- `${SUBAGENT_REUSE_HINT} Follow-up: babysit_send { id: "${id}" }, ` +
2864
+ `${subagentReuseHint(readMeta(id)?.reapAfter)} Follow-up: babysit_send { id: "${id}" }, ` +
2808
2865
  `or babysit_kill when done.\n\n${completed.body}`,
2809
2866
  status: st,
2810
2867
  progress: prog,
@@ -3486,8 +3543,8 @@ export default function (pi: ExtensionAPI) {
3486
3543
  }
3487
3544
  }
3488
3545
 
3489
- const expandedWidgetSessions = new Set<string>();
3490
- const widgetListState: WidgetSessionListState = { mode: "running", page: 0 };
3546
+ let sessionWidget: ReturnType<typeof createSessionWidget> | undefined;
3547
+ let unsubscribeWidgetInput: (() => void) | undefined;
3491
3548
 
3492
3549
  const refreshWidget = async (ctx: ExtensionContext, snapshot?: BsSession[]) => {
3493
3550
  if (!ctx.hasUI) return;
@@ -3504,10 +3561,6 @@ export default function (pi: ExtensionAPI) {
3504
3561
  }
3505
3562
  }
3506
3563
  const idle = subs.filter((session) => progressById.get(session.id)?.done).length;
3507
- const sessionIds = new Set(all.map((session) => session.id));
3508
- for (const id of expandedWidgetSessions) {
3509
- if (!sessionIds.has(id)) expandedWidgetSessions.delete(id);
3510
- }
3511
3564
 
3512
3565
  const theme = ctx.ui.theme;
3513
3566
  const summaryLines = renderWidgetLines(procs, subs.length - idle, idle, theme);
@@ -3518,7 +3571,7 @@ export default function (pi: ExtensionAPI) {
3518
3571
  session.id,
3519
3572
  isSubagent,
3520
3573
  isSubagent ? progressById.get(session.id) : undefined,
3521
- WIDGET_EXPANDED_TAIL_LINES,
3574
+ WIDGET_COLLAPSED_TAIL_LINES,
3522
3575
  );
3523
3576
  }),
3524
3577
  );
@@ -3539,7 +3592,7 @@ export default function (pi: ExtensionAPI) {
3539
3592
  id,
3540
3593
  header: renderWidgetSessionHeader(id, isSubagent ? "agent" : "process", state, elapsedOf(id), theme),
3541
3594
  tail: activeTails.get(id) ?? [],
3542
- expandable: true,
3595
+ viewable: true,
3543
3596
  active: true,
3544
3597
  details,
3545
3598
  };
@@ -3554,27 +3607,37 @@ export default function (pi: ExtensionAPI) {
3554
3607
  theme,
3555
3608
  ),
3556
3609
  details,
3557
- // Only read/parse the five visible sessions on demand.
3610
+ // Only read/parse visible sessions on demand.
3558
3611
  get tail() {
3559
3612
  try {
3560
- return widgetTail(id, isSubagent, undefined, WIDGET_EXPANDED_TAIL_LINES);
3613
+ return widgetTail(id, isSubagent, undefined, WIDGET_COLLAPSED_TAIL_LINES);
3561
3614
  } catch {
3562
3615
  return [];
3563
3616
  }
3564
3617
  },
3565
- expandable: true,
3618
+ viewable: true,
3566
3619
  };
3567
3620
  });
3568
3621
 
3569
3622
  if (ctx.mode === "tui") {
3570
- ctx.ui.setWidget(
3571
- "pi-babysit",
3572
- () => createWidgetComponent(summaryLines, displays, expandedWidgetSessions, widgetListState),
3573
- { placement: "belowEditor" },
3574
- );
3623
+ if (!sessionWidget) {
3624
+ ctx.ui.setWidget("pi-babysit", (tui) => {
3625
+ sessionWidget = createSessionWidget({
3626
+ // Keep the viewer bounded while leaving room for editor/status/footer.
3627
+ getHeight: () => Math.max(3, tui.terminal.rows - 8),
3628
+ requestRender: () => tui.requestRender(),
3629
+ getTheme: () => ctx.ui.theme,
3630
+ readLog: (id) => ({ ...readViewerLog(logPath(id)), isAgent: kindOf(id) === "subagent" }),
3631
+ });
3632
+ return sessionWidget;
3633
+ }, { placement: "belowEditor" });
3634
+ unsubscribeWidgetInput = ctx.ui.onTerminalInput((data) =>
3635
+ sessionWidget?.handleKey(data) ? { consume: true } : undefined);
3636
+ }
3637
+ sessionWidget?.update(summaryLines, displays);
3575
3638
  } else {
3576
3639
  const activeIds = new Set(active.map((session) => session.id));
3577
- const component = createWidgetComponent(summaryLines, displays.filter((session) => activeIds.has(session.id)), new Set());
3640
+ const component = createWidgetComponent(summaryLines, displays.filter((session) => activeIds.has(session.id)), () => {});
3578
3641
  ctx.ui.setWidget("pi-babysit", component.render(10_000), {
3579
3642
  placement: "belowEditor",
3580
3643
  });
@@ -3756,6 +3819,9 @@ export default function (pi: ExtensionAPI) {
3756
3819
  });
3757
3820
 
3758
3821
  pi.on("session_shutdown", async (event) => {
3822
+ unsubscribeWidgetInput?.();
3823
+ unsubscribeWidgetInput = undefined;
3824
+ sessionWidget = undefined;
3759
3825
  if (pollTimer) clearInterval(pollTimer);
3760
3826
  pollTimer = undefined;
3761
3827
  // Pi also emits session_shutdown while hot-reloading and switching sessions.
@@ -3901,6 +3967,11 @@ export default function (pi: ExtensionAPI) {
3901
3967
  "Absolute auto-kill after this long (e.g. 30m). Default: none for processes (dev servers may run forever), 15m for subagents. 'none' disables.",
3902
3968
  }),
3903
3969
  ),
3970
+ reapAfter: Type.Optional(
3971
+ Type.String({
3972
+ description: "Subagent only: stop the worker after this long in task-complete IDLE (e.g. 30s, 5m). Defaults to PI_BABYSIT_REAP_AFTER, then PI_SUBAGENT_REAP_AFTER, then 120s. none/off/0 disables; absolute timeout still applies. Follow-up tasks cancel the idle timer.",
3973
+ }),
3974
+ ),
3904
3975
  idleTimeout: Type.Optional(
3905
3976
  Type.String({
3906
3977
  description:
@@ -4221,6 +4292,7 @@ export default function (pi: ExtensionAPI) {
4221
4292
  maxUsageTokens: params.maxUsageTokens,
4222
4293
  }
4223
4294
  : undefined,
4295
+ reapAfter: params.reapAfter,
4224
4296
  timeout: params.timeout ?? "15m",
4225
4297
  idleTimeout: params.idleTimeout,
4226
4298
  });
@@ -5032,9 +5104,10 @@ export default function (pi: ExtensionAPI) {
5032
5104
  // output + a copy-paste `babysit attach` take-over hint; running subagent →
5033
5105
  // read-only progress; finished → summary. Re-run to refresh.
5034
5106
  pi.registerCommand("babysit", {
5035
- description: "Pick a session to inspect, or `/babysit gc [days]` to remove old terminal roots",
5107
+ description: "Pick a session to inspect; /babysit view opens its log viewer; /babysit gc [days] removes old terminal roots",
5036
5108
  handler: async (args, ctx) => {
5037
- const command = args.trim();
5109
+ const view = args.trim() === "view";
5110
+ const command = view ? "" : args.trim();
5038
5111
  if (command === "gc" || command.startsWith("gc ")) {
5039
5112
  const daysText = command.slice(2).trim();
5040
5113
  const days = daysText ? Number(daysText) : 14;
@@ -5069,7 +5142,7 @@ export default function (pi: ExtensionAPI) {
5069
5142
  return;
5070
5143
  }
5071
5144
  if (command) {
5072
- ctx.ui.notify("Usage: /babysit or /babysit gc [days]", "error");
5145
+ ctx.ui.notify("Usage: /babysit, /babysit view, or /babysit gc [days]", "error");
5073
5146
  return;
5074
5147
  }
5075
5148
  if (!(await babysitAvailable())) {
@@ -5106,6 +5179,11 @@ export default function (pi: ExtensionAPI) {
5106
5179
  if (!choice) return;
5107
5180
  const picked = sessions[labels.indexOf(choice)];
5108
5181
  if (!picked) return;
5182
+ if (view && ctx.mode === "tui") {
5183
+ await refreshWidget(ctx);
5184
+ sessionWidget?.open(picked.id);
5185
+ return;
5186
+ }
5109
5187
  const kind = kindOf(picked.id);
5110
5188
  const elapsed = picked.state === "running" ? elapsedOf(picked.id) : null;
5111
5189
  const elapsedSuffix = elapsed ? ` ${elapsed}` : "";
package/log-viewer.ts ADDED
@@ -0,0 +1,346 @@
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
+ export function createLogViewer(options: LogViewerOptions) {
122
+ let header = "",
123
+ raw = "",
124
+ description = "",
125
+ isAgent = false;
126
+ let cache: string[] | undefined,
127
+ cacheWidth = -1,
128
+ cacheTheme: Theme | undefined;
129
+ const body: Component = {
130
+ invalidate() {
131
+ cache = undefined;
132
+ },
133
+ render(width) {
134
+ const theme = options.getTheme();
135
+ if (cache && cacheWidth === width && cacheTheme === theme) return cache;
136
+ const blocks = isAgent
137
+ ? agentBlocks(raw)
138
+ : [{ kind: "result" as const, text: sanitize(raw, true) }];
139
+ const lines: string[] = description
140
+ ? [
141
+ theme.fg("muted", isAgent ? "[task]" : "[command]"),
142
+ ...new Text(sanitize(description), 0, 0).render(width),
143
+ "",
144
+ ]
145
+ : [];
146
+ for (const b of blocks) {
147
+ if (isAgent)
148
+ lines.push(
149
+ theme.fg(
150
+ b.kind === "error"
151
+ ? "error"
152
+ : b.kind === "assistant"
153
+ ? "accent"
154
+ : "toolTitle",
155
+ `[${b.kind}]`,
156
+ ),
157
+ );
158
+ lines.push(
159
+ ...(b.kind === "assistant"
160
+ ? new Markdown(b.text, 0, 0, getMarkdownTheme()).render(width)
161
+ : new Text(b.text, 0, 0).render(width)),
162
+ );
163
+ }
164
+ if (!blocks.some((block) => block.text.trim()))
165
+ lines.push(theme.fg("muted", "(no output available)"));
166
+ cache =
167
+ lines.length > 2000
168
+ ? [
169
+ theme.fg("warning", "[Earlier log lines clipped]"),
170
+ ...lines.slice(-1999),
171
+ ]
172
+ : lines;
173
+ cacheWidth = width;
174
+ cacheTheme = theme;
175
+ return cache;
176
+ },
177
+ };
178
+ const scrollView = new ScrollView(body, {
179
+ follow: "end",
180
+ overscroll: "contain",
181
+ scrollbar: "always",
182
+ scrollbarTrackStyle: (s) => options.getTheme().fg("scrollbarTrack", s),
183
+ scrollbarThumbStyle: (s) => options.getTheme().fg("scrollbarThumb", s),
184
+ });
185
+ const heading = {
186
+ invalidate() {},
187
+ render(width: number) {
188
+ const close = truncateToWidth("[Close]", width);
189
+ const room = Math.max(0, width - visibleWidth(close) - 1);
190
+ const title = truncateToWidth(sanitize(header).replace(/\n/g, " "), room);
191
+ return [
192
+ options.getTheme().fg("accent", title) +
193
+ " ".repeat(
194
+ Math.max(0, width - visibleWidth(title) - visibleWidth(close)),
195
+ ) +
196
+ options.getTheme().fg("muted", close),
197
+ ];
198
+ },
199
+ handleMouse(e: TuiMouseEvent) {
200
+ if (
201
+ e.button !== "left" ||
202
+ e.x < Math.max(0, e.width - 7) ||
203
+ e.x >= e.width
204
+ )
205
+ return;
206
+ if (e.type === "press") {
207
+ options.onClose();
208
+ return { handled: true };
209
+ }
210
+ if (e.type === "click" || e.type === "release") return { handled: true };
211
+ },
212
+ };
213
+ const footer: Component = {
214
+ invalidate() {},
215
+ render: (width) => [
216
+ options.getTheme().fg("dim", truncateToWidth("↑↓ · Esc close", width)),
217
+ ],
218
+ };
219
+ let geometry = {
220
+ width: 0,
221
+ height: 0,
222
+ thumbTop: 0,
223
+ thumbHeight: 0,
224
+ maxTop: 0,
225
+ };
226
+ let dragOffset: number | undefined;
227
+ // Stock widget Containers are opaque to native layout. Draw and route this
228
+ // viewport locally, retaining the public ScrollView's follow/scroll state.
229
+ return {
230
+ scrollView,
231
+ render(width: number) {
232
+ width = Math.max(0, Math.floor(width));
233
+ const height = Math.max(0, Math.floor(options.getHeight()) - 2);
234
+ const contentWidth = Math.max(0, width - 1);
235
+ const lines = body.render(Math.max(1, contentWidth));
236
+ scrollView.updateLayout(lines.length, height, options.requestRender);
237
+ const visible = lines.slice(
238
+ scrollView.scrollTop,
239
+ scrollView.scrollTop + height,
240
+ );
241
+ const thumbHeight =
242
+ height === 0
243
+ ? 0
244
+ : Math.max(
245
+ 1,
246
+ Math.min(
247
+ height,
248
+ Math.round((height * height) / Math.max(1, lines.length)),
249
+ ),
250
+ );
251
+ const maxTop = Math.max(0, lines.length - height);
252
+ const thumbTop =
253
+ maxTop === 0
254
+ ? 0
255
+ : Math.round(
256
+ (scrollView.scrollTop / maxTop) * (height - thumbHeight),
257
+ );
258
+ geometry = { width, height, thumbTop, thumbHeight, maxTop };
259
+ return [
260
+ ...heading.render(width),
261
+ ...Array.from({ length: height }, (_, row) => {
262
+ if (width === 0) return "";
263
+ const text = truncateToWidth(visible[row] ?? "", contentWidth);
264
+ const thumb = row >= thumbTop && row < thumbTop + thumbHeight;
265
+ return (
266
+ text +
267
+ " ".repeat(Math.max(0, contentWidth - visibleWidth(text))) +
268
+ options
269
+ .getTheme()
270
+ .fg(
271
+ thumb ? "scrollbarThumb" : "scrollbarTrack",
272
+ thumb ? (dragOffset === undefined ? "┃" : "█") : "│",
273
+ )
274
+ );
275
+ }),
276
+ ...footer.render(width),
277
+ ];
278
+ },
279
+ handleMouse(e: TuiMouseEvent) {
280
+ if (dragOffset !== undefined) {
281
+ if (e.type === "release") {
282
+ dragOffset = undefined;
283
+ return { handled: true, render: true };
284
+ }
285
+ if (e.type === "drag" || e.type === "move") {
286
+ const travel = geometry.height - geometry.thumbHeight;
287
+ const offset = Math.max(0, Math.min(travel, e.y - 1 - dragOffset));
288
+ scrollView.scrollTo(
289
+ travel > 0
290
+ ? Math.round((offset / travel) * geometry.maxTop)
291
+ : scrollView.scrollTop,
292
+ );
293
+ return { handled: true, render: true };
294
+ }
295
+ }
296
+ if (e.y === 0) return heading.handleMouse(e);
297
+ if (e.y < 1 || e.y > geometry.height || e.x < 0 || e.x >= geometry.width)
298
+ return;
299
+ if (e.type === "wheel") {
300
+ scrollView.scrollBy(e.wheelDelta ?? 0);
301
+ return { handled: true, render: true };
302
+ }
303
+ if (e.x === geometry.width - 1 && e.button === "left") {
304
+ if (e.type === "press") {
305
+ const row = e.y - 1;
306
+ if (
307
+ row >= geometry.thumbTop &&
308
+ row < geometry.thumbTop + geometry.thumbHeight
309
+ ) {
310
+ dragOffset = row - geometry.thumbTop;
311
+ return { handled: true, capture: true, render: true };
312
+ }
313
+ scrollView.scrollBy(
314
+ row < geometry.thumbTop ? -geometry.height : geometry.height,
315
+ );
316
+ }
317
+ if (["press", "click", "release"].includes(e.type))
318
+ return { handled: true, render: true };
319
+ }
320
+ },
321
+ invalidate() {
322
+ body.invalidate();
323
+ },
324
+ update(nextHeader: string, logText: string, agent: boolean, context = "") {
325
+ header = nextHeader;
326
+ if (raw !== logText || isAgent !== agent || description !== context) {
327
+ raw = logText;
328
+ isAgent = agent;
329
+ description = context;
330
+ body.invalidate();
331
+ }
332
+ options.requestRender();
333
+ },
334
+ handleKey(data: string): boolean {
335
+ if (matchesKey(data, Key.escape)) {
336
+ options.onClose();
337
+ return true;
338
+ }
339
+ if (matchesKey(data, Key.up)) scrollView.scrollBy(-1);
340
+ else if (matchesKey(data, Key.down)) scrollView.scrollBy(1);
341
+ else return false;
342
+ options.requestRender();
343
+ return true;
344
+ },
345
+ };
346
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yusukeshib/pi-babysit",
3
- "version": "0.6.13",
3
+ "version": "0.6.15",
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
  }