pi-better-subagents 0.1.13 → 0.1.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.
@@ -15,6 +15,7 @@ import { DEFAULT_MAX_READ_BYTES, readAppendedLines, type LogCursor } from "./log
15
15
  import { logPathFor } from "./registry.ts";
16
16
  import type { RunStatus } from "./registry.ts";
17
17
  import { loadConfig, type SubagentConfig } from "./config.ts";
18
+ import { observeStall } from "./shared-stall-detector.ts";
18
19
 
19
20
  // ---- thresholds -----------------------------------------------------------
20
21
 
@@ -42,6 +43,13 @@ function positiveMs(n: unknown, fallback: number): number {
42
43
  return Number.isFinite(v) && v > 0 ? Math.floor(v) : fallback;
43
44
  }
44
45
 
46
+ function envDuration(name: string): number | undefined {
47
+ const raw = process.env[name];
48
+ if (!raw || raw.trim() === "") return undefined;
49
+ const value = Number(raw);
50
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined;
51
+ }
52
+
45
53
  /** Merge partial thresholds / config keys onto defaults. */
46
54
  export function resolveHealthThresholds(
47
55
  partial?: Partial<HealthThresholds> | Pick<
@@ -63,7 +71,11 @@ export function resolveHealthThresholds(
63
71
 
64
72
  /** Load thresholds from extension config.json (best-effort). */
65
73
  export function loadHealthThresholdsFromConfig(config: SubagentConfig = loadConfig()): HealthThresholds {
66
- return resolveHealthThresholds(config);
74
+ return resolveHealthThresholds({
75
+ ...config,
76
+ healthQuietMs: config.healthQuietMs ?? envDuration("PI_BETTER_STALL_QUIET_MS"),
77
+ healthStaleMs: config.healthStaleMs ?? envDuration("PI_BETTER_STALL_MS"),
78
+ });
67
79
  }
68
80
 
69
81
  // ---- event facts ----------------------------------------------------------
@@ -703,37 +715,28 @@ export function observeRunHealth(input: ObserveRunHealthInput): HealthObservatio
703
715
  || modelState === "retrying"
704
716
  || modelState === "error";
705
717
 
706
- let activity: ActivityHealth = "healthy";
707
- if (
708
- input.status === "failed"
718
+ const terminal = input.status === "failed"
709
719
  || input.status === "completed"
710
720
  || input.status === "killed"
711
721
  || input.status === "lost"
712
- || input.status === "exited"
713
- ) {
714
- // Terminal runs are not residual-stale workers.
715
- if (meaningfulAgeMs !== undefined && meaningfulAgeMs >= thresholds.quietMs) activity = "quiet";
716
- else activity = "healthy";
717
- } else if (explainedByPhase) {
718
- // Active known phase: never residual stale.
719
- if (meaningfulAgeMs !== undefined && meaningfulAgeMs >= thresholds.quietMs) activity = "quiet";
720
- else activity = "healthy";
721
- } else if (meaningfulAgeMs === undefined) {
722
- if (input.startedAt !== undefined) {
723
- const age = Math.max(0, now - input.startedAt);
724
- if (age >= thresholds.staleMs) activity = "stale";
725
- else if (age >= thresholds.quietMs) activity = "quiet";
726
- else activity = "healthy";
727
- } else {
728
- activity = "stale";
729
- }
730
- } else if (meaningfulAgeMs >= thresholds.staleMs) {
731
- activity = "stale";
732
- } else if (meaningfulAgeMs >= thresholds.quietMs) {
733
- activity = "quiet";
734
- } else {
735
- activity = "healthy";
736
- }
722
+ || input.status === "exited";
723
+ const stall = observeStall({
724
+ now,
725
+ lastProgressAt: facts.lastMeaningfulAt,
726
+ startedAt: input.startedAt,
727
+ // Terminal work and explicit model/tool/compaction phases can be quiet,
728
+ // but neither is an unexplained stalled worker.
729
+ exempt: terminal || explainedByPhase,
730
+ thresholds: { quietMs: thresholds.quietMs, stallMs: thresholds.staleMs },
731
+ });
732
+ const activity: ActivityHealth = stall.state === "stalled"
733
+ ? "stale"
734
+ : stall.state === "quiet"
735
+ ? "quiet"
736
+ // Legacy metadata without any timestamp remains conservative.
737
+ : stall.state === "unknown" && !terminal && !explainedByPhase
738
+ ? "stale"
739
+ : "healthy";
737
740
 
738
741
  const process: ProcessObservation = {
739
742
  liveness: processLiveness(input.status, input.process?.supervised),
package/index.ts CHANGED
@@ -47,6 +47,7 @@ import {
47
47
  writeMeta,
48
48
  readMeta,
49
49
  listMetas,
50
+ onMetaChanged,
50
51
  effectiveStatus,
51
52
  ownedByThisParent,
52
53
  navigatorVisibleRuns,
@@ -287,7 +288,7 @@ function spendFor(id: string, now: number): { usage: Usage; tool: string | null
287
288
 
288
289
  /**
289
290
  * Observe health for a widget/navigator row. Best-effort; never throws into the tick.
290
- * Full log parse is gated by size/mtime so the 1 Hz frame does not re-read and
291
+ * Full log parse is gated by size/mtime so event/detail refresh does not re-read and
291
292
  * reparse every complete log when nothing changed (#67).
292
293
  *
293
294
  * When `displayStatus` is omitted, uses durable `meta.status`.
@@ -386,7 +387,7 @@ function stopTicker(): void {
386
387
  // Reconciliation never kills anything; it only writes truth. The ticker
387
388
  // exists only while current-parent running/orphaned work needs monitoring.
388
389
 
389
- /** How often supervision is reconciled. Independent of the 1 Hz widget tick. */
390
+ /** How often supervision is reconciled. Independent of TUI render scheduling. */
390
391
  const HEALTH_TICK_MS = 15_000;
391
392
  let healthTicker: ReturnType<typeof setInterval> | undefined;
392
393
  /** ExtensionAPI retained so health transitions can deliver coordinator follow-ups (#65). */
@@ -710,7 +711,7 @@ function subagentWorkRows(now: number): BackgroundWorkRow[] {
710
711
  // One registry scan per rebuild: both the start times and the rows below are
711
712
  // built from this snapshot.
712
713
  const visible = sessionVisibleNavigatorRuns(now);
713
- const startedById = new Map(visible.map((m) => [m.id, m.startedAt]));
714
+ const metaById = new Map(visible.map((m) => [m.id, m]));
714
715
  return navigatorRows(visible, now).map((row) => {
715
716
  const bits = [];
716
717
  if (row.model) bits.push(row.effort ? `${row.model} ${row.effort}` : row.model);
@@ -730,7 +731,14 @@ function subagentWorkRows(now: number): BackgroundWorkRow[] {
730
731
  elapsed: row.elapsed,
731
732
  primary: bits.join(" · ") || "subagent run",
732
733
  facts: row.healthFacts,
733
- sortStartedAt: startedById.get(row.id) ?? now,
734
+ sortStartedAt: metaById.get(row.id)?.startedAt ?? now,
735
+ expiresAt: (() => {
736
+ const meta = metaById.get(row.id);
737
+ const endedAt = meta?.endedAt ?? meta?.lostAt;
738
+ return meta && isTerminalNavigatorStatus(effectiveStatus(meta)) && typeof endedAt === "number"
739
+ ? endedAt + TERMINAL_NAVIGATOR_RETENTION_MS
740
+ : undefined;
741
+ })(),
734
742
  };
735
743
  });
736
744
  }
@@ -756,7 +764,7 @@ function subagentWorkDetail(id: string, now: number, options?: { logTailLines?:
756
764
  statusTone: statusTone(detail.status),
757
765
  subtitle: detail.currentTool ? `current tool ${detail.currentTool}` : undefined,
758
766
  metadata,
759
- // The shared navigator refreshes this provider once a second while the
767
+ // The shared navigator refreshes this provider on a coarse deadline while the
760
768
  // detail overlay is open. Read the selected logical tail rows each
761
769
  // time so the evidence behaves like `tail -f`, not a single parsed
762
770
  // activity/result snapshot.
@@ -779,6 +787,7 @@ function ensureSubagentProvider(): void {
779
787
  const outcome = navigatorCloseRun(id) as { action: string; id: string; status?: string };
780
788
  return { ...outcome, providerId: "subagents" };
781
789
  },
790
+ onVisibleChanged: onMetaChanged,
782
791
  };
783
792
  unregisterSubagentProvider = registerBackgroundWorkProvider(provider);
784
793
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-subagents",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/registry.ts CHANGED
@@ -148,6 +148,7 @@ function metaPathFor(id: string): string {
148
148
  }
149
149
 
150
150
  let seq = 0;
151
+ const metaChangedListeners = new Set<() => void>();
151
152
  /** Monotonic, readable, collision-free run id: `sa_<base36-time>_<seq>`. */
152
153
  export function nextRunId(): string {
153
154
  seq += 1;
@@ -157,6 +158,14 @@ export function nextRunId(): string {
157
158
  export function writeMeta(meta: RunMeta): void {
158
159
  mkdirSync(runDir(meta.id), { recursive: true });
159
160
  writeFileSync(metaPathFor(meta.id), JSON.stringify(meta, null, 2));
161
+ for (const listener of metaChangedListeners) {
162
+ try { listener(); } catch { /* best effort */ }
163
+ }
164
+ }
165
+
166
+ export function onMetaChanged(listener: () => void): () => void {
167
+ metaChangedListeners.add(listener);
168
+ return () => metaChangedListeners.delete(listener);
160
169
  }
161
170
 
162
171
  export function readMeta(id: string): RunMeta | undefined {
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { Component } from "@earendil-works/pi-tui";
3
+ import { createRenderScheduler, type RenderScheduler } from "./shared-render-scheduler.ts";
3
4
 
4
5
  export default function navigatorExtension(): void {
5
6
  // Internal shared package. Pi may scan its symlink in the extension directory
@@ -25,6 +26,7 @@ export type BackgroundWorkRow = {
25
26
  secondary?: string;
26
27
  facts?: string[];
27
28
  sortStartedAt: number;
29
+ expiresAt?: number;
28
30
  };
29
31
 
30
32
  export type BackgroundWorkDetail = {
@@ -79,7 +81,7 @@ type NavigatorState = {
79
81
  mainListFocused?: boolean;
80
82
  mainListCloseArm?: { id: string; armedAt: number };
81
83
  mainListCloseArmTimer?: ReturnType<typeof setTimeout>;
82
- mainListTimer?: ReturnType<typeof setInterval>;
84
+ mainListDeadlineScheduler?: RenderScheduler;
83
85
  detailOverlayRows?: number;
84
86
  dispose?: () => void;
85
87
  };
@@ -91,15 +93,10 @@ const FACTORY_REFRESH = "__piBetterHarnessNavigatorRefresh";
91
93
  export const NAVIGATOR_STATUS_KEY = "background-work-nav";
92
94
  export const CLOSE_CONFIRM_STATUS_KEY = "background-work-close";
93
95
  export const MAIN_LIST_WIDGET_KEY = "background-work-list";
94
- export const DETAIL_TICK_MS = 1000;
96
+ export const DETAIL_TICK_MS = 10_000;
95
97
  export const CLOSE_ARM_MS = 3000;
96
98
  export const DEFAULT_LOG_TAIL_ROWS = 10;
97
99
  export const LOG_TAIL_ROW_CHOICES = [10, 25] as const;
98
- // Row rebuild cadence. Each tick asks every provider for rows, which stats and
99
- // parses run logs, so this is a per-provider I/O cadence and not a paint rate:
100
- // the widget repaints from render() whenever the TUI asks. 1 Hz matches
101
- // DETAIL_TICK_MS and the elapsed/spinner resolution the rows can actually show.
102
- const MAIN_LIST_TICK_MS = 1000;
103
100
  const MAIN_LIST_FALLBACK_WIDTH = 100;
104
101
  const DETAIL_OVERLAY_HEADER_MARGIN_ROWS = 5;
105
102
  const DETAIL_OVERLAY_FOOTER_MARGIN_ROWS = 3;
@@ -173,9 +170,10 @@ export function ensureBackgroundWorkNavigator(ctx: ExtensionContext, deps: HostD
173
170
  try { (ctx.ui as any).setWidget?.(MAIN_LIST_WIDGET_KEY, undefined); } catch { /* ignore */ }
174
171
  s.mainListWidgetInstalled = false;
175
172
  s.mainListRequestRender = undefined;
173
+ s.mainListDeadlineScheduler?.dispose();
174
+ s.mainListDeadlineScheduler = createRenderScheduler(() => refreshMainListWidget());
176
175
  installNavigatorEditor(ctx.ui as any, deps);
177
176
  s.lastHint = undefined;
178
- startMainListWidget(ctx);
179
177
  refreshBackgroundWorkNavigator(ctx);
180
178
  refreshMainListWidget();
181
179
  }
@@ -193,6 +191,8 @@ export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext): void {
193
191
  }
194
192
  s.lastHint = undefined;
195
193
  s.lastMainListLines = undefined;
194
+ s.mainListDeadlineScheduler?.dispose();
195
+ s.mainListDeadlineScheduler = undefined;
196
196
  s.mainListWidgetInstalled = false;
197
197
  s.mainListRequestRender = undefined;
198
198
  s.detailOverlayRows = undefined;
@@ -226,17 +226,7 @@ function visibleCount(): number {
226
226
  }, 0);
227
227
  }
228
228
 
229
- function startMainListWidget(ctx: ExtensionContext): void {
230
- const s = state();
231
- if (s.mainListTimer || !isNavigatorUiAvailable(ctx)) return;
232
- s.mainListTimer = setInterval(() => refreshMainListWidget(), MAIN_LIST_TICK_MS);
233
- s.mainListTimer.unref?.();
234
- }
235
-
236
229
  function stopMainListWidget(): void {
237
- const s = state();
238
- if (s.mainListTimer) clearInterval(s.mainListTimer);
239
- s.mainListTimer = undefined;
240
230
  clearMainListCloseArm();
241
231
  }
242
232
 
@@ -254,7 +244,16 @@ function refreshMainListWidget(): void {
254
244
  const ctx = s.uiCtx;
255
245
  const deps = s.deps;
256
246
  if (!ctx || !deps || !isNavigatorUiAvailable(ctx)) return;
257
- const rows = listRows();
247
+ const now = Date.now();
248
+ const rows = listRows(now);
249
+ s.mainListDeadlineScheduler?.cancel();
250
+ const nextExpiry = rows.reduce<number | undefined>((next, row) => {
251
+ if (row.expiresAt === undefined || row.expiresAt <= now) return next;
252
+ return next === undefined ? row.expiresAt : Math.min(next, row.expiresAt);
253
+ }, undefined);
254
+ if (nextExpiry !== undefined) {
255
+ s.mainListDeadlineScheduler?.schedule(nextExpiry - now);
256
+ }
258
257
  syncMainListSelection(rows);
259
258
  s.lastMainListLines = rows.length ? buildMainListLines(rows, MAIN_LIST_FALLBACK_WIDTH, deps.truncate, themeFg(ctx), {
260
259
  selectedId: s.mainListFocused ? s.mainListSelectedId : undefined,
@@ -462,7 +461,7 @@ function statusGlyph(row: InternalRow): string {
462
461
 
463
462
  function statusIndicator(row: InternalRow, now = Date.now()): { glyph: string; color: string } {
464
463
  if (row.statusTone !== "running") return { glyph: statusGlyph(row), color: toneColor(row.statusTone, row.status) };
465
- const frame = Math.floor(now / MAIN_LIST_TICK_MS) % RUNNING_DOT_FRAMES.length;
464
+ const frame = Math.floor(now / DETAIL_TICK_MS) % RUNNING_DOT_FRAMES.length;
466
465
  return { glyph: RUNNING_DOT_GLYPH, color: RUNNING_DOT_FRAMES[frame]! };
467
466
  }
468
467
 
@@ -676,7 +675,6 @@ function createOverlayComponent(
676
675
  const idx = overlayState.rows.findIndex((row) => row.navigatorId === detailId);
677
676
  if (idx >= 0) overlayState.selected = idx;
678
677
  }
679
- let detailTimer: ReturnType<typeof setInterval> | undefined;
680
678
  let closeArm: { id: string; armedAt: number } | undefined;
681
679
  let closeArmTimer: ReturnType<typeof setTimeout> | undefined;
682
680
  let closed = false;
@@ -687,6 +685,13 @@ function createOverlayComponent(
687
685
  try { tui.requestRender(); } catch { /* ignore */ }
688
686
  }
689
687
 
688
+ const detailScheduler = createRenderScheduler(() => {
689
+ if (!detailId || mode !== "detail" || closed) return;
690
+ detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
691
+ requestRender();
692
+ startDetailTimer();
693
+ });
694
+
690
695
  function refreshRows(): void {
691
696
  const selectedId = overlayState.rows[overlayState.selected]?.navigatorId;
692
697
  overlayState.rows = listRows();
@@ -704,18 +709,14 @@ function createOverlayComponent(
704
709
  }
705
710
 
706
711
  function stopDetailTimer(): void {
707
- if (detailTimer) clearInterval(detailTimer);
708
- detailTimer = undefined;
712
+ detailScheduler.cancel();
709
713
  }
710
714
 
711
715
  function startDetailTimer(): void {
712
- stopDetailTimer();
713
- detailTimer = setInterval(() => {
714
- if (!detailId || mode !== "detail") return;
715
- detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
716
- requestRender();
717
- }, DETAIL_TICK_MS);
718
- detailTimer.unref?.();
716
+ detailScheduler.cancel();
717
+ if (detail?.status === "running" || detail?.status === "orphaned") {
718
+ detailScheduler.schedule(DETAIL_TICK_MS);
719
+ }
719
720
  }
720
721
 
721
722
  function selectedRow(): InternalRow | undefined {
@@ -759,7 +760,7 @@ function createOverlayComponent(
759
760
  if (closed) return;
760
761
  closed = true;
761
762
  clearCloseArm();
762
- stopDetailTimer();
763
+ detailScheduler.dispose();
763
764
  try { done(null); } catch { /* ignore */ }
764
765
  }
765
766
 
@@ -842,7 +843,7 @@ function createOverlayComponent(
842
843
  invalidate() {},
843
844
  dispose() {
844
845
  clearCloseArm();
845
- stopDetailTimer();
846
+ detailScheduler.dispose();
846
847
  },
847
848
  };
848
849
  }
@@ -0,0 +1,47 @@
1
+ // Generated from packages/render-scheduler/index.ts. Do not edit directly.
2
+ export interface RenderScheduler {
3
+ request(): void;
4
+ schedule(delayMs: number): void;
5
+ cancel(): void;
6
+ dispose(): void;
7
+ pending(): boolean;
8
+ }
9
+
10
+ /**
11
+ * Schedule TUI paints as replaceable one-shot deadlines. Callers reschedule
12
+ * only while visible state still changes; static and hidden UI stays idle.
13
+ */
14
+ export function createRenderScheduler(requestRender: () => void): RenderScheduler {
15
+ let timer: ReturnType<typeof setTimeout> | undefined;
16
+ let disposed = false;
17
+
18
+ const cancel = (): void => {
19
+ if (timer) clearTimeout(timer);
20
+ timer = undefined;
21
+ };
22
+
23
+ return {
24
+ request() {
25
+ if (disposed) return;
26
+ cancel();
27
+ requestRender();
28
+ },
29
+ schedule(delayMs: number) {
30
+ if (disposed) return;
31
+ cancel();
32
+ timer = setTimeout(() => {
33
+ timer = undefined;
34
+ if (!disposed) requestRender();
35
+ }, Math.max(0, delayMs));
36
+ timer.unref?.();
37
+ },
38
+ cancel,
39
+ dispose() {
40
+ disposed = true;
41
+ cancel();
42
+ },
43
+ pending() {
44
+ return timer !== undefined;
45
+ },
46
+ };
47
+ }
@@ -0,0 +1,69 @@
1
+ // Generated from packages/stall-detector/index.ts. Do not edit directly.
2
+ export interface StallThresholds {
3
+ /** Age of observable progress before work becomes quiet. */
4
+ quietMs: number;
5
+ /** Age of observable progress before work becomes stalled. */
6
+ stallMs: number;
7
+ }
8
+
9
+ export type StallState = "healthy" | "quiet" | "stalled" | "unknown";
10
+
11
+ export interface StallObservation {
12
+ state: StallState;
13
+ /** Most recent meaningful evidence, falling back to the start time. */
14
+ observedAt?: number;
15
+ ageMs?: number;
16
+ thresholds: StallThresholds;
17
+ }
18
+
19
+ export interface ObserveStallInput {
20
+ now: number;
21
+ /** Explicit meaningful progress, never a timer tick or metadata write. */
22
+ lastProgressAt?: number;
23
+ /** Fallback anchor for work that has not produced progress yet. */
24
+ startedAt?: number;
25
+ /** Known active phases that explain a lack of observable progress. */
26
+ exempt?: boolean;
27
+ thresholds?: Partial<StallThresholds>;
28
+ }
29
+
30
+ export const DEFAULT_STALL_THRESHOLDS: Readonly<StallThresholds> = Object.freeze({
31
+ quietMs: 60_000,
32
+ stallMs: 5 * 60_000,
33
+ });
34
+
35
+ function positiveMs(value: unknown, fallback: number): number {
36
+ const parsed = Number(value);
37
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
38
+ }
39
+
40
+ /** Normalize caller config and ensure quiet never exceeds the stall threshold. */
41
+ export function resolveStallThresholds(partial?: Partial<StallThresholds> | null): StallThresholds {
42
+ const quietMs = positiveMs(partial?.quietMs, DEFAULT_STALL_THRESHOLDS.quietMs);
43
+ const stallMs = positiveMs(partial?.stallMs, DEFAULT_STALL_THRESHOLDS.stallMs);
44
+ return { quietMs: Math.min(quietMs, stallMs), stallMs: Math.max(quietMs, stallMs) };
45
+ }
46
+
47
+ /**
48
+ * Classify the age of observable progress. This is deliberately observational:
49
+ * it does not claim a live process is broken and never performs recovery.
50
+ */
51
+ export function observeStall(input: ObserveStallInput): StallObservation {
52
+ const thresholds = resolveStallThresholds(input.thresholds);
53
+ const observedAt = input.lastProgressAt ?? input.startedAt;
54
+ if (typeof observedAt !== "number" || !Number.isFinite(observedAt)) {
55
+ return { state: "unknown", thresholds };
56
+ }
57
+
58
+ const ageMs = Math.max(0, input.now - observedAt);
59
+ if (input.exempt) {
60
+ return { state: ageMs >= thresholds.quietMs ? "quiet" : "healthy", observedAt, ageMs, thresholds };
61
+ }
62
+ if (ageMs >= thresholds.stallMs) {
63
+ return { state: "stalled", observedAt, ageMs, thresholds };
64
+ }
65
+ if (ageMs >= thresholds.quietMs) {
66
+ return { state: "quiet", observedAt, ageMs, thresholds };
67
+ }
68
+ return { state: "healthy", observedAt, ageMs, thresholds };
69
+ }
package/widget.mjs CHANGED
@@ -216,7 +216,7 @@ export function isSpendCacheFresh(cached, now, logSize, ttlMs = SPEND_REFRESH_MS
216
216
  /**
217
217
  * Whether a cached health-log parse is still valid for the widget tick.
218
218
  * Invalidates on log size or mtime change so growth/rewrite re-extracts, while
219
- * unchanged logs skip the synchronous full-log reparse on every 1 Hz frame.
219
+ * unchanged logs skip synchronous full-log reparses across repeated renders.
220
220
  *
221
221
  * @param {{ logSize?: number, mtimeMs?: number }|null|undefined} cached
222
222
  * @param {number} logSize