pi-better-subagents 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,9 +11,11 @@
11
11
  */
12
12
  import * as PiCodingAgent from "@earendil-works/pi-coding-agent";
13
13
  import type { BashOperations, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
- import { emptyFailureState, reduceFailure, type FailureEvent, type FailureState } from "./shared-failure-observations.ts";
15
- import { DISPOSITION_TOOL, INTENT_ID_PATTERN, MAX_EXPECTED_EXIT_CODES, describeOpenTargets, foldToolEnd, foldToolStart,
16
- newIncidentModel, readCommandIntent, readDispositionRequest, resolveDisposition, type IncidentModel, type IncidentSink } from "./incident-model.ts";
14
+ // The intent validator is shared with background tasks through the vendored failure-observations module (#325).
15
+ import { emptyFailureState, reduceFailure, INTENT_ID_PATTERN, MAX_EXPECTED_EXIT_CODES, readCommandIntent, withoutAbsentIntent,
16
+ type FailureEvent, type FailureState } from "./shared-failure-observations.ts";
17
+ import { DISPOSITION_TOOL, describeOpenTargets, foldToolEnd, foldToolStart,
18
+ newIncidentModel, readDispositionRequest, resolveDisposition, type IncidentModel, type IncidentSink } from "./incident-model.ts";
17
19
 
18
20
  const { createBashToolDefinition } = PiCodingAgent;
19
21
 
@@ -95,7 +97,7 @@ export function intentBashDefinition(cwd: string, operations: BashOperations) {
95
97
  if (error) throw new Error(`Invalid command intent: ${error}. The command was not run.`);
96
98
  if (intent.attemptId) {
97
99
  const reused = sessionToolCalls(ctx).some((row) => row.type === "tool_execution_start" && row.toolName === "bash" &&
98
- row.toolCallId !== toolCallId && (row.args as any)?.attemptId === intent.attemptId);
100
+ row.toolCallId !== toolCallId && (withoutAbsentIntent(row.args) as any)?.attemptId === intent.attemptId);
99
101
  if (reused) throw new Error(`Invalid command intent: attemptId ${intent.attemptId} was already used. The command was not run.`);
100
102
  }
101
103
  const { operationId: _o, attemptId: _a, expectedExitCodes: _e, ...command } = params ?? {};
@@ -47,6 +47,8 @@ Automatic recovery still requires an exact retry: the same tool name, arguments,
47
47
 
48
48
  Sandboxed children (the task runtime) get two structured additions. The parent honours them only for runs it launched on the task runtime: `meta.taskRuntime` and a parent-authored provenance record written before the child starts, outside the run directory, where the confined child cannot write. For any other run, intent-looking arguments are ordinary arguments and `failure_disposition` calls are ignored. If the metadata cannot be read for a moment, the parent waits and reads it again rather than falling back to the exact rule for the rest of the run. The wait is bounded: when the run ends, or after 30 seconds, the log is scanned under the exact-retry rule so real failures stay visible, with an `Observation incomplete` note that the metadata could not be read.
49
49
 
50
+ Trust is decided once per scan, when the scan begins. A running trusted run whose metadata later becomes unreadable (a corrupt `meta.json`, say) keeps its trusted scan for as long as the parent keeps that scan in memory. Only after a parent restart, a `/reload`, or an eviction from the scan cache does the bounded wait apply again, and the scan after it uses the exact-retry rule with the same note. When the metadata is readable again, a later trusted scan closes the note once the incidents that the exact-rule scan left unresolved are resolved. Failures from the trusted period never hold it open, however often the scan is rebuilt.
51
+
50
52
  - `bash` accepts optional `operationId`, `attemptId`, and `expectedExitCodes`, validated before the command runs.
51
53
  - `operationId` names one logical operation across modified retries. A later success with the same `operationId` (a changed scope, timeout, or flag) recovers the earlier failure automatically. Without it, the exact rule applies. `attemptId` names one execution for use as evidence and never changes operation identity.
52
54
  - `expectedExitCodes` (distinct integers 1–255; a `0` is ignored, since exit 0 is already success) declares intentional non-zero exits, such as `[1]` for an `rg`/`grep` no-match or a `git diff --exit-code` probe. Only the final shell exit code is classified, taken from the tool's structured result, never from output text. Timeouts, aborts, and undeclared codes stay ordinary failures.
package/failures.ts CHANGED
@@ -99,9 +99,55 @@ function settleMetadataGap(id: string, scan: Scan): void {
99
99
  }
100
100
  const held = [...intentBearing].some((incident) => findIncident(state, incident)?.status === "unresolved");
101
101
  if (held) return;
102
- observeFailures(failurePath(id), [{ id: `run-metadata-readable:${gap.id}`, operation: "run-metadata", kind: "recovered", incidents: [gap.id] }]);
102
+ observeFailures(failurePath(id), [{ id: failureIdentity("run-metadata-readable", gap.id), operation: "run-metadata", kind: "recovered", incidents: [gap.id] }]);
103
103
  scan.gap = undefined;
104
104
  }
