@yusukeshib/pi-babysit 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -75,8 +75,10 @@ re-enables `bash`, including shell backgrounding (`… &`, `nohup`, `setsid`,
75
75
  | `/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 |
76
76
  | `/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; deletion uses a GC lock and atomic rename |
77
77
 
78
- A minimal widget above the editor shows live counts
79
- (`N processes · M subagents working · K idle`).
78
+ A widget below the editor separates session **kind** from task **state** at a glance:
79
+ summary counts use `RUNNING` / `IDLE`, and every row is labeled
80
+ `[PROCESS] [RUNNING]`, `[AGENT] [RUNNING]`, or `[AGENT] [IDLE]`. The displayed
81
+ `age` is total session age, not time spent in the current state.
80
82
 
81
83
  ## Logs without context flooding
82
84
 
@@ -125,6 +127,14 @@ suite at the end. Background subagents must likewise be collected before the
125
127
  parent task finishes so their answer and nested usage are not lost.
126
128
  Set `PI_BABYSIT_ALLOW_BASH=1` only as an explicit emergency escape hatch.
127
129
 
130
+ ## Pi shutdown and reload
131
+
132
+ A real Pi quit terminates every running process and subagent in the current Pi
133
+ session namespace. The workers are killed concurrently, so shutdown time does
134
+ not grow linearly when many sessions are active. Lifecycle transitions that keep
135
+ Pi running—`/reload`, `/new`, `/resume`, and `/fork`—leave detached workers
136
+ alone. Reloading or resuming the same Pi session reconnects to its namespace.
137
+
128
138
  ## Unexpected worker loss
129
139
 
130
140
  If the babysit supervisor disappears without recording an exit, pi-babysit
package/index.ts CHANGED
@@ -367,6 +367,35 @@ async function awaitConfirmedTermination(id: string): Promise<BsSession | null>
367
367
  return statusOf(id);
368
368
  }
369
369
 
370
+ export function shouldTerminateSessionsOnShutdown(reason: string | undefined): boolean {
371
+ return reason === "quit";
372
+ }
373
+
374
+ async function terminateRunningSessions(): Promise<void> {
375
+ let listed: Awaited<ReturnType<typeof listSessions>>;
376
+ try {
377
+ listed = await listSessions();
378
+ } catch {
379
+ return;
380
+ }
381
+ if (listed.error) return;
382
+
383
+ // A namespace may contain many independent commands and agents. Terminate
384
+ // them concurrently so quitting Pi does not wait for serial kill timeouts.
385
+ // allSettled ensures one unreachable worker cannot skip cleanup for others.
386
+ await Promise.allSettled(
387
+ listed.sessions
388
+ .filter((session) => session.state === "running")
389
+ .map(async (session) => {
390
+ const result = await bs(["kill", "-s", session.id, "--json"]);
391
+ if (result.code !== 0 || validateKillResponse(result.stdout)) return;
392
+ // A later resume of this Pi session must not turn intentional shutdown
393
+ // cleanup into a process-completion notification.
394
+ suppressNotify(session.id, "kill");
395
+ }),
396
+ );
397
+ }
398
+
370
399
  // ---------------------------------------------------------------------------
371
400
  // per-session metadata
372
401
  // ---------------------------------------------------------------------------
