pi-better-harness 0.35.1 → 0.35.2

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
@@ -55,8 +55,8 @@ optional interface is off. See the [usage guide](docs/usage.md#outcome--todo).
55
55
 
56
56
  Subagent and background-task rows can be clicked to open their existing navigator
57
57
  details. Enter and keyboard controls remain available, and editor input stays
58
- passive. No permanent status bar is added; existing critical permission and
59
- paused notices remain unchanged.
58
+ passive. Harness packages do not write to Pi's status bar. Goal holds and resume
59
+ instructions remain available through `/goal`; permission checks are unchanged.
60
60
 
61
61
  ## Screenshots
62
62
 
@@ -90,7 +90,6 @@ const UpdatePlanSchema = Type.Object({
90
90
 
91
91
  type WorkflowPlanInput = Omit<RushPlanUpdate, "event"> & { path?: string; event?: string };
92
92
 
93
- const LEGACY_PLAN_NAV_STATUS_KEY = "pi-better-plan-nav";
94
93
  const goalHeaderRuntime = (goal: GoalSnapshot) => ({ status: goal.status, clock: formatClockDuration(goalTiming(goal).activeSeconds) });
95
94
  const PLAN_ACTIONS: readonly AutocompleteItem[] = [
96
95
  { value: "clear", label: "clear", description: "Remove the current Todo without changing its outcome" },
@@ -289,7 +288,6 @@ export default function taskContextExtension(pi: ExtensionAPI): void {
289
288
  if ((enabled || display) && rushBinding) loadRushPlan(ctx);
290
289
  displayMode = state.displayMode;
291
290
  expanded = state.expanded;
292
- if (ctx.hasUI) ctx.ui.setStatus(LEGACY_PLAN_NAV_STATUS_KEY, undefined);
293
291
  refresh(true);
294
292
  if (enabled) scheduleCompletedPlanClear();
295
293
  };
@@ -695,7 +693,6 @@ export default function taskContextExtension(pi: ExtensionAPI): void {
695
693
  refreshWidget = undefined;
696
694
  widgetContext = undefined;
697
695
  try {
698
- ctx.ui.setStatus(LEGACY_PLAN_NAV_STATUS_KEY, undefined);
699
696
  ctx.ui.setWidget(EXTENSION_NAME, undefined);
700
697
  } catch { /* best-effort cleanup */ }
701
698
  legacyPlanMask?.dispose();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.12.1",
3
+ "version": "0.12.2",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -175,18 +175,15 @@ export function isNavigatorUiAvailable(ctx: ExtensionContext | undefined): boole
175
175
  }
176
176
 
177
177
  export function navigatorFooterHint(_count: number): string | null {
178
- // Retain the compatibility helper so refresh/reload clears any old badge.
178
+ // Retain the compatibility helper without publishing a badge.
179
179
  return null;
180
180
  }
181
181
 
182
182
  export function applyNavigatorFooter(ui: { setStatus(key: string, value: string | undefined): void }, count: number): string | null {
183
- const hint = navigatorFooterHint(count);
184
- ui.setStatus(NAVIGATOR_STATUS_KEY, hint ?? undefined);
185
- return hint;
183
+ return navigatorFooterHint(count);
186
184
  }
187
185
 
188
186
  export function applyCloseConfirmFooter(ui: { setStatus(key: string, value: string | undefined): void }, hint: string | null | undefined): string | null {
189
- ui.setStatus(CLOSE_CONFIRM_STATUS_KEY, hint ?? undefined);
190
187
  return hint ?? null;
191
188
  }
192
189
 
@@ -219,8 +216,6 @@ export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext): void {
219
216
  s.uiCtx = undefined;
220
217
  if (activeCtx && isNavigatorUiAvailable(activeCtx)) {
221
218
  const ui = activeCtx.ui;
222
- try { applyNavigatorFooter(ui as any, 0); } catch { /* ignore */ }
223
- try { applyCloseConfirmFooter(ui as any, null); } catch { /* ignore */ }
224
219
  try { (ui as any).setWidget?.(MAIN_LIST_WIDGET_KEY, undefined); } catch { /* ignore */ }
225
220
  }
226
221
  s.lastHint = undefined;
@@ -241,12 +236,6 @@ export function refreshBackgroundWorkNavigator(ctx?: ExtensionContext): void {
241
236
  const activeCtx = isNavigatorUiAvailable(ctx) ? ctx : s.uiCtx;
242
237
  if (!isNavigatorUiAvailable(activeCtx)) return;
243
238
  try {
244
- const count = visibleCount();
245
- const hint = navigatorFooterHint(count);
246
- if (hint !== s.lastHint) {
247
- applyNavigatorFooter(activeCtx!.ui as any, count);
248
- s.lastHint = hint;
249
- }
250
239
  refreshMainListWidget();
251
240
  } catch { /* ignore */ }
252
241
  }
@@ -271,7 +260,6 @@ function clearMainListCloseArm(): void {
271
260
  s.mainListCloseArmTimer = undefined;
272
261
  if (!s.mainListCloseArm) return;
273
262
  s.mainListCloseArm = undefined;
274
- try { applyCloseConfirmFooter((s.uiCtx as any).ui, null); } catch { /* ignore */ }
275
263
  }
276
264
 
277
265
  function refreshMainListWidget(): void {
@@ -293,6 +281,7 @@ function refreshMainListWidget(): void {
293
281
  const nextSignature = rows.length ? mainListSignature(rows, {
294
282
  selectedId: s.mainListFocused ? s.mainListSelectedId : undefined,
295
283
  focused: s.mainListFocused === true,
284
+ closeHint: closeHintFor(rows.find((row) => row.navigatorId === s.mainListCloseArm?.id)),
296
285
  }) : undefined;
297
286
  const changed = nextSignature !== s.lastMainListSignature;
298
287
  s.lastMainListSignature = nextSignature;
@@ -416,6 +405,9 @@ function createMainListWidget(tui: { requestRender?(): void }, theme: unknown, d
416
405
  selectedId: state().mainListFocused ? state().mainListSelectedId : undefined,
417
406
  focused: state().mainListFocused === true,
418
407
  });
408
+ const armedRow = rows.find((row) => row.navigatorId === state().mainListCloseArm?.id);
409
+ const hint = closeHintFor(armedRow);
410
+ if (hint) { layout.lines.push(deps.truncate(` ${hint}`, renderWidth(width))); layout.owners.push(undefined); }
419
411
  lineWidth = renderWidth(width);
420
412
  // The leading gap line belongs to the widget too, so its slot stays ownerless.
421
413
  lineOwners = [undefined, ...layout.owners];
@@ -630,9 +622,10 @@ function statusIndicator(row: InternalRow, now = Date.now()): { glyph: string; c
630
622
  return { glyph: RUNNING_DOT_GLYPH, color: "accent" };
631
623
  }
632
624
 
633
- function mainListSignature(rows: InternalRow[], options: { selectedId?: string; focused?: boolean } = {}): string {
625
+ function mainListSignature(rows: InternalRow[], options: { selectedId?: string; focused?: boolean; closeHint?: string | null } = {}): string {
634
626
  return JSON.stringify({
635
627
  focused: options.focused === true,
628
+ closeHint: options.closeHint ?? null,
636
629
  selectedId: options.focused === true ? options.selectedId ?? null : null,
637
630
  rows: rows.map((row) => ({
638
631
  navigatorId: row.navigatorId,
@@ -863,7 +856,6 @@ function handleMainListCloseKey(): void {
863
856
  }
864
857
  clearMainListCloseArm();
865
858
  s.mainListCloseArm = { id: row.navigatorId, armedAt: now };
866
- try { applyCloseConfirmFooter((s.uiCtx as any).ui, closeHintFor(row)); } catch { /* ignore */ }
867
859
  s.mainListCloseArmTimer = setTimeout(() => {
868
860
  clearMainListCloseArm();
869
861
  refreshMainListWidget();
@@ -1034,7 +1026,6 @@ function createOverlayComponent(
1034
1026
  closeArmTimer = undefined;
1035
1027
  if (closeArm) {
1036
1028
  closeArm = undefined;
1037
- try { applyCloseConfirmFooter((state().uiCtx as any).ui, null); } catch { /* ignore */ }
1038
1029
  }
1039
1030
  }
1040
1031
 
@@ -1108,11 +1099,9 @@ function createOverlayComponent(
1108
1099
  }
1109
1100
  clearCloseArm();
1110
1101
  closeArm = { id: row.navigatorId, armedAt: now };
1111
- try { applyCloseConfirmFooter((state().uiCtx as any).ui, closeHintFor(row)); } catch { /* ignore */ }
1112
1102
  closeArmTimer = setTimeout(() => {
1113
1103
  closeArmTimer = undefined;
1114
1104
  closeArm = undefined;
1115
- try { applyCloseConfirmFooter((state().uiCtx as any).ui, null); } catch { /* ignore */ }
1116
1105
  requestRender();
1117
1106
  }, CLOSE_ARM_MS);
1118
1107
  closeArmTimer.unref?.();
@@ -1149,6 +1138,9 @@ function createOverlayComponent(
1149
1138
  selectedId: selectedRow()?.navigatorId,
1150
1139
  focused: true,
1151
1140
  });
1141
+ const armedRow = overlayState.rows.find((row) => row.navigatorId === closeArm?.id);
1142
+ const hint = closeHintFor(armedRow);
1143
+ if (hint) { rail.lines.push(deps.truncate(` ${hint}`, renderWidth(width))); rail.owners.push(undefined); }
1152
1144
  const railLines = rail.lines;
1153
1145
  const editorLines = renderEditorLines(width);
1154
1146
  const bottomLines = [...railLines, ...editorLines];
@@ -14,7 +14,7 @@ Use `pi-better-goal` when a Pi session should keep an explicit objective visible
14
14
 
15
15
  - `/goal` runtime for starting, pausing, resuming, completing, and clearing the current objective.
16
16
  - `/goal settings` opens an interactive settings page in the TUI. Select a row with Up/Down and toggle with Space/Enter; changes save immediately, and Escape closes the page. All three controls default to `on`. Automatic continuation controls idle and background-drain wakes; conversational resume controls the agent's Escape-pause resume tool; **Pause on Esc** controls whether editor Escape pauses the active goal. Goal kickoff, `/goal resume`, and `alt+g` remain available with these controls off. For scripts or non-interactive sessions, use `/goal settings auto-continue on|off`, `/goal settings conversational-resume on|off`, and `/goal settings pause-on-escape on|off`; bare `/goal settings` prints the saved values outside the TUI.
17
- - `escape` pauses the active goal by default, and it stays paused while you talk to the agent. With **Pause on Esc** off, observed editor Escape leaves the goal active while preserving native streaming interruption and menu/dialog cancellation. Automatic continuation remains independent. Hosts without terminal/focus inspection and unknown custom editors retain generic abort-to-pause behavior because they cannot identify Escape reliably. With conversational resume enabled, say "go" (the agent then calls `goal_resume`), or use `/goal resume` or `alt+g`. `/goal pause` resumes only through `/goal resume` or `alt+g`. The status line shows the available resume path, and paused goals are never poked. On a macOS terminal without Option-as-Meta, `alt+g` types `©`; use `/goal resume` there.
17
+ - `escape` pauses the active goal by default, and it stays paused while you talk to the agent. With **Pause on Esc** off, observed editor Escape leaves the goal active while preserving native streaming interruption and menu/dialog cancellation. Automatic continuation remains independent. Hosts without terminal/focus inspection and unknown custom editors retain generic abort-to-pause behavior because they cannot identify Escape reliably. With conversational resume enabled, say "go" (the agent then calls `goal_resume`), or use `/goal resume` or `alt+g`. `/goal pause` resumes only through `/goal resume` or `alt+g`. Paused goals are never poked. On a macOS terminal without Option-as-Meta, `alt+g` types `©`; use `/goal resume` there.
18
18
  - Background work that finishes while an `ask_user_question` is pending is handed to the agent right after the answer.
19
19
  - A compact goal widget that does not replace Pi's footer.
20
20
  - Background activity tracking for subagents and other registered providers.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-goal",
3
- "version": "0.10.3",
3
+ "version": "0.10.4",
4
4
  "description": "Pi extension for goal tracking with background-aware continuation.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -374,7 +374,7 @@ export default function (pi: ExtensionAPI): void {
374
374
  * Chat `notify` appends lines above the Working/bash status region. While the
375
375
  * agent is streaming, that height change desyncs Pi main-screen differential
376
376
  * rendering and stacks "Working..." / "Elapsed Xs" into scrollback. Keep
377
- * mid-stream feedback out of the transcript; use the footer status instead.
377
+ * mid-stream non-error feedback out of the transcript.
378
378
  */
379
379
  const notifyGoal = (
380
380
  ctx: ExtensionContext,
@@ -385,11 +385,6 @@ export default function (pi: ExtensionAPI): void {
385
385
  return;
386
386
  }
387
387
  if (isForegroundBusy(ctx) && type !== "error") {
388
- try {
389
- ctx.ui.setStatus(EXTENSION_NAME, message);
390
- } catch {
391
- // Footer status is best-effort only.
392
- }
393
388
  return;
394
389
  }
395
390
  ctx.ui.notify(message, type);
@@ -424,7 +419,6 @@ export default function (pi: ExtensionAPI): void {
424
419
  clearIdleContinuation();
425
420
  syncPollingState();
426
421
  syncResumeTool(goal);
427
- applyStatus(ctx);
428
422
  // Force a full redraw when the dock height changes (absent ↔ visible clock).
429
423
  refreshGoalWidget?.(!wasVisible || !isGoalClockVisible(goal));
430
424
  };
@@ -443,7 +437,6 @@ export default function (pi: ExtensionAPI): void {
443
437
  clearIdleContinuation();
444
438
  syncPollingState();
445
439
  syncResumeTool(null);
446
- applyStatus(ctx);
447
440
  refreshGoalWidget?.(wasVisible);
448
441
  };
449
442
 
@@ -726,41 +719,9 @@ export default function (pi: ExtensionAPI): void {
726
719
  pi.events.emit(EVENT_TERMINAL_ATTENTION, snapshot);
727
720
  if (execution !== executionGeneration) return null;
728
721
  }
729
- applyStatus(ctx);
730
-
731
722
  return snapshot;
732
723
  };
733
724
 
734
- /** Footer status from the goal state and the latest activity snapshot. */
735
- const statusText = (ctx: ExtensionContext, snapshot: ActivitySnapshot | null): string | undefined => {
736
- const goal = getGoal(ctx);
737
- let waitingOnAnswer = 0;
738
- if (snapshot) {
739
- for (const activeAtStart of pendingQuestions.values()) {
740
- waitingOnAnswer += finishedSinceQuestion(activeAtStart, snapshot).length;
741
- }
742
- }
743
- if (waitingOnAnswer > 0) return `${waitingOnAnswer} background done; waiting on your answer`;
744
- if (goal?.status === "paused") return pausedGoalStatus(goal, preferences.conversationalResume);
745
- if (snapshot?.backgroundRunning) return undefined;
746
- const continuation = goal ? currentContinuationState(ctx, goal.goalId) : null;
747
- if (continuation?.blocked) return "waiting: no progress";
748
- const goalStall = observeGoalStall(goal, continuation, {
749
- foregroundRunning,
750
- backgroundRunning: snapshot?.backgroundRunning ?? false,
751
- });
752
- return goalStall?.state === "stalled" ? "goal stalled" : undefined;
753
- };
754
-
755
- function applyStatus(ctx: ExtensionContext): void {
756
- if (!ctx.hasUI) return;
757
- try {
758
- ctx.ui.setStatus(EXTENSION_NAME, statusText(ctx, latestSnapshot));
759
- } catch {
760
- // UI status is best-effort only.
761
- }
762
- }
763
-
764
725
  /**
765
726
  * `goal_resume` is in the model's tool list only while an escape-paused goal
766
727
  * waits. Pi activates newly registered tools by default, so this also removes
@@ -918,7 +879,6 @@ export default function (pi: ExtensionAPI): void {
918
879
  preferences = writeGoalPreference(key, enabled);
919
880
  if (previous.autoContinue !== preferences.autoContinue) clearIdleContinuation();
920
881
  syncResumeTool(getGoal(ctx));
921
- applyStatus(ctx);
922
882
  if (!previous.autoContinue && preferences.autoContinue) {
923
883
  const snapshot = await publishSnapshot(ctx);
924
884
  const goal = getGoal(ctx);
@@ -1578,7 +1538,6 @@ export default function (pi: ExtensionAPI): void {
1578
1538
  if (blocked) {
1579
1539
  refreshGoalWidget?.();
1580
1540
  if (ctx.hasUI) {
1581
- ctx.ui.setStatus(EXTENSION_NAME, "waiting: no progress");
1582
1541
  ctx.ui.notify(
1583
1542
  `Goal automatic continuation is waiting after ${noProgressRetries} identical retries (${evidence.summary}). Use /goal resume or alt+g to resume it.`,
1584
1543
  "warning",
@@ -1630,7 +1589,6 @@ export default function (pi: ExtensionAPI): void {
1630
1589
  latestSnapshot = null;
1631
1590
  collectionPending = false;
1632
1591
  try {
1633
- ctx.ui.setStatus(EXTENSION_NAME, undefined);
1634
1592
  ctx.ui.setWidget(EXTENSION_NAME, undefined);
1635
1593
  } catch {
1636
1594
  // Best-effort cleanup.
@@ -93,7 +93,7 @@ A profile sets defaults for later `remote_bash` calls. `use` accepts a configure
93
93
  }
94
94
  ```
95
95
 
96
- Call `ssh_profile` with `status` to inspect the active profile or `clear` to remove it. A footer chip shows the active target, workdir, and whether its mux is up or down. An explicit `host`, `workdir`, or environment entry on `remote_bash` takes precedence over the corresponding profile default.
96
+ Call `ssh_profile` with `status` to inspect the active profile or `clear` to remove it. An explicit `host`, `workdir`, or environment entry on `remote_bash` takes precedence over the corresponding profile default.
97
97
 
98
98
  ## ControlMaster
99
99
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-ssh",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Pi extension for safe synchronous remote bash commands over reusable SSH connections.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -21,8 +21,6 @@ import { executeRemoteBash, resolveRemoteBashConnection } from "./remote-bash.js
21
21
  import type { RemoteBashDependencies, RemoteBashResult } from "./remote-bash.js";
22
22
  import { resolveSshCommand } from "./shared-ssh-core/index.js";
23
23
 
24
- const STATUS_KEY = "pi-better-ssh";
25
-
26
24
  const RemoteBashParams = Type.Object({
27
25
  command: Type.String({ description: "Bash command string to execute remotely." }),
28
26
  host: Type.Optional(Type.String({ description: "SSH Host alias or user@host target. Optional when an active SSH profile supplies the host." })),
@@ -114,11 +112,7 @@ export function registerSshExtension(pi: ExtensionAPI, dependencies: RegisterSsh
114
112
  ...dependencies,
115
113
  muxRegistry,
116
114
  getActiveProfile: () => activeProfile,
117
- onResult: (result, params, ctx) => {
118
- if (activeProfile && params.host === undefined) {
119
- setFooter(ctx, activeProfile, result.mux.state);
120
- }
121
- },
115
+ onResult: () => {},
122
116
  });
123
117
 
124
118
  const resolveMuxTarget = (params: {
@@ -158,22 +152,11 @@ export function registerSshExtension(pi: ExtensionAPI, dependencies: RegisterSsh
158
152
  }
159
153
  };
160
154
 
161
- const setFooter = (ctx: ExtensionContext, profile: SshProfile | undefined, muxState?: "up" | "down"): void => {
162
- try {
163
- ctx.ui.setStatus(STATUS_KEY, profile ? formatSshProfileChip(profile, muxState ?? "down") : undefined);
164
- } catch {
165
- // Footer status is best-effort in non-interactive and partial harness contexts.
166
- }
167
- };
168
-
169
- const refreshFooter = async (ctx: ExtensionContext) => {
155
+ const refreshMux = async (ctx: ExtensionContext) => {
170
156
  if (!activeProfile) {
171
- setFooter(ctx, undefined);
172
157
  return undefined;
173
158
  }
174
- const mux = await queryMux(activeProfile, ctx.sessionManager.getSessionId());
175
- setFooter(ctx, activeProfile, mux.state);
176
- return mux;
159
+ return queryMux(activeProfile, ctx.sessionManager.getSessionId());
177
160
  };
178
161
 
179
162
  pi.registerTool({
@@ -192,19 +175,18 @@ export function registerSshExtension(pi: ExtensionAPI, dependencies: RegisterSsh
192
175
  const aliases = listSshHostAliases(dependencies.sshConfigPath);
193
176
  activeProfile = createSshProfile(params, aliases, dependencies.sshConfigPath);
194
177
  pi.appendEntry(SSH_PROFILE_ENTRY_TYPE, profileStateEntry(activeProfile));
195
- const mux = await refreshFooter(ctx);
178
+ const mux = await refreshMux(ctx);
196
179
  const details = { action: "use" as const, active: activeProfile, mux };
197
180
  return { content: [{ type: "text" as const, text: `Active SSH profile: ${formatSshProfileChip(activeProfile, mux?.state ?? "down")}` }], details };
198
181
  }
199
182
  if (params.action === "clear") {
200
183
  activeProfile = undefined;
201
184
  pi.appendEntry(SSH_PROFILE_ENTRY_TYPE, profileStateEntry(undefined));
202
- setFooter(ctx, undefined);
203
185
  const details = { action: "clear" as const, active: null };
204
186
  return { content: [{ type: "text" as const, text: "Active SSH profile cleared." }], details };
205
187
  }
206
188
 
207
- const mux = activeProfile ? await refreshFooter(ctx) : undefined;
189
+ const mux = activeProfile ? await refreshMux(ctx) : undefined;
208
190
  const details = { action: "status" as const, active: activeProfile ?? null, ...(mux ? { mux } : {}) };
209
191
  return {
210
192
  content: [{
@@ -235,12 +217,6 @@ export function registerSshExtension(pi: ExtensionAPI, dependencies: RegisterSsh
235
217
  ? await muxRegistry.statusAll(sessionScope)
236
218
  : await muxRegistry.stopAll(sessionScope);
237
219
  const masters = results.map(muxResultDetails);
238
- if (activeProfile) {
239
- const activeTarget = resolveMuxTarget({ host: activeProfile.host }).identity.target;
240
- const activeMux = masters.find((master) => master.target === activeTarget);
241
- if (params.action === "stop") setFooter(ctx, activeProfile, "down");
242
- else if (activeMux) setFooter(ctx, activeProfile, activeMux.state === "up" ? "up" : "down");
243
- }
244
220
  const details = { action: params.action, scope: "all" as const, masters };
245
221
  return { content: [{ type: "text" as const, text: formatMuxResults(masters) }], details };
246
222
  }
@@ -250,12 +226,6 @@ export function registerSshExtension(pi: ExtensionAPI, dependencies: RegisterSsh
250
226
  ? await muxRegistry.statusTarget(resolved, sessionScope)
251
227
  : await muxRegistry.stopTarget(resolved, sessionScope);
252
228
  const masters = [muxResultDetails(result)];
253
- if (activeProfile) {
254
- const activeTarget = resolveMuxTarget({ host: activeProfile.host }).identity.target;
255
- if (resolved.identity.target === activeTarget) {
256
- setFooter(ctx, activeProfile, result.state === "up" ? "up" : "down");
257
- }
258
- }
259
229
  const details = { action: params.action, scope: "target" as const, masters };
260
230
  return { content: [{ type: "text" as const, text: formatMuxResults(masters) }], details };
261
231
  },
@@ -263,11 +233,10 @@ export function registerSshExtension(pi: ExtensionAPI, dependencies: RegisterSsh
263
233
 
264
234
  const restore = async (ctx: ExtensionContext): Promise<void> => {
265
235
  activeProfile = restoreSshProfile(ctx.sessionManager.getBranch());
266
- await refreshFooter(ctx);
236
+ await refreshMux(ctx);
267
237
  };
268
238
  pi.on("session_start", async (_event, ctx) => restore(ctx));
269
239
  pi.on("session_tree", async (_event, ctx) => restore(ctx));
270
- pi.on("session_shutdown", async (_event, ctx) => setFooter(ctx, undefined));
271
240
  }
272
241
 
273
242
  export default function sshExtension(pi: ExtensionAPI): void {
@@ -11,6 +11,7 @@ import { isAbsolute, resolve } from "node:path";
11
11
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
12
12
  import { presentCatalog, presentCatalogEntry, type LaunchEnricher, type OperationView } from "./agent-inspection.ts";
13
13
  import { createCatalogComponent } from "./catalog-ui.ts";
14
+ import type { ScopedModelLike } from "./catalog-editor.ts";
14
15
  import {
15
16
  CATALOG_SCHEMA_VERSION,
16
17
  DiagnosticCodes,
@@ -95,6 +96,11 @@ export interface AgentCommandHost {
95
96
  hasUI: boolean;
96
97
  mode?: "tui" | "rpc" | "json" | "print";
97
98
  isProjectTrusted(): boolean;
99
+ /**
100
+ * Session model scope from `ctx.scopedModels`. Empty or absent means no
101
+ * scope, which Pi treats as "all available models are usable".
102
+ */
103
+ scopedModels?: readonly ScopedModelLike[];
98
104
  ui: AgentCommandUi;
99
105
  }
100
106
 
@@ -150,10 +156,20 @@ export function executeAgentsCommandContext(args: string, ctx: ExtensionCommandC
150
156
  hasUI: ctx.hasUI,
151
157
  mode: ctx.mode,
152
158
  isProjectTrusted: () => ctx.isProjectTrusted(),
159
+ scopedModels: scopedModelsFromContext(ctx),
153
160
  ui: ctx.ui,
154
161
  }, deps);
155
162
  }
156
163
 
164
+ /**
165
+ * Older Pi versions do not expose `ctx.scopedModels`, so they retain the
166
+ * unscoped picker. Current hosts use an empty array when no scope is configured.
167
+ */
168
+ function scopedModelsFromContext(ctx: ExtensionCommandContext): readonly ScopedModelLike[] {
169
+ const scoped = (ctx as unknown as { scopedModels?: unknown }).scopedModels;
170
+ return Array.isArray(scoped) ? scoped as readonly ScopedModelLike[] : [];
171
+ }
172
+
157
173
  export async function executeAgentsCommand(args: string, host: AgentCommandHost, deps: AgentCommandDeps = {}): Promise<AgentCommandResult> {
158
174
  const parsed = parseCommandArgs(args);
159
175
  if (parsed.positionals.length === 0 && parsed.flags.size === 0 && host.mode === "tui" && host.hasUI && host.ui.custom) parsed.positionals.push("list");
@@ -729,6 +745,7 @@ async function finishInspection(host: AgentCommandHost, commandResult: AgentComm
729
745
  createCatalogComponent(entries, theme, () => tui.requestRender(), () => done(), view?.id, deps.sessionSettings ? {
730
746
  get: () => presentCatalog(readCatalog(location(host, deps)), deps.enrich),
731
747
  models: () => deps.editableModels?.() ?? [],
748
+ scopedModels: () => host.scopedModels ?? [],
732
749
  settings: () => deps.sessionSettings!.snapshot(),
733
750
  change: (id, key, value) => deps.sessionSettings!.change(id, key, value),
734
751
  save(id) {
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Model choices for the agent catalog's session-settings picker.
3
+ *
4
+ * Pi scopes model selection with `--models` / `enabledModels` and exposes the
5
+ * resolved set to extensions as `ctx.scopedModels`. Pi's contract for that
6
+ * field (pi-coding-agent extension types): "Empty when no scoping is
7
+ * configured (all available models are usable)." Pi's own `/model` selector
8
+ * applies the same rule: it starts in the `scoped` view when the resolved set
9
+ * is non-empty and in the `all` view when it is empty.
10
+ *
11
+ * The catalog picker follows those semantics, because an agent's model is a
12
+ * per-session preference rather than a second model policy:
13
+ *
14
+ * - non-empty scope: offer exactly the scoped models, in the order the session
15
+ * configured them;
16
+ * - empty scope: nothing is restricted, so fall back to the foreground
17
+ * registry's available models, which is what the picker offered before
18
+ * scoping existed.
19
+ *
20
+ * A saved preference outside the scope is never rewritten: it stays on the
21
+ * definition and in the detail view, but the picker does not offer it, so the
22
+ * scope is the only source of selectable models. Only in the no-scope fallback
23
+ * does the current value stay in the list when it is no longer available, which
24
+ * is what the picker did before scoping existed. Nothing here validates or
25
+ * restricts launches: catalog and launch resolution keep using the value they
26
+ * already had.
27
+ */
28
+
29
+ /** Structural view of one `ctx.scopedModels` entry (`{ model, thinkingLevel? }`). */
30
+ export interface ScopedModelLike {
31
+ readonly model?: { readonly provider?: unknown; readonly id?: unknown } | null;
32
+ readonly provider?: unknown;
33
+ readonly id?: unknown;
34
+ }
35
+
36
+ export type CatalogModelScope = "scoped" | "all";
37
+
38
+ export interface CatalogModelChoices {
39
+ /** Values the picker offers, in display order. */
40
+ values: string[];
41
+ /** `scoped` when the session restricts models; `all` when the fallback applies. */
42
+ scope: CatalogModelScope;
43
+ /** True when no session scope was configured and available models were used. */
44
+ fallback: boolean;
45
+ /**
46
+ * Saved value the offered set does not contain. Scoped mode leaves it out of
47
+ * `values`; the no-scope fallback keeps it in `values` so it stays selectable.
48
+ */
49
+ retained?: string;
50
+ }
51
+
52
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
53
+ return typeof value === "object" && value !== null ? value as Record<string, unknown> : undefined;
54
+ }
55
+
56
+ function referencePart(value: unknown): string | undefined {
57
+ if (typeof value !== "string") return undefined;
58
+ const trimmed = value.trim();
59
+ return trimmed.length > 0 ? trimmed : undefined;
60
+ }
61
+
62
+ /**
63
+ * Canonical `provider/model` reference for a scoped model entry.
64
+ * Accepts `{ model: { provider, id } }` (Pi's shape) and a bare
65
+ * `{ provider, id }` so callers can pass either.
66
+ */
67
+ export function modelReference(entry: unknown): string | undefined {
68
+ const record = asRecord(entry);
69
+ if (!record) return undefined;
70
+ const scoped = asRecord(record.model) ?? record;
71
+ const provider = referencePart(scoped.provider);
72
+ const id = referencePart(scoped.id);
73
+ return provider && id ? `${provider}/${id}` : undefined;
74
+ }
75
+
76
+ /** Scoped model references, in session order, with malformed and duplicate entries dropped. */
77
+ export function scopedModelReferences(scoped: readonly unknown[] | null | undefined): string[] {
78
+ const seen = new Set<string>();
79
+ const references: string[] = [];
80
+ for (const entry of scoped ?? []) {
81
+ const reference = modelReference(entry);
82
+ if (!reference || seen.has(reference)) continue;
83
+ seen.add(reference);
84
+ references.push(reference);
85
+ }
86
+ return references;
87
+ }
88
+
89
+ /**
90
+ * Picker choices for the model field.
91
+ *
92
+ * `scoped` is the session scope (`ctx.scopedModels`); `available` is the
93
+ * foreground registry's available models used only when the scope is empty;
94
+ * `current` is the saved catalog/session value, reported through `retained`
95
+ * when the offered set does not contain it.
96
+ */
97
+ export function catalogModelChoices(input: {
98
+ scoped?: readonly unknown[] | null;
99
+ available?: readonly string[] | null;
100
+ current?: string | null;
101
+ }): CatalogModelChoices {
102
+ const scoped = scopedModelReferences(input.scoped);
103
+ const fallback = scoped.length === 0;
104
+ const values = fallback
105
+ ? [...new Set((input.available ?? []).map((value) => value.trim()).filter((value) => value.length > 0))].sort()
106
+ : [...scoped];
107
+ const current = referencePart(input.current);
108
+ const retained = current && !values.includes(current) ? current : undefined;
109
+ if (retained && fallback) values.push(retained);
110
+ return { values, scope: fallback ? "all" : "scoped", fallback, ...(retained ? { retained } : {}) };
111
+ }
112
+
113
+ /** One-line note above the model list explaining where the options come from. */
114
+ export function catalogModelScopeLabel(choices: CatalogModelChoices): string {
115
+ if (choices.fallback) return "All available models \u00b7 no session scope";
116
+ const count = choices.values.length;
117
+ const scope = `Session model scope \u00b7 ${count} model${count === 1 ? "" : "s"}`;
118
+ return choices.retained ? `${scope} \u00b7 saved value is outside this scope` : scope;
119
+ }
120
+
121
+ /** Option label; only the retained saved value of the no-scope fallback is annotated. */
122
+ export function catalogModelOptionLabel(choices: CatalogModelChoices, value: string): string {
123
+ if (choices.scope !== "all" || !choices.retained || value !== choices.retained) return value;
124
+ return `${value} \u00b7 saved, unavailable`;
125
+ }
@@ -1,13 +1,17 @@
1
1
  import { Input, SelectList, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
2
  import type { OperationList, OperationView } from "./agent-inspection.ts";
3
3
  import { EFFORT_LEVELS } from "./catalog-schema.ts";
4
+ import { catalogModelChoices, catalogModelOptionLabel, catalogModelScopeLabel, type ScopedModelLike } from "./catalog-editor.ts";
4
5
  import type { AgentSessionOverrides, AgentSettingKey } from "./agent-session-settings.ts";
5
6
 
6
7
  type Theme = { fg(color: string, text: string): string; bg?(color: string, text: string): string; bold?(text: string): string; inverse?(text: string): string };
7
8
 
8
9
  export interface CatalogEditor {
9
10
  get(): OperationList;
11
+ /** Available foreground models, used only when the session scope is empty. */
10
12
  models(): readonly string[];
13
+ /** Session model scope (`ctx.scopedModels`). Empty or absent means no scope. */
14
+ scopedModels?(): readonly ScopedModelLike[];
11
15
  settings(): AgentSessionOverrides;
12
16
  change(id: string, key: AgentSettingKey, value: string | null): void;
13
17
  save(id: string): { ok: boolean; message: string };
@@ -142,6 +146,7 @@ export function createCatalogComponent(
142
146
  let picker: SelectList | undefined;
143
147
  let filterPicker: ((filter: string) => void) | undefined;
144
148
  let pickerKey: AgentSettingKey | undefined;
149
+ let modelScopeNote: string | undefined;
145
150
  const search = new Input();
146
151
  let focused = false;
147
152
  let message = "";
@@ -162,8 +167,15 @@ export function createCatalogComponent(
162
167
  if (!current || !editor) return;
163
168
  if (!current.definitionValid) { report("Repair the catalog definition before editing.", true); return; }
164
169
  const key: AgentSettingKey = settingRow === 0 ? "model" : "effort";
165
- const values = key === "model" ? [...new Set([...(editor.models()), ...(current.fields?.model.value ? [current.fields.model.value] : [])])].sort() : [...EFFORT_LEVELS];
166
- const items = [{ value: "", label: "Inherit" }, ...values.map((value) => ({ value, label: value }))];
170
+ // Pi treats an empty scope as unrestricted. Scoped choices never include
171
+ // an out-of-scope saved value; the detail view still displays it.
172
+ const choices = key === "model"
173
+ ? catalogModelChoices({ scoped: editor.scopedModels?.(), available: editor.models(), current: current.fields?.model.value })
174
+ : undefined;
175
+ modelScopeNote = choices ? catalogModelScopeLabel(choices) : undefined;
176
+ const items = [{ value: "", label: "Inherit" }, ...(choices
177
+ ? choices.values.map((value) => ({ value, label: catalogModelOptionLabel(choices, value) }))
178
+ : EFFORT_LEVELS.map((value) => ({ value, label: value })))];
167
179
  pickerKey = key;
168
180
  search.setValue("");
169
181
  search.focused = focused && key === "model";
@@ -189,7 +201,11 @@ export function createCatalogComponent(
189
201
  picker.onSelect = onSelect;
190
202
  };
191
203
  filterPicker("");
192
- picker!.setSelectedIndex(Math.max(0, items.findIndex((item) => item.value === current.fields?.[key].value)));
204
+ // A saved value the scope does not offer is not in the list. Highlight
205
+ // the first offered model instead of "Inherit" so Enter does not clear
206
+ // the saved preference by accident.
207
+ const currentIndex = items.findIndex((item) => item.value === current.fields?.[key].value);
208
+ picker!.setSelectedIndex(Math.max(0, currentIndex >= 0 ? currentIndex : choices && choices.scope === "scoped" ? 1 : 0));
193
209
  }
194
210
  const filtered = () => catalog.entries.filter((entry) =>
195
211
  [entry.id, entry.identity.name, entry.identity.description, entry.role?.id]
@@ -226,6 +242,7 @@ export function createCatalogComponent(
226
242
 
227
243
  if (picker) return fit([
228
244
  theme.fg("accent", `Agents / ${pickerKey}`), "",
245
+ ...(pickerKey === "model" && modelScopeNote ? [theme.fg("dim", modelScopeNote), ""] : []),
229
246
  ...(pickerKey === "model" ? search.render(safeWidth) : []),
230
247
  ...picker.render(safeWidth),
231
248
  ...(message ? [theme.fg(error ? "error" : "muted", message)] : []),
@@ -51,7 +51,9 @@ new sessions start from catalog defaults.
51
51
  The catalog host supplies a live `getSessionSettings` getter so inspection
52
52
  reflects edits made while the overlay is open. `loadLaunchSnapshot` reads that
53
53
  getter once and freezes the overlay for each batch. Later edits cannot alter
54
- already-admitted jobs. Model choices come from the current Pi model registry.
54
+ already-admitted jobs. Model choices come from the session's Pi model scope
55
+ (`ctx.scopedModels`); an empty scope is no restriction, so they fall back to the
56
+ current Pi model registry's available models.
55
57
 
56
58
  Ctrl+S saves only the selected definition's own preferences, never flattened
57
59
  inherited settings. Bundled roles are copied to user scope; existing user and
@@ -10,7 +10,9 @@ In an interactive TUI, `/agents`, `/agents list`, and `/agents show` open a focu
10
10
 
11
11
  ### Model and effort edits
12
12
 
13
- When lifecycle supplies `sessionSettings`, press `e` in detail to edit Model or Effort. Up/Down selects the setting and Enter opens its choices. Model choices come from the foreground registry, plus the current preference if unavailable; type to filter model names. Effort choices use the existing catalog effort levels. Escape cancels an unselected choice. Only these two fields are editable; instructions, base role, tier, and execution controls are unchanged.
13
+ When lifecycle supplies `sessionSettings`, press `e` in detail to edit Model or Effort. Up/Down selects the setting and Enter opens its choices. Model choices follow the session's model scope (`ctx.scopedModels`, the same resolved set `/scoped-models` shows), with a header row naming the source, and type filters the names. A saved value outside the scope stays on the definition and in the detail view but is not offered by the picker; the header says so instead. An empty scope means no restriction in Pi, so the picker falls back to the foreground registry's available models, keeping a current preference that is no longer available labelled `saved`. Effort choices use the existing catalog effort levels. Escape cancels an unselected choice. Only these two fields are editable; instructions, base role, tier, and execution controls are unchanged.
14
+
15
+ The scope affects only the picker. Launch and runtime model resolution keep reading the catalog/session preference as before, and the package imposes no global model restriction.
14
16
 
15
17
  A selected value immediately appends a custom entry to the current session branch. It does not write a catalog file. Reloading the extension or session restores the active branch's edits. Branches share their common ancestor settings but subsequent edits do not cross to siblings. Role edits flow into named agents that inherit that field. Choosing `Inherit` removes the selected definition's own value, including a saved agent override, instead of storing an explicit null preference. For a role with no parent, that removes its default. Later role/file edits remain live.
16
18
 
@@ -74,6 +74,10 @@ Credential-like keys (`token`, `apiKey`, `password`, `connectionString`, and sim
74
74
 
75
75
  ## Sources
76
76
 
77
+ For external consumers, the repo-root `agent-catalog.yaml` publishes the shared
78
+ definitions through a [Git subscription contract](catalog-subscription.md).
79
+ This publication mechanism does not change local source precedence below.
80
+
77
81
  Precedence is project, then personal, then bundled. The higher source replaces the whole definition. Omitted agent fields inherit from the effective role, not from the shadowed agent.
78
82
 
79
83
  | Scope | Path |
@@ -615,10 +615,8 @@ is unchanged in every mode.
615
615
  still running, press `←` to focus the main-window subagent list above the
616
616
  input line.
617
617
  - If the editor contains text, `←` keeps normal cursor-left behavior.
618
- - While running runs exist, the default footer shows `← subagents · N`. The
619
- live widget also includes a secondary `← to navigate` hint on its title line
620
- for terminals that do not render the default footer status. The hint clears
621
- when no non-dismissed current-parent run is still running.
618
+ - The work list shows a navigation hint while running work is visible. The
619
+ subagent navigator does not write to the status bar.
622
620
  - While the main-window list is focused, the title hint changes to
623
621
  `Enter to view · x to stop`; the selected row is marked with `›`. Press
624
622
  `↓` from the bottom row to return to the input line.
@@ -660,7 +658,7 @@ is unchanged in every mode.
660
658
  ### Two-press `x` stop/dismiss
661
659
 
662
660
  - First `x` on the selected (list) or viewed (detail) run arms the action for three
663
- seconds and shows a footer hint: `x again to stop <name>` while running, or
661
+ seconds and shows a hint in the navigator: `x again to stop <name>` while running, or
664
662
  `x again to dismiss <name>` when terminal.
665
663
  - Second `x` within the window, on the **same** run, acts:
666
664
  - **Running** — stop the process group (shared `subagent_stop` semantics),
@@ -671,7 +669,7 @@ is unchanged in every mode.
671
669
 
672
670
  ### Dismissal is navigator-only
673
671
 
674
- Dismissed runs leave the navigator list and footer count. Logs, prompt, session
672
+ Dismissed runs leave the navigator list. Logs, prompt, session
675
673
  data, metadata, and id-based tool access stay intact. `subagent_list`,
676
674
  `subagent_output`, `subagent_result`, and `subagent_stop` still resolve dismissed
677
675
  run ids. Dismissal survives `/reload` as dismissed in the navigator.
@@ -679,9 +677,8 @@ run ids. Dismissal survives `/reload` as dismissed in the navigator.
679
677
  ### Reload and teardown
680
678
 
681
679
  `/reload` and session restart reinstall the empty-editor wrapper without stacking
682
- duplicate handlers, republish the footer count, and clear any leftover overlay
683
- timers or close-confirm state. Session shutdown disposes open navigator timers
684
- and clears navigator footer statuses (TUI only).
680
+ duplicate handlers and clear any leftover overlay timers or close-confirm
681
+ state. Session shutdown disposes open navigator timers.
685
682
 
686
683
  ## Design notes
687
684
 
@@ -20,8 +20,6 @@ import { CustomEditor } from "@earendil-works/pi-coding-agent";
20
20
  import { matchesKey, Key, truncateToWidth, type AutocompleteItem } from "@earendil-works/pi-tui";
21
21
  import { Type } from "@earendil-works/pi-ai";
22
22
  import {
23
- CLOSE_CONFIRM_STATUS_KEY,
24
- NAVIGATOR_STATUS_KEY,
25
23
  disposeBackgroundWorkNavigator,
26
24
  ensureBackgroundWorkNavigator,
27
25
  isNavigatorUiAvailable,
@@ -986,13 +984,11 @@ export function setIdentityProbeForTests(probe: ProcessProbe | undefined): void
986
984
  //
987
985
  // Human-facing TUI surface. Glue points, all gated on isNavigatorUiAvailable so
988
986
  // print/RPC sessions never see any of it:
989
- // 1. footer hint `← subagents · N` via the DEFAULT footer status mechanism
990
- // (setStatus — the full footer is never replaced);
991
- // 2. an editor wrapper that intercepts bare ← only when the editor is empty
987
+ // 1. an editor wrapper that intercepts bare ← only when the editor is empty
992
988
  // and at least one non-dismissed current-parent run is running,
993
989
  // delegating everything else to the wrapped
994
990
  // editor (composition via navigator.mjs, tested with fakes);
995
- // 3. a focused overlay (ctx.ui.custom(..., { overlay: true })) listing the
991
+ // 2. a focused overlay (ctx.ui.custom(..., { overlay: true })) listing the
996
992
  // #44 navigatorVisibleRuns newest first, with Enter → live detail view
997
993
  // that refreshes once per second (#46) and two-press `x` Close (#47).
998
994
  // Detail + close-arm timers dispose on back, Escape, overlay close,
@@ -1103,14 +1099,6 @@ function navigatorCloseRun(id: string) {
1103
1099
  return outcome;
1104
1100
  }
1105
1101
 
1106
- /** Publish/clear the Close confirmation footer hint (TUI only). */
1107
- function publishCloseConfirmHint(ctx: ExtensionContext, hint: string | null): void {
1108
- if (!isNavigatorUiAvailable(ctx)) return;
1109
- try {
1110
- ctx.ui.setStatus(CLOSE_CONFIRM_STATUS_KEY, hint ?? undefined);
1111
- } catch { /* ignore */ }
1112
- }
1113
-
1114
1102
  function statusTone(status: string): BackgroundWorkRow["statusTone"] {
1115
1103
  switch (status) {
1116
1104
  case "running": return "running";
@@ -1409,7 +1397,7 @@ function openNavigator(ctx: ExtensionContext, initialDetailId?: string): void {
1409
1397
  ensureNavigator(ctx);
1410
1398
  }
1411
1399
 
1412
- /** Publish/clear the running-only `← subagents · N` footer hint (dirty-checked). */
1400
+ /** Refresh the shared navigator after run changes. */
1413
1401
  function updateNavigatorFooter(ctx: ExtensionContext | undefined): void {
1414
1402
  refreshBackgroundWorkNavigator(ctx);
1415
1403
  }
@@ -2460,8 +2448,6 @@ export default function (pi: ExtensionAPI) {
2460
2448
  // - Drop any leftover overlay timers/confirm state from a prior session
2461
2449
  // (defensive if the host skipped session_shutdown before re-start).
2462
2450
  // - Reinstall the editor wrapper without stacking (marked factory).
2463
- // - Clear + republish footer statuses (pi clears extension statuses on
2464
- // session switch/reload; dirty-check only dedupes within a session).
2465
2451
  // - Repaint the widget even if its last in-memory lines match; the host
2466
2452
  // may have dropped extension UI during reload/session replacement.
2467
2453
  ensureSubagentProvider();
@@ -2471,9 +2457,6 @@ export default function (pi: ExtensionAPI) {
2471
2457
  disposeBackgroundWorkNavigator(ctx);
2472
2458
  widgetNavActive = false;
2473
2459
  widgetNavSelectedId = undefined;
2474
- if (isNavigatorUiAvailable(ctx)) {
2475
- try { ctx.ui.setStatus(CLOSE_CONFIRM_STATUS_KEY, undefined); } catch { /* ignore */ }
2476
- }
2477
2460
  ensureNavigator(ctx);
2478
2461
  publishAcceptanceHooks();
2479
2462
  updateNavigatorFooter(ctx);
@@ -2522,14 +2505,7 @@ export default function (pi: ExtensionAPI) {
2522
2505
  // Always dispose navigator detail timers (no UI call — just clearInterval).
2523
2506
  // Safe in every mode; the dispose hook is only set when a TUI overlay opened.
2524
2507
  disposeBackgroundWorkNavigator(ctx);
2525
- // Widget clear is intentional in every mode that exposes ui (incl. RPC
2526
- // — pi docs: setWidget works in both TUI and RPC). Navigator cleanup
2527
- // is TUI-only: the footer hint is never published outside TUI, so
2528
- // clearing it in RPC would be a pure UI leak (setStatus subagents-nav).
2508
+ // Widget clear is intentional in every mode that exposes ui, including RPC.
2529
2509
  try { ctx.ui.setWidget("subagents", WIDGET_CLEAR); } catch { /* ignore */ }
2530
- if (isNavigatorUiAvailable(ctx)) {
2531
- try { ctx.ui.setStatus(NAVIGATOR_STATUS_KEY, undefined); } catch { /* ignore */ }
2532
- try { ctx.ui.setStatus(CLOSE_CONFIRM_STATUS_KEY, undefined); } catch { /* ignore */ }
2533
- }
2534
2510
  });
2535
2511
  }
@@ -37,29 +37,18 @@ export const DETAIL_TICK_MS = 1000;
37
37
  /** Two-press Close confirmation window (ms). Exclusive at the boundary. */
38
38
  export const CLOSE_ARM_MS = 3000;
39
39
 
40
- /** Footer status key for the `← subagents · N` hint (default footer mechanism). */
40
+ /** Legacy status keys retained for compatibility. */
41
41
  export const NAVIGATOR_STATUS_KEY = "subagents-nav";
42
42
 
43
- /** Footer status key for the Close confirmation hint (coexists with count hint). */
44
43
  export const CLOSE_CONFIRM_STATUS_KEY = "subagents-close";
45
44
 
46
- /**
47
- * Footer hint text: `← subagents · N` while at least one visible
48
- * current-parent run exists, null when none (caller clears the status).
49
- */
45
+ /** Legacy footer helpers no longer write status. */
50
46
  export function navigatorFooterHint(count) {
51
- return count > 0 ? `← subagents · ${count}` : null;
47
+ return null;
52
48
  }
53
49
 
54
- /**
55
- * Publish the footer hint through pi's default footer status mechanism.
56
- * Sets `← subagents · N` for count ≥ 1, clears the status at 0. Returns the
57
- * hint (or null) so callers can dirty-check. NEVER touches the footer itself.
58
- */
59
50
  export function applyNavigatorFooter(ui, count) {
60
- const hint = navigatorFooterHint(count);
61
- ui.setStatus(NAVIGATOR_STATUS_KEY, hint ?? undefined);
62
- return hint;
51
+ return navigatorFooterHint(count);
63
52
  }
64
53
 
65
54
  // ---------------------------------------------------------------------------
@@ -79,13 +68,8 @@ export function closeConfirmHint(row) {
79
68
  return stoppable ? `x again to stop ${label}` : `x again to dismiss ${label}`;
80
69
  }
81
70
 
82
- /**
83
- * Publish/clear the Close confirmation hint through pi's default footer status
84
- * mechanism (a dedicated key so it coexists with `← subagents · N`).
85
- * Pass null/undefined to clear.
86
- */
71
+ /** Preserve the hint for compatibility without writing status. */
87
72
  export function applyCloseConfirmFooter(ui, hint) {
88
- ui.setStatus(CLOSE_CONFIRM_STATUS_KEY, hint ?? undefined);
89
73
  return hint ?? null;
90
74
  }
91
75
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-subagents",
3
- "version": "0.23.1",
3
+ "version": "0.23.2",
4
4
  "description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -175,18 +175,15 @@ export function isNavigatorUiAvailable(ctx: ExtensionContext | undefined): boole
175
175
  }
176
176
 
177
177
  export function navigatorFooterHint(_count: number): string | null {
178
- // Retain the compatibility helper so refresh/reload clears any old badge.
178
+ // Retain the compatibility helper without publishing a badge.
179
179
  return null;
180
180
  }
181
181
 
182
182
  export function applyNavigatorFooter(ui: { setStatus(key: string, value: string | undefined): void }, count: number): string | null {
183
- const hint = navigatorFooterHint(count);
184
- ui.setStatus(NAVIGATOR_STATUS_KEY, hint ?? undefined);
185
- return hint;
183
+ return navigatorFooterHint(count);
186
184
  }
187
185
 
188
186
  export function applyCloseConfirmFooter(ui: { setStatus(key: string, value: string | undefined): void }, hint: string | null | undefined): string | null {
189
- ui.setStatus(CLOSE_CONFIRM_STATUS_KEY, hint ?? undefined);
190
187
  return hint ?? null;
191
188
  }
192
189
 
@@ -219,8 +216,6 @@ export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext): void {
219
216
  s.uiCtx = undefined;
220
217
  if (activeCtx && isNavigatorUiAvailable(activeCtx)) {
221
218
  const ui = activeCtx.ui;
222
- try { applyNavigatorFooter(ui as any, 0); } catch { /* ignore */ }
223
- try { applyCloseConfirmFooter(ui as any, null); } catch { /* ignore */ }
224
219
  try { (ui as any).setWidget?.(MAIN_LIST_WIDGET_KEY, undefined); } catch { /* ignore */ }
225
220
  }
226
221
  s.lastHint = undefined;
@@ -241,12 +236,6 @@ export function refreshBackgroundWorkNavigator(ctx?: ExtensionContext): void {
241
236
  const activeCtx = isNavigatorUiAvailable(ctx) ? ctx : s.uiCtx;
242
237
  if (!isNavigatorUiAvailable(activeCtx)) return;
243
238
  try {
244
- const count = visibleCount();
245
- const hint = navigatorFooterHint(count);
246
- if (hint !== s.lastHint) {
247
- applyNavigatorFooter(activeCtx!.ui as any, count);
248
- s.lastHint = hint;
249
- }
250
239
  refreshMainListWidget();
251
240
  } catch { /* ignore */ }
252
241
  }
@@ -271,7 +260,6 @@ function clearMainListCloseArm(): void {
271
260
  s.mainListCloseArmTimer = undefined;
272
261
  if (!s.mainListCloseArm) return;
273
262
  s.mainListCloseArm = undefined;
274
- try { applyCloseConfirmFooter((s.uiCtx as any).ui, null); } catch { /* ignore */ }
275
263
  }
276
264
 
277
265
  function refreshMainListWidget(): void {
@@ -293,6 +281,7 @@ function refreshMainListWidget(): void {
293
281
  const nextSignature = rows.length ? mainListSignature(rows, {
294
282
  selectedId: s.mainListFocused ? s.mainListSelectedId : undefined,
295
283
  focused: s.mainListFocused === true,
284
+ closeHint: closeHintFor(rows.find((row) => row.navigatorId === s.mainListCloseArm?.id)),
296
285
  }) : undefined;
297
286
  const changed = nextSignature !== s.lastMainListSignature;
298
287
  s.lastMainListSignature = nextSignature;
@@ -416,6 +405,9 @@ function createMainListWidget(tui: { requestRender?(): void }, theme: unknown, d
416
405
  selectedId: state().mainListFocused ? state().mainListSelectedId : undefined,
417
406
  focused: state().mainListFocused === true,
418
407
  });
408
+ const armedRow = rows.find((row) => row.navigatorId === state().mainListCloseArm?.id);
409
+ const hint = closeHintFor(armedRow);
410
+ if (hint) { layout.lines.push(deps.truncate(` ${hint}`, renderWidth(width))); layout.owners.push(undefined); }
419
411
  lineWidth = renderWidth(width);
420
412
  // The leading gap line belongs to the widget too, so its slot stays ownerless.
421
413
  lineOwners = [undefined, ...layout.owners];
@@ -630,9 +622,10 @@ function statusIndicator(row: InternalRow, now = Date.now()): { glyph: string; c
630
622
  return { glyph: RUNNING_DOT_GLYPH, color: "accent" };
631
623
  }
632
624
 
633
- function mainListSignature(rows: InternalRow[], options: { selectedId?: string; focused?: boolean } = {}): string {
625
+ function mainListSignature(rows: InternalRow[], options: { selectedId?: string; focused?: boolean; closeHint?: string | null } = {}): string {
634
626
  return JSON.stringify({
635
627
  focused: options.focused === true,
628
+ closeHint: options.closeHint ?? null,
636
629
  selectedId: options.focused === true ? options.selectedId ?? null : null,
637
630
  rows: rows.map((row) => ({
638
631
  navigatorId: row.navigatorId,
@@ -863,7 +856,6 @@ function handleMainListCloseKey(): void {
863
856
  }
864
857
  clearMainListCloseArm();
865
858
  s.mainListCloseArm = { id: row.navigatorId, armedAt: now };
866
- try { applyCloseConfirmFooter((s.uiCtx as any).ui, closeHintFor(row)); } catch { /* ignore */ }
867
859
  s.mainListCloseArmTimer = setTimeout(() => {
868
860
  clearMainListCloseArm();
869
861
  refreshMainListWidget();
@@ -1034,7 +1026,6 @@ function createOverlayComponent(
1034
1026
  closeArmTimer = undefined;
1035
1027
  if (closeArm) {
1036
1028
  closeArm = undefined;
1037
- try { applyCloseConfirmFooter((state().uiCtx as any).ui, null); } catch { /* ignore */ }
1038
1029
  }
1039
1030
  }
1040
1031
 
@@ -1108,11 +1099,9 @@ function createOverlayComponent(
1108
1099
  }
1109
1100
  clearCloseArm();
1110
1101
  closeArm = { id: row.navigatorId, armedAt: now };
1111
- try { applyCloseConfirmFooter((state().uiCtx as any).ui, closeHintFor(row)); } catch { /* ignore */ }
1112
1102
  closeArmTimer = setTimeout(() => {
1113
1103
  closeArmTimer = undefined;
1114
1104
  closeArm = undefined;
1115
- try { applyCloseConfirmFooter((state().uiCtx as any).ui, null); } catch { /* ignore */ }
1116
1105
  requestRender();
1117
1106
  }, CLOSE_ARM_MS);
1118
1107
  closeArmTimer.unref?.();
@@ -1149,6 +1138,9 @@ function createOverlayComponent(
1149
1138
  selectedId: selectedRow()?.navigatorId,
1150
1139
  focused: true,
1151
1140
  });
1141
+ const armedRow = overlayState.rows.find((row) => row.navigatorId === closeArm?.id);
1142
+ const hint = closeHintFor(armedRow);
1143
+ if (hint) { rail.lines.push(deps.truncate(` ${hint}`, renderWidth(width))); rail.owners.push(undefined); }
1152
1144
  const railLines = rail.lines;
1153
1145
  const editorLines = renderEditorLines(width);
1154
1146
  const bottomLines = [...railLines, ...editorLines];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-harness",
3
- "version": "0.35.1",
3
+ "version": "0.35.2",
4
4
  "description": "Pi extension bundle for a write sandbox, subagents, background tasks, SSH, goals, and Outcome & Todo.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -54,11 +54,11 @@
54
54
  "test": "node --import tsx --import ../../scripts/isolate-registry.mjs --test test/*.test.mjs test/task-context/*.test.ts"
55
55
  },
56
56
  "dependencies": {
57
- "pi-better-background-tasks": "0.12.1",
58
- "pi-better-goal": "0.10.3",
57
+ "pi-better-background-tasks": "0.12.2",
58
+ "pi-better-goal": "0.10.4",
59
59
  "pi-better-sandbox": "0.14.0",
60
- "pi-better-ssh": "0.1.3",
61
- "pi-better-subagents": "0.23.1",
60
+ "pi-better-ssh": "0.1.4",
61
+ "pi-better-subagents": "0.23.2",
62
62
  "proper-lockfile": "^4.1.2",
63
63
  "shell-quote": "1.12.0",
64
64
  "smol-toml": "1.9.0",