105
+ /**
106
+ * Scan-period markers (#332). While a "run metadata could not be read" gap is open, each scan that
107
+ * starts under a different rule from the last marked one journals a no-op marker (a `delivered`
108
+ * event naming no incident) whose id records the rule: `run-metadata-scan:<rule>:<journal length>`.
109
+ * The ids are read back by prefix, so they are plain strings, not `failureIdentity` hashes.
110
+ */
111
+ const SCAN_MARKER_PREFIX = "run-metadata-scan:";
112
+ type ScanRule = "exact" | "trusted";
113
+ function scanRuleAt(marker: string): ScanRule | undefined {
114
+ if (!marker.startsWith(SCAN_MARKER_PREFIX)) return undefined;
115
+ const rule = marker.slice(SCAN_MARKER_PREFIX.length).split(":")[0];
116
+ return rule === "exact" || rule === "trusted" ? rule : undefined;
117
+ }
118
+ /** Journal that a scan under `rule` begins, unless the last marked period already used it. */
119
+ function markScanPeriod(path: string, state: FailureState, rule: ScanRule): FailureState {
120
+ let last: ScanRule | undefined;
121
+ for (let i = state.seen.length - 1; i >= 0 && !last; i -= 1) last = scanRuleAt(state.seen[i]!);
122
+ if (last === rule) return state;
123
+ return observeFailures(path, [{ id: `${SCAN_MARKER_PREFIX}${rule}:${state.seen.length}`, operation: "run-metadata", kind: "delivered", incidents: [] }]);
124
+ }
125
+ /**
126
+ * The unresolved tool incidents first journaled by an exact-rule scan, which a trusted scan cannot
127
+ * re-key. Each incident belongs to the period of the last marker before it, so a failure from a
128
+ * trusted period is never mistaken for a leftover, however often the scan is rebuilt (a parent
129
+ * restart, `/reload`, or cache eviction), and a later exact-rule period's failures always are. An
130
+ * incident before every marker is a leftover only when the journal has no exact-rule marker at all
131
+ * (the first trusted scan of a gap, or a journal from before the markers existed).
132
+ */
133
+ function exactRuleLeftovers(path: string, prior: FailureState): Set<string> {
134
+ const state = markScanPeriod(path, prior, "trusted");
135
+ const period = new Map<string, ScanRule | undefined>();
136
+ let current: ScanRule | undefined;
137
+ let sawExact = false;
138
+ for (const id of state.seen) {
139
+ const rule = scanRuleAt(id);
140
+ if (rule) { current = rule; sawExact ||= rule === "exact"; continue; }
141
+ period.set(id, current);
142
+ }
143
+ const leftover = (id: string) => {
144
+ if (!period.has(id)) return false;
145
+ const rule = period.get(id);
146
+ return rule === "exact" || (rule === undefined && !sawExact);
147
+ };
148
+ return new Set(Object.values(state.observations)
149
+ .filter((x) => x.category === "tool" && x.status === "unresolved" && leftover(x.id)).map((x) => x.id));
150
+ }
105
151
  /** Scan complete source records, independent of the finite progress/transcript tail. */
106
152
  export function collectRunFailures(id: string, cwd: string, terminal = false): FailureState {
107
153
  const path = failurePath(id);
@@ -151,6 +197,7 @@ export function collectRunFailures(id: string, cwd: string, terminal = false): F
151
197
  summary: "Run metadata could not be read; tool failures are scanned under the exact-retry rule" }]);
152
198
  }
153
199
  if (!waited) return prior;
200
+ markScanPeriod(path, readRunFailures(id), "exact");
154
201
  scan = { offset: 0, head, identity, model: newIncidentModel(false) };
155
202
  } else {
156
203
  trustUnknownSince.delete(id);
@@ -160,8 +207,7 @@ export function collectRunFailures(id: string, cwd: string, terminal = false): F
160
207
  // differently is still unresolved (#332).
161
208
  const prior = readRunFailures(id);
162
209
  const deferred = prior.observations[failureIdentity("run-metadata")];
163
- if (deferred && deferred.status === "unresolved") scan.gap = { id: deferred.id,
164
- before: new Set(Object.values(prior.observations).filter((x) => x.category === "tool" && x.status === "unresolved").map((x) => x.id)) };
210
+ if (deferred && deferred.status === "unresolved") scan.gap = { id: deferred.id, before: exactRuleLeftovers(path, prior) };
165
211
  }
166
212
  }
167
213
  scan.head = head;
package/incident-model.ts CHANGED
@@ -8,12 +8,10 @@
8
8
  * `operationId` or the exact tool/arguments/cwd hash; expected exits require a code
9
9
  * declared on the attempt before it ran and a structured exit code from the tool.
10
10
  */
11
- import { failureIdentity, findIncident, validateDisposition, INCIDENT_DISPOSITIONS, INTENT_ID_PATTERN, MAX_EXPECTED_EXIT_CODES,
11
+ import { failureIdentity, findIncident, validateDisposition, INCIDENT_DISPOSITIONS,
12
12
  REJECTED_INTENT_CATEGORY, readCommandIntent, withoutAbsentIntent, type CommandIntent, type FailureEvent, type FailureState, type IncidentDisposition } from "./shared-failure-observations.ts";
13
13
 
14
14
  export const DISPOSITION_TOOL = "failure_disposition";
15
- // The intent validator is shared with background tasks through the vendored failure-observations module (#325).
16
- export { INTENT_ID_PATTERN, MAX_EXPECTED_EXIT_CODES, readCommandIntent, withoutAbsentIntent, type CommandIntent };
17
15
 
18
16
  function stable(value: unknown): unknown {
19
17
  if (Array.isArray(value)) return value.map(stable);
@@ -209,10 +207,12 @@ export function foldToolStart(model: IncidentModel, row: any, cwd: string, sink:
209
207
  }
210
208
  const parsed = toolName === "bash" ? readCommandIntent(row.args) : { intent: {} as CommandIntent };
211
209
  const intent = parsed.intent;
212
- // Mirrors the child's check: any earlier bash start that named this attemptId, valid or not.
213
- const rawAttemptId = toolName === "bash" && typeof row.args?.attemptId === "string" ? row.args.attemptId as string : undefined;
214
- const reused = rawAttemptId !== undefined && model.namedAttempts.has(rawAttemptId);
215
- if (rawAttemptId !== undefined) model.namedAttempts.add(rawAttemptId);
210
+ // Mirrors the child's check: any earlier bash start that named this attemptId, valid or not. The name
211
+ // is read after the same normalization the child applies, so `attemptId: ""` or `null` names nothing (#332).
212
+ const namedArgs = toolName === "bash" ? withoutAbsentIntent(row.args) : undefined;
213
+ const namedAttemptId = typeof namedArgs?.attemptId === "string" ? namedArgs.attemptId as string : undefined;
214
+ const reused = namedAttemptId !== undefined && model.namedAttempts.has(namedAttemptId);
215
+ if (namedAttemptId !== undefined) model.namedAttempts.add(namedAttemptId);
216
216
  const predictedRejection = parsed.error ?? (reused ? `attemptId ${intent.attemptId} was already used` : undefined);
217
217
  // A predicted refusal falls back to exact identity: if the child ran the command after all, it is
218
218
  // an ordinary failure of exactly that command, never a failure of the operation it named.
package/output-payload.ts CHANGED
@@ -48,6 +48,7 @@ import {
48
48
  import {
49
49
  actionableFailures,
50
50
  failureJournalFingerprint,
51
+ fileChangeIdentity,
51
52
  failureRevision,
52
53
  formatIncidentSummary,
53
54
  formatTerminalIncidentSummary,
@@ -777,16 +778,7 @@ const LIST_INCIDENT_CACHE_MAX = 4096;
777
778
  let listIncidentComputations = 0;
778
779
 
779
780
  function listIncidentKey(id: string, terminal: boolean): string {
780
- return JSON.stringify([terminal, logIdentityKey(logPathFor(id)), failureJournalFingerprint(failurePath(id))]);
781
- }
782
-
783
- function logIdentityKey(path: string): string {
784
- try {
785
- const stats = statSync(path, { bigint: true });
786
- return `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeNs}:${stats.ctimeNs}`;
787
- } catch (error) {
788
- return `unreadable:${(error as NodeJS.ErrnoException).code ?? "error"}`;
789
- }
781
+ return JSON.stringify([terminal, fileChangeIdentity(logPathFor(id)), failureJournalFingerprint(failurePath(id))]);
790
782
  }
791
783
 
792
784
  export function listIncidentCount(id: string, cwd: string, terminal: boolean): ListIncidentCount {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-subagents",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -75,7 +75,8 @@ export function failureIdentity(...parts: unknown[]): string {
75
75
  return createHash("sha256").update(JSON.stringify(parts)).digest("hex").slice(0, 32);
76
76
  }
77
77
  function text(value: string | undefined, fallback: string): string {
78
- return (value || fallback).replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 400);
78
+ // The 400-unit cut can split a surrogate pair; a lone half would render as U+FFFD (#332).
79
+ return dropLoneSurrogates((value || fallback).replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 400));
79
80
  }
80
81
 
81
82
  /** Agent tool attempts: the agent handles its own tool errors, so one failure is not yet actionable. */
@@ -517,6 +518,8 @@ export interface TerminalFailureParts {
517
518
  rows: string[];
518
519
  /** Counts and separate facts: earlier-reported, unclassified, expected, closed history, correctness. */
519
520
  notes: string[];
521
+ /** The unclassified-count note, when it is among `notes`: a byte budget keeps it longer than the others. */
522
+ unclassifiedNote?: string;
520
523
  }
521
524
  /**
522
525
  * Terminal/completion facts: actionable and incomplete incidents from `incidents` as rows,
@@ -530,12 +533,14 @@ export function terminalFailureParts(state: FailureState, incidents: readonly st
530
533
  const earlier = activeFailures(state).filter((x) => !wanted.has(x.id) && reportable(x)).length;
531
534
  if (earlier) notes.push(`${earlier} actionable incident${earlier === 1 ? " was" : "s were"} reported earlier; not repeated here.`);
532
535
  const counts = failureCounts(state);
533
- if (counts.unclassified) notes.push(`${counts.unclassified} earlier tool failure${counts.unclassified === 1 ? "" : "s"} remain${counts.unclassified === 1 ? "s" : ""} unclassified.`);
536
+ const unclassifiedNote = counts.unclassified
537
+ ? `${counts.unclassified} earlier tool failure${counts.unclassified === 1 ? "" : "s"} remain${counts.unclassified === 1 ? "s" : ""} unclassified.` : undefined;
538
+ if (unclassifiedNote) notes.push(unclassifiedNote);
534
539
  if (counts.expected) notes.push(`${counts.expected} expected failure${counts.expected === 1 ? "" : "s"} recorded.`);
535
540
  const history = closedHistoryLine(state);
536
541
  if (history) notes.push(history);
537
542
  if (counts.unclassified || counts.actionRequired) notes.push(CORRECTNESS_NOTE);
538
- return { rows, notes };
543
+ return { rows, notes, ...(unclassifiedNote ? { unclassifiedNote } : {}) };
539
544
  }
540
545
  export function formatTerminalFailureFacts(state: FailureState, incidents: readonly string[] = []): string {
541
546
  const { rows, notes } = terminalFailureParts(state, incidents);
@@ -643,7 +648,10 @@ export interface IncidentPageRequest extends IncidentRowOptions {
643
648
  * Caller-owned incident pages. Whole rows are preferred; a row larger than the
644
649
  * page is split at a code-point boundary and resumes at that byte, so pages
645
650
  * reconstruct formatFailureLines() exactly (join with "\n" except after a page
646
- * that `endsPartial`). A page never exceeds `maxBytes`.
651
+ * that `endsPartial`). A page never exceeds `maxBytes`, so one smaller than the
652
+ * next code point (up to 4 bytes) returns no text, `hasMore`, and a `nextCursor`
653
+ * equal to `cursor`: the caller retries with a larger page, as with
654
+ * pageVerbatimText. Consumers never ask for fewer than 4 bytes.
647
655
  */
648
656
  export function pageFailureIncidents(state: FailureState, request: IncidentPageRequest = {}): FailureIncidentPage {
649
657
  // A cursor carries its view: following a history cursor stays in history without the flag.
@@ -761,14 +769,19 @@ export function incidentVerbatimPage(state: FailureState, request: IncidentPageR
761
769
  * it, so a cache keyed by it never serves stale incident counts.
762
770
  */
763
771
  export function failureJournalFingerprint(path: string): string {
764
- let file: string;
772
+ return `${fileChangeIdentity(path)}|${existsSync(`${path}.observed`) ? 1 : 0}|${pendingWrites.get(path)?.length ?? 0}`;
773
+ }
774
+ /**
775
+ * A file's identity and change stamp (device, inode, size, and nanosecond mtime/ctime), or its read
776
+ * error. Any append, rewrite, replacement, or deletion changes it; for cache keys, never for trust.
777
+ */
778
+ export function fileChangeIdentity(path: string): string {
765
779
  try {
766
780
  const stats = statSync(path, { bigint: true });
767
- file = `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeNs}:${stats.ctimeNs}`;
781
+ return `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeNs}:${stats.ctimeNs}`;
768
782
  } catch (error) {
769
- file = `unreadable:${(error as NodeJS.ErrnoException).code ?? "error"}`;
783
+ return `unreadable:${(error as NodeJS.ErrnoException).code ?? "error"}`;
770
784
  }
771
- return `${file}|${existsSync(`${path}.observed`) ? 1 : 0}|${pendingWrites.get(path)?.length ?? 0}`;
772
785
  }
773
786
 
774
787
  export function incidentCursorAt(state: FailureState, offset: number, resource?: string, options: IncidentRowOptions = {}): string {
@@ -903,7 +916,7 @@ export function formatTerminalIncidentSummary(state: FailureState, options: Inci
903
916
  // cursor's retrieval hint, the unclassified count, the cursor, and the count line. The
904
917
  // correctness note goes last, and only when it alone does not fit.
905
918
  const correctness: string[] = notes.filter((x) => x === CORRECTNESS_NOTE);
906
- const unclassified = notes.filter((x) => x !== CORRECTNESS_NOTE && /remains? unclassified\.$/.test(x));
919
+ const unclassified = notes.filter((x) => x === parts.unclassifiedNote);
907
920
  const low = notes.filter((x) => !correctness.includes(x) && !unclassified.includes(x));
908
921
  const ladder: Array<{ mode: Header | undefined; notes: string[] }> = [];
909
922
  for (let keep = low.length - 1; keep >= 0; keep -= 1) ladder.push({ mode: "full", notes: [...low.slice(0, keep), ...unclassified, ...correctness] });
@@ -95,6 +95,13 @@ type NavigatorState = {
95
95
  editorComponent?: Component;
96
96
  /** The editor Pi currently has mounted (the wrapper the factory returned last). */
97
97
  editorFocusTarget?: unknown;
98
+ /** How many times that wrapper has rendered, so an overlay can tell whether it is still on screen. */
99
+ editorRenders?: number;
100
+ /**
101
+ * Whether that wrapper rendered between the overlay's last two frames: true while it is on screen,
102
+ * including inside another extension's editor that wraps it. Undefined until two frames compare.
103
+ */
104
+ editorRenderedLastFrame?: boolean;
98
105
  detailOverlayRows?: number;
99
106
  dispose?: () => void;
100
107
  /** The mounted detail overlay, reused instead of stacking a second one. */
@@ -632,7 +639,7 @@ function installNavigatorEditor(ui: any, deps: HostDeps): unknown {
632
639
 
633
640
  function wrapEditor(inner: any, deps: HostDeps): unknown {
634
641
  if (inner && typeof inner.render === "function") state().editorComponent = inner as Component;
635
- const proxy = new Proxy(inner, {
642
+ const proxy: any = new Proxy(inner, {
636
643
  get(target, prop) {
637
644
  if (prop === "handleInput") {
638
645
  return (data: string) => {
@@ -642,6 +649,13 @@ function wrapEditor(inner: any, deps: HostDeps): unknown {
642
649
  target.handleInput(data);
643
650
  };
644
651
  }
652
+ if (prop === "render" && typeof target.render === "function") {
653
+ return (...args: unknown[]) => {
654
+ const s = state();
655
+ if (s.editorFocusTarget === proxy) s.editorRenders = (s.editorRenders ?? 0) + 1;
656
+ return target.render(...args);
657
+ };
658
+ }
645
659
  const value = Reflect.get(target, prop);
646
660
  return typeof value === "function" ? value.bind(target) : value;
647
661
  },
@@ -709,6 +723,19 @@ function handleMainListInput(data: string, deps: HostDeps): boolean {
709
723
  return false;
710
724
  }
711
725
 
726
+ /**
727
+ * Whether pi-tui has the editor our wrapper returned mounted (#332). Pi mounts whatever the factory
728
+ * returns, so another extension's `setEditorComponent` (a foreign factory, or `undefined` for Pi's
729
+ * default editor) unmounts it, and no key reaches our input handler after that. `undefined` means
730
+ * the host cannot say (no `isComponentMounted`), and callers keep their earlier behaviour.
731
+ */
732
+ function liveEditorMounted(tui: unknown): boolean | undefined {
733
+ const live = state().editorFocusTarget;
734
+ const check = (tui as { isComponentMounted?: (component: unknown) => boolean } | undefined)?.isComponentMounted;
735
+ if (!live || typeof check !== "function") return undefined;
736
+ try { return check.call(tui, live) === true; } catch { return undefined; }
737
+ }
738
+
712
739
  /** Close a detail overlay that is still mounted while the editor has focus. */
713
740
  function dismissOverlay(): void {
714
741
  const s = state();
@@ -762,6 +789,7 @@ function openNavigator(): void {
762
789
  try {
763
790
  try { s.dispose?.(); } catch { /* ignore */ }
764
791
  s.dispose = undefined;
792
+ s.editorRenderedLastFrame = undefined;
765
793
  let disposeToken: (() => void) | undefined;
766
794
  let overlayRef: NavigatorState["overlay"];
767
795
  let handle: { focus?(): void; unfocus?(options?: { target: unknown }): void } | undefined;
@@ -774,8 +802,12 @@ function openNavigator(): void {
774
802
  // Pi restores focus to the editor that was focused when the overlay opened. If an
775
803
  // extension re-installed the editor since, that instance is unmounted and every key
776
804
  // would be lost; hand focus to the live editor before closing.
805
+ // Only an editor on screen can take focus: after another extension replaced ours, Pi already
806
+ // focused its replacement, and handing focus to our unmounted wrapper would swallow every key.
807
+ // An extension that wraps ours mounts its own component, so pi-tui does not find ours, but ours
808
+ // still renders and handles every key through it: that is still the live editor (#332).
777
809
  const liveEditor = s.editorFocusTarget;
778
- if (liveEditor) {
810
+ if (liveEditor && (liveEditorMounted(tui) !== false || s.editorRenderedLastFrame !== false)) {
779
811
  try { handle?.unfocus?.({ target: liveEditor }); } catch { /* ignore */ }
780
812
  }
781
813
  done(value);
@@ -952,8 +984,31 @@ function createOverlayComponent(
952
984
  requestRender();
953
985
  }
954
986
 
987
+ let editorRendersSeen: number | undefined;
988
+ /**
989
+ * Our editor wrapper was uninstalled: close, since no key of ours can reach this overlay again and
990
+ * it would cover the editor that replaced ours (#332). An extension that composes the editor inside
991
+ * its own non-container component still renders ours, so the wrapper also counts as live while it
992
+ * rendered since the previous overlay frame (Pi renders the editor before it composites overlays).
993
+ */
994
+ function closeIfEditorReplaced(): void {
995
+ const rendersBefore = editorRendersSeen;
996
+ const renders = state().editorRenders ?? 0;
997
+ editorRendersSeen = renders;
998
+ state().editorRenderedLastFrame = rendersBefore === undefined ? undefined : renders !== rendersBefore;
999
+ if (closed || liveEditorMounted(tui) !== false || rendersBefore === undefined || renders !== rendersBefore) return;
1000
+ // Not from inside pi-tui's render pass: hiding an overlay there would change the stack it is drawing.
1001
+ queueMicrotask(() => {
1002
+ if (closed) return;
1003
+ unfocusMainList();
1004
+ close();
1005
+ requestRender();
1006
+ });
1007
+ }
1008
+
955
1009
  return {
956
1010
  render(width: number) {
1011
+ closeIfEditorReplaced();
957
1012
  refreshRows();
958
1013
  const railLines = buildMainListLines(overlayState.rows, width, deps.truncate, fg, {
959
1014
  selectedId: selectedRow()?.navigatorId,
@@ -109,8 +109,12 @@ export type SandboxCommandArgs = SandboxTarget & {
109
109
  internalHelperExecutable?: boolean;
110
110
  };
111
111
 
112
- /** The wrapper command to spawn: the backend executable and its full argv. */
113
- export type SandboxCommand = { file: string; fileArgs: string[] };
112
+ /**
113
+ * The wrapper command to spawn: the backend executable and its full argv.
114
+ * `notices` are lines the caller shows with the launch output, such as a
115
+ * placeholder the Linux backend had to leave in the user's files.
116
+ */
117
+ export type SandboxCommand = { file: string; fileArgs: string[]; notices?: string[] };
114
118
 
115
119
  /** The caller's default-on / explicit-request / opt-out decision. */
116
120
  export type SandboxRequest = {
@@ -131,7 +135,11 @@ export type SandboxSeams = {
131
135
  platform?: () => string;
132
136
  /** Defaults to a PATH scan that stats and access-checks without executing. */
133
137
  lookupExecutable?: (name: string) => string | undefined;
134
- /** Defaults to `fs.realpathSync`. Must throw when the path does not exist. */
138
+ /**
139
+ * Defaults to `fs.realpathSync.native`, which (unlike the JS `realpathSync`)
140
+ * returns the on-disk case of every existing component on a case-insensitive
141
+ * volume, as the kernel sees it. Must throw when the path does not exist.
142
+ */
135
143
  canonicalize?: (path: string) => string;
136
144
  /** Defaults to `fs.writeFileSync`. */
137
145
  writeProfile?: (path: string, contents: string) => void;
@@ -325,7 +333,7 @@ function currentPlatform(seams: SandboxSeams): string {
325
333
  * canonicalizes through its real parent chain.
326
334
  */
327
335
  export function canonicalizePath(path: string, seams: SandboxSeams = {}): string {
328
- const canonicalize = seams.canonicalize ?? realpathSync;
336
+ const canonicalize = seams.canonicalize ?? realpathSync.native;
329
337
  const absolute = resolve(path);
330
338
  try {
331
339
  const resolved = canonicalize(absolute);
@@ -337,7 +345,7 @@ export function canonicalizePath(path: string, seams: SandboxSeams = {}): string
337
345
  try {
338
346
  const source = statSync(resolved);
339
347
  const alias = statSync(candidate);
340
- if (source.dev === alias.dev && source.ino === alias.ino) return realpathSync(candidate);
348
+ if (source.dev === alias.dev && source.ino === alias.ino) return realpathSync.native(candidate);
341
349
  } catch { /* An unrelated Data-volume path keeps its original identity. */ }
342
350
  }
343
351
  return resolved;
@@ -358,7 +366,7 @@ function compile(
358
366
  // bind-mounts it; the macOS backend has always tolerated a not-yet-created
359
367
  // one. Keep both behaviors rather than unifying them here.
360
368
  const writableRoot = strictRoot
361
- ? (seams.canonicalize ?? realpathSync)(policy.writableRoot)
369
+ ? (seams.canonicalize ?? realpathSync.native)(policy.writableRoot)
362
370
  : canonicalizePath(policy.writableRoot, seams);
363
371
 
364
372
  // The broad profile also keeps each literal entry: its removal grants would
@@ -544,6 +552,67 @@ function contains(root: string, target: string): boolean {
544
552
  return target.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
545
553
  }
546
554
 
555
+ /** Per-device answer to "does this volume fold case?" (APFS/HFS+ default). */
556
+ const caseFoldingByDevice = new Map<number, boolean>();
557
+
558
+ /**
559
+ * Whether the volume holding `path` (its nearest existing ancestor) ignores
560
+ * case: an existing component whose case-swapped spelling names the same
561
+ * inode proves it. macOS only; false whenever it cannot be shown.
562
+ */
563
+ function caseInsensitiveVolume(path: string): boolean {
564
+ if (process.platform !== "darwin") return false;
565
+ let current = path;
566
+ while (!existsSync(current) && dirname(current) !== current) current = dirname(current);
567
+ let device: number;
568
+ try { device = statSync(current).dev; } catch { return false; }
569
+ const known = caseFoldingByDevice.get(device);
570
+ if (known !== undefined) return known;
571
+ for (let probe = current; dirname(probe) !== probe; probe = dirname(probe)) {
572
+ const name = basename(probe);
573
+ const swapped = [...name].map((c) => c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase()).join("");
574
+ if (swapped === name) continue;
575
+ try {
576
+ const original = statSync(probe);
577
+ if (original.dev !== device) break;
578
+ let folded = false;
579
+ try {
580
+ const other = statSync(join(dirname(probe), swapped));
581
+ folded = other.dev === original.dev && other.ino === original.ino;
582
+ } catch { /* The swapped name is absent: case-sensitive. */ }
583
+ caseFoldingByDevice.set(device, folded);
584
+ return folded;
585
+ } catch { return false; }
586
+ }
587
+ return false;
588
+ }
589
+
590
+ /**
591
+ * Spell `path` the way the policy spells it when they differ only in case on a
592
+ * case-insensitive volume. `canonicalizePath` fixes the case of every existing
593
+ * component, but a missing tail (`~/.NPMRC` while `~/.npmrc` is absent) keeps
594
+ * the caller's case, and the kernel still treats it as the protected name. The
595
+ * longest policy path that matches case-insensitively wins, so the pre-check
596
+ * decides as the kernel does (#354).
597
+ */
598
+ function alignCaseToPolicy(path: string, policy: CompiledSandboxWritePolicy): string {
599
+ const broad = policy.broad;
600
+ const candidates = [policy.writableRoot, ...policy.denyWrite, ...(policy.credentialPaths ?? []),
601
+ ...(policy.runtimeWrite ?? []), ...(policy.compatibilityWrite ?? []),
602
+ ...(broad ? [broad.home, ...broad.tempRoots, ...broad.deleteRoots, ...broad.codePaths] : [])];
603
+ const lower = path.toLowerCase();
604
+ if (lower.length !== path.length) return path;
605
+ let best: string | undefined;
606
+ for (const candidate of candidates) {
607
+ const folded = candidate.toLowerCase();
608
+ if (folded.length !== candidate.length || !contains(folded, lower)) continue;
609
+ if (!best || candidate.length > best.length) best = candidate;
610
+ }
611
+ // Already spelled as the policy spells it: nothing to probe.
612
+ if (!best || path.startsWith(best) || !caseInsensitiveVolume(path)) return path;
613
+ return `${best}${path.slice(best.length)}`;
614
+ }
615
+
547
616
  function isCredential(path: string, policy: CompiledSandboxWritePolicy): boolean {
548
617
  return (policy.credentialPaths ?? []).some((credential) => contains(credential, path));
549
618
  }
@@ -558,7 +627,7 @@ export function evaluateReadAccess(
558
627
  policy: CompiledSandboxWritePolicy,
559
628
  seams: SandboxSeams = {},
560
629
  ): ReadAccessDecision {
561
- const path = canonicalizePath(target, seams);
630
+ const path = alignCaseToPolicy(canonicalizePath(target, seams), policy);
562
631
  const permissions = policy.permissions;
563
632
  if (!permissions) return { allowed: true, path };
564
633
  if (policy.broad) {
@@ -585,7 +654,7 @@ export function evaluateWriteAccess(
585
654
  policy: CompiledSandboxWritePolicy,
586
655
  seams: SandboxSeams = {},
587
656
  ): WriteAccessDecision {
588
- const path = canonicalizePath(target, seams);
657
+ const path = alignCaseToPolicy(canonicalizePath(target, seams), policy);
589
658
  if (policy.broad) return evaluateBroadWrite(path, policy, policy.broad, seams);
590
659
  if (!policy.permissions && !contains(policy.writableRoot, path)) {
591
660
  return { allowed: false, path, reason: "outside-writable-root" };
@@ -1370,10 +1439,28 @@ function buildLinuxBroadCommand(
1370
1439
  const readOnly = new Set<string>();
1371
1440
  const directoryLocation = (path: string) => [...DIRECTORY_LOCATIONS]
1372
1441
  .some((name) => canonicalizePath(join(broad.home, name), seams) === path);
1442
+ // The literal fixed-list entry (a dangling `~/.ssh` link) or its canonical path.
1443
+ const directoryEntry = (path: string) => directoryLocation(path) ||
1444
+ [...DIRECTORY_LOCATIONS].some((name) => join(broad.home, name) === path);
1373
1445
  // Directories holding a protected symlink: read-only, so the link cannot be
1374
1446
  // replaced, with their existing entries bound writable again below.
1375
1447
  const linkParents = new Set<string>();
1376
- const danglingTargets = new Set(protectedPaths.flatMap((path) => walkSymlinks(path).unresolved));
1448
+ // Where dangling links would land. Only the first missing component of each
1449
+ // needs a guard: nothing can be created below a read-only mount or a locked
1450
+ // parent. It is a directory when more of the path lies below it, or when the
1451
+ // link is a directory-type entry (`~/.ssh -> ~/work/ssh`).
1452
+ const firstMissing = new Map<string, boolean>();
1453
+ const belowFirstMissing = new Set<string>();
1454
+ for (const path of protectedPaths) {
1455
+ const unresolved = walkSymlinks(path).unresolved;
1456
+ if (!unresolved.length) continue;
1457
+ const first = unresolved[0]!;
1458
+ const full = unresolved.at(-1)!;
1459
+ firstMissing.set(first, firstMissing.get(first) === true || first !== full || directoryEntry(path));
1460
+ if (full !== first) belowFirstMissing.add(full);
1461
+ }
1462
+ const created: string[] = [];
1463
+ const makeDirectory = seams.makeDirectory ?? ((target: string) => mkdirSync(target, { recursive: true, mode: 0o700 }));
1377
1464
  const guard = (path: string, hide: boolean) => {
1378
1465
  let directory: boolean | undefined;
1379
1466
  let link = false;
@@ -1387,21 +1474,39 @@ function buildLinuxBroadCommand(
1387
1474
  if (link && dirname(path) !== broad.home && writableAt(dirname(path))) linkParents.add(dirname(path));
1388
1475
  return;
1389
1476
  }
1390
- if (danglingTargets.has(path)) {
1477
+ if (belowFirstMissing.has(path) && !firstMissing.has(path)) return; // Its first missing component is guarded.
1478
+ if (firstMissing.has(path)) {
1391
1479
  // 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.
1480
+ // A missing directory gets an empty read-only placeholder directory,
1481
+ // so only that one name is taken, not every new entry beside it
1482
+ // (`~/.cache/<tool>/…` would otherwise lock all of `~/.cache`). A
1483
+ // missing file is not planted in the user's files: its parent is
1484
+ // locked instead (entries stay writable, see below), except in the
1485
+ // workspace root and temp, which must keep accepting new entries,
1486
+ // so the file placeholder is bound there. Each placeholder made is
1487
+ // reported, and the workspace and temp fail closed without one.
1396
1488
  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);
1489
+ const ancestor = dirname(path);
1490
+ if (ancestor === broad.home) return; // Home is read-only already.
1491
+ const failClosed = ancestor === project || broad.tempRoots.includes(ancestor);
1492
+ const directory = firstMissing.get(path)!;
1493
+ if (directory || failClosed) {
1494
+ const existed = exists(path);
1495
+ let made: boolean;
1496
+ if (directory) {
1497
+ try { makeDirectory(path); } catch { /* Re-checked below. */ }
1498
+ made = exists(path);
1499
+ } else {
1500
+ made = materialize(path);
1501
+ }
1502
+ if (made) {
1503
+ if (!existed) created.push(`${path}${directory ? "/" : ""}`);
1504
+ readOnly.add(path);
1505
+ return;
1506
+ }
1507
+ if (failClosed) throw new Error(`Cannot protect denied path: ${path}`);
1404
1508
  }
1509
+ linkParents.add(ancestor);
1405
1510
  return;
1406
1511
  }
1407
1512
  if (!exists(path)) {
@@ -1476,6 +1581,9 @@ function buildLinuxBroadCommand(
1476
1581
  file: bwrap,
1477
1582
  fileArgs: [...fileArgs, ...(!permissions.network ? ["--unshare-net"] : []),
1478
1583
  "--", args.execPath, ...args.execArgs],
1584
+ ...(created.length && { notices: [
1585
+ `Sandbox: created an empty read-only placeholder where a protected symlink points at nothing, and left it in place: ${created.join(", ")}`,
1586
+ ] }),
1479
1587
  };
1480
1588
  }
1481
1589
 
@@ -123,7 +123,7 @@ export function removeDecision(path: string, policy: CompiledSandboxWritePolicy)
123
123
  const literalLeaf: SandboxSeams = {
124
124
  canonicalize: (candidate) => {
125
125
  if (candidate === entry) throw new Error("the leaf entry is judged literally");
126
- return realpathSync(candidate);
126
+ return realpathSync.native(candidate);
127
127
  },
128
128
  };
129
129
  return evaluateDeleteAccess(entry, policy, literalLeaf);
@@ -124,6 +124,7 @@ export function createTaskBashOperations(
124
124
  .map(([key, value]) => `${key}=${value}`), shell.shell, ...shell.args, command],
125
125
  }, { sandboxEnabled: true, explicitSandbox: true });
126
126
  if (!wrapped) throw new Error("Sandbox: no task execution backend is available.");
127
+ for (const line of wrapped.notices ?? []) options.onData(Buffer.from(`${line}\n`));
127
128
  if (options.signal?.aborted) throw new Error("aborted");
128
129
  if (options.timeout !== undefined && (!Number.isFinite(options.timeout) || options.timeout <= 0 || options.timeout * 1000 > 2147483647)) {
129
130
  throw new Error("Invalid timeout: must be a positive, supported number of seconds");
package/task-policy.ts CHANGED
@@ -90,7 +90,7 @@ export function prepareTaskRuntime(options: {
90
90
  }
91
91
  const support = describeSandboxSupport();
92
92
  if (!support.supported) throw new Error(`Task sandbox unavailable: ${support.reason}`);
93
- const root = realpathSync(options.root);
93
+ const root = realpathSync.native(options.root);
94
94
  const home = homedir();
95
95
  if (root === dirname(root) || root === canonicalizePath(home)) throw new Error("Task sandbox requires a project directory, not the filesystem or home root.");
96
96
  const permissions = options.permissions ?? DEFAULT_TASK_PERMISSIONS;