pi-better-background-tasks 0.4.0 → 0.5.0

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
@@ -99,9 +99,11 @@ After `/new`, `/resume`, fork, or switching to another session, the previous
99
99
  session's tasks keep running but are paused from Pi's side: watches do not poll,
100
100
  remote tmux output is not collected, and `timeout_seconds` deadlines are not
101
101
  enforced until that session is active again. An overdue deadline is enforced as
102
- soon as the session resumes, so a timeout can land late but is never skipped. A
103
- local process that exits in the meantime is recorded as finished, and its
104
- callback is delivered when its session resumes.
102
+ soon as the session resumes, so a timeout can land late but is never skipped.
103
+ While the same Pi process stays open, a local process that exits in the meantime
104
+ is recorded as finished, and its callback is delivered when its session resumes.
105
+ If you quit Pi first, nothing records that exit: when the session is resumed in a
106
+ new Pi process, a task whose process is gone is marked lost.
105
107
 
106
108
  ## Watch conditions
107
109
 
@@ -151,7 +153,7 @@ evidence is reported as **observation incomplete**.
151
153
  Pass `operation_id` and `expected_exit_codes` on `bg_task_spawn`, `bg_task_watch`,
152
154
  or `bg_task` to declare intent before launch; a malformed declaration starts
153
155
  nothing. An exit code in `expected_exit_codes` (distinct integers 1-255, such as
154
- `[1]` for a no-match probe) is recorded as an **Expected failure** that needs no
156
+ `[1]` for a no-match probe; a `0` is ignored) is recorded as an **Expected failure** that needs no
155
157
  action; signals and timeouts never are. When a task with an `operation_id`
156
158
  succeeds, earlier failed tasks with the same `operation_id`, kind, cwd, SSH