@@ -2188,13 +2217,56 @@ async function spawnSubagent(
2188
2217
  // A subagent whose task is done stays alive as an idle RPC worker (for
2189
2218
  // follow-ups), so "running" in babysit does NOT mean "working" — count
2190
2219
  // processes / busy subagents / idle subagents separately.
2191
- function renderWidgetLines(procs: number, busy: number, idle: number): string[] {
2220
+ export type WidgetSessionKind = "process" | "agent";
2221
+ export type WidgetSessionState = "running" | "idle";
2222
+
2223
+ export function widgetSummaryText(procs: number, busy: number, idle: number): string {
2224
+ const sections: string[] = [];
2225
+ const running: string[] = [];
2226
+ if (procs > 0) running.push(`${procs} process${procs > 1 ? "es" : ""}`);
2227
+ if (busy > 0) running.push(`${busy} agent${busy > 1 ? "s" : ""}`);
2228
+ if (running.length > 0) sections.push(`RUNNING ${running.join(" · ")}`);
2229
+ if (idle > 0) sections.push(`IDLE ${idle} agent${idle > 1 ? "s" : ""}`);
2230
+ return sections.join(" │ ");
2231
+ }
2232
+
2233
+ export function widgetSessionHeader(
2234
+ id: string,
2235
+ kind: WidgetSessionKind,
2236
+ state: WidgetSessionState,
2237
+ elapsed?: string,
2238
+ ): string {
2239
+ const icon = state === "running" ? "▶" : "○";
2240
+ return ` ${icon} ${id} [${kind.toUpperCase()}] [${state.toUpperCase()}]${elapsed ? ` · age ${elapsed}` : ""}`;
2241
+ }
2242
+
2243
+ function renderWidgetLines(procs: number, busy: number, idle: number, theme: Theme): string[] {
2192
2244
  if (procs === 0 && busy === 0 && idle === 0) return [];
2193
- const parts: string[] = [];
2194
- if (procs > 0) parts.push(`${procs} process${procs > 1 ? "es" : ""}`);
2195
- if (busy > 0) parts.push(`${busy} subagent${busy > 1 ? "s" : ""} working`);
2196
- if (idle > 0) parts.push(`${idle} idle`);
2197
- return [`\x1b[44;97m ${parts.join(" \u00b7 ")} \x1b[0m`];
2245
+ const sections: string[] = [];
2246
+ const running: string[] = [];
2247
+ if (procs > 0) running.push(`${procs} process${procs > 1 ? "es" : ""}`);
2248
+ if (busy > 0) running.push(`${busy} agent${busy > 1 ? "s" : ""}`);
2249
+ if (running.length > 0) {
2250
+ sections.push(`${theme.fg("success", theme.bold("RUNNING"))} ${running.join(" · ")}`);
2251
+ }
2252
+ if (idle > 0) {
2253
+ sections.push(`${theme.fg("muted", theme.bold("IDLE"))} ${idle} agent${idle > 1 ? "s" : ""}`);
2254
+ }
2255
+ return [theme.bg("toolPendingBg", ` ${sections.join(" │ ")} `)];
2256
+ }
2257
+
2258
+ function renderWidgetSessionHeader(
2259
+ id: string,
2260
+ kind: WidgetSessionKind,
2261
+ state: WidgetSessionState,
2262
+ elapsed: string,
2263
+ theme: Theme,
2264
+ ): string {
2265
+ const running = state === "running";
2266
+ const icon = theme.fg(running ? "success" : "muted", running ? "▶" : "○");
2267
+ const kindLabel = theme.fg(kind === "process" ? "accent" : "warning", theme.bold(`[${kind.toUpperCase()}]`));
2268
+ const stateLabel = theme.fg(running ? "success" : "muted", theme.bold(`[${state.toUpperCase()}]`));
2269
+ return ` ${icon} ${id} ${kindLabel} ${stateLabel}${elapsed ? theme.fg("dim", ` · age ${elapsed}`) : ""}`;
2198
2270
  }
2199
2271
 
2200
2272
  // How many trailing output lines to show per running session in the widget.
@@ -3095,7 +3167,8 @@ export default function (pi: ExtensionAPI) {
3095
3167
  }
3096
3168
  }
3097
3169
  const idle = subs.filter((session) => progressById.get(session.id)?.done).length;
3098
- const lines = renderWidgetLines(procs, subs.length - idle, idle);
3170
+ const theme = ctx.ui.theme;
3171
+ const lines = renderWidgetLines(procs, subs.length - idle, idle, theme);
3099
3172
  const tails = await Promise.all(
3100
3173
  active.map((session) => {
3101
3174
  const isSubagent = kindOf(session.id) === "subagent";
@@ -3108,11 +3181,17 @@ export default function (pi: ExtensionAPI) {
3108
3181
  );
3109
3182
  active.forEach((session, index) => {
3110
3183
  const isSubagent = kindOf(session.id) === "subagent";
3111
- const tag = isSubagent
3112
- ? progressById.get(session.id)?.done ? "sub idle" : "sub"
3113
- : "proc";
3184
+ const state: WidgetSessionState = isSubagent && progressById.get(session.id)?.done
3185
+ ? "idle"
3186
+ : "running";
3114
3187
  const elapsed = elapsedOf(session.id);
3115
- const header = ` ⏳ ${session.id} [${tag}]${elapsed ? ` ${elapsed}` : ""}`;
3188
+ const header = renderWidgetSessionHeader(
3189
+ session.id,
3190
+ isSubagent ? "agent" : "process",
3191
+ state,
3192
+ elapsed,
3193
+ theme,
3194
+ );
3116
3195
  if (tails[index].length === 1) {
3117
3196
  lines.push(`${header} │ ${tails[index][0]}`);
3118
3197
  } else {
@@ -3288,9 +3367,15 @@ export default function (pi: ExtensionAPI) {
3288
3367
  }, POLL_MS);
3289
3368
  });
3290
3369
 
3291
- pi.on("session_shutdown", async () => {
3370
+ pi.on("session_shutdown", async (event) => {
3292
3371
  if (pollTimer) clearInterval(pollTimer);
3293
3372
  pollTimer = undefined;
3373
+ // Pi also emits session_shutdown while hot-reloading and switching sessions.
3374
+ // Preserve detached work for those lifecycle transitions; only a real Pi
3375
+ // quit owns and terminates every worker in the current session namespace.
3376
+ if (shouldTerminateSessionsOnShutdown(event?.reason)) {
3377
+ await terminateRunningSessions();
3378
+ }
3294
3379
  releaseRootLease(rootLeasePath);
3295
3380
  rootLeasePath = undefined;
3296
3381
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yusukeshib/pi-babysit",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Run any shell command and pi subagents under babysit, with context-safe captured output.",
5
5
  "keywords": [
6
6
  "pi-package",
package/self-reap.ts CHANGED
@@ -100,12 +100,15 @@ export default function (pi: ExtensionAPI) {
100
100
 
101
101
  // agent_end can precede automatic retries, compaction retries, and queued
102
102
  // continuations. Start the grace timer only once Pi is truly settled.
103
- pi.on("agent_settled", (_event, ctx) => {
103
+ pi.on("agent_settled", () => {
104
104
  cancel();
105
105
  if (lastEndWasParked) return;
106
106
  timer = setTimeout(() => {
107
- // Graceful shutdown emits session_shutdown for every loaded extension.
108
- ctx.shutdown();
107
+ // In RPC mode ctx.shutdown() only sets a flag that is checked on the next
108
+ // command or agent_settled event. An already-idle worker receives neither,
109
+ // so it would remain alive indefinitely. At this point the grace elapsed,
110
+ // the task is settled, and parked turns were excluded above; exit cleanly.
111
+ process.exit(0);
109
112
  }, graceMs);
110
113
  timer.unref?.();
111
114
  });