pi-better-subagents 0.1.23 → 0.1.25

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/cleanup.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  listMetas,
14
14
  ownedByThisParent,
15
15
  readMeta,
16
+ removeMetaArtifacts,
16
17
  runDir,
17
18
  sessionsDir,
18
19
  type RunMeta,
@@ -190,7 +191,8 @@ function cleanRunDirs(cutoff: number, errors: string[]): number {
190
191
  if (meta) {
191
192
  if (!TERMINAL_CLEANUP_STATUSES.has(meta.status)) continue;
192
193
  if (terminalTimestamp(meta) >= cutoff) continue;
193
- if (removePath(dir, errors)) removed += 1;
194
+ if (removeMetaArtifacts(meta)) removed += 1;
195
+ else errors.push(`${dir}: could not remove run artifacts`);
194
196
  continue;
195
197
  }
196
198
 
@@ -379,7 +381,9 @@ export function enforceRegistrySizeCapOnce(options: SizeCapOptions = {}): SizeCa
379
381
  const plan = planRegistrySizeCap(collectSizeCapEntries(), { maxBytes, protectedIds });
380
382
  const removed: string[] = [];
381
383
  for (const id of plan.remove) {
382
- if (removePath(runDir(id), errors)) removed.push(id);
384
+ const meta = readMeta(id);
385
+ if (meta ? removeMetaArtifacts(meta) : removePath(runDir(id), errors)) removed.push(id);
386
+ else if (meta) errors.push(`${runDir(id)}: could not remove run artifacts`);
383
387
  }
384
388
 
385
389
  try {
package/index.ts CHANGED
@@ -49,6 +49,9 @@ import {
49
49
  writeMeta,
50
50
  readMeta,
51
51
  listMetas,
52
+ listActiveMetasForParent,
53
+ listMetasForOrigin,
54
+ listMetasForParent,
52
55
  onMetaChanged,
53
56
  effectiveStatus,
54
57
  ownedByThisParent,
@@ -124,6 +127,44 @@ const SUBAGENT_TOOLS = [
124
127
  "subagent_result",
125
128
  ];
126
129
 
130
+ const GOAL_READY_EVENT = "pi-better-goal:ready";
131
+ const GOAL_REGISTER_PROVIDER_EVENT = "pi-better-goal:register-provider";
132
+ const goalReadySubscriptions = new WeakSet<ExtensionAPI>();
133
+
134
+ function registerSubagentsGoalProvider(pi: ExtensionAPI): void {
135
+ const emitProvider = (): void => {
136
+ pi.events?.emit(GOAL_REGISTER_PROVIDER_EVENT, {
137
+ id: "subagents",
138
+ label: "Subagents",
139
+ getActivity: () => ({
140
+ providerId: "subagents",
141
+ label: "Subagents",
142
+ items: listMetasForParent(process.pid).map((meta) => {
143
+ const status = effectiveStatus(meta);
144
+ const active = status === "running" || status === "orphaned";
145
+ return {
146
+ id: meta.id,
147
+ ...(meta.name ? { label: meta.name } : {}),
148
+ status,
149
+ active,
150
+ unhealthy: status === "orphaned",
151
+ terminal: !active,
152
+ attention: status === "orphaned" || status === "failed" || status === "killed" || status === "lost" || status === "exited",
153
+ startedAt: meta.startedAt,
154
+ ...(meta.endedAt !== undefined ? { endedAt: meta.endedAt } : {}),
155
+ };
156
+ }),
157
+ }),
158
+ onActivityChanged: onMetaChanged,
159
+ });
160
+ };
161
+ if (!goalReadySubscriptions.has(pi)) {
162
+ pi.events?.on?.(GOAL_READY_EVENT, emitProvider);
163
+ goalReadySubscriptions.add(pi);
164
+ }
165
+ emitProvider();
166
+ }
167
+
127
168
  // ---- retired live status widget ------------------------------------------
128
169
  //
129
170
  // The shared background-work navigator owns the active subagent list. This
@@ -224,7 +265,7 @@ function hasSelfProcessIdentity(meta: RunMeta): boolean {
224
265
 
225
266
  function stopCurrentSessionSubagents(ctx: ExtensionContext): void {
226
267
  const origin = callbackOriginFromContext(ctx);
227
- for (const summary of listMetas()) {
268
+ for (const summary of listMetasForParent(process.pid)) {
228
269
  if (!ownedByThisParent(summary)) continue;
229
270
  if (summary.status !== "running" && summary.status !== "orphaned") continue;
230
271
  if (!belongsToOrigin(summary, origin)) continue;
@@ -286,7 +327,7 @@ function enqueueCompletionCallback(pi: ExtensionAPI, id: string): void {
286
327
 
287
328
  /** Recover only records explicitly marked pending; legacy terminal runs never replay. */
288
329
  function recoverCompletionCallbacks(pi: ExtensionAPI): void {
289
- for (const meta of listMetas()) {
330
+ for (const meta of listMetasForParent(process.pid)) {
290
331
  if (!ownedByThisParent(meta)) continue;
291
332
  enqueueCompletionCallback(pi, meta.id);
292
333
  }
@@ -504,15 +545,12 @@ function deliverHealthCallback(pi: ExtensionAPI | undefined, meta: RunMeta, stat
504
545
  function reconcileHealth(): void {
505
546
  const ctx = uiCtx;
506
547
  const pi = healthPi;
507
- // Free work while the loop is already running: nothing else will ever
508
- // reconcile a record whose parent is gone.
509
- reconcileAbandonedRuns();
510
- for (const summary of listMetas()) {
548
+ for (const summary of listMetasForParent(process.pid)) {
511
549
  if (!ownedByThisParent(summary)) continue;
512
550
  // running/orphaned: process reconcile. lost: durable callback recovery only.
513
551
  if (summary.status !== "running" && summary.status !== "orphaned" && summary.status !== "lost") continue;
514
552
  // Re-read under the id: finalizeRun / subagent_stop may have written a
515
- // terminal status since listMetas() snapshotted.
553
+ // terminal status since the owned index was read.
516
554
  const meta = readMeta(summary.id);
517
555
  if (!meta) continue;
518
556
  if (meta.status !== "running" && meta.status !== "orphaned" && meta.status !== "lost") continue;
@@ -544,7 +582,7 @@ function reconcileHealth(): void {
544
582
  }
545
583
  }
546
584
  // Stop existing the moment nothing current-parent needs monitoring/recovery.
547
- if (!needsMonitoring(listMetas())) stopHealthTicker();
585
+ if (!needsMonitoring(listMetasForParent(process.pid))) stopHealthTicker();
548
586
  }
549
587
 
550
588
  /**
@@ -691,17 +729,14 @@ function navigatorRunningCount(): number {
691
729
  }
692
730
 
693
731
  /**
694
- * The visible run set. `listMetas` reads and parses one `meta.json` per run in
695
- * the registry, so a caller that needs this twice should scan once and pass the
696
- * snapshot down (see `subagentWorkRows`) rather than ask again.
697
- *
698
- * Deliberately NOT cached across calls. A time-based memo here was tried and
699
- * reverted: it made a run created outside this process — another pi session's
700
- * spawn, or a test seeding one — invisible until the window expired, and the
701
- * navigator then acted on a set that no longer matched the registry.
732
+ * The visible run set. The durable origin index limits reads to this session;
733
+ * each rebuild still reads owned non-terminal metadata so external status
734
+ * changes are visible immediately without a time-based memo.
702
735
  */
703
736
  function sessionVisibleNavigatorRuns(now: number = Date.now()): RunMeta[] {
704
- return navigatorVisibleRuns(listMetas())
737
+ const origin = activeCallbackOrigin;
738
+ if (!origin) return [];
739
+ return navigatorVisibleRuns(listMetasForOrigin(origin))
705
740
  .filter(belongsToActiveNavigatorSession)
706
741
  .filter((m) => !isExpiredTerminalNavigatorRun(m, now));
707
742
  }
@@ -1055,6 +1090,7 @@ export default function (pi: ExtensionAPI) {
1055
1090
  // coordinator follow-ups that fire outside a tool-call stack (#65).
1056
1091
  healthPi = pi;
1057
1092
  ensureSubagentProvider();
1093
+ registerSubagentsGoalProvider(pi);
1058
1094
 
1059
1095
  type SpawnParams = {
1060
1096
  prompt: string; name?: string; model?: string; thinking?: ThinkingLevel; tools?: string;
@@ -1260,7 +1296,7 @@ export default function (pi: ExtensionAPI) {
1260
1296
  const cfg = loadConfig();
1261
1297
  const maxConcurrent = cfg.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
1262
1298
  const countRunning = () =>
1263
- listMetas().filter((m) => ownedByThisParent(m) && effectiveStatus(m) === "running").length;
1299
+ listActiveMetasForParent(process.pid).filter((m) => effectiveStatus(m) === "running").length;
1264
1300
  // Shared with batch-spawn: reserve before any async work so an interleaved
1265
1301
  // batch cannot oversubscribe after this check and before writeMeta.
1266
1302
  const gate = getSharedCapacityGate(countRunning);
@@ -1351,7 +1387,7 @@ export default function (pi: ExtensionAPI) {
1351
1387
  const cfg = loadConfig();
1352
1388
  const maxConcurrent = cfg.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
1353
1389
  const countRunning = () =>
1354
- listMetas().filter((m) => ownedByThisParent(m) && effectiveStatus(m) === "running").length;
1390
+ listActiveMetasForParent(process.pid).filter((m) => effectiveStatus(m) === "running").length;
1355
1391
  const launchAvailable = p.onCapacity === "launch-available";
1356
1392
  // Shared with single-spawn. Reservations count against maxConcurrent so a
1357
1393
  // concurrent single spawn cannot take a slot the batch already admitted.
@@ -1530,7 +1566,7 @@ export default function (pi: ExtensionAPI) {
1530
1566
  // Resume supervision reconciliation + durable health-callback recovery
1531
1567
  // across /reload while current-parent work still needs the ticker
1532
1568
  // (running/orphaned, or unmarked lost); it stops itself when idle.
1533
- if (needsMonitoring(listMetas())) ensureHealthTicker();
1569
+ if (needsMonitoring(listMetasForParent(process.pid))) ensureHealthTicker();
1534
1570
  });
1535
1571
 
1536
1572
  pi.on("session_before_switch", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-subagents",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
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
@@ -7,7 +7,8 @@
7
7
  * for runs this process spawned.
8
8
  */
9
9
 
10
- import { mkdirSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
10
+ import { createHash } from "node:crypto";
11
+ import { mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync, statSync, unlinkSync } from "node:fs";
11
12
  import { tmpdir } from "node:os";
12
13
  import { join } from "node:path";
13
14
  import { processExists } from "./spawn.ts";
@@ -151,7 +152,31 @@ function metaPathFor(id: string): string {
151
152
  }
152
153
 
153
154
  let seq = 0;
155
+ const metaCache = new Map<string, RunMeta>();
156
+ // Owned snapshots are process-resident. A cheap directory signature catches
157
+ // cross-process index changes before any cached IDs are reused.
158
+ const indexIdsCache = new Map<string, { ids: Set<string>; signature: string }>();
159
+ const initializedIndexes = new Set<string>();
154
160
  const metaChangedListeners = new Set<() => void>();
161
+ const registryIo = { fullDirectoryReads: 0, indexDirectoryReads: 0, metadataFileReads: 0, indexRevisionChecks: 0 };
162
+
163
+ export interface RegistryIoMetrics {
164
+ fullDirectoryReads: number;
165
+ indexDirectoryReads: number;
166
+ metadataFileReads: number;
167
+ indexRevisionChecks: number;
168
+ }
169
+
170
+ export function getRegistryIoMetrics(): RegistryIoMetrics {
171
+ return { ...registryIo };
172
+ }
173
+
174
+ export function resetRegistryIoMetrics(): void {
175
+ registryIo.fullDirectoryReads = 0;
176
+ registryIo.indexDirectoryReads = 0;
177
+ registryIo.metadataFileReads = 0;
178
+ registryIo.indexRevisionChecks = 0;
179
+ }
155
180
  /** Monotonic, readable, collision-free run id: `sa_<base36-time>_<seq>`. */
156
181
  export function nextRunId(): string {
157
182
  seq += 1;
@@ -161,6 +186,8 @@ export function nextRunId(): string {
161
186
  export function writeMeta(meta: RunMeta): void {
162
187
  mkdirSync(runDir(meta.id), { recursive: true });
163
188
  writeFileSync(metaPathFor(meta.id), JSON.stringify(meta, null, 2));
189
+ metaCache.set(meta.id, meta);
190
+ indexMeta(meta);
164
191
  for (const listener of metaChangedListeners) {
165
192
  try { listener(); } catch { /* best effort */ }
166
193
  }
@@ -173,26 +200,203 @@ export function onMetaChanged(listener: () => void): () => void {
173
200
 
174
201
  export function readMeta(id: string): RunMeta | undefined {
175
202
  try {
176
- return JSON.parse(readFileSync(metaPathFor(id), "utf-8")) as RunMeta;
203
+ registryIo.metadataFileReads += 1;
204
+ const meta = JSON.parse(readFileSync(metaPathFor(id), "utf-8")) as RunMeta;
205
+ metaCache.set(id, meta);
206
+ return meta;
177
207
  } catch {
208
+ metaCache.delete(id);
178
209
  return undefined;
179
210
  }
180
211
  }
181
212
 
213
+ export function removeMetaArtifacts(meta: RunMeta): boolean {
214
+ try {
215
+ rmSync(runDir(meta.id), { recursive: true, force: true });
216
+ metaCache.delete(meta.id);
217
+ removeIndexEntry(join(baseDir(), "by-parent", String(meta.spawnPid)), meta.id);
218
+ removeIndexEntry(join(baseDir(), "by-parent-active", String(meta.spawnPid)), meta.id);
219
+ removeIndexEntry(join(baseDir(), "by-origin", originKey(originOf(meta))), meta.id);
220
+ for (const listener of metaChangedListeners) {
221
+ try { listener(); } catch { /* best effort */ }
222
+ }
223
+ return true;
224
+ } catch {
225
+ return false;
226
+ }
227
+ }
228
+
182
229
  /** All runs, newest first. */
183
230
  export function listMetas(): RunMeta[] {
184
231
  let ids: string[];
185
232
  try {
233
+ registryIo.fullDirectoryReads += 1;
186
234
  ids = readdirSync(join(baseDir(), "runs"));
187
235
  } catch {
188
236
  return [];
189
237
  }
190
238
  return ids
191
- .map(readMeta)
239
+ .map(readMetaForSweep)
192
240
  .filter((m): m is RunMeta => m !== undefined)
193
241
  .sort((a, b) => b.startedAt - a.startedAt);
194
242
  }
195
243
 
244
+ export function listMetasForParent(parentPid: number): RunMeta[] {
245
+ const directory = join(baseDir(), "by-parent", String(parentPid));
246
+ ensureIndex(directory, (meta) => meta.spawnPid === parentPid);
247
+ return readIndexedMetas(directory).filter((meta) => meta.spawnPid === parentPid);
248
+ }
249
+
250
+ export function listActiveMetasForParent(parentPid: number): RunMeta[] {
251
+ const directory = join(baseDir(), "by-parent-active", String(parentPid));
252
+ ensureActiveParentIndex(directory, parentPid);
253
+ return readIndexedMetas(directory)
254
+ .filter((meta) => meta.spawnPid === parentPid && isActiveStatus(meta.status));
255
+ }
256
+
257
+ export function listMetasForOrigin(origin: RunCallbackOrigin): RunMeta[] {
258
+ const directory = join(baseDir(), "by-origin", originKey(origin));
259
+ ensureIndex(directory, (meta) => belongsToOrigin(meta, origin));
260
+ return readIndexedMetas(directory).filter((meta) => belongsToOrigin(meta, origin));
261
+ }
262
+
263
+ function readMetaForSweep(id: string): RunMeta | undefined {
264
+ const cached = metaCache.get(id);
265
+ if (cached && cached.status !== "running" && cached.status !== "orphaned") return cached;
266
+ return readMeta(id);
267
+ }
268
+
269
+ function readIndexedMetas(directory: string): RunMeta[] {
270
+ return readIndexIds(directory)
271
+ .map((id) => metaCache.get(id) ?? readMeta(id))
272
+ .filter((meta): meta is RunMeta => meta !== undefined)
273
+ .sort((a, b) => b.startedAt - a.startedAt);
274
+ }
275
+
276
+ function indexMeta(meta: RunMeta): void {
277
+ try {
278
+ writeIndexEntry(join(baseDir(), "by-parent", String(meta.spawnPid)), meta.id);
279
+ const activeDirectory = join(baseDir(), "by-parent-active", String(meta.spawnPid));
280
+ if (isActiveStatus(meta.status)) writeIndexEntry(activeDirectory, meta.id);
281
+ else removeIndexEntry(activeDirectory, meta.id);
282
+ writeIndexEntry(join(baseDir(), "by-origin", originKey(originOf(meta))), meta.id);
283
+ } catch {
284
+ // Indexes are accelerators; meta.json remains authoritative.
285
+ }
286
+ }
287
+
288
+ function writeIndexEntry(directory: string, id: string): void {
289
+ mkdirSync(directory, { recursive: true });
290
+ const cached = indexIdsCache.get(directory);
291
+ const cacheWasCurrent = cached ? cached.signature === indexDirectorySignature(directory) : false;
292
+ try {
293
+ writeFileSync(join(directory, id), "", { flag: "wx" });
294
+ } catch (error) {
295
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
296
+ }
297
+ if (cached && cacheWasCurrent) {
298
+ cached.ids.add(id);
299
+ cached.signature = indexDirectorySignature(directory);
300
+ } else if (cached) {
301
+ indexIdsCache.delete(directory);
302
+ }
303
+ }
304
+
305
+ function removeIndexEntry(directory: string, id: string): void {
306
+ const cached = indexIdsCache.get(directory);
307
+ const cacheWasCurrent = cached ? cached.signature === indexDirectorySignature(directory) : false;
308
+ try { unlinkSync(join(directory, id)); } catch { /* stale index entries are harmless */ }
309
+ if (cached && cacheWasCurrent) {
310
+ cached.ids.delete(id);
311
+ cached.signature = indexDirectorySignature(directory);
312
+ } else if (cached) {
313
+ indexIdsCache.delete(directory);
314
+ }
315
+ }
316
+
317
+ function readIndexIds(directory: string): string[] {
318
+ const signature = indexDirectorySignature(directory);
319
+ const cached = indexIdsCache.get(directory);
320
+ if (cached && cached.signature === signature) return [...cached.ids];
321
+ try {
322
+ registryIo.indexDirectoryReads += 1;
323
+ const ids = new Set(readdirSync(directory).filter((id) => id !== ".initialized"));
324
+ indexIdsCache.set(directory, { ids, signature: indexDirectorySignature(directory) });
325
+ return [...ids];
326
+ } catch {
327
+ indexIdsCache.delete(directory);
328
+ return [];
329
+ }
330
+ }
331
+
332
+ function isActiveStatus(status: RunStatus): boolean {
333
+ return status === "running" || status === "orphaned";
334
+ }
335
+
336
+ function indexDirectorySignature(directory: string): string {
337
+ registryIo.indexRevisionChecks += 1;
338
+ try {
339
+ const stat = statSync(directory);
340
+ return `${stat.dev}:${stat.ino}:${stat.mtimeMs}:${stat.ctimeMs}`;
341
+ } catch {
342
+ return "missing";
343
+ }
344
+ }
345
+
346
+ function ensureIndex(directory: string, matches: (meta: RunMeta) => boolean): void {
347
+ if (initializedIndexes.has(directory)) return;
348
+ try {
349
+ readFileSync(join(directory, ".initialized"));
350
+ initializedIndexes.add(directory);
351
+ return;
352
+ } catch {
353
+ // Existing registries are backfilled once for each owner.
354
+ }
355
+ indexIdsCache.delete(directory);
356
+ const owned = listMetas().filter(matches);
357
+ mkdirSync(directory, { recursive: true });
358
+ for (const meta of owned) writeIndexEntry(directory, meta.id);
359
+ writeFileSync(join(directory, ".initialized"), "1");
360
+ initializedIndexes.add(directory);
361
+ }
362
+
363
+ function ensureActiveParentIndex(directory: string, parentPid: number): void {
364
+ if (initializedIndexes.has(directory)) return;
365
+ try {
366
+ readFileSync(join(directory, ".initialized"));
367
+ initializedIndexes.add(directory);
368
+ return;
369
+ } catch {
370
+ // Build the active set from the already owner-scoped parent index.
371
+ }
372
+ indexIdsCache.delete(directory);
373
+ const active = listMetasForParent(parentPid).filter((meta) => isActiveStatus(meta.status));
374
+ mkdirSync(directory, { recursive: true });
375
+ for (const meta of active) writeIndexEntry(directory, meta.id);
376
+ writeFileSync(join(directory, ".initialized"), "1");
377
+ initializedIndexes.add(directory);
378
+ }
379
+
380
+ function originOf(meta: RunMeta): RunCallbackOrigin {
381
+ return meta.callbackOrigin ?? { cwd: meta.cwd };
382
+ }
383
+
384
+ function belongsToOrigin(meta: RunMeta, origin: RunCallbackOrigin): boolean {
385
+ const candidate = originOf(meta);
386
+ if (candidate.cwd !== origin.cwd) return false;
387
+ if (candidate.sessionId || origin.sessionId) return candidate.sessionId === origin.sessionId;
388
+ return true;
389
+ }
390
+
391
+ function originKey(origin: RunCallbackOrigin): string {
392
+ return createHash("sha256")
393
+ .update(origin.cwd)
394
+ .update("\0")
395
+ .update(origin.sessionId ?? "")
396
+ .digest("hex")
397
+ .slice(0, 24);
398
+ }
399
+
196
400
  /**
197
401
  * Reconcile the recorded status with reality for display. A run marked
198
402
  * "running" whose PID is no longer alive exited without our handler firing
@@ -97,6 +97,7 @@ type NavigatorState = {
97
97
  };
98
98
 
99
99
  const GLOBAL_KEY = Symbol.for("pi-better-harness.navigator.state");
100
+ const PLAN_NAVIGATION_KEY = Symbol.for("pi-better-harness.plan-navigation.state");
100
101
  const FACTORY_MARK = "__piBetterHarnessNavigatorFactory";
101
102
  const FACTORY_REFRESH = "__piBetterHarnessNavigatorRefresh";
102
103
 
@@ -108,7 +109,7 @@ export const CLOSE_ARM_MS = 3000;
108
109
  export const DEFAULT_LOG_TAIL_ROWS = 10;
109
110
  export const LOG_TAIL_ROW_CHOICES = [10, 25] as const;
110
111
  const MAIN_LIST_FALLBACK_WIDTH = 100;
111
- const DETAIL_OVERLAY_FOOTER_ROWS = 3;
112
+ const DETAIL_OVERLAY_BOTTOM_MARGIN_ROWS = 0;
112
113
  const EVIDENCE_SECTION_ID = "__evidence__";
113
114
  const RUNNING_DOT_GLYPH = "●";
114
115
 
@@ -120,6 +121,14 @@ function state(): NavigatorState {
120
121
  return g[GLOBAL_KEY]!;
121
122
  }
122
123
 
124
+ type PlanNavigationState = { visible: boolean; releaseWorkFocus?: () => void };
125
+
126
+ function planNavigationState(): PlanNavigationState {
127
+ const global = globalThis as typeof globalThis & { [PLAN_NAVIGATION_KEY]?: PlanNavigationState };
128
+ if (!global[PLAN_NAVIGATION_KEY]) global[PLAN_NAVIGATION_KEY] = { visible: false };
129
+ return global[PLAN_NAVIGATION_KEY]!;
130
+ }
131
+
123
132
  export function registerBackgroundWorkProvider(provider: BackgroundWorkProvider): () => void {
124
133
  const s = state();
125
134
  const previousUnsub = s.unsubscribers.get(provider.id);
@@ -161,7 +170,7 @@ export function isNavigatorUiAvailable(ctx: ExtensionContext | undefined): boole
161
170
  }
162
171
 
163
172
  export function navigatorFooterHint(count: number): string | null {
164
- return count > 0 ? `← navigate · ${count}` : null;
173
+ return count > 0 ? `← work · ${count}` : null;
165
174
  }
166
175
 
167
176
  export function applyNavigatorFooter(ui: { setStatus(key: string, value: string | undefined): void }, count: number): string | null {
@@ -185,6 +194,7 @@ export function ensureBackgroundWorkNavigator(ctx: ExtensionContext, deps: HostD
185
194
  s.mainListRequestRender = undefined;
186
195
  s.mainListDeadlineScheduler?.dispose();
187
196
  s.mainListDeadlineScheduler = createRenderScheduler(() => refreshMainListWidget());
197
+ planNavigationState().releaseWorkFocus = unfocusMainList;
188
198
  installNavigatorEditor(ctx.ui as any, deps);
189
199
  s.lastHint = undefined;
190
200
  refreshBackgroundWorkNavigator(ctx);
@@ -211,7 +221,9 @@ export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext): void {
211
221
  s.mainListDeadlineScheduler = undefined;
212
222
  s.mainListWidgetInstalled = false;
213
223
  s.mainListRequestRender = undefined;
224
+ s.editorComponent = undefined;
214
225
  s.detailOverlayRows = undefined;
226
+ planNavigationState().releaseWorkFocus = undefined;
215
227
  s.mainListSelectedId = undefined;
216
228
  s.mainListFocused = false;
217
229
  }
@@ -459,7 +471,10 @@ function buildMainListLines(
459
471
  }
460
472
 
461
473
  function shortcutsLine(focused: boolean, fg: (color: string, value: string) => string): string {
462
- const keys = focused ? "↑↓ switch · Enter detail · x stop · Esc unfocus" : "← to navigate";
474
+ const plan = planNavigationState().visible ? " · → plan" : "";
475
+ const keys = focused
476
+ ? `↑↓ switch · Enter detail · x stop${plan} · Esc unfocus`
477
+ : `← work navigator${plan}`;
463
478
  return dim(keys, fg);
464
479
  }
465
480
 
@@ -1025,7 +1040,7 @@ function buildTranscriptDetailLines(
1025
1040
  }
1026
1041
 
1027
1042
  function detailOverlayOptions() {
1028
- const marginBottom = DETAIL_OVERLAY_FOOTER_ROWS;
1043
+ const marginBottom = DETAIL_OVERLAY_BOTTOM_MARGIN_ROWS;
1029
1044
  return {
1030
1045
  anchor: "top-left" as const,
1031
1046
  width: "100%" as const,