@yusukeshib/pi-babysit 0.6.5 → 0.6.7

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 (3) hide show
  1. package/README.md +8 -2
  2. package/index.ts +151 -66
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -80,8 +80,14 @@ backgrounding (`… &`, `nohup`, `setsid`, `disown`). Set
80
80
 
81
81
  A widget below the editor separates session **kind** from task **state** at a glance:
82
82
  summary counts use `RUNNING` / `IDLE`, and every row is labeled
83
- `PROCESS RUNNING`, `AGENT RUNNING`, or `AGENT IDLE`. The displayed `age`
84
- is total session age, not time spent in the current state. Transcript details
83
+ `PROCESS RUNNING`, `AGENT RUNNING`, or `AGENT IDLE`. In fullscreen mode, the
84
+ `SESSIONS(n) < 1/4 >` list combines running, idle, finished, and failed sessions
85
+ (newest first, five per page); the fraction shows the current/total page. Click the arrows to page or anywhere else on the header to collapse
86
+ and expand the list. Click a row to toggle between its latest line and latest 20
87
+ available output lines. A session with no available output shows an explicit
88
+ placeholder when expanded. Mouse clicks require fullscreen mode; `/babysit`
89
+ works in regular mode. The displayed `age` is total session age, not time spent
90
+ in the current state. Transcript details
85
91
  from tool results, snapshots, and completion notifications collapse or expand
86
92
  with Pi's tool-output toggle (`Ctrl+O` by default).
87
93
 
package/index.ts CHANGED
@@ -43,7 +43,7 @@ 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 } from "@earendil-works/pi-tui";
46
+ import { Box, Markdown, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
47
47
  import { Type, type TSchema } from "typebox";
48
48
  import { StringEnum } from "@earendil-works/pi-ai";
49
49
  import { type AgentConfig, type AgentScope, discoverAgents } from "./agents";
@@ -1229,16 +1229,18 @@ async function selectedProcessOutput(
1229
1229
  return tail ? `\n\nSelected tail (${lines} lines max):\n${clip(tail, maxBytes)}` : "";
1230
1230
  }
1231
1231
 
1232
+ function escapeCommandForDisplay(command: string): string {
1233
+ return command
1234
+ .replace(/\r/g, "\\r")
1235
+ .replace(/\n/g, "\\n")
1236
+ .replace(/\t/g, "\\t")
1237
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g, (char) =>
1238
+ `\\x${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`,
1239
+ );
1240
+ }
1241
+
1232
1242
  export function summarizeNotificationCommand(command: string | undefined): string {
1233
- const preview =
1234
- (command ?? "?")
1235
- .trim()
1236
- .replace(/\r/g, "\\r")
1237
- .replace(/\n/g, "\\n")
1238
- .replace(/\t/g, "\\t")
1239
- .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g, (char) =>
1240
- `\\x${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`,
1241
- ) || "?";
1243
+ const preview = escapeCommandForDisplay((command ?? "?").trim()) || "?";
1242
1244
  const bytes = Buffer.from(preview, "utf8");
1243
1245
  if (bytes.length <= NOTIFY_COMMAND_MAX_BYTES) return preview;
1244
1246
  if (NOTIFY_COMMAND_MAX_BYTES === 0) return "";
@@ -2287,14 +2289,23 @@ async function spawnSubagent(
2287
2289
  }
2288
2290
 
2289
2291
  // ---------------------------------------------------------------------------
2290
- // widget (live counts)
2292
+ // widget (live counts and browsable sessions)
2291
2293
  // ---------------------------------------------------------------------------
2292
2294
 
2295
+ export function widgetSessionGroups(sessions: BsSession[], startedAt: (id: string) => number) {
2296
+ const active = sessions.filter((session) => session.state === "running");
2297
+ const started = new Map(sessions.map((session) => [session.id, startedAt(session.id)]));
2298
+ const newestFirst = (a: BsSession, b: BsSession) =>
2299
+ (started.get(b.id) ?? 0) - (started.get(a.id) ?? 0) || b.id.localeCompare(a.id);
2300
+ const finished = sessions.filter((session) => session.state !== "running").sort(newestFirst);
2301
+ return { active, finished, all: [...sessions].sort(newestFirst) };
2302
+ }
2303
+
2293
2304
  // A subagent whose task is done stays alive as an idle RPC worker (for
2294
2305
  // follow-ups), so "running" in babysit does NOT mean "working" — count
2295
2306
  // processes / busy subagents / idle subagents separately.
2296
2307
  export type WidgetSessionKind = "process" | "agent";
2297
- export type WidgetSessionState = "running" | "idle";
2308
+ export type WidgetSessionState = "running" | "idle" | "finished" | "failed";
2298
2309
 
2299
2310
  export function widgetSummaryText(procs: number, busy: number, idle: number): string {
2300
2311
  const sections: string[] = [];
@@ -2312,7 +2323,7 @@ export function widgetSessionHeader(
2312
2323
  state: WidgetSessionState,
2313
2324
  elapsed?: string,
2314
2325
  ): string {
2315
- const icon = state === "running" ? "▶" : "○";
2326
+ const icon = state === "running" ? "▶" : state === "failed" ? "✗" : "○";
2316
2327
  return ` ${icon} ${id} ${kind.toUpperCase()} ${state.toUpperCase()}${elapsed ? ` age ${elapsed}` : ""}`;
2317
2328
  }
2318
2329
 
@@ -2338,17 +2349,23 @@ function renderWidgetSessionHeader(
2338
2349
  elapsed: string,
2339
2350
  theme: Theme,
2340
2351
  ): string {
2341
- const running = state === "running";
2342
- const icon = theme.fg(running ? "success" : "muted", running ? "▶" : "○");
2352
+ const color = state === "running" ? "success" : state === "failed" ? "error" : "muted";
2353
+ const icon = theme.fg(color, state === "running" ? "▶" : state === "failed" ? "✗" : "○");
2343
2354
  const kindLabel = theme.fg(kind === "process" ? "accent" : "warning", theme.bold(kind.toUpperCase()));
2344
- const stateLabel = theme.fg(running ? "success" : "muted", theme.bold(state.toUpperCase()));
2355
+ const stateLabel = theme.fg(color, theme.bold(state.toUpperCase()));
2345
2356
  return ` ${icon} ${id} ${kindLabel} ${stateLabel}${elapsed ? theme.fg("dim", ` age ${elapsed}`) : ""}`;
2346
2357
  }
2347
2358
 
2348
- // How many trailing output lines to show per running session in the widget.
2359
+ // How many trailing output lines to show per session in the widget.
2349
2360
  const WIDGET_COLLAPSED_TAIL_LINES = 1;
2350
2361
  const WIDGET_EXPANDED_TAIL_LINES = 20;
2351
2362
  const WIDGET_TAIL_WIDTH = 100;
2363
+ const WIDGET_SESSION_PAGE_SIZE = 5;
2364
+
2365
+ export interface WidgetSessionListState {
2366
+ open: boolean;
2367
+ page: number;
2368
+ }
2352
2369
 
2353
2370
  export interface WidgetSessionDisplay {
2354
2371
  id: string;
@@ -2361,6 +2378,7 @@ interface WidgetMouseEvent {
2361
2378
  type: string;
2362
2379
  button: string;
2363
2380
  y: number;
2381
+ x?: number;
2364
2382
  }
2365
2383
 
2366
2384
  /**
@@ -2371,45 +2389,96 @@ interface WidgetMouseEvent {
2371
2389
  export function createWidgetComponent(
2372
2390
  summaryLines: string[],
2373
2391
  sessions: WidgetSessionDisplay[],
2374
- expandedProcessIds: Set<string>,
2392
+ expandedSessionIds: Set<string>,
2393
+ listState?: WidgetSessionListState,
2375
2394
  ) {
2376
- let processIdByLine: Array<string | undefined> = [];
2395
+ type Target = { type: "session"; id: string } | { type: "sessions"; endX: number; prevX?: number; nextX?: number };
2396
+ let targetByLine: Array<Target | undefined> = [];
2377
2397
 
2378
2398
  return {
2379
2399
  render(width: number): string[] {
2380
2400
  const lines: string[] = [];
2381
- processIdByLine = [];
2382
- const push = (text: string, processId?: string) => {
2401
+ targetByLine = [];
2402
+ const push = (text: string, target?: Target) => {
2383
2403
  lines.push(truncateToWidth(text, Math.max(0, width), ""));
2384
- processIdByLine.push(processId);
2404
+ targetByLine.push(target);
2385
2405
  };
2386
-
2387
- for (const line of summaryLines) push(line);
2388
- for (const session of sessions) {
2389
- const expanded = session.expandable && expandedProcessIds.has(session.id);
2406
+ const pushSession = (session: WidgetSessionDisplay) => {
2407
+ const expanded = session.expandable && expandedSessionIds.has(session.id);
2390
2408
  const tail = session.tail.slice(
2391
2409
  expanded ? -WIDGET_EXPANDED_TAIL_LINES : -WIDGET_COLLAPSED_TAIL_LINES,
2392
2410
  );
2393
- const processId = session.expandable ? session.id : undefined;
2411
+ const target = session.expandable ? { type: "session" as const, id: session.id } : undefined;
2394
2412
  if (!expanded && tail.length === 1) {
2395
- push(`${session.header} ${tail[0]}`, processId);
2413
+ push(`${session.header} ${tail[0]}`, target);
2396
2414
  } else {
2397
- push(session.header, processId);
2398
- for (const line of tail) push(` ${line}`, processId);
2415
+ push(session.header, target);
2416
+ for (const line of tail) push(` ${line}`, target);
2417
+ if (expanded && tail.length === 0) push(" (no output available)", target);
2399
2418
  }
2419
+ };
2420
+
2421
+ for (const line of summaryLines.slice(0, -1)) push(line);
2422
+ const summary = summaryLines.at(-1);
2423
+ if (listState && sessions.length) {
2424
+ const pageCount = Math.ceil(sessions.length / WIDGET_SESSION_PAGE_SIZE);
2425
+ listState.page = Math.min(Math.max(0, listState.page), pageCount - 1);
2426
+ const label = ` SESSIONS(${sessions.length})`;
2427
+ const showArrows = listState.open && pageCount > 1;
2428
+ const suffix = `${label}${showArrows ? ` < ${listState.page + 1}/${pageCount} >` : ""}`;
2429
+ // Keep the count clickable on narrow terminals by moving it to its own row.
2430
+ const inline = summary !== undefined && visibleWidth(summary) + visibleWidth(suffix) <= width;
2431
+ if (summary !== undefined && !inline) {
2432
+ push(summary, { type: "sessions", endX: Math.min(visibleWidth(summary), Math.max(0, width)) });
2433
+ }
2434
+ const prefix = inline ? summary ?? "" : "";
2435
+ const prefixWidth = visibleWidth(prefix);
2436
+ push(`${prefix}${suffix}`, {
2437
+ type: "sessions",
2438
+ endX: Math.min(prefixWidth + visibleWidth(suffix), Math.max(0, width)),
2439
+ prevX: showArrows ? prefixWidth + suffix.indexOf("<") : undefined,
2440
+ nextX: showArrows ? prefixWidth + suffix.indexOf(">") : undefined,
2441
+ });
2442
+ if (listState.open) {
2443
+ const start = listState.page * WIDGET_SESSION_PAGE_SIZE;
2444
+ for (const session of sessions.slice(start, start + WIDGET_SESSION_PAGE_SIZE)) {
2445
+ pushSession(session);
2446
+ }
2447
+ }
2448
+ } else {
2449
+ if (summary !== undefined) push(summary);
2450
+ for (const session of sessions) pushSession(session);
2400
2451
  }
2401
2452
  return lines;
2402
2453
  },
2403
2454
  handleMouse(event: WidgetMouseEvent) {
2404
- if (event.type !== "click" || event.button !== "left") return undefined;
2405
- const id = processIdByLine[event.y];
2406
- if (!id) return undefined;
2407
- if (expandedProcessIds.has(id)) expandedProcessIds.delete(id);
2408
- else expandedProcessIds.add(id);
2409
- return { handled: true, render: true };
2455
+ if (event.button !== "left") return undefined;
2456
+ const target = targetByLine[event.y];
2457
+ if (!target) return undefined;
2458
+ if (target.type === "sessions" && (event.x === undefined || event.x < 0 || event.x >= target.endX)) return undefined;
2459
+ // Toggle on press so a terminal that omits release still works.
2460
+ if (event.type === "press") {
2461
+ if (target.type === "session") {
2462
+ if (expandedSessionIds.has(target.id)) expandedSessionIds.delete(target.id);
2463
+ else expandedSessionIds.add(target.id);
2464
+ } else if (listState) {
2465
+ const pageCount = Math.ceil(sessions.length / WIDGET_SESSION_PAGE_SIZE);
2466
+ if (event.x === target.prevX && target.prevX !== undefined) {
2467
+ listState.page = (listState.page - 1 + pageCount) % pageCount;
2468
+ } else if (event.x === target.nextX && target.nextX !== undefined) {
2469
+ listState.page = (listState.page + 1) % pageCount;
2470
+ } else {
2471
+ listState.open = !listState.open;
2472
+ }
2473
+ }
2474
+ return { handled: true, render: true };
2475
+ }
2476
+ // Consume Pi's synthesized click without toggling a second time.
2477
+ if (event.type === "click") return { handled: true, render: false };
2478
+ return undefined;
2410
2479
  },
2411
2480
  invalidate() {
2412
- processIdByLine = [];
2481
+ targetByLine = [];
2413
2482
  },
2414
2483
  };
2415
2484
  }
@@ -2454,21 +2523,23 @@ function readTailLines(file: string, lines: number, maxBytes = 64_000): string[]
2454
2523
  }
2455
2524
  }
2456
2525
 
2457
- // Trailing lines to show for a running session (sanitized, unprefixed).
2526
+ // Trailing lines to show for a session (sanitized, unprefixed).
2458
2527
  // Process tails are read directly from the bounded end of output.log, avoiding
2459
- // one `babysit log` subprocess per active process on every poll.
2460
- async function widgetTail(
2528
+ // one `babysit log` subprocess per visible process.
2529
+ export function widgetTail(
2461
2530
  id: string,
2462
2531
  isSub: boolean,
2463
2532
  subagentProgress?: Progress,
2464
2533
  lines = WIDGET_COLLAPSED_TAIL_LINES,
2465
- ): Promise<string[]> {
2534
+ ): string[] {
2466
2535
  let raw: string[];
2467
2536
  if (!isSub) {
2468
2537
  raw = readTailLines(logPath(id), lines);
2469
2538
  } else {
2470
2539
  const progress = subagentProgress ?? taskProgressOf(id).progress;
2471
- if (progress.finalText.trim()) {
2540
+ if (progress.streamingText.trim()) {
2541
+ raw = progress.streamingText.trim().split("\n");
2542
+ } else if (progress.finalText.trim()) {
2472
2543
  raw = progress.finalText.trim().split("\n");
2473
2544
  } else if (progress.toolCalls.length > 0) {
2474
2545
  raw = progress.toolCalls.map((tool) => tool.summary);
@@ -3365,13 +3436,13 @@ export default function (pi: ExtensionAPI) {
3365
3436
  }
3366
3437
  }
3367
3438
 
3368
- const expandedWidgetProcesses = new Set<string>();
3439
+ const expandedWidgetSessions = new Set<string>();
3440
+ const widgetListState: WidgetSessionListState = { open: true, page: 0 };
3369
3441
 
3370
3442
  const refreshWidget = async (ctx: ExtensionContext, snapshot?: BsSession[]) => {
3371
3443
  if (!ctx.hasUI) return;
3372
- const active = (snapshot ?? (await listSessions()).sessions).filter(
3373
- (session) => session.state === "running",
3374
- );
3444
+ const all = snapshot ?? (await listSessions()).sessions;
3445
+ const { active, all: ordered } = widgetSessionGroups(all, (id) => readMeta(id)?.startedAt ?? 0);
3375
3446
  const subs = active.filter((session) => kindOf(session.id) === "subagent");
3376
3447
  const procs = active.length - subs.length;
3377
3448
  const progressById = new Map<string, Progress>();
@@ -3383,11 +3454,9 @@ export default function (pi: ExtensionAPI) {
3383
3454
  }
3384
3455
  }
3385
3456
  const idle = subs.filter((session) => progressById.get(session.id)?.done).length;
3386
- const activeProcessIds = new Set(
3387
- active.filter((session) => kindOf(session.id) !== "subagent").map((session) => session.id),
3388
- );
3389
- for (const id of expandedWidgetProcesses) {
3390
- if (!activeProcessIds.has(id)) expandedWidgetProcesses.delete(id);
3457
+ const sessionIds = new Set(all.map((session) => session.id));
3458
+ for (const id of expandedWidgetSessions) {
3459
+ if (!sessionIds.has(id)) expandedWidgetSessions.delete(id);
3391
3460
  }
3392
3461
 
3393
3462
  const theme = ctx.ui.theme;
@@ -3399,37 +3468,53 @@ export default function (pi: ExtensionAPI) {
3399
3468
  session.id,
3400
3469
  isSubagent,
3401
3470
  isSubagent ? progressById.get(session.id) : undefined,
3402
- isSubagent ? WIDGET_COLLAPSED_TAIL_LINES : WIDGET_EXPANDED_TAIL_LINES,
3471
+ WIDGET_EXPANDED_TAIL_LINES,
3403
3472
  );
3404
3473
  }),
3405
3474
  );
3406
- const displays = active.map((session, index): WidgetSessionDisplay => {
3407
- const isSubagent = kindOf(session.id) === "subagent";
3408
- const state: WidgetSessionState = isSubagent && progressById.get(session.id)?.done
3409
- ? "idle"
3410
- : "running";
3475
+ const activeTails = new Map(active.map((session, index) => [session.id, tails[index]]));
3476
+ const displays = ordered.map((session): WidgetSessionDisplay => {
3477
+ const id = session.id;
3478
+ const isSubagent = kindOf(id) === "subagent";
3479
+ if (session.state === "running") {
3480
+ const state: WidgetSessionState = isSubagent && progressById.get(id)?.done ? "idle" : "running";
3481
+ return {
3482
+ id,
3483
+ header: renderWidgetSessionHeader(id, isSubagent ? "agent" : "process", state, elapsedOf(id), theme),
3484
+ tail: activeTails.get(id) ?? [],
3485
+ expandable: true,
3486
+ };
3487
+ }
3411
3488
  return {
3412
- id: session.id,
3489
+ id,
3413
3490
  header: renderWidgetSessionHeader(
3414
- session.id,
3491
+ id,
3415
3492
  isSubagent ? "agent" : "process",
3416
- state,
3417
- elapsedOf(session.id),
3493
+ session.state === "exited" && session.exit_code === 0 ? "finished" : "failed",
3494
+ "",
3418
3495
  theme,
3419
3496
  ),
3420
- tail: tails[index],
3421
- expandable: !isSubagent,
3497
+ // Only read/parse the five visible sessions on demand.
3498
+ get tail() {
3499
+ try {
3500
+ return widgetTail(id, isSubagent, undefined, WIDGET_EXPANDED_TAIL_LINES);
3501
+ } catch {
3502
+ return [];
3503
+ }
3504
+ },
3505
+ expandable: true,
3422
3506
  };
3423
3507
  });
3424
3508
 
3425
3509
  if (ctx.mode === "tui") {
3426
3510
  ctx.ui.setWidget(
3427
3511
  "pi-babysit",
3428
- () => createWidgetComponent(summaryLines, displays, expandedWidgetProcesses),
3512
+ () => createWidgetComponent(summaryLines, displays, expandedWidgetSessions, widgetListState),
3429
3513
  { placement: "belowEditor" },
3430
3514
  );
3431
3515
  } else {
3432
- const component = createWidgetComponent(summaryLines, displays, new Set());
3516
+ const activeIds = new Set(active.map((session) => session.id));
3517
+ const component = createWidgetComponent(summaryLines, displays.filter((session) => activeIds.has(session.id)), new Set());
3433
3518
  ctx.ui.setWidget("pi-babysit", component.render(10_000), {
3434
3519
  placement: "belowEditor",
3435
3520
  });
@@ -4147,7 +4232,7 @@ export default function (pi: ExtensionAPI) {
4147
4232
  const title = theme.bold(`babysit_run ${kind}`);
4148
4233
  const detail =
4149
4234
  typeof payload === "string" && payload.length > 0
4150
- ? ` ${summarizeNotificationCommand(payload)}`
4235
+ ? ` ${escapeCommandForDisplay(payload)}`
4151
4236
  : "";
4152
4237
  return new Text(theme.fg("warning", `${title}${agent}${detail}`), 0, 0);
4153
4238
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yusukeshib/pi-babysit",
3
- "version": "0.6.5",
3
+ "version": "0.6.7",
4
4
  "description": "Run any shell command and pi subagents under babysit, with context-safe captured output.",
5
5
  "keywords": [
6
6
  "pi-package",