@yusukeshib/pi-babysit 0.6.6 → 0.6.8

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 +13 -7
  2. package/index.ts +128 -77
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -80,13 +80,19 @@ 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`. In fullscreen mode, click
84
- any live row to toggle between its latest line and latest 20 available output
85
- lines. Finished sessions are behind a clickable `FINISHED` row; browse all
86
- retained sessions five at a time and click a row to expand it, including failed
87
- processes and agents. 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
83
+ `PROCESS RUNNING`, `AGENT RUNNING`, or `AGENT IDLE`. In fullscreen mode, the
84
+ `babysits(n running)` list shows live sessions (including idle agents) by default.
85
+ Click the header to toggle to `babysits(n all)`, which also shows finished and failed
86
+ sessions, and click again to return to running-only. Both modes show newest first,
87
+ ten per page; `< 1/4 >` shows the current/total page when needed. Click the
88
+ arrows to page. Collapsed rows occupy one line: processes show the command,
89
+ while agents show a task preview. Click an agent row for the full wrapped task,
90
+ its RPC worker launch argv (bold, readable text rather than a copy-paste shell
91
+ command), and up to 20 output lines. Opening another row closes the previous
92
+ one. A session with no available output shows an explicit placeholder when
93
+ expanded. Mouse clicks
94
+ require fullscreen mode; `/babysit` works in regular mode. The displayed `age`
95
+ is total session age, not time spent
90
96
  in the current state. Transcript details
91
97
  from tool results, snapshots, and completion notifications collapse or expand
92
98
  with Pi's tool-output toggle (`Ctrl+O` by default).
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, wrapTextWithAnsi } 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";
@@ -463,6 +463,7 @@ interface Meta {
463
463
  startedAt?: number;
464
464
  // subagent
465
465
  task?: string;
466
+ launchCommand?: string;
466
467
  promptOffset?: number;
467
468
  model?: string;
468
469
  tools?: string[];
@@ -1229,16 +1230,24 @@ async function selectedProcessOutput(
1229
1230
  return tail ? `\n\nSelected tail (${lines} lines max):\n${clip(tail, maxBytes)}` : "";
1230
1231
  }
1231
1232
 
1233
+ function escapeCommandForDisplay(command: string): string {
1234
+ return command
1235
+ .replace(/\r/g, "\\r")
1236
+ .replace(/\n/g, "\\n")
1237
+ .replace(/\t/g, "\\t")
1238
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g, (char) =>
1239
+ `\\x${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`,
1240
+ );
1241
+ }
1242
+
1243
+ export function readableLaunchCommand(command: string): string {
1244
+ // Older sessions quoted every argv element, including safe paths and flags.
1245
+ if (!command.startsWith("'") || !command.endsWith("'") || !command.includes("' '")) return command;
1246
+ return command.slice(1, -1).split("' '").map((arg) => arg.replace(/'\\''/g, "'")).join(" ");
1247
+ }
1248
+
1232
1249
  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
- ) || "?";
1250
+ const preview = escapeCommandForDisplay((command ?? "?").trim()) || "?";
1242
1251
  const bytes = Buffer.from(preview, "utf8");
1243
1252
  if (bytes.length <= NOTIFY_COMMAND_MAX_BYTES) return preview;
1244
1253
  if (NOTIFY_COMMAND_MAX_BYTES === 0) return "";
@@ -2152,6 +2161,7 @@ async function spawnSubagent(
2152
2161
  PI_BIN,
2153
2162
  ...piArgs,
2154
2163
  );
2164
+ const launchCommand = bsArgs.slice(bsArgs.indexOf("--") + 1).join(" ");
2155
2165
 
2156
2166
  let r: Awaited<ReturnType<typeof bs>>;
2157
2167
  try {
@@ -2185,6 +2195,7 @@ async function spawnSubagent(
2185
2195
  kind: "subagent",
2186
2196
  name: opts.name ?? id,
2187
2197
  task: opts.task,
2198
+ launchCommand,
2188
2199
  notified: true,
2189
2200
  depth: opts.depth,
2190
2201
  maxDepth: opts.maxDepth,
@@ -2272,6 +2283,7 @@ async function spawnSubagent(
2272
2283
  kind: "subagent",
2273
2284
  name: opts.name ?? id,
2274
2285
  task: opts.task,
2286
+ launchCommand,
2275
2287
  promptOffset: resp.offset,
2276
2288
  model: resolvedModel,
2277
2289
  tools,
@@ -2287,16 +2299,16 @@ async function spawnSubagent(
2287
2299
  }
2288
2300
 
2289
2301
  // ---------------------------------------------------------------------------
2290
- // widget (live counts and browsable finished sessions)
2302
+ // widget (live counts and browsable sessions)
2291
2303
  // ---------------------------------------------------------------------------
2292
2304
 
2293
2305
  export function widgetSessionGroups(sessions: BsSession[], startedAt: (id: string) => number) {
2294
2306
  const active = sessions.filter((session) => session.state === "running");
2295
- // Default view stays compact; the finished section pages over every retained session.
2296
- const finished = sessions.filter((session) => session.state !== "running");
2297
- const started = new Map(finished.map((session) => [session.id, startedAt(session.id)]));
2298
- finished.sort((a, b) => (started.get(b.id) ?? 0) - (started.get(a.id) ?? 0) || b.id.localeCompare(a.id));
2299
- return { active, finished };
2307
+ const started = new Map(sessions.map((session) => [session.id, startedAt(session.id)]));
2308
+ const newestFirst = (a: BsSession, b: BsSession) =>
2309
+ (started.get(b.id) ?? 0) - (started.get(a.id) ?? 0) || b.id.localeCompare(a.id);
2310
+ const finished = sessions.filter((session) => session.state !== "running").sort(newestFirst);
2311
+ return { active, finished, all: [...sessions].sort(newestFirst) };
2300
2312
  }
2301
2313
 
2302
2314
  // A subagent whose task is done stays alive as an idle RPC worker (for
@@ -2358,10 +2370,10 @@ function renderWidgetSessionHeader(
2358
2370
  const WIDGET_COLLAPSED_TAIL_LINES = 1;
2359
2371
  const WIDGET_EXPANDED_TAIL_LINES = 20;
2360
2372
  const WIDGET_TAIL_WIDTH = 100;
2361
- const WIDGET_FINISHED_PAGE_SIZE = 5;
2373
+ const WIDGET_SESSION_PAGE_SIZE = 10;
2362
2374
 
2363
- export interface WidgetFinishedState {
2364
- open: boolean;
2375
+ export interface WidgetSessionListState {
2376
+ mode: "running" | "all";
2365
2377
  page: number;
2366
2378
  }
2367
2379
 
@@ -2370,12 +2382,15 @@ export interface WidgetSessionDisplay {
2370
2382
  header: string;
2371
2383
  tail: string[];
2372
2384
  expandable: boolean;
2385
+ active?: boolean;
2386
+ details?: string[];
2373
2387
  }
2374
2388
 
2375
2389
  interface WidgetMouseEvent {
2376
2390
  type: string;
2377
2391
  button: string;
2378
2392
  y: number;
2393
+ x?: number;
2379
2394
  }
2380
2395
 
2381
2396
  /**
@@ -2387,17 +2402,17 @@ export function createWidgetComponent(
2387
2402
  summaryLines: string[],
2388
2403
  sessions: WidgetSessionDisplay[],
2389
2404
  expandedSessionIds: Set<string>,
2390
- finished?: { sessions: WidgetSessionDisplay[]; state: WidgetFinishedState },
2405
+ listState?: WidgetSessionListState,
2391
2406
  ) {
2392
- type Target = { type: "session"; id: string } | { type: "finished" | "prev" | "next" };
2407
+ type Target = { type: "session"; id: string } | { type: "sessions"; endX: number; prevX?: number; nextX?: number };
2393
2408
  let targetByLine: Array<Target | undefined> = [];
2394
2409
 
2395
2410
  return {
2396
2411
  render(width: number): string[] {
2397
2412
  const lines: string[] = [];
2398
2413
  targetByLine = [];
2399
- const push = (text: string, target?: Target) => {
2400
- lines.push(truncateToWidth(text, Math.max(0, width), ""));
2414
+ const push = (text: string, target?: Target, ellipsis = "") => {
2415
+ lines.push(truncateToWidth(text, Math.max(0, width), ellipsis));
2401
2416
  targetByLine.push(target);
2402
2417
  };
2403
2418
  const pushSession = (session: WidgetSessionDisplay) => {
@@ -2406,50 +2421,86 @@ export function createWidgetComponent(
2406
2421
  expanded ? -WIDGET_EXPANDED_TAIL_LINES : -WIDGET_COLLAPSED_TAIL_LINES,
2407
2422
  );
2408
2423
  const target = session.expandable ? { type: "session" as const, id: session.id } : undefined;
2409
- if (!expanded && tail.length === 1) {
2424
+ const details = session.details ?? [];
2425
+ const pushDetail = (detail: string) => {
2426
+ const text = detail.startsWith("task: ") ? detail.slice(6) : detail;
2427
+ if (width > 6) {
2428
+ for (const line of wrapTextWithAnsi(text, width - 6)) push(` ${line}`, target);
2429
+ } else {
2430
+ push(` ${text}`, target);
2431
+ }
2432
+ };
2433
+ if (!expanded && details[0]?.startsWith("task: ")) {
2434
+ push(`${session.header} ${details[0].slice(6)}`, target, "…");
2435
+ } else if (!expanded && details.length) {
2436
+ push(`${session.header} ${details[0]}`, target);
2437
+ for (const detail of details.slice(1)) pushDetail(detail);
2438
+ } else if (!expanded && tail.length === 1) {
2410
2439
  push(`${session.header} ${tail[0]}`, target);
2411
2440
  } else {
2412
2441
  push(session.header, target);
2442
+ for (const detail of details) pushDetail(detail);
2413
2443
  for (const line of tail) push(` ${line}`, target);
2414
2444
  if (expanded && tail.length === 0) push(" (no output available)", target);
2415
2445
  }
2416
2446
  };
2417
2447
 
2418
- for (const line of summaryLines) push(line);
2419
- if (finished?.sessions.length) {
2420
- const pageCount = Math.ceil(finished.sessions.length / WIDGET_FINISHED_PAGE_SIZE);
2421
- finished.state.page = Math.min(Math.max(0, finished.state.page), pageCount - 1);
2422
- push(
2423
- ` FINISHED ${finished.sessions.length} — ${finished.state.open ? "hide" : "browse"} (click)`,
2424
- { type: "finished" },
2425
- );
2426
- if (finished.state.open) {
2427
- if (pageCount > 1) {
2428
- if (finished.state.page > 0) push(" ← Previous", { type: "prev" });
2429
- push(` ${finished.state.page === pageCount - 1 ? "↻ First" : "Next →"} (${finished.state.page + 1}/${pageCount})`, { type: "next" });
2430
- }
2431
- const start = finished.state.page * WIDGET_FINISHED_PAGE_SIZE;
2432
- for (const session of finished.sessions.slice(start, start + WIDGET_FINISHED_PAGE_SIZE)) {
2433
- pushSession(session);
2434
- }
2448
+ for (const line of summaryLines.slice(0, -1)) push(line);
2449
+ const summary = summaryLines.at(-1);
2450
+ if (listState && sessions.length) {
2451
+ const visible = listState.mode === "running" ? sessions.filter((session) => session.active) : sessions;
2452
+ const pageCount = Math.max(1, Math.ceil(visible.length / WIDGET_SESSION_PAGE_SIZE));
2453
+ listState.page = Math.min(Math.max(0, listState.page), pageCount - 1);
2454
+ const label = ` babysits(${visible.length} ${listState.mode})`;
2455
+ const showArrows = pageCount > 1;
2456
+ const suffix = `${label}${showArrows ? ` < ${listState.page + 1}/${pageCount} >` : ""}`;
2457
+ // Keep the count clickable on narrow terminals by moving it to its own row.
2458
+ const inline = summary !== undefined && visibleWidth(summary) + visibleWidth(suffix) <= width;
2459
+ if (summary !== undefined && !inline) {
2460
+ push(summary, { type: "sessions", endX: Math.min(visibleWidth(summary), Math.max(0, width)) });
2461
+ }
2462
+ const prefix = inline ? summary ?? "" : "";
2463
+ const prefixWidth = visibleWidth(prefix);
2464
+ push(`${prefix}${suffix}`, {
2465
+ type: "sessions",
2466
+ endX: Math.min(prefixWidth + visibleWidth(suffix), Math.max(0, width)),
2467
+ prevX: showArrows ? prefixWidth + suffix.indexOf("<") : undefined,
2468
+ nextX: showArrows ? prefixWidth + suffix.indexOf(">") : undefined,
2469
+ });
2470
+ const start = listState.page * WIDGET_SESSION_PAGE_SIZE;
2471
+ for (const session of visible.slice(start, start + WIDGET_SESSION_PAGE_SIZE)) {
2472
+ pushSession(session);
2435
2473
  }
2474
+ } else {
2475
+ if (summary !== undefined) push(summary);
2476
+ for (const session of sessions) pushSession(session);
2436
2477
  }
2437
- for (const session of sessions) pushSession(session);
2438
2478
  return lines;
2439
2479
  },
2440
2480
  handleMouse(event: WidgetMouseEvent) {
2441
2481
  if (event.button !== "left") return undefined;
2442
2482
  const target = targetByLine[event.y];
2443
2483
  if (!target) return undefined;
2484
+ if (target.type === "sessions" && (event.x === undefined || event.x < 0 || event.x >= target.endX)) return undefined;
2444
2485
  // Toggle on press so a terminal that omits release still works.
2445
2486
  if (event.type === "press") {
2446
2487
  if (target.type === "session") {
2447
- if (expandedSessionIds.has(target.id)) expandedSessionIds.delete(target.id);
2448
- else expandedSessionIds.add(target.id);
2449
- } else if (finished) {
2450
- if (target.type === "finished") finished.state.open = !finished.state.open;
2451
- else if (target.type === "prev") finished.state.page--;
2452
- else finished.state.page = (finished.state.page + 1) % Math.ceil(finished.sessions.length / WIDGET_FINISHED_PAGE_SIZE);
2488
+ if (expandedSessionIds.has(target.id)) expandedSessionIds.clear();
2489
+ else {
2490
+ expandedSessionIds.clear();
2491
+ expandedSessionIds.add(target.id);
2492
+ }
2493
+ } else if (listState) {
2494
+ const count = listState.mode === "running" ? sessions.filter((session) => session.active).length : sessions.length;
2495
+ const pageCount = Math.max(1, Math.ceil(count / WIDGET_SESSION_PAGE_SIZE));
2496
+ if (event.x === target.prevX && target.prevX !== undefined) {
2497
+ listState.page = (listState.page - 1 + pageCount) % pageCount;
2498
+ } else if (event.x === target.nextX && target.nextX !== undefined) {
2499
+ listState.page = (listState.page + 1) % pageCount;
2500
+ } else {
2501
+ listState.mode = listState.mode === "running" ? "all" : "running";
2502
+ listState.page = 0;
2503
+ }
2453
2504
  }
2454
2505
  return { handled: true, render: true };
2455
2506
  }
@@ -3417,12 +3468,12 @@ export default function (pi: ExtensionAPI) {
3417
3468
  }
3418
3469
 
3419
3470
  const expandedWidgetSessions = new Set<string>();
3420
- const finishedWidgetState: WidgetFinishedState = { open: false, page: 0 };
3471
+ const widgetListState: WidgetSessionListState = { mode: "running", page: 0 };
3421
3472
 
3422
3473
  const refreshWidget = async (ctx: ExtensionContext, snapshot?: BsSession[]) => {
3423
3474
  if (!ctx.hasUI) return;
3424
3475
  const all = snapshot ?? (await listSessions()).sessions;
3425
- const { active, finished } = widgetSessionGroups(all, (id) => readMeta(id)?.startedAt ?? 0);
3476
+ const { active, all: ordered } = widgetSessionGroups(all, (id) => readMeta(id)?.startedAt ?? 0);
3426
3477
  const subs = active.filter((session) => kindOf(session.id) === "subagent");
3427
3478
  const procs = active.length - subs.length;
3428
3479
  const progressById = new Map<string, Progress>();
@@ -3452,27 +3503,28 @@ export default function (pi: ExtensionAPI) {
3452
3503
  );
3453
3504
  }),
3454
3505
  );
3455
- const displays = active.map((session, index): WidgetSessionDisplay => {
3456
- const isSubagent = kindOf(session.id) === "subagent";
3457
- const state: WidgetSessionState = isSubagent && progressById.get(session.id)?.done
3458
- ? "idle"
3459
- : "running";
3460
- return {
3461
- id: session.id,
3462
- header: renderWidgetSessionHeader(
3463
- session.id,
3464
- isSubagent ? "agent" : "process",
3465
- state,
3466
- elapsedOf(session.id),
3467
- theme,
3468
- ),
3469
- tail: tails[index],
3470
- expandable: true,
3471
- };
3472
- });
3473
- const finishedDisplays = finished.map((session): WidgetSessionDisplay => {
3474
- const isSubagent = kindOf(session.id) === "subagent";
3506
+ const activeTails = new Map(active.map((session, index) => [session.id, tails[index]]));
3507
+ const displays = ordered.map((session): WidgetSessionDisplay => {
3475
3508
  const id = session.id;
3509
+ const meta = readMeta(id);
3510
+ const isSubagent = meta?.kind === "subagent";
3511
+ const details = isSubagent
3512
+ ? [
3513
+ ...(meta?.task ? [`task: ${escapeCommandForDisplay(meta.task)}`] : []),
3514
+ ...(meta?.launchCommand ? [theme.bold(escapeCommandForDisplay(readableLaunchCommand(meta.launchCommand)))] : []),
3515
+ ]
3516
+ : meta?.command ? [theme.bold(summarizeNotificationCommand(meta.command))] : [];
3517
+ if (session.state === "running") {
3518
+ const state: WidgetSessionState = isSubagent && progressById.get(id)?.done ? "idle" : "running";
3519
+ return {
3520
+ id,
3521
+ header: renderWidgetSessionHeader(id, isSubagent ? "agent" : "process", state, elapsedOf(id), theme),
3522
+ tail: activeTails.get(id) ?? [],
3523
+ expandable: true,
3524
+ active: true,
3525
+ details,
3526
+ };
3527
+ }
3476
3528
  return {
3477
3529
  id,
3478
3530
  header: renderWidgetSessionHeader(
@@ -3482,7 +3534,8 @@ export default function (pi: ExtensionAPI) {
3482
3534
  "",
3483
3535
  theme,
3484
3536
  ),
3485
- // Only read/parse the five visible finished sessions on demand.
3537
+ details,
3538
+ // Only read/parse the five visible sessions on demand.
3486
3539
  get tail() {
3487
3540
  try {
3488
3541
  return widgetTail(id, isSubagent, undefined, WIDGET_EXPANDED_TAIL_LINES);
@@ -3497,14 +3550,12 @@ export default function (pi: ExtensionAPI) {
3497
3550
  if (ctx.mode === "tui") {
3498
3551
  ctx.ui.setWidget(
3499
3552
  "pi-babysit",
3500
- () => createWidgetComponent(summaryLines, displays, expandedWidgetSessions, {
3501
- sessions: finishedDisplays,
3502
- state: finishedWidgetState,
3503
- }),
3553
+ () => createWidgetComponent(summaryLines, displays, expandedWidgetSessions, widgetListState),
3504
3554
  { placement: "belowEditor" },
3505
3555
  );
3506
3556
  } else {
3507
- const component = createWidgetComponent(summaryLines, displays, new Set());
3557
+ const activeIds = new Set(active.map((session) => session.id));
3558
+ const component = createWidgetComponent(summaryLines, displays.filter((session) => activeIds.has(session.id)), new Set());
3508
3559
  ctx.ui.setWidget("pi-babysit", component.render(10_000), {
3509
3560
  placement: "belowEditor",
3510
3561
  });
@@ -4222,7 +4273,7 @@ export default function (pi: ExtensionAPI) {
4222
4273
  const title = theme.bold(`babysit_run ${kind}`);
4223
4274
  const detail =
4224
4275
  typeof payload === "string" && payload.length > 0
4225
- ? ` ${summarizeNotificationCommand(payload)}`
4276
+ ? ` ${escapeCommandForDisplay(payload)}`
4226
4277
  : "";
4227
4278
  return new Text(theme.fg("warning", `${title}${agent}${detail}`), 0, 0);
4228
4279
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yusukeshib/pi-babysit",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
4
4
  "description": "Run any shell command and pi subagents under babysit, with context-safe captured output.",
5
5
  "keywords": [
6
6
  "pi-package",