pi-better-subagents 0.6.0 → 0.7.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.
@@ -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);