157
159
  target, and owner (the same session id, or for sessionless tasks the same Pi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/failures.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { join } from "node:path";
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import {
4
- activeFailures, failureAttentionHandled, failureIdentity, formatFailureSummary, markFailureAttentionDelivered,
4
+ actionableFailures, activeFailures, failureAttentionHandled, failureIdentity, formatFailureSummary, markFailureAttentionDelivered,
5
5
  observeFailures, pendingAttentionNote, pendingAttentionRows, pendingFailureAttention, readCommandIntent, readFailureState,
6
6
  type FailureEvent, type FailureState,
7
7
  } from "./shared-failure-observations.js";
@@ -12,6 +12,14 @@ import type { BackgroundTaskMeta } from "./types.js";
12
12
 
13
13
  export const failurePath = (id: string): string => join(taskDir(id), "failures.jsonl");
14
14
  export const failureSummary = (id: string): string => formatFailureSummary(readFailureState(failurePath(id)));
15
+ /**
16
+ * The failure summary plus whether anything needs action. A navigator row leads with failure text
17
+ * only when something does; the quiet history line never displaces the command (#332).
18
+ */
19
+ export function failureView(id: string): { text: string; actionable: boolean } {
20
+ const state = readFailureState(failurePath(id));
21
+ return { text: formatFailureSummary(state), actionable: actionableFailures(state).length > 0 };
22
+ }
15
23
 
16
24
  export function recordFailure(meta: BackgroundTaskMeta, operation: string, summary: string, eventKey: unknown,
17
25
  options: { category?: string; expected?: boolean; incomplete?: boolean; evidence?: string; at?: number } = {}): void {
@@ -10,8 +10,8 @@ import {
10
10
  } from "./shared-navigator.ts";
11
11
  import { CustomEditor } from "@earendil-works/pi-coding-agent";
12
12
  import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
13
- import { activeFailures, failureLabel, readFailureState } from "./shared-failure-observations.js";
14
- import { failurePath, failureSummary } from "./failures.js";
13
+ import { actionableFailures, failureLabel, readFailureState } from "./shared-failure-observations.js";
14
+ import { failurePath, failureView } from "./failures.js";
15
15
  import { readLog } from "./logs.js";
16
16
  import { listMetasForOrigin, onMetaChanged, readMeta, writeMeta } from "./registry.js";
17
17
  import { stopTask } from "./runtime.js";
@@ -105,7 +105,9 @@ function isExpiredTerminalNavigatorRow(meta: BackgroundTaskMeta, now: number): b
105
105
  }
106
106
 
107
107
  function rowFromMeta(meta: BackgroundTaskMeta, now: number): BackgroundWorkRow {
108
- const failure = failureSummary(meta.id);
108
+ // Only a failure that needs action replaces the row's command; history stays in the detail view (#332).
109
+ const view = failureView(meta.id);
110
+ const failure = view.actionable ? view.text : "";
109
111
  const elapsed = formatDuration((meta.endedAt ?? now) - meta.startedAt);
110
112
  return {
111
113
  providerId: "background-tasks",
@@ -129,7 +131,8 @@ function rowFromMeta(meta: BackgroundTaskMeta, now: number): BackgroundWorkRow {
129
131
 
130
132
  function detailFromMeta(meta: BackgroundTaskMeta | undefined, now: number, options?: { logTailLines?: number }): BackgroundWorkDetail | null {
131
133
  if (!meta) return null;
132
- const failure = failureSummary(meta.id);
134
+ const view = failureView(meta.id);
135
+ const failure = view.text;
133
136
  const log = readLog(meta.logPath, options?.logTailLines ?? NAVIGATOR_DETAIL_ROWS);
134
137
  const command = commandLabel(meta);
135
138
  const metadata = [
@@ -164,7 +167,7 @@ function detailFromMeta(meta: BackgroundTaskMeta | undefined, now: number, optio
164
167
  title: meta.name || meta.id,
165
168
  status: meta.status,
166
169
  statusTone: toneForStatus(meta.status),
167
- subtitle: failure ? failure.split("\n")[0]! : compactCommandLabel(meta),
170
+ subtitle: view.actionable ? failure.split("\n")[0]! : compactCommandLabel(meta),
168
171
  metadata,
169
172
  foldedSections: [{
170
173
  id: "command",
@@ -217,7 +220,7 @@ function secondaryLabel(meta: BackgroundTaskMeta): string | undefined {
217
220
 
218
221
  function factsForMeta(meta: BackgroundTaskMeta, now: number): string[] {
219
222
  const facts: string[] = [];
220
- const incident = activeFailures(readFailureState(failurePath(meta.id)))[0];
223
+ const incident = actionableFailures(readFailureState(failurePath(meta.id)))[0];
221
224
  if (incident) facts.push(`${failureLabel(incident)}: ${incident.summary}`);
222
225
  if (meta.status === "running") {
223
226
  const stall = observeBackgroundTaskStall(meta, now);
package/src/output.ts CHANGED
@@ -548,13 +548,16 @@ export function formatStatus(
548
548
  if (pageKind === "f") return formatLog(meta.id, { ...options, raw: true });
549
549
  if (pageKind === "t") return formatVerbose(meta, options);
550
550
  if (options.verbose) return formatVerbose(meta, options);
551
+ // A bg_task_list page cursor pages the list, not this task: say so rather than report it as a
552
+ // stale status cursor (#332).
553
+ const listCursor = pageKind === "l";
551
554
  const state = failureStateFor(meta.id);
552
555
  const resource = `status:${scopeKey(options)}:${meta.id}`;
553
556
  const revision = inspectStatusRevision({
554
557
  resource,
555
558
  contentRevision: contentRevision(meta),
556
559
  failureRevision: failureRevision(state),
557
- cursor: options.cursor,
560
+ cursor: listCursor ? undefined : options.cursor,
558
561
  });
559
562
  if (options.cursor && revision.change === "none") {
560
563
  return assembleBackgroundContent({
@@ -574,6 +577,7 @@ export function formatStatus(
574
577
  // Change/reset and read-gap facts are short and decision-relevant: they are
575
578
  // budgeted with the decision section, ahead of long incident rows.
576
579
  const changeFacts = [
580
+ ...(listCursor ? ["cursor ignored: it is a bg_task_list page cursor; pass it to bg_task_list, or pass this status's statusCursor here."] : []),
577
581
  ...(revision.change === "failure" ? ["change=failure"] : []),
578
582
  ...(revision.reset ? [`reset=${revision.reset}`] : []),
579
583
  ...(log.error ? [`log unreadable: ${oneLine(log.error, 200)}; cannot treat this as an empty healthy log.`] : []),
package/src/runtime.ts CHANGED
@@ -34,8 +34,11 @@ const remoteSessionStarts = new Map<string, Promise<CommandResult>>();
34
34
  const activePolls = new Set<string>();
35
35
  const logRetentionTimers = new Map<string, ReturnType<typeof setInterval>>();
36
36
  const LOG_RETENTION_CHECK_MS = 1000;
37
- const handoffTimers = new Map<string, ReturnType<typeof setInterval>>();
37
+ const handoffTimers = new Map<string, ReturnType<typeof setTimeout>>();
38
+ // The handoff check backs off from 250 ms to 4 s while the task keeps running,
39
+ // and returns to 250 ms once its process is gone so the grace period is timed closely.
38
40
  const HANDOFF_CHECK_MS = 250;
41
+ const HANDOFF_MAX_CHECK_MS = 4_000;
39
42
  const HANDOFF_LOST_GRACE_MS = 5_000;
40
43
  let scheduledWorkSuspended = false;
41
44
  const REMOTE_SESSION_POLL_MS = 100;
@@ -62,8 +65,9 @@ export const DEFAULT_WATCH_TIMEOUT_SECONDS = 15 * 60;
62
65
  * tasks keep running, but their watches do not poll, remote tmux output is not
63
66
  * collected, and their `timeout_seconds` deadlines are not enforced until that
64
67
  * session is active again; an overdue deadline is enforced immediately on resume.
65
- * A local process that exits meanwhile is recorded as terminal and its callback
66
- * is delivered when its session resumes.
68
+ * A local process that exits meanwhile, while this Pi process is still running,
69
+ * is recorded as terminal and its callback is delivered when its session resumes;
70
+ * after Pi quits, a resumed task whose process is gone is marked lost instead.
67
71
  */
68
72
  export function suspendScheduledWork(): void {
69
73
  scheduledWorkSuspended = true;
@@ -71,7 +75,7 @@ export function suspendScheduledWork(): void {
71
75
  for (const timer of remoteSessionTimers.values()) clearTimeout(timer);
72
76
  for (const timer of processTimeoutTimers.values()) clearTimeout(timer);
73
77
  for (const timer of logRetentionTimers.values()) clearInterval(timer);
74
- for (const timer of handoffTimers.values()) clearInterval(timer);
78
+ for (const timer of handoffTimers.values()) clearTimeout(timer);
75
79
  handoffTimers.clear();
76
80
  watcherTimers.clear();
77
81
  remoteSessionTimers.clear();
@@ -528,7 +532,9 @@ function scheduleHandoff(
528
532
  stopHandoff(id);
529
533
  if (scheduledWorkSuspended) return;
530
534
  let deadSince: number | undefined;
531
- const timer = setInterval(() => {
535
+ let delayMs = HANDOFF_CHECK_MS;
536
+ const check = () => {
537
+ handoffTimers.delete(id);
532
538
  const meta = readMeta(id);
533
539
  if (!meta) {
534
540
  stopHandoff(id);
@@ -554,16 +560,26 @@ function scheduleHandoff(
554
560
  clearProcessTimeout(id);
555
561
  stopLogRetention(id);
556
562
  markProcessLost(pi, meta, getActiveSession);
563
+ return;
557
564
  }
565
+ delayMs = HANDOFF_CHECK_MS;
566
+ } else {
567
+ delayMs = Math.min(HANDOFF_MAX_CHECK_MS, delayMs * 2);
558
568
  }
559
- }, HANDOFF_CHECK_MS);
560
- timer.unref();
561
- handoffTimers.set(id, timer);
569
+ arm();
570
+ };
571
+ const arm = () => {
572
+ if (scheduledWorkSuspended) return;
573
+ const timer = setTimeout(check, delayMs);
574
+ timer.unref();
575
+ handoffTimers.set(id, timer);
576
+ };
577
+ arm();
562
578
  }
563
579
 
564
580
  function stopHandoff(id: string): void {
565
581
  const timer = handoffTimers.get(id);
566
- if (timer) clearInterval(timer);
582
+ if (timer) clearTimeout(timer);
567
583
  handoffTimers.delete(id);
568
584
  }
569
585
 
@@ -104,24 +104,60 @@ export interface CommandIntent {
104
104
  export type CommandIntentField = keyof CommandIntent;
105
105
  const INTENT_FIELDS: readonly CommandIntentField[] = ["operationId", "attemptId", "expectedExitCodes"];
106
106
  /**
107
- * The one normalization of intent-bearing arguments: an explicit `null` intent field means
108
- * "not declared", exactly like an omitted one. Models routinely send optional fields as null, and
109
- * Pi's argument validation (0.87+) drops optional nulls before a tool runs, while the process log
110
- * and session record keep the raw arguments. Every reader of intent, the executing tool and the
111
- * replay alike, goes through this so both sides see the same declaration from the same input.
107
+ * Pi's own argument coercion for the intent fields (pi-ai `validateToolArguments` against the confined
108
+ * bash schema, where each field is `anyOf: [field schema, null]`). Pi applies it before execute, but the
109
+ * process log and session record keep the raw arguments; mirroring it lets the parent's replay read
110
+ * the declaration the child executed with (#332).
111
+ * - ids: a number or boolean becomes its string; `""` fails the pattern and falls through to null.
112
+ * - exit codes: array items that read as integers become integers (null -> 0, true -> 1);
113
+ * a scalar `0`, `""`, or `false` falls through to null.
114
+ */
115
+ function coerceInteger(value: unknown): unknown {
116
+ if (value === null) return 0;
117
+ if (typeof value === "string" && value.trim() !== "" && Number.isInteger(Number(value))) return Number(value);
118
+ if (typeof value === "boolean") return value ? 1 : 0;
119
+ return value;
120
+ }
121
+ function normalizeIntentField(key: CommandIntentField, value: unknown): unknown {
122
+ if (value == null) return undefined;
123
+ if (key === "expectedExitCodes") {
124
+ if (value === 0 || value === "" || value === false) return undefined;
125
+ if (!Array.isArray(value)) return value;
126
+ // 0 is a no-op declaration: exit 0 is already success. A list of only zeros declares nothing.
127
+ const codes = value.map(coerceInteger).filter((code) => code !== 0);
128
+ if (codes.length === 0 && value.length > 0) return undefined;
129
+ return codes.length === value.length && codes.every((code, index) => code === value[index]) ? value : codes;
130
+ }
131
+ if (value === "") return undefined;
132
+ return typeof value === "number" || typeof value === "boolean" ? String(value) : value;
133
+ }
134
+ /**
135
+ * The one normalization of intent-bearing arguments. An explicit `null` intent field means "not
136
+ * declared", exactly like an omitted one: models routinely send optional fields as null, and Pi's
137
+ * argument validation drops or nulls them before a tool runs, while the process log and session
138
+ * record keep the raw arguments. Values Pi would coerce are coerced the same way, and `0` is dropped
139
+ * from `expectedExitCodes`. Every reader of intent, the executing tool and the replay alike, and the
140
+ * exact operation identity go through this, so all see the same declaration from the same input.
141
+ * Malformed values are kept as they are for the validator to refuse.
112
142
  */
113
143
  export function withoutAbsentIntent<T>(args: T): T {
114
144
  if (!args || typeof args !== "object" || Array.isArray(args)) return args;
115
145
  const input = args as Record<string, unknown>;
116
- if (!INTENT_FIELDS.some((key) => key in input && input[key] == null)) return args;
117
- const out = { ...input };
118
- for (const key of INTENT_FIELDS) if (key in out && out[key] == null) delete out[key];
119
- return out as T;
146
+ let out: Record<string, unknown> | undefined;
147
+ for (const key of INTENT_FIELDS) {
148
+ if (!(key in input)) continue;
149
+ const value = normalizeIntentField(key, input[key]);
150
+ if (value === input[key]) continue;
151
+ out ??= { ...input };
152
+ if (value === undefined) delete out[key];
153
+ else out[key] = value;
154
+ }
155
+ return (out ?? args) as T;
120
156
  }
121
157
  /**
122
- * Validate structured intent fields. Absent fields (omitted, undefined, or null) are fine; malformed
158
+ * Validate structured intent fields after `withoutAbsentIntent`. Absent fields are fine; malformed
123
159
  * ones are an error, and the caller must not run the command. `names` renames fields in the error
124
- * (e.g. snake_case parameters).
160
+ * (e.g. snake_case parameters). `0` in `expectedExitCodes` is accepted and dropped (#332).
125
161
  */
126
162
  export function readCommandIntent(args: unknown, names: Partial<Record<CommandIntentField, string>> = {}): { intent: CommandIntent; error?: string } {
127
163
  const input = withoutAbsentIntent((args && typeof args === "object" ? args : {}) as Record<string, unknown>);
@@ -138,7 +174,7 @@ export function readCommandIntent(args: unknown, names: Partial<Record<CommandIn
138
174
  if (codes !== undefined) {
139
175
  if (!Array.isArray(codes) || codes.length === 0 || codes.length > MAX_EXPECTED_EXIT_CODES ||
140
176
  !codes.every((code) => Number.isInteger(code) && code >= 1 && code <= 255) || new Set(codes).size !== codes.length) {
141
- return { intent: {}, error: `${names.expectedExitCodes ?? "expectedExitCodes"} must be 1-${MAX_EXPECTED_EXIT_CODES} distinct integers from 1 to 255` };
177
+ return { intent: {}, error: `${names.expectedExitCodes ?? "expectedExitCodes"} must be 1-${MAX_EXPECTED_EXIT_CODES} distinct integers from 1 to 255 (0 is allowed and ignored)` };
142
178
  }
143
179
  intent.expectedExitCodes = [...codes] as number[];
144
180
  }
@@ -819,29 +819,22 @@ export interface OutputControls {
819
819
  maxBytes?: unknown;
820
820
  /** Raw requested line count (canonical `lines`, else deprecated `tail_lines`). */
821
821
  lines?: unknown;
822
- /** Deprecated alias names the caller used (whether or not the canonical name won). */
823
- deprecated: string[];
824
822
  }
825
823
 
826
824
  /**
827
825
  * Resolve output-control parameters. Both spellings work; when both are given
828
- * the canonical name wins. Values are returned unvalidated so each surface
829
- * keeps its own defaults and hard caps.
826
+ * the canonical name wins. An explicit `null` is "not given", so a null
827
+ * canonical value never hides an alias value (#332). Values are returned
828
+ * unvalidated so each surface keeps its own defaults and hard caps.
830
829
  */
831
830
  export function readOutputControls(params: unknown): OutputControls {
832
831
  const p = (params && typeof params === "object" ? params : {}) as Record<string, unknown>;
833
- const deprecated: string[] = [];
834
- const pick = (canonical: keyof typeof OUTPUT_CONTROL_ALIASES): unknown => {
835
- const alias = OUTPUT_CONTROL_ALIASES[canonical];
836
- if (p[alias] !== undefined) deprecated.push(alias);
837
- return p[canonical] !== undefined ? p[canonical] : p[alias];
838
- };
832
+ const pick = (canonical: keyof typeof OUTPUT_CONTROL_ALIASES): unknown => p[canonical] ?? p[OUTPUT_CONTROL_ALIASES[canonical]] ?? undefined;
839
833
  const maxBytes = pick("max_bytes");
840
834
  const lines = pick("lines");
841
835
  return {
842
836
  ...(maxBytes !== undefined ? { maxBytes } : {}),
843
837
  ...(lines !== undefined ? { lines } : {}),
844
- deprecated,
845
838
  };
846
839
  }
847
840
 
@@ -851,7 +844,8 @@ export type OutputInclude = (typeof OUTPUT_INCLUDE_VALUES)[number];
851
844
 
852
845
  /** Parse an `include` request into known values and unknown ones (both deduplicated). */
853
846
  export function readOutputInclude(value: unknown): { include: Set<OutputInclude>; unknown: string[] } {
854
- const raw = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
847
+ // Any other non-null value (a number, boolean, or object) is one unknown entry, never silently nothing (#332).
848
+ const raw = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : value == null ? [] : [value];
855
849
  const include = new Set<OutputInclude>();
856
850
  const unknown: string[] = [];
857
851
  for (const entry of raw) {
@@ -93,8 +93,12 @@ type NavigatorState = {
93
93
  mainListCloseArmTimer?: ReturnType<typeof setTimeout>;
94
94
  mainListDeadlineScheduler?: RenderScheduler;
95
95
  editorComponent?: Component;
96
+ /** The editor Pi currently has mounted (the wrapper the factory returned last). */
97
+ editorFocusTarget?: unknown;
96
98
  detailOverlayRows?: number;
97
99
  dispose?: () => void;
100
+ /** The mounted detail overlay, reused instead of stacking a second one. */
101
+ overlay?: { showDetail(navigatorId: string): void; focus(): void };
98
102
  };
99
103
 
100
104
  const GLOBAL_KEY = Symbol.for("pi-better-harness.navigator.state");
@@ -199,6 +203,7 @@ export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext): void {
199
203
  const s = state();
200
204
  try { s.dispose?.(); } catch { /* ignore */ }
201
205
  s.dispose = undefined;
206
+ s.overlay = undefined;
202
207
  stopMainListWidget();
203
208
  const activeCtx = ctx ?? s.uiCtx;
204
209
  s.uiCtx = undefined;
@@ -627,7 +632,7 @@ function installNavigatorEditor(ui: any, deps: HostDeps): unknown {
627
632
 
628
633
  function wrapEditor(inner: any, deps: HostDeps): unknown {
629
634
  if (inner && typeof inner.render === "function") state().editorComponent = inner as Component;
630
- return new Proxy(inner, {
635
+ const proxy = new Proxy(inner, {
631
636
  get(target, prop) {
632
637
  if (prop === "handleInput") {
633
638
  return (data: string) => {
@@ -644,12 +649,24 @@ function wrapEditor(inner: any, deps: HostDeps): unknown {
644
649
  return Reflect.set(target, prop, value);
645
650
  },
646
651
  });
652
+ // Pi mounts and focuses whatever the factory returns; an extension that
653
+ // re-installs the editor replaces it, so this always names the live one.
654
+ state().editorFocusTarget = proxy;
655
+ return proxy;
647
656
  }
648
657
 
649
658
  function handleMainListInput(data: string, deps: HostDeps): boolean {
650
659
  const s = state();
651
660
  const rows = listRows();
652
- if (rows.length === 0) return false;
661
+ if (rows.length === 0) {
662
+ // Every row is gone, but an overlay that lost focus may still be mounted: Esc still closes it.
663
+ if (s.overlay && deps.matchKey(data, "escape")) {
664
+ unfocusMainList();
665
+ dismissOverlay();
666
+ return true;
667
+ }
668
+ return false;
669
+ }
653
670
  if (!s.mainListFocused) {
654
671
  if (!deps.isOpenTrigger(data)) return false;
655
672
  focusMainList();
@@ -673,7 +690,10 @@ function handleMainListInput(data: string, deps: HostDeps): boolean {
673
690
  }
674
691
  if (deps.matchKey(data, "enter")) {
675
692
  const selected = selectedMainListRow();
676
- if (selected?.parentRow) unfocusMainList();
693
+ if (selected?.parentRow) {
694
+ unfocusMainList();
695
+ dismissOverlay();
696
+ }
677
697
  else openNavigator();
678
698
  return true;
679
699
  }
@@ -683,11 +703,19 @@ function handleMainListInput(data: string, deps: HostDeps): boolean {
683
703
  }
684
704
  if (deps.matchKey(data, "escape") || deps.isOpenTrigger(data)) {
685
705
  unfocusMainList();
706
+ dismissOverlay();
686
707
  return true;
687
708
  }
688
709
  return false;
689
710
  }
690
711
 
712
+ /** Close a detail overlay that is still mounted while the editor has focus. */
713
+ function dismissOverlay(): void {
714
+ const s = state();
715
+ if (!s.overlay) return;
716
+ try { s.dispose?.(); } catch { /* ignore */ }
717
+ }
718
+
691
719
  function handleMainListCloseKey(): void {
692
720
  const row = selectedMainListRow();
693
721
  if (!row || row.parentRow) return;
@@ -721,20 +749,54 @@ function openNavigator(): void {
721
749
  const rows = listRows();
722
750
  if (rows.length === 0) return;
723
751
  const selectedId = selectedMainListRow()?.navigatorId ?? rows[0]!.navigatorId;
752
+ // An overlay that lost focus (another UI took it and handed it to the editor)
753
+ // is still mounted: switch its detail and focus it rather than stacking a
754
+ // second overlay over it, whose Esc would only reveal the stale one below.
755
+ const mounted = s.overlay;
756
+ if (mounted) {
757
+ // showDetail may close the overlay (and clear s.overlay); focus() is then a no-op.
758
+ mounted.showDetail(selectedId);
759
+ mounted.focus();
760
+ return;
761
+ }
724
762
  try {
725
763
  try { s.dispose?.(); } catch { /* ignore */ }
726
764
  s.dispose = undefined;
727
765
  let disposeToken: (() => void) | undefined;
766
+ let overlayRef: NavigatorState["overlay"];
767
+ let handle: { focus?(): void; unfocus?(options?: { target: unknown }): void } | undefined;
768
+ const release = () => {
769
+ if (overlayRef && s.overlay === overlayRef) s.overlay = undefined;
770
+ };
728
771
  const opened = (ctx.ui as any).custom((tui: any, theme: any, _keybindings: any, done: (v: null) => void) => {
729
- const component = createOverlayComponent(rows, deps, tui, theme, done, () => {
772
+ const component = createOverlayComponent(rows, deps, tui, theme, (value) => {
773
+ release();
774
+ // Pi restores focus to the editor that was focused when the overlay opened. If an
775
+ // extension re-installed the editor since, that instance is unmounted and every key
776
+ // would be lost; hand focus to the live editor before closing.
777
+ const liveEditor = s.editorFocusTarget;
778
+ if (liveEditor) {
779
+ try { handle?.unfocus?.({ target: liveEditor }); } catch { /* ignore */ }
780
+ }
781
+ done(value);
782
+ }, () => {
730
783
  s.lastHint = undefined;
731
784
  refreshBackgroundWorkNavigator(ctx);
732
785
  }, selectedId);
733
- disposeToken = () => component.dispose();
786
+ overlayRef = {
787
+ showDetail: (navigatorId) => component.showDetail(navigatorId),
788
+ focus: () => {
789
+ if (s.overlay !== overlayRef) return;
790
+ try { handle?.focus?.(); } catch { /* ignore */ }
791
+ },
792
+ };
793
+ s.overlay = overlayRef;
794
+ disposeToken = () => component.dismiss();
734
795
  s.dispose = disposeToken;
735
796
  return component;
736
- }, { overlay: true, overlayOptions: detailOverlayOptions });
797
+ }, { overlay: true, overlayOptions: detailOverlayOptions, onHandle: (h: typeof handle) => { handle = h; } });
737
798
  const clear = () => {
799
+ release();
738
800
  if (s.dispose === disposeToken) s.dispose = undefined;
739
801
  };
740
802
  void Promise.resolve(opened).then(clear, clear);
@@ -750,16 +812,16 @@ function createOverlayComponent(
750
812
  theme: { fg?(color: string, value: string): string } | undefined,
751
813
  done: (v: null) => void,
752
814
  onClosed: () => void,
753
- initialDetailId?: string,
815
+ initialDetailId: string,
754
816
  ) {
817
+ // The overlay only ever shows one row's detail; the rail below it is the list.
755
818
  const overlayState: OverlayState = { rows: initialRows, selected: 0 };
756
- let mode: "list" | "detail" = initialDetailId ? "detail" : "list";
757
819
  let logTailRows: number = DEFAULT_LOG_TAIL_ROWS;
758
820
  const expandedSections = new Set<string>();
759
- let detailId: string | null = initialDetailId ?? null;
760
- let detail: BackgroundWorkDetail | null = detailId ? (detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? null) : null;
821
+ let detailId: string = initialDetailId;
822
+ let detail: BackgroundWorkDetail | null = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? null;
761
823
  applyDefaultExpandedSections(detail, expandedSections);
762
- if (detailId) {
824
+ {
763
825
  const idx = overlayState.rows.findIndex((row) => row.navigatorId === detailId);
764
826
  if (idx >= 0) overlayState.selected = idx;
765
827
  }
@@ -776,7 +838,7 @@ function createOverlayComponent(
776
838
  }
777
839
 
778
840
  const detailScheduler = createRenderScheduler(() => {
779
- if (!detailId || mode !== "detail" || closed) return;
841
+ if (closed) return;
780
842
  detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
781
843
  requestRender();
782
844
  startDetailTimer();
@@ -837,29 +899,11 @@ function createOverlayComponent(
837
899
  expandedSections.clear();
838
900
  detail = detailFor(row.navigatorId, Date.now(), { logTailLines: logTailRows }) ?? fallbackDetail(row);
839
901
  applyDefaultExpandedSections(detail, expandedSections);
840
- mode = "detail";
841
902
  startDetailTimer();
842
903
  requestRender();
843
904
  }
844
905
 
845
- if (detailId) startDetailTimer();
846
-
847
- function leaveDetail(): void {
848
- if (mode !== "detail") return;
849
- const viewedId = detailId;
850
- clearCloseArm();
851
- stopDetailTimer();
852
- mode = "list";
853
- detail = null;
854
- detailId = null;
855
- expandedSections.clear();
856
- refreshRows();
857
- if (viewedId) {
858
- const idx = overlayState.rows.findIndex((row) => row.navigatorId === viewedId);
859
- if (idx >= 0) overlayState.selected = idx;
860
- }
861
- requestRender();
862
- }
906
+ startDetailTimer();
863
907
 
864
908
  function close(): void {
865
909
  if (closed) return;
@@ -871,20 +915,28 @@ function createOverlayComponent(
871
915
 
872
916
  function handleCloseKey(): void {
873
917
  const row = selectedRow();
874
- if (!row) return;
918
+ if (!row || row.parentRow) return;
875
919
  const now = Date.now();
876
920
  if (closeArm?.id === row.navigatorId && now >= closeArm.armedAt && now < closeArm.armedAt + CLOSE_ARM_MS) {
877
921
  clearCloseArm();
922
+ const before = overlayState.rows;
923
+ const closedIdx = overlayState.selected;
878
924
  closeFor(row);
879
- if (mode === "detail") {
880
- stopDetailTimer();
881
- mode = "list";
882
- detail = null;
883
- detailId = null;
884
- }
885
- refreshRows();
925
+ stopDetailTimer();
926
+ const after = listRows();
927
+ const nextId = neighbourCloseTarget(before, closedIdx, row.navigatorId, after);
886
928
  onClosed();
887
- requestRender();
929
+ if (!nextId) {
930
+ // Nothing left to show: hand the keyboard back to the editor.
931
+ unfocusMainList();
932
+ close();
933
+ return;
934
+ }
935
+ state().mainListSelectedId = nextId;
936
+ overlayState.rows = after;
937
+ overlayState.selected = Math.max(0, after.findIndex((candidate) => candidate.navigatorId === nextId));
938
+ refreshMainListWidget();
939
+ openDetail();
888
940
  return;
889
941
  }
890
942
  clearCloseArm();
@@ -903,18 +955,16 @@ function createOverlayComponent(
903
955
  return {
904
956
  render(width: number) {
905
957
  refreshRows();
906
- const railLines = mode === "detail"
907
- ? buildMainListLines(overlayState.rows, width, deps.truncate, fg, {
908
- selectedId: selectedRow()?.navigatorId,
909
- focused: true,
910
- })
911
- : [];
912
- const editorLines = mode === "detail" ? renderEditorLines(width) : [];
958
+ const railLines = buildMainListLines(overlayState.rows, width, deps.truncate, fg, {
959
+ selectedId: selectedRow()?.navigatorId,
960
+ focused: true,
961
+ });
962
+ const editorLines = renderEditorLines(width);
913
963
  const bottomLines = [...railLines, ...editorLines];
914
964
  const overlayRows = state().detailOverlayRows;
915
965
  const detailRows = overlayRows === undefined ? undefined : Math.max(1, overlayRows - bottomLines.length);
916
966
  let contentLines: string[];
917
- if (mode === "detail" && detail?.transcript && deps.createTranscriptComponent) {
967
+ if (detail?.transcript && deps.createTranscriptComponent) {
918
968
  if (transcriptDetail !== detail || !transcriptComponent) {
919
969
  transcriptDetail = detail;
920
970
  transcriptComponent = deps.createTranscriptComponent(detail, theme);
@@ -930,15 +980,13 @@ function createOverlayComponent(
930
980
  } else {
931
981
  transcriptDetail = null;
932
982
  transcriptComponent = null;
933
- contentLines = mode === "detail"
934
- ? buildDetailLines(detail, width, deps.truncate, fg, { expandedSections, logTailRows, minRows: detailRows, maxRows: detailRows, bottomFooter: false })
935
- : buildListLines(overlayState, width, deps.truncate, fg);
983
+ contentLines = buildDetailLines(detail, width, deps.truncate, fg, { expandedSections, logTailRows, minRows: detailRows, maxRows: detailRows, bottomFooter: false });
936
984
  }
937
- if (mode !== "detail") return contentLines;
938
- if (detailRows === undefined) return [...contentLines, ...bottomLines];
985
+ if (overlayRows === undefined || detailRows === undefined) return [...contentLines, ...bottomLines];
939
986
  const fittedContent = contentLines.slice(0, detailRows);
940
987
  while (fittedContent.length < detailRows) fittedContent.push("");
941
- return [...fittedContent, ...bottomLines];
988
+ // A terminal shorter than rail + editor keeps the bottom rows (the input frame) and drops the top.
989
+ return [...fittedContent, ...bottomLines].slice(-Math.max(1, overlayRows));
942
990
  },
943
991
  handleInput(data: string) {
944
992
  if (closed) return;
@@ -946,56 +994,53 @@ function createOverlayComponent(
946
994
  handleCloseKey();
947
995
  return;
948
996
  }
949
- if (mode === "detail") {
950
- if (deps.matchKey(data, "up")) {
951
- selectOverlayRow(overlayState.selected - 1);
952
- activateSelectedRow();
953
- }
954
- else if (deps.matchKey(data, "down")) {
955
- selectOverlayRow(overlayState.selected + 1);
956
- activateSelectedRow();
957
- }
958
- else if (deps.matchKey(data, "left")) {
959
- const mainIdx = overlayState.rows.findIndex((row) => row.parentRow && row.id === "main");
960
- if (mainIdx >= 0) selectOverlayRow(mainIdx);
961
- close();
962
- }
963
- else if (deps.matchKey(data, "enter")) {
964
- const row = selectedRow();
965
- if (row?.parentRow || row?.navigatorId !== detailId) openDetail();
966
- else {
967
- const sectionId = firstToggleableSectionId(detail);
968
- if (!sectionId) return;
969
- if (expandedSections.has(sectionId)) expandedSections.delete(sectionId);
970
- else expandedSections.add(sectionId);
971
- requestRender();
972
- }
973
- }
974
- else if (data === "l" || data === "L") {
975
- logTailRows = cycleLogTailRows(logTailRows);
976
- if (detailId) detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
997
+ if (deps.matchKey(data, "up")) {
998
+ selectOverlayRow(overlayState.selected - 1);
999
+ activateSelectedRow();
1000
+ }
1001
+ else if (deps.matchKey(data, "down")) {
1002
+ selectOverlayRow(overlayState.selected + 1);
1003
+ activateSelectedRow();
1004
+ }
1005
+ else if (deps.matchKey(data, "left")) {
1006
+ const mainIdx = overlayState.rows.findIndex((row) => row.parentRow && row.id === "main");
1007
+ if (mainIdx >= 0) selectOverlayRow(mainIdx);
1008
+ close();
1009
+ }
1010
+ else if (deps.matchKey(data, "enter")) {
1011
+ const row = selectedRow();
1012
+ if (row?.parentRow || row?.navigatorId !== detailId) openDetail();
1013
+ else {
1014
+ const sectionId = firstToggleableSectionId(detail);
1015
+ if (!sectionId) return;
1016
+ if (expandedSections.has(sectionId)) expandedSections.delete(sectionId);
1017
+ else expandedSections.add(sectionId);
977
1018
  requestRender();
978
1019
  }
979
- else if (deps.matchKey(data, "escape")) {
980
- unfocusMainList();
981
- close();
982
- }
983
- return;
984
1020
  }
985
- if (deps.matchKey(data, "up")) {
986
- overlayState.selected = Math.max(0, overlayState.selected - 1);
987
- clearCloseArm();
1021
+ else if (data === "l" || data === "L") {
1022
+ logTailRows = cycleLogTailRows(logTailRows);
1023
+ if (detailId) detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
988
1024
  requestRender();
989
- } else if (deps.matchKey(data, "down")) {
990
- overlayState.selected = Math.min(Math.max(0, overlayState.rows.length - 1), overlayState.selected + 1);
991
- clearCloseArm();
992
- requestRender();
993
- } else if (deps.matchKey(data, "enter")) {
994
- openDetail();
995
- } else if (deps.matchKey(data, "escape")) {
1025
+ }
1026
+ else if (deps.matchKey(data, "escape")) {
1027
+ unfocusMainList();
996
1028
  close();
997
1029
  }
998
1030
  },
1031
+ /** Show another row's detail in this overlay (the main list moved while it was unfocused). */
1032
+ showDetail(navigatorId: string) {
1033
+ if (closed) return;
1034
+ refreshRows();
1035
+ const idx = overlayState.rows.findIndex((row) => row.navigatorId === navigatorId);
1036
+ if (idx < 0) return;
1037
+ overlayState.selected = idx;
1038
+ state().mainListSelectedId = navigatorId;
1039
+ if (overlayState.rows[idx]!.parentRow) close();
1040
+ else openDetail();
1041
+ },
1042
+ /** Close the overlay (done), e.g. when the navigator is disposed. */
1043
+ dismiss() { close(); },
999
1044
  invalidate() { transcriptComponent?.invalidate(); },
1000
1045
  dispose() {
1001
1046
  clearCloseArm();
@@ -1004,6 +1049,30 @@ function createOverlayComponent(
1004
1049
  };
1005
1050
  }
1006
1051
 
1052
+ /**
1053
+ * After a confirmed close, the row whose detail the overlay shows next: the
1054
+ * nearest closable row below the closed one in the pre-close order, else the
1055
+ * nearest above it. `undefined` means nothing closable remains.
1056
+ */
1057
+ function neighbourCloseTarget(
1058
+ before: ReadonlyArray<{ navigatorId: string; parentRow?: boolean }>,
1059
+ closedIdx: number,
1060
+ closedId: string,
1061
+ after: ReadonlyArray<{ navigatorId: string; parentRow?: boolean }>,
1062
+ ): string | undefined {
1063
+ const remaining = new Set(after.filter((row) => !row.parentRow && row.navigatorId !== closedId).map((row) => row.navigatorId));
1064
+ for (let i = closedIdx + 1; i < before.length; i += 1) {
1065
+ const id = before[i]?.navigatorId;
1066
+ if (id && remaining.has(id)) return id;
1067
+ }
1068
+ for (let i = Math.min(closedIdx, before.length) - 1; i >= 0; i -= 1) {
1069
+ const id = before[i]?.navigatorId;
1070
+ if (id && remaining.has(id)) return id;
1071
+ }
1072
+ // Rows that appeared since the last render.
1073
+ return after.find((row) => remaining.has(row.navigatorId))?.navigatorId;
1074
+ }
1075
+
1007
1076
  function buildTranscriptDetailLines(
1008
1077
  detail: BackgroundWorkDetail,
1009
1078
  transcriptLines: string[],
@@ -1088,33 +1157,6 @@ function fallbackDetail(row: InternalRow): BackgroundWorkDetail {
1088
1157
  };
1089
1158
  }
1090
1159
 
1091
- function buildListLines(nav: OverlayState, width: number, truncate: (s: string, width: number) => string, fg: (color: string, value: string) => string): string[] {
1092
- const lines: string[] = [];
1093
- lines.push(rule(`Work · ${nav.rows.length}`, width));
1094
- const selected = nav.rows[nav.selected];
1095
- const closeAction = selected ? "x close" : null;
1096
- lines.push(dim(` ${["↑↓ select", "Enter view", closeAction, "Esc close"].filter(Boolean).join(" · ")}`, fg));
1097
- lines.push("");
1098
- if (nav.rows.length === 0) lines.push(" (no work)");
1099
- let lastProvider = "";
1100
- for (let i = 0; i < nav.rows.length; i += 1) {
1101
- const row = nav.rows[i]!;
1102
- if (row.providerLabel !== lastProvider) {
1103
- lines.push(dim(section(row.providerLabel, width), fg));
1104
- lastProvider = row.providerLabel;
1105
- }
1106
- const prefix = i === nav.selected ? fg("accent", "› ") : " ";
1107
- const status = fg(toneColor(row.statusTone, row.status), row.status);
1108
- const facts = (row.facts ?? []).filter(Boolean).slice(0, 2);
1109
- const suffix = facts.length ? ` · ${facts.join(" · ")}` : "";
1110
- lines.push(`${prefix}${row.name || row.id} · ${row.kind} · ${row.elapsed} · ${row.primary} · ${status}${suffix}`);
1111
- if (row.secondary) lines.push(` ${row.secondary}`);
1112
- }
1113
- lines.push("");
1114
- lines.push(dim(rule("", width), fg));
1115
- return lines.map((line) => safeTruncate(line, width, truncate));
1116
- }
1117
-
1118
1160
  function buildDetailLines(
1119
1161
  detail: BackgroundWorkDetail | null,
1120
1162
  width: number,
@@ -1173,7 +1215,9 @@ function buildDetailLines(
1173
1215
  lines.push(` ${preview} ${folded}`);
1174
1216
  } else {
1175
1217
  const wrapped = wrapEvidenceText(body, width - 6);
1176
- const shown = wrapped.slice(0, tailRows);
1218
+ let shown = wrapped.slice(0, tailRows);
1219
+ // Same budget as the tail branch: the header row and the trailing blank come out of maxRows too.
1220
+ if (options.maxRows !== undefined) shown = shown.slice(0, Math.max(1, options.maxRows - lines.length - 2));
1177
1221
  lines.push(dim(section(`${detail.evidence.label} · showing ${shown.length}/${wrapped.length} rows`, width), fg));
1178
1222
  for (const raw of shown) lines.push(raw ? ` ${raw}` : " ");
1179
1223
  }
@@ -414,13 +414,22 @@ function lexicalAndCanonical(home: string, name: string, seams: SandboxSeams): s
414
414
 
415
415
  /**
416
416
  * Every symlink directory entry met while resolving `path`, component by
417
- * component (as `readlink` would follow them). A chain such as
417
+ * component (as `readlink` would follow them), plus where a dangling chain
418
+ * would land. A chain such as
418
419
  * `~/.zshrc -> ~/.dotfiles/zshrc -> ~/dotfiles/zshrc` has an intermediate link
419
420
  * in a removable directory; replacing it would retarget the protected entry, so
420
- * each hop the user could replace is protected too. Bounded: a loop or more
421
- * than 40 hops stops the walk (such a path resolves to nothing).
421
+ * each hop the user could replace is protected too. When a followed link
422
+ * points at something missing, creating it would give the entry content, so
423
+ * the first missing component (which covers every missing directory below it)
424
+ * and the full path it would resolve to are protected as well. Bounded: a loop
425
+ * or more than 40 hops stops the walk (such a path resolves to nothing).
422
426
  */
423
427
  function symlinkHops(path: string): string[] {
428
+ const { hops, unresolved } = walkSymlinks(path);
429
+ return [...hops, ...unresolved];
430
+ }
431
+
432
+ function walkSymlinks(path: string): { hops: string[]; unresolved: string[] } {
424
433
  const hops: string[] = [];
425
434
  const seen = new Set<string>();
426
435
  let followed = 0;
@@ -433,7 +442,13 @@ function symlinkHops(path: string): string[] {
433
442
  if (component === "..") { current = dirname(current); continue; }
434
443
  const entry = join(current, component);
435
444
  let link: boolean;
436
- try { link = lstatSync(entry).isSymbolicLink(); } catch { break; }
445
+ try {
446
+ link = lstatSync(entry).isSymbolicLink();
447
+ } catch {
448
+ // Missing. Only a followed link makes this a dangling target; a
449
+ // plain absent path is guarded as it is.
450
+ return { hops, unresolved: followed ? [...new Set([entry, resolve(entry, ...pending)])] : [] };
451
+ }
437
452
  if (!link) { current = entry; continue; }
438
453
  if (++followed > 40 || seen.has(entry)) break;
439
454
  seen.add(entry);
@@ -448,7 +463,12 @@ function symlinkHops(path: string): string[] {
448
463
  pending = [...target.split(sep), ...pending];
449
464
  }
450
465
  }
451
- return hops;
466
+ return { hops, unresolved: [] };
467
+ }
468
+
469
+ /** Whether a symlink resolves to nothing: dangling (ENOENT), or looping or over 40 hops (ELOOP). */
470
+ function unresolvable(path: string): boolean {
471
+ try { statSync(path); return false; } catch { return true; }
452
472
  }
453
473
 
454
474
  function broadCredentialPaths(home: string, seams: SandboxSeams): string[] {
@@ -1296,7 +1316,10 @@ function maskSources(profilePath: string): { directory: string; file: string } {
1296
1316
  * home itself stay read-only, so sibling repositories cannot even be edited in
1297
1317
  * place there. Credentials are masked by empty mode-000 mounts; code that runs
1298
1318
  * later and caller-denied paths are read-only binds. Every writable bind and
1299
- * protected leaf is a mount point, which Linux refuses to rename.
1319
+ * protected leaf is a mount point, which Linux refuses to rename. A protected
1320
+ * symlink inside a writable directory (a stow link in `~/.config`) makes that
1321
+ * directory read-only with each existing entry bound writable again, so only
1322
+ * new top-level entries there are refused.
1300
1323
  */
1301
1324
  function buildLinuxBroadCommand(
1302
1325
  bwrap: string,
@@ -1347,15 +1370,38 @@ function buildLinuxBroadCommand(
1347
1370
  const readOnly = new Set<string>();
1348
1371
  const directoryLocation = (path: string) => [...DIRECTORY_LOCATIONS]
1349
1372
  .some((name) => canonicalizePath(join(broad.home, name), seams) === path);
1373
+ // Directories holding a protected symlink: read-only, so the link cannot be
1374
+ // replaced, with their existing entries bound writable again below.
1375
+ const linkParents = new Set<string>();
1376
+ const danglingTargets = new Set(protectedPaths.flatMap((path) => walkSymlinks(path).unresolved));
1350
1377
  const guard = (path: string, hide: boolean) => {
1351
1378
  let directory: boolean | undefined;
1352
- if (canonicalizePath(path, seams) !== path) {
1379
+ let link = false;
1380
+ try { link = lstatSync(path).isSymbolicLink(); } catch { /* absent or intermediate link */ }
1381
+ if (canonicalizePath(path, seams) !== path || (link && unresolvable(path))) {
1353
1382
  // A literal twin of a canonical deny entry: its target is guarded on
1354
- // its own. A symlink leaf inside a writable root could be replaced,
1355
- // so its directory becomes read-only; home itself already is.
1356
- let link = false;
1357
- try { link = lstatSync(path).isSymbolicLink(); } catch { /* absent or intermediate link */ }
1358
- if (link && dirname(path) !== broad.home && writableAt(dirname(path))) readOnly.add(dirname(path));
1383
+ // its own. A looping link resolves to nothing (ELOOP), and a dangling
1384
+ // one's missing target is guarded below, so only the link itself
1385
+ // needs guarding here. A symlink leaf inside a writable root could be
1386
+ // replaced, so its directory is protected; home already is.
1387
+ if (link && dirname(path) !== broad.home && writableAt(dirname(path))) linkParents.add(dirname(path));
1388
+ return;
1389
+ }
1390
+ if (danglingTargets.has(path)) {
1391
+ // Where a dangling link would land: nothing may be created there.
1392
+ // Lock its nearest existing ancestor (entries stay writable, see
1393
+ // below) rather than plant a placeholder in the user's files. Temp
1394
+ // and the workspace root stay open to new entries, so a placeholder
1395
+ // is bound there instead, failing closed if it cannot be made.
1396
+ if (!writableAt(path)) return;
1397
+ let ancestor = dirname(path);
1398
+ while (!exists(ancestor) && dirname(ancestor) !== ancestor) ancestor = dirname(ancestor);
1399
+ if (ancestor === project || broad.tempRoots.includes(ancestor)) {
1400
+ if (!materialize(path)) throw new Error(`Cannot protect denied path: ${path}`);
1401
+ readOnly.add(path);
1402
+ } else if (ancestor !== broad.home) {
1403
+ linkParents.add(ancestor);
1404
+ }
1359
1405
  return;
1360
1406
  }
1361
1407
  if (!exists(path)) {
@@ -1384,19 +1430,37 @@ function buildLinuxBroadCommand(
1384
1430
  for (const path of writeDenied) guard(path, false);
1385
1431
  if (permissions.projectFiles === "read") readOnly.add(project);
1386
1432
 
1433
+ // A read-only link parent keeps its existing entries writable: each one is
1434
+ // bound back read-write (a mount point, so it cannot be renamed away either).
1435
+ // Only new top-level entries in that directory are lost. Protected entries
1436
+ // keep their own mounts, and a symlink entry is never bound: a bind follows
1437
+ // it, and writes through it already meet its target's own mounts.
1438
+ const rebound = new Set<string>();
1439
+ for (const parent of linkParents) readOnly.add(parent);
1440
+ for (const parent of linkParents) {
1441
+ for (const entry of list(parent)) {
1442
+ if (entry.symlink) continue;
1443
+ const path = join(parent, entry.name);
1444
+ if (insideProtected(path) || readOnly.has(path) || writable.has(path)) continue;
1445
+ rebound.add(path);
1446
+ }
1447
+ }
1448
+
1387
1449
  // Intermediate directories between a writable root and anything bound
1388
1450
  // inside it become mount points too, so none of them can be renamed away.
1389
1451
  const anchors = new Set<string>();
1390
1452
  for (const target of [...writableRoots, ...readOnly, ...hidden.map((entry) => entry.path)]) {
1391
1453
  for (const parent of protectedAncestors([target])) {
1392
1454
  if (writableRoots.some((root) => root !== parent && contains(root, parent)) &&
1393
- !readOnly.has(parent) && !writable.has(parent)) anchors.add(parent);
1455
+ !readOnly.has(parent) && !writable.has(parent) && !rebound.has(parent)) anchors.add(parent);
1394
1456
  }
1395
1457
  }
1396
- type Mount = { option: "--bind" | "--ro-bind"; source: string; path: string; rank: number };
1458
+ type Mount = { option: "--bind" | "--bind-try" | "--ro-bind"; source: string; path: string; rank: number };
1397
1459
  const mounts: Mount[] = [
1398
1460
  ...[...anchors].map((path) => ({ option: "--bind" as const, source: path, path, rank: 0 })),
1399
1461
  ...writableRoots.map((path) => ({ option: "--bind" as const, source: path, path, rank: 1 })),
1462
+ // `-try`: an entry removed before a resumed launch stays read-only instead of failing it.
1463
+ ...[...rebound].map((path) => ({ option: "--bind-try" as const, source: path, path, rank: 1 })),
1400
1464
  ...[...readOnly].map((path) => ({ option: "--ro-bind" as const, source: path, path, rank: 2 })),
1401
1465
  ...hidden.map(({ path, source }) => ({ option: "--ro-bind" as const, source, path, rank: 3 })),
1402
1466
  ].sort((a, b) => a.path.length - b.path.length || a.rank - b.rank);
package/src/tools.ts CHANGED
@@ -71,7 +71,7 @@ const RemoteSchema = Type.Object({
71
71
  // TypeBox union, and a Null-first union reports a malformed array as "must be null".
72
72
  const IntentFields = {
73
73
  operation_id: Type.Optional(Type.Unsafe<string | null>({ anyOf: [{ type: "null" }, { type: "string" }], description: "Optional stable id (letters, digits, . _ : / -; up to 64) for one logical operation. Reuse it when you retry with a changed command, scope, or timeout: when a later task with the same operation_id succeeds, earlier failures of that operation in this session are recovered." })),
74
- expected_exit_codes: Type.Optional(Type.Unsafe<number[] | null>({ anyOf: [{ type: "array", items: { type: "integer", minimum: 1, maximum: 255 }, minItems: 1, maxItems: 16 }, { type: "null" }], description: "Optional distinct non-zero exit codes that are intentional for this command (e.g. [1] for a no-match probe). Declared before launch; recorded as expected failures, not incidents needing action. Signals and timeouts are never expected." })),
74
+ expected_exit_codes: Type.Optional(Type.Unsafe<number[] | null>({ anyOf: [{ type: "array", items: { type: "integer", minimum: 0, maximum: 255 }, minItems: 1, maxItems: 16 }, { type: "null" }], description: "Optional distinct non-zero exit codes that are intentional for this command (e.g. [1] for a no-match probe). 0 is allowed and ignored: exit 0 is already success. Declared before launch; recorded as expected failures, not incidents needing action. Signals and timeouts are never expected." })),
75
75
  };
76
76
 
77
77
  const CommandFields = {