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.
- package/README.md +1 -1
- package/child-incidents.ts +2 -2
- package/docs/failure-observations.md +1 -1
- package/failures.ts +48 -6
- package/incident-model.ts +11 -4
- package/index.ts +47 -18
- package/output-payload.ts +2 -3
- package/package.json +1 -1
- package/permission-policy.ts +9 -3
- package/registry.ts +19 -2
- package/shared-failure-observations.ts +48 -12
- package/shared-log-utils.ts +6 -12
- package/shared-navigator.ts +169 -125
- package/shared-sandbox-core.ts +78 -14
- package/shared-task-apply-patch.ts +409 -0
- package/shared-task-files.ts +69 -7
- package/shared-task-sandbox.ts +11 -0
- package/shared-task-tools.ts +111 -0
- package/subagent-tools.ts +95 -0
- package/task-guard.ts +42 -7
- package/task-policy.ts +28 -0
package/shared-navigator.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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)
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
760
|
-
let detail: BackgroundWorkDetail | null =
|
|
821
|
+
let detailId: string = initialDetailId;
|
|
822
|
+
let detail: BackgroundWorkDetail | null = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? null;
|
|
761
823
|
applyDefaultExpandedSections(detail, expandedSections);
|
|
762
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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
|
-
|
|
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 =
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
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 (
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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 (
|
|
986
|
-
|
|
987
|
-
|
|
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
|
-
}
|
|
990
|
-
|
|
991
|
-
|
|
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
|
-
|
|
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
|
}
|
package/shared-sandbox-core.ts
CHANGED
|
@@ -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)
|
|
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.
|
|
421
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
|
1355
|
-
//
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
if (link && dirname(path) !== broad.home && writableAt(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);
|