pi-better-harness 0.34.1 → 0.34.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/node_modules/pi-better-background-tasks/package.json +1 -1
- package/node_modules/pi-better-background-tasks/src/shared-navigator.ts +107 -47
- package/node_modules/pi-better-subagents/README.md +17 -0
- package/node_modules/pi-better-subagents/child-ownership.ts +130 -0
- package/node_modules/pi-better-subagents/index.ts +4 -3
- package/node_modules/pi-better-subagents/package.json +2 -1
- package/node_modules/pi-better-subagents/shared-navigator.ts +107 -47
- package/package.json +4 -3
|
@@ -348,20 +348,60 @@ type MainListWidget = Component & {
|
|
|
348
348
|
handleMouse(event: WidgetMouseEvent): WidgetMouseResult | undefined;
|
|
349
349
|
};
|
|
350
350
|
|
|
351
|
+
const BACK_TO_MAIN = "← main (Esc)";
|
|
352
|
+
const BACK_TARGET = "back-to-main";
|
|
353
|
+
|
|
354
|
+
function highlightPointerTarget(theme: unknown, line: string, width: number): string {
|
|
355
|
+
const colors = theme as { bg?(color: string, value: string): string } | undefined;
|
|
356
|
+
return colors?.bg?.("selectedBg", line + " ".repeat(Math.max(0, width - visibleWidth(line))))
|
|
357
|
+
?? themeFgFromTheme(theme)("accent", line);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function createPointerNavigation(targetAt: (event: WidgetMouseEvent) => string | undefined, activate: (target: string) => boolean) {
|
|
361
|
+
let hovered: string | undefined;
|
|
362
|
+
let pressed: { target: string; y: number } | undefined;
|
|
363
|
+
return {
|
|
364
|
+
get hovered() { return hovered; },
|
|
365
|
+
reset() { hovered = undefined; pressed = undefined; },
|
|
366
|
+
handleMouse(event: WidgetMouseEvent): WidgetMouseResult | undefined {
|
|
367
|
+
if (event.type === "move" || event.type === "leave") {
|
|
368
|
+
const target = event.type === "leave" ? undefined : targetAt(event);
|
|
369
|
+
const changed = hovered !== target;
|
|
370
|
+
hovered = target;
|
|
371
|
+
return changed || target ? { handled: true, render: changed } : undefined;
|
|
372
|
+
}
|
|
373
|
+
if (event.button !== "left" || event.shift || event.alt || event.ctrl) {
|
|
374
|
+
pressed = undefined;
|
|
375
|
+
return undefined;
|
|
376
|
+
}
|
|
377
|
+
if (event.type === "press") {
|
|
378
|
+
const target = targetAt(event);
|
|
379
|
+
pressed = target ? { target, y: event.y } : undefined;
|
|
380
|
+
return target ? { handled: true, render: false } : undefined;
|
|
381
|
+
}
|
|
382
|
+
if (event.type !== "click") return undefined;
|
|
383
|
+
const claim = pressed;
|
|
384
|
+
pressed = undefined;
|
|
385
|
+
if (!claim || claim.y !== event.y || targetAt(event) !== claim.target || !activate(claim.target)) return undefined;
|
|
386
|
+
return { handled: true, render: true };
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
351
391
|
function createMainListWidget(tui: { requestRender?(): void }, theme: unknown, deps: HostDeps): MainListWidget {
|
|
352
392
|
const requestRender = () => tui.requestRender?.();
|
|
353
393
|
state().mainListRequestRender = requestRender;
|
|
354
394
|
// navigatorId per rendered line, so a pointer row resolves by structure, never by row text.
|
|
355
395
|
let lineOwners: Array<string | undefined> = [];
|
|
356
396
|
let lineWidth = MAIN_LIST_FALLBACK_WIDTH;
|
|
357
|
-
// Pi synthesizes a click only for the component that handled the initial press.
|
|
358
|
-
let pressedRow: { navigatorId: string; y: number } | undefined;
|
|
359
397
|
const rowAt = (event: WidgetMouseEvent): string | undefined => {
|
|
360
398
|
const width = Number.isFinite(event.width) && event.width > 0 ? Math.floor(event.width) : lineWidth;
|
|
361
399
|
if (!Number.isFinite(event.x) || event.x < 0 || event.x >= width) return undefined;
|
|
362
400
|
if (!Number.isInteger(event.y) || event.y < 0 || event.y >= lineOwners.length) return undefined;
|
|
363
|
-
|
|
401
|
+
const id = lineOwners[event.y];
|
|
402
|
+
return id && listRows().some((row) => row.navigatorId === id) ? id : undefined;
|
|
364
403
|
};
|
|
404
|
+
const pointer = createPointerNavigation(rowAt, openNavigatorRow);
|
|
365
405
|
return {
|
|
366
406
|
render(width: number): string[] {
|
|
367
407
|
const rows = listRows();
|
|
@@ -369,7 +409,7 @@ function createMainListWidget(tui: { requestRender?(): void }, theme: unknown, d
|
|
|
369
409
|
if (!rows.length) {
|
|
370
410
|
state().lastMainListLines = undefined;
|
|
371
411
|
lineOwners = [];
|
|
372
|
-
|
|
412
|
+
pointer.reset();
|
|
373
413
|
return [];
|
|
374
414
|
}
|
|
375
415
|
const layout = buildMainListLayout(rows, renderWidth(width), deps.truncate, themeFgFromTheme(theme), {
|
|
@@ -379,41 +419,24 @@ function createMainListWidget(tui: { requestRender?(): void }, theme: unknown, d
|
|
|
379
419
|
lineWidth = renderWidth(width);
|
|
380
420
|
// The leading gap line belongs to the widget too, so its slot stays ownerless.
|
|
381
421
|
lineOwners = [undefined, ...layout.owners];
|
|
382
|
-
const lines = ["", ...layout.lines]
|
|
422
|
+
const lines = ["", ...layout.lines].map((line, index) =>
|
|
423
|
+
pointer.hovered && lineOwners[index] === pointer.hovered ? highlightPointerTarget(theme, line, lineWidth) : line);
|
|
383
424
|
state().lastMainListLines = lines;
|
|
384
425
|
return lines;
|
|
385
426
|
},
|
|
386
427
|
handleMouse(event: WidgetMouseEvent): WidgetMouseResult | undefined {
|
|
387
|
-
|
|
388
|
-
if (event.button !== "left" || event.shift === true || event.alt === true || event.ctrl === true) {
|
|
389
|
-
pressedRow = undefined;
|
|
390
|
-
return undefined;
|
|
391
|
-
}
|
|
392
|
-
if (event.type === "press") {
|
|
393
|
-
pressedRow = undefined;
|
|
394
|
-
const navigatorId = rowAt(event);
|
|
395
|
-
if (!navigatorId || !listRows().some((row) => row.navigatorId === navigatorId)) return undefined;
|
|
396
|
-
pressedRow = { navigatorId, y: event.y };
|
|
397
|
-
return { handled: true, render: false };
|
|
398
|
-
}
|
|
399
|
-
if (event.type !== "click") return undefined;
|
|
400
|
-
const pressed = pressedRow;
|
|
401
|
-
pressedRow = undefined;
|
|
402
|
-
// A click after another component's press, or after the layout moved, is not the row we claimed.
|
|
403
|
-
if (!pressed || pressed.y !== event.y || rowAt(event) !== pressed.navigatorId) return undefined;
|
|
404
|
-
if (!openNavigatorRow(pressed.navigatorId)) return undefined;
|
|
405
|
-
return { handled: true, render: true };
|
|
428
|
+
return pointer.handleMouse(event);
|
|
406
429
|
},
|
|
407
430
|
invalidate() {
|
|
408
431
|
state().lastMainListLines = undefined;
|
|
409
432
|
lineOwners = [];
|
|
410
|
-
|
|
433
|
+
pointer.reset();
|
|
411
434
|
},
|
|
412
435
|
dispose() {
|
|
413
436
|
const s = state();
|
|
414
437
|
if (s.mainListRequestRender === requestRender) s.mainListRequestRender = undefined;
|
|
415
438
|
lineOwners = [];
|
|
416
|
-
|
|
439
|
+
pointer.reset();
|
|
417
440
|
},
|
|
418
441
|
};
|
|
419
442
|
}
|
|
@@ -958,6 +981,22 @@ function createOverlayComponent(
|
|
|
958
981
|
let closed = false;
|
|
959
982
|
let transcriptDetail: BackgroundWorkDetail | null = null;
|
|
960
983
|
let transcriptComponent: Component | null = null;
|
|
984
|
+
let pointerTargets: Array<string | undefined> = [];
|
|
985
|
+
let pointerWidth = 0;
|
|
986
|
+
const pointer = createPointerNavigation((event) => {
|
|
987
|
+
if (closed || !Number.isInteger(event.y) || !Number.isFinite(event.x) || event.x < 0 || event.x >= pointerWidth) return undefined;
|
|
988
|
+
const target = pointerTargets[event.y];
|
|
989
|
+
if (target === BACK_TARGET) return event.x >= 3 && event.x < 3 + visibleWidth(BACK_TO_MAIN) ? target : undefined;
|
|
990
|
+
return target && listRows().some((row) => row.navigatorId === target) ? target : undefined;
|
|
991
|
+
}, (target) => {
|
|
992
|
+
if (target === BACK_TARGET) { backToMain(); return true; }
|
|
993
|
+
refreshRows();
|
|
994
|
+
const index = overlayState.rows.findIndex((row) => row.navigatorId === target);
|
|
995
|
+
if (index < 0) return false;
|
|
996
|
+
selectOverlayRow(index);
|
|
997
|
+
openDetail();
|
|
998
|
+
return true;
|
|
999
|
+
});
|
|
961
1000
|
|
|
962
1001
|
const fg = (color: string, value: string) => theme?.fg ? theme.fg(color, value) : value;
|
|
963
1002
|
|
|
@@ -1036,6 +1075,11 @@ function createOverlayComponent(
|
|
|
1036
1075
|
try { done(null); } catch { /* ignore */ }
|
|
1037
1076
|
}
|
|
1038
1077
|
|
|
1078
|
+
function backToMain(): void {
|
|
1079
|
+
unfocusMainList();
|
|
1080
|
+
close();
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1039
1083
|
function handleCloseKey(): void {
|
|
1040
1084
|
const row = selectedRow();
|
|
1041
1085
|
if (!row || row.parentRow) return;
|
|
@@ -1101,14 +1145,17 @@ function createOverlayComponent(
|
|
|
1101
1145
|
render(width: number) {
|
|
1102
1146
|
closeIfEditorReplaced();
|
|
1103
1147
|
refreshRows();
|
|
1104
|
-
const
|
|
1148
|
+
const rail = buildMainListLayout(overlayState.rows, width, deps.truncate, fg, {
|
|
1105
1149
|
selectedId: selectedRow()?.navigatorId,
|
|
1106
1150
|
focused: true,
|
|
1107
1151
|
});
|
|
1152
|
+
const railLines = rail.lines;
|
|
1108
1153
|
const editorLines = renderEditorLines(width);
|
|
1109
1154
|
const bottomLines = [...railLines, ...editorLines];
|
|
1110
1155
|
const overlayRows = state().detailOverlayRows;
|
|
1111
1156
|
const detailRows = overlayRows === undefined ? undefined : Math.max(1, overlayRows - bottomLines.length);
|
|
1157
|
+
const backControl = pointer.hovered === BACK_TARGET
|
|
1158
|
+
? highlightPointerTarget(theme, BACK_TO_MAIN, visibleWidth(BACK_TO_MAIN)) : undefined;
|
|
1112
1159
|
let contentLines: string[];
|
|
1113
1160
|
if (detail?.transcript && deps.createTranscriptComponent) {
|
|
1114
1161
|
if (transcriptDetail !== detail || !transcriptComponent) {
|
|
@@ -1121,18 +1168,30 @@ function createOverlayComponent(
|
|
|
1121
1168
|
width,
|
|
1122
1169
|
deps.truncate,
|
|
1123
1170
|
fg,
|
|
1124
|
-
{ minRows: detailRows, maxRows: detailRows, bottomFooter: false, logTailRows },
|
|
1171
|
+
{ minRows: detailRows, maxRows: detailRows, bottomFooter: false, logTailRows, backControl },
|
|
1125
1172
|
);
|
|
1126
1173
|
} else {
|
|
1127
1174
|
transcriptDetail = null;
|
|
1128
1175
|
transcriptComponent = null;
|
|
1129
|
-
contentLines = buildDetailLines(detail, width, deps.truncate, fg, { expandedSections, logTailRows, minRows: detailRows, maxRows: detailRows, bottomFooter: false });
|
|
1176
|
+
contentLines = buildDetailLines(detail, width, deps.truncate, fg, { expandedSections, logTailRows, minRows: detailRows, maxRows: detailRows, bottomFooter: false, backControl });
|
|
1130
1177
|
}
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1178
|
+
const fittedContent = detailRows === undefined ? contentLines : contentLines.slice(0, detailRows);
|
|
1179
|
+
if (detailRows !== undefined) while (fittedContent.length < detailRows) fittedContent.push("");
|
|
1180
|
+
const contentTargets = fittedContent.map((_, index) => index === 1 ? BACK_TARGET : undefined);
|
|
1181
|
+
const allLines = [...fittedContent, ...bottomLines];
|
|
1182
|
+
const allTargets = [...contentTargets, ...rail.owners, ...editorLines.map(() => undefined)];
|
|
1183
|
+
// Match hit targets to the same viewport clipping as the visible lines.
|
|
1184
|
+
const start = overlayRows === undefined ? 0 : Math.max(0, allLines.length - Math.max(1, overlayRows));
|
|
1185
|
+
pointerTargets = allTargets.slice(start);
|
|
1186
|
+
pointerWidth = renderWidth(width);
|
|
1187
|
+
return allLines.slice(start).map((line, index) => {
|
|
1188
|
+
if (!pointer.hovered || pointerTargets[index] !== pointer.hovered) return line;
|
|
1189
|
+
if (pointer.hovered === BACK_TARGET) return line;
|
|
1190
|
+
return highlightPointerTarget(theme, line, width);
|
|
1191
|
+
});
|
|
1192
|
+
},
|
|
1193
|
+
handleMouse(event: WidgetMouseEvent): WidgetMouseResult | undefined {
|
|
1194
|
+
return pointer.handleMouse(event);
|
|
1136
1195
|
},
|
|
1137
1196
|
handleInput(data: string) {
|
|
1138
1197
|
if (closed) return;
|
|
@@ -1151,7 +1210,7 @@ function createOverlayComponent(
|
|
|
1151
1210
|
else if (deps.matchKey(data, "left")) {
|
|
1152
1211
|
const mainIdx = overlayState.rows.findIndex((row) => row.parentRow && row.id === "main");
|
|
1153
1212
|
if (mainIdx >= 0) selectOverlayRow(mainIdx);
|
|
1154
|
-
|
|
1213
|
+
backToMain();
|
|
1155
1214
|
}
|
|
1156
1215
|
else if (deps.matchKey(data, "enter")) {
|
|
1157
1216
|
const row = selectedRow();
|
|
@@ -1179,8 +1238,7 @@ function createOverlayComponent(
|
|
|
1179
1238
|
requestRender();
|
|
1180
1239
|
}
|
|
1181
1240
|
else if (deps.matchKey(data, "escape")) {
|
|
1182
|
-
|
|
1183
|
-
close();
|
|
1241
|
+
backToMain();
|
|
1184
1242
|
}
|
|
1185
1243
|
},
|
|
1186
1244
|
/** Show another row's detail in this overlay (the main list moved while it was unfocused). */
|
|
@@ -1195,8 +1253,10 @@ function createOverlayComponent(
|
|
|
1195
1253
|
},
|
|
1196
1254
|
/** Close the overlay (done), e.g. when the navigator is disposed. */
|
|
1197
1255
|
dismiss() { close(); },
|
|
1198
|
-
invalidate() { transcriptComponent?.invalidate(); },
|
|
1256
|
+
invalidate() { pointer.reset(); pointerTargets = []; transcriptComponent?.invalidate(); },
|
|
1199
1257
|
dispose() {
|
|
1258
|
+
pointer.reset();
|
|
1259
|
+
pointerTargets = [];
|
|
1200
1260
|
clearCloseArm();
|
|
1201
1261
|
detailScheduler.dispose();
|
|
1202
1262
|
},
|
|
@@ -1233,14 +1293,14 @@ function buildTranscriptDetailLines(
|
|
|
1233
1293
|
width: number,
|
|
1234
1294
|
truncate: (s: string, width: number) => string,
|
|
1235
1295
|
fg: (color: string, value: string) => string,
|
|
1236
|
-
options: { minRows?: number; maxRows?: number; bottomFooter?: boolean; logTailRows?: number } = {},
|
|
1296
|
+
options: { minRows?: number; maxRows?: number; bottomFooter?: boolean; logTailRows?: number; backControl?: string } = {},
|
|
1237
1297
|
): string[] {
|
|
1238
|
-
const actions = [...(detail.footerActions ?? ["x close"])
|
|
1298
|
+
const actions = [...(detail.footerActions ?? ["x close"])].join(" · ");
|
|
1239
1299
|
const tailRows = options.logTailRows ?? DEFAULT_LOG_TAIL_ROWS;
|
|
1240
1300
|
let shownTranscriptLines = transcriptLines.length ? transcriptLines.slice(-tailRows) : [" (no transcript yet)"];
|
|
1241
1301
|
const lines: string[] = [
|
|
1242
1302
|
fg("accent", rule(detail.title, width)),
|
|
1243
|
-
dim(`
|
|
1303
|
+
` ${options.backControl ?? fg("accent", BACK_TO_MAIN)}${dim(` · ${actions}`, fg)}`,
|
|
1244
1304
|
"",
|
|
1245
1305
|
` status ${fg(toneColor(detail.statusTone, detail.status), detail.status)}`,
|
|
1246
1306
|
];
|
|
@@ -1264,7 +1324,7 @@ function buildTranscriptDetailLines(
|
|
|
1264
1324
|
lines.push(...shownTranscriptLines);
|
|
1265
1325
|
lines.push("");
|
|
1266
1326
|
if (options.bottomFooter === false) return lines.map((line) => safeTruncate(line, width, truncate));
|
|
1267
|
-
const footerLines = [dim(`
|
|
1327
|
+
const footerLines = [dim(` ${BACK_TO_MAIN} · ${actions}`, fg), dim(rule("", width), fg)];
|
|
1268
1328
|
padBeforeFooter(lines, footerLines.length, options.minRows);
|
|
1269
1329
|
lines.push(...footerLines);
|
|
1270
1330
|
return lines.map((line) => safeTruncate(line, width, truncate));
|
|
@@ -1320,10 +1380,10 @@ function buildDetailLines(
|
|
|
1320
1380
|
width: number,
|
|
1321
1381
|
truncate: (s: string, width: number) => string,
|
|
1322
1382
|
fg: (color: string, value: string) => string,
|
|
1323
|
-
options: { expandedSections?: Set<string>; logTailRows?: number; minRows?: number; maxRows?: number; bottomFooter?: boolean } = {},
|
|
1383
|
+
options: { expandedSections?: Set<string>; logTailRows?: number; minRows?: number; maxRows?: number; bottomFooter?: boolean; backControl?: string } = {},
|
|
1324
1384
|
): string[] {
|
|
1325
1385
|
if (!detail) {
|
|
1326
|
-
const lines = [rule("Work unavailable", width),
|
|
1386
|
+
const lines = [rule("Work unavailable", width), ` ${options.backControl ?? fg("accent", BACK_TO_MAIN)}`, ""];
|
|
1327
1387
|
if (options.bottomFooter === false) return lines.map((line) => safeTruncate(line, width, truncate));
|
|
1328
1388
|
const footerLines = [dim(rule("", width), fg)];
|
|
1329
1389
|
padBeforeFooter(lines, footerLines.length, options.minRows);
|
|
@@ -1336,8 +1396,8 @@ function buildDetailLines(
|
|
|
1336
1396
|
const foldedAction = toggleableSectionId
|
|
1337
1397
|
? (options.expandedSections?.has(toggleableSectionId) ? "Enter collapse" : "Enter expand")
|
|
1338
1398
|
: null;
|
|
1339
|
-
const actions = [foldedAction, ...(detail.footerActions?.length ? detail.footerActions : ["x close"]), "l 10/25"
|
|
1340
|
-
lines.push(dim(`
|
|
1399
|
+
const actions = [foldedAction, ...(detail.footerActions?.length ? detail.footerActions : ["x close"]), "l 10/25"].filter(Boolean).join(" · ");
|
|
1400
|
+
lines.push(` ${options.backControl ?? fg("accent", BACK_TO_MAIN)}${dim(` · ${actions}`, fg)}`);
|
|
1341
1401
|
lines.push("");
|
|
1342
1402
|
lines.push(` status ${fg(toneColor(detail.statusTone, detail.status), detail.status)}`);
|
|
1343
1403
|
if (detail.subtitle) lines.push(` summary ${detail.subtitle}`);
|
|
@@ -1393,7 +1453,7 @@ function buildDetailLines(
|
|
|
1393
1453
|
}
|
|
1394
1454
|
lines.push("");
|
|
1395
1455
|
if (options.bottomFooter === false) return lines.map((line) => safeTruncate(line, width, truncate));
|
|
1396
|
-
const footerLines = [dim(`
|
|
1456
|
+
const footerLines = [dim(` ${BACK_TO_MAIN} · ${actions}`, fg), dim(rule("", width), fg)];
|
|
1397
1457
|
padBeforeFooter(lines, footerLines.length, options.minRows);
|
|
1398
1458
|
lines.push(...footerLines);
|
|
1399
1459
|
return lines.map((line) => safeTruncate(line, width, truncate));
|
|
@@ -25,6 +25,23 @@ launch starts it again. No separate background task or coordinator heartbeat is
|
|
|
25
25
|
needed. Completion delivery retries use the callback batcher's own timer. Lost-run
|
|
26
26
|
alerts retain supervision until their handoff is handled, so lost work is not silent.
|
|
27
27
|
|
|
28
|
+
## Worker Ownership
|
|
29
|
+
|
|
30
|
+
Every child launch, including clean, batch and unsandboxed launches, loads a Pi
|
|
31
|
+
hook that keeps external CI/PR monitoring with its coordinator. Workers finish
|
|
32
|
+
their assigned implementation or diagnosis, run focused checks, push only when
|
|
33
|
+
authorized, write the result, and return. Pending CI is a valid handoff, not a pass.
|
|
34
|
+
|
|
35
|
+
The hook refuses recognized `gh run watch`, `gh pr checks --watch`, and bundled
|
|
36
|
+
delivery-watcher commands through `bash` and `run_application`. Short status reads,
|
|
37
|
+
failed-log retrieval, and legitimate long local tests and builds remain allowed.
|
|
38
|
+
If background tools are explicitly available to a child, recognized watcher
|
|
39
|
+
commands are refused there too. Coordinator sessions are unaffected.
|
|
40
|
+
|
|
41
|
+
This is an operational ownership guard, not a security boundary or a classifier
|
|
42
|
+
for arbitrary programs. Indirect scripts and custom polling implementations still
|
|
43
|
+
rely on the worker handoff contract. Command permissions and timeouts are unchanged.
|
|
44
|
+
|
|
28
45
|
## Install
|
|
29
46
|
|
|
30
47
|
```sh
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { parse } from "shell-quote";
|
|
3
|
+
import { STEER_FILE_ENV } from "./child-steer.ts";
|
|
4
|
+
|
|
5
|
+
export const WORKER_HANDOFF = "Worker ownership: complete your assigned implementation or diagnosis, run focused checks, push only when authorized, write the requested result, then return. Pending CI is a handoff, not a failed implementation or a CI pass. The coordinator owns CI/PR monitoring, review scheduling, readiness, enqueueing and merge. Do not start external delivery watchers or background monitoring. Short status reads and failed-log retrieval for diagnosis are allowed. Legitimate local tests and builds may run to completion within their command limits.";
|
|
6
|
+
const REFUSAL = "Worker ownership: CI/PR monitoring belongs to the coordinator. Return the PR/run ID, pushed HEAD and current evidence with CI pending; do not retry the watcher through another tool or script. Local tests/builds and short diagnostic status/log reads remain allowed.";
|
|
7
|
+
const WATCH_SCRIPTS = new Set(["watch-pr-delivery.mjs", "watch-delivery.mjs", "watch-candidate-delivery.mjs"]);
|
|
8
|
+
const nameOf = (value: string) => value.replaceAll("\\", "/").split("/").at(-1)!.replace(/\.exe$/i, "");
|
|
9
|
+
|
|
10
|
+
// Preserve command boundaries without treating multiline strings or heredoc data as commands.
|
|
11
|
+
function commandLines(command: string): string[] {
|
|
12
|
+
const lines: string[] = [];
|
|
13
|
+
const documents: { delimiter: string; tabs: boolean }[] = [];
|
|
14
|
+
let quote = "", escaped = false, out = "";
|
|
15
|
+
for (const line of command.split("\n")) {
|
|
16
|
+
if (documents.length) {
|
|
17
|
+
const document = documents[0]!;
|
|
18
|
+
if ((document.tabs ? line.replace(/^\t+/, "") : line) === document.delimiter) documents.shift();
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
let comment = false;
|
|
22
|
+
for (const char of `${line}\n`) {
|
|
23
|
+
if (!quote && !escaped && char === "#" && (!out || /[\s;&|()<>]$/.test(out))) comment = true;
|
|
24
|
+
if (comment && char !== "\n") continue;
|
|
25
|
+
if (escaped) { out += char === "\n" ? "" : `\\${char}`; escaped = false; continue; }
|
|
26
|
+
if (char === "\\" && quote !== "'") { escaped = true; continue; }
|
|
27
|
+
if (char === quote) quote = "";
|
|
28
|
+
else if (!quote && (char === "'" || char === '"')) quote = char;
|
|
29
|
+
if (char !== "\n" || quote) { out += char; continue; }
|
|
30
|
+
lines.push(out);
|
|
31
|
+
try {
|
|
32
|
+
const tokens = parse(out);
|
|
33
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
34
|
+
const token = tokens[i], delimiter = tokens[i + 1];
|
|
35
|
+
if (typeof token === "object" && "op" in token && ["<<", "<<-"].includes(token.op) && typeof delimiter === "string") {
|
|
36
|
+
documents.push({ delimiter, tabs: token.op === "<<-" });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
} catch { /* The command tool, not this operational guard, validates shell syntax. */ }
|
|
40
|
+
out = "";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (out) lines.push(out);
|
|
44
|
+
return lines;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function shellWatcher(command: string, depth: number): boolean {
|
|
48
|
+
if (depth > 3) return false;
|
|
49
|
+
let tokens;
|
|
50
|
+
try { tokens = commandLines(command).flatMap((line) => [...parse(line), { op: ";" as const }]); } catch { return false; }
|
|
51
|
+
let words: string[] = [];
|
|
52
|
+
let redirect = false;
|
|
53
|
+
for (const token of tokens) {
|
|
54
|
+
if (typeof token === "string" || ("op" in token && token.op === "glob")) {
|
|
55
|
+
if (redirect) { redirect = false; continue; }
|
|
56
|
+
words.push(typeof token === "string" ? token : token.pattern);
|
|
57
|
+
} else if ("op" in token && ["<", ">", ">>", ">&", "<&", ">|", "<>", "&>", "&>>", "<<", "<<-", "<<<"].includes(token.op)) {
|
|
58
|
+
if (words.length === 1 && /^\d+$/.test(words[0]!)) words = [];
|
|
59
|
+
redirect = true;
|
|
60
|
+
} else {
|
|
61
|
+
if (argvWatcher(words, depth + 1)) return true;
|
|
62
|
+
words = [];
|
|
63
|
+
redirect = false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return argvWatcher(words, depth + 1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function argvWatcher(input: readonly string[], depth = 0): boolean {
|
|
70
|
+
if (depth > 4) return false;
|
|
71
|
+
let words = [...input];
|
|
72
|
+
while (/^[A-Za-z_][A-Za-z_0-9]*=/.test(words[0] ?? "")) words.shift();
|
|
73
|
+
if (nameOf(words[0] ?? "") === "env") {
|
|
74
|
+
words.shift();
|
|
75
|
+
while (words.length) {
|
|
76
|
+
if (["-u", "--unset", "-C", "--chdir"].includes(words[0]!)) words.splice(0, 2);
|
|
77
|
+
else if (words[0] === "--" || words[0]!.startsWith("-") || /^[A-Za-z_][A-Za-z_0-9]*=/.test(words[0]!)) words.shift();
|
|
78
|
+
else break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const program = nameOf(words.shift() ?? "");
|
|
82
|
+
if (["bash", "sh", "zsh", "dash"].includes(program)) {
|
|
83
|
+
const at = words.findIndex((word) => /^-[^-]*c/.test(word));
|
|
84
|
+
return at >= 0 && typeof words[at + 1] === "string" && shellWatcher(words[at + 1]!, depth + 1);
|
|
85
|
+
}
|
|
86
|
+
if (words.includes("--help") || words.includes("-h")) return false;
|
|
87
|
+
if (program === "gh") {
|
|
88
|
+
while (words.length) {
|
|
89
|
+
if (["--repo", "-R", "--hostname"].includes(words[0]!)) words.splice(0, 2);
|
|
90
|
+
else if (/^(?:--repo=.+|--hostname=.+|-R.+)$/.test(words[0]!)) words.shift();
|
|
91
|
+
else break;
|
|
92
|
+
}
|
|
93
|
+
return (words[0] === "run" && words[1] === "watch") ||
|
|
94
|
+
(words[0] === "pr" && words[1] === "checks" && words.slice(2).some((word) => ["--watch", "--watch=true"].includes(word)));
|
|
95
|
+
}
|
|
96
|
+
if (WATCH_SCRIPTS.has(program)) return true;
|
|
97
|
+
if (program === "node" || program === "nodejs") {
|
|
98
|
+
while (words[0]?.startsWith("-")) {
|
|
99
|
+
if (["-e", "--eval", "-p", "--print"].includes(words[0])) return false;
|
|
100
|
+
const takesValue = ["--import", "--loader", "--require", "-r"].includes(words[0]);
|
|
101
|
+
words.splice(0, takesValue ? 2 : 1);
|
|
102
|
+
}
|
|
103
|
+
return WATCH_SCRIPTS.has(nameOf(words[0] ?? ""));
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Recognizes supported watcher entrypoints, not arbitrary program semantics or a security boundary. */
|
|
109
|
+
export function workerMonitoringRefusal(toolName: string, input: unknown): string | undefined {
|
|
110
|
+
if (!input || typeof input !== "object") return;
|
|
111
|
+
const value = input as Record<string, unknown>;
|
|
112
|
+
const argv = toolName === "run_application" && typeof value.executable === "string" &&
|
|
113
|
+
(value.args === undefined || (Array.isArray(value.args) && value.args.every((arg) => typeof arg === "string")))
|
|
114
|
+
? [value.executable, ...((value.args ?? []) as string[])] : undefined;
|
|
115
|
+
if (argv && argvWatcher(argv)) return REFUSAL;
|
|
116
|
+
if (["bash", "remote_bash", "bg_task", "bg_task_spawn", "bg_task_watch"].includes(toolName)) {
|
|
117
|
+
if (typeof value.command === "string" && shellWatcher(value.command, 0)) return REFUSAL;
|
|
118
|
+
if (Array.isArray(value.argv) && value.argv.every((arg) => typeof arg === "string") && argvWatcher(value.argv)) return REFUSAL;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export default function childOwnership(pi: Pick<ExtensionAPI, "on">, env: NodeJS.ProcessEnv = process.env): void {
|
|
123
|
+
// This marker is supplied by the parent on every child launch, including clean/unsandboxed runs.
|
|
124
|
+
if (!env[STEER_FILE_ENV]) return;
|
|
125
|
+
pi.on("before_agent_start", (event) => ({ systemPrompt: `${event.systemPrompt}\n\n${WORKER_HANDOFF}` }));
|
|
126
|
+
pi.on("tool_call", (event) => {
|
|
127
|
+
const reason = workerMonitoringRefusal(event.toolName, event.input);
|
|
128
|
+
if (reason) return { block: true, reason };
|
|
129
|
+
});
|
|
130
|
+
}
|
|
@@ -1786,9 +1786,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1786
1786
|
const { args: resolvedExtArgs, missing } = extensionArgs(resolution, resolveExtensionPath);
|
|
1787
1787
|
const trustedExtArgs = [...new Set(taskTools?.trusted.map((tool) => tool.loadPath) ?? [])]
|
|
1788
1788
|
.filter((path) => !resolvedExtArgs.includes(path)).flatMap((path) => ["--extension", path]);
|
|
1789
|
-
//
|
|
1790
|
-
//
|
|
1791
|
-
const extArgs = [...resolvedExtArgs, ...trustedExtArgs,
|
|
1789
|
+
// Harness child controls ride along in every mode, including clean and unsandboxed runs.
|
|
1790
|
+
// They deliver deadline steers and keep external monitoring with the coordinator.
|
|
1791
|
+
const extArgs = [...resolvedExtArgs, ...trustedExtArgs,
|
|
1792
|
+
"--extension", childSteerExtensionPath(), "--extension", join(selfDir(), "child-ownership.ts")]
|
|
1792
1793
|
.map((value, index, all) => sandboxEnabled && all[index - 1] === "--extension" ? canonicalizePath(value) : value);
|
|
1793
1794
|
if (sandboxEnabled && resolution.mode === "inherit") {
|
|
1794
1795
|
throw new Error("Task confinement requires explicit extensions; inheritExtensions is unsupported while sandboxing is enabled.");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-subagents",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.2",
|
|
4
4
|
"description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
},
|
|
63
63
|
"dependencies": {
|
|
64
64
|
"proper-lockfile": "^4.1.2",
|
|
65
|
+
"shell-quote": "1.12.0",
|
|
65
66
|
"smol-toml": "1.9.0",
|
|
66
67
|
"yaml": "^2.9.1"
|
|
67
68
|
}
|
|
@@ -348,20 +348,60 @@ type MainListWidget = Component & {
|
|
|
348
348
|
handleMouse(event: WidgetMouseEvent): WidgetMouseResult | undefined;
|
|
349
349
|
};
|
|
350
350
|
|
|
351
|
+
const BACK_TO_MAIN = "← main (Esc)";
|
|
352
|
+
const BACK_TARGET = "back-to-main";
|
|
353
|
+
|
|
354
|
+
function highlightPointerTarget(theme: unknown, line: string, width: number): string {
|
|
355
|
+
const colors = theme as { bg?(color: string, value: string): string } | undefined;
|
|
356
|
+
return colors?.bg?.("selectedBg", line + " ".repeat(Math.max(0, width - visibleWidth(line))))
|
|
357
|
+
?? themeFgFromTheme(theme)("accent", line);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function createPointerNavigation(targetAt: (event: WidgetMouseEvent) => string | undefined, activate: (target: string) => boolean) {
|
|
361
|
+
let hovered: string | undefined;
|
|
362
|
+
let pressed: { target: string; y: number } | undefined;
|
|
363
|
+
return {
|
|
364
|
+
get hovered() { return hovered; },
|
|
365
|
+
reset() { hovered = undefined; pressed = undefined; },
|
|
366
|
+
handleMouse(event: WidgetMouseEvent): WidgetMouseResult | undefined {
|
|
367
|
+
if (event.type === "move" || event.type === "leave") {
|
|
368
|
+
const target = event.type === "leave" ? undefined : targetAt(event);
|
|
369
|
+
const changed = hovered !== target;
|
|
370
|
+
hovered = target;
|
|
371
|
+
return changed || target ? { handled: true, render: changed } : undefined;
|
|
372
|
+
}
|
|
373
|
+
if (event.button !== "left" || event.shift || event.alt || event.ctrl) {
|
|
374
|
+
pressed = undefined;
|
|
375
|
+
return undefined;
|
|
376
|
+
}
|
|
377
|
+
if (event.type === "press") {
|
|
378
|
+
const target = targetAt(event);
|
|
379
|
+
pressed = target ? { target, y: event.y } : undefined;
|
|
380
|
+
return target ? { handled: true, render: false } : undefined;
|
|
381
|
+
}
|
|
382
|
+
if (event.type !== "click") return undefined;
|
|
383
|
+
const claim = pressed;
|
|
384
|
+
pressed = undefined;
|
|
385
|
+
if (!claim || claim.y !== event.y || targetAt(event) !== claim.target || !activate(claim.target)) return undefined;
|
|
386
|
+
return { handled: true, render: true };
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
351
391
|
function createMainListWidget(tui: { requestRender?(): void }, theme: unknown, deps: HostDeps): MainListWidget {
|
|
352
392
|
const requestRender = () => tui.requestRender?.();
|
|
353
393
|
state().mainListRequestRender = requestRender;
|
|
354
394
|
// navigatorId per rendered line, so a pointer row resolves by structure, never by row text.
|
|
355
395
|
let lineOwners: Array<string | undefined> = [];
|
|
356
396
|
let lineWidth = MAIN_LIST_FALLBACK_WIDTH;
|
|
357
|
-
// Pi synthesizes a click only for the component that handled the initial press.
|
|
358
|
-
let pressedRow: { navigatorId: string; y: number } | undefined;
|
|
359
397
|
const rowAt = (event: WidgetMouseEvent): string | undefined => {
|
|
360
398
|
const width = Number.isFinite(event.width) && event.width > 0 ? Math.floor(event.width) : lineWidth;
|
|
361
399
|
if (!Number.isFinite(event.x) || event.x < 0 || event.x >= width) return undefined;
|
|
362
400
|
if (!Number.isInteger(event.y) || event.y < 0 || event.y >= lineOwners.length) return undefined;
|
|
363
|
-
|
|
401
|
+
const id = lineOwners[event.y];
|
|
402
|
+
return id && listRows().some((row) => row.navigatorId === id) ? id : undefined;
|
|
364
403
|
};
|
|
404
|
+
const pointer = createPointerNavigation(rowAt, openNavigatorRow);
|
|
365
405
|
return {
|
|
366
406
|
render(width: number): string[] {
|
|
367
407
|
const rows = listRows();
|
|
@@ -369,7 +409,7 @@ function createMainListWidget(tui: { requestRender?(): void }, theme: unknown, d
|
|
|
369
409
|
if (!rows.length) {
|
|
370
410
|
state().lastMainListLines = undefined;
|
|
371
411
|
lineOwners = [];
|
|
372
|
-
|
|
412
|
+
pointer.reset();
|
|
373
413
|
return [];
|
|
374
414
|
}
|
|
375
415
|
const layout = buildMainListLayout(rows, renderWidth(width), deps.truncate, themeFgFromTheme(theme), {
|
|
@@ -379,41 +419,24 @@ function createMainListWidget(tui: { requestRender?(): void }, theme: unknown, d
|
|
|
379
419
|
lineWidth = renderWidth(width);
|
|
380
420
|
// The leading gap line belongs to the widget too, so its slot stays ownerless.
|
|
381
421
|
lineOwners = [undefined, ...layout.owners];
|
|
382
|
-
const lines = ["", ...layout.lines]
|
|
422
|
+
const lines = ["", ...layout.lines].map((line, index) =>
|
|
423
|
+
pointer.hovered && lineOwners[index] === pointer.hovered ? highlightPointerTarget(theme, line, lineWidth) : line);
|
|
383
424
|
state().lastMainListLines = lines;
|
|
384
425
|
return lines;
|
|
385
426
|
},
|
|
386
427
|
handleMouse(event: WidgetMouseEvent): WidgetMouseResult | undefined {
|
|
387
|
-
|
|
388
|
-
if (event.button !== "left" || event.shift === true || event.alt === true || event.ctrl === true) {
|
|
389
|
-
pressedRow = undefined;
|
|
390
|
-
return undefined;
|
|
391
|
-
}
|
|
392
|
-
if (event.type === "press") {
|
|
393
|
-
pressedRow = undefined;
|
|
394
|
-
const navigatorId = rowAt(event);
|
|
395
|
-
if (!navigatorId || !listRows().some((row) => row.navigatorId === navigatorId)) return undefined;
|
|
396
|
-
pressedRow = { navigatorId, y: event.y };
|
|
397
|
-
return { handled: true, render: false };
|
|
398
|
-
}
|
|
399
|
-
if (event.type !== "click") return undefined;
|
|
400
|
-
const pressed = pressedRow;
|
|
401
|
-
pressedRow = undefined;
|
|
402
|
-
// A click after another component's press, or after the layout moved, is not the row we claimed.
|
|
403
|
-
if (!pressed || pressed.y !== event.y || rowAt(event) !== pressed.navigatorId) return undefined;
|
|
404
|
-
if (!openNavigatorRow(pressed.navigatorId)) return undefined;
|
|
405
|
-
return { handled: true, render: true };
|
|
428
|
+
return pointer.handleMouse(event);
|
|
406
429
|
},
|
|
407
430
|
invalidate() {
|
|
408
431
|
state().lastMainListLines = undefined;
|
|
409
432
|
lineOwners = [];
|
|
410
|
-
|
|
433
|
+
pointer.reset();
|
|
411
434
|
},
|
|
412
435
|
dispose() {
|
|
413
436
|
const s = state();
|
|
414
437
|
if (s.mainListRequestRender === requestRender) s.mainListRequestRender = undefined;
|
|
415
438
|
lineOwners = [];
|
|
416
|
-
|
|
439
|
+
pointer.reset();
|
|
417
440
|
},
|
|
418
441
|
};
|
|
419
442
|
}
|
|
@@ -958,6 +981,22 @@ function createOverlayComponent(
|
|
|
958
981
|
let closed = false;
|
|
959
982
|
let transcriptDetail: BackgroundWorkDetail | null = null;
|
|
960
983
|
let transcriptComponent: Component | null = null;
|
|
984
|
+
let pointerTargets: Array<string | undefined> = [];
|
|
985
|
+
let pointerWidth = 0;
|
|
986
|
+
const pointer = createPointerNavigation((event) => {
|
|
987
|
+
if (closed || !Number.isInteger(event.y) || !Number.isFinite(event.x) || event.x < 0 || event.x >= pointerWidth) return undefined;
|
|
988
|
+
const target = pointerTargets[event.y];
|
|
989
|
+
if (target === BACK_TARGET) return event.x >= 3 && event.x < 3 + visibleWidth(BACK_TO_MAIN) ? target : undefined;
|
|
990
|
+
return target && listRows().some((row) => row.navigatorId === target) ? target : undefined;
|
|
991
|
+
}, (target) => {
|
|
992
|
+
if (target === BACK_TARGET) { backToMain(); return true; }
|
|
993
|
+
refreshRows();
|
|
994
|
+
const index = overlayState.rows.findIndex((row) => row.navigatorId === target);
|
|
995
|
+
if (index < 0) return false;
|
|
996
|
+
selectOverlayRow(index);
|
|
997
|
+
openDetail();
|
|
998
|
+
return true;
|
|
999
|
+
});
|
|
961
1000
|
|
|
962
1001
|
const fg = (color: string, value: string) => theme?.fg ? theme.fg(color, value) : value;
|
|
963
1002
|
|
|
@@ -1036,6 +1075,11 @@ function createOverlayComponent(
|
|
|
1036
1075
|
try { done(null); } catch { /* ignore */ }
|
|
1037
1076
|
}
|
|
1038
1077
|
|
|
1078
|
+
function backToMain(): void {
|
|
1079
|
+
unfocusMainList();
|
|
1080
|
+
close();
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1039
1083
|
function handleCloseKey(): void {
|
|
1040
1084
|
const row = selectedRow();
|
|
1041
1085
|
if (!row || row.parentRow) return;
|
|
@@ -1101,14 +1145,17 @@ function createOverlayComponent(
|
|
|
1101
1145
|
render(width: number) {
|
|
1102
1146
|
closeIfEditorReplaced();
|
|
1103
1147
|
refreshRows();
|
|
1104
|
-
const
|
|
1148
|
+
const rail = buildMainListLayout(overlayState.rows, width, deps.truncate, fg, {
|
|
1105
1149
|
selectedId: selectedRow()?.navigatorId,
|
|
1106
1150
|
focused: true,
|
|
1107
1151
|
});
|
|
1152
|
+
const railLines = rail.lines;
|
|
1108
1153
|
const editorLines = renderEditorLines(width);
|
|
1109
1154
|
const bottomLines = [...railLines, ...editorLines];
|
|
1110
1155
|
const overlayRows = state().detailOverlayRows;
|
|
1111
1156
|
const detailRows = overlayRows === undefined ? undefined : Math.max(1, overlayRows - bottomLines.length);
|
|
1157
|
+
const backControl = pointer.hovered === BACK_TARGET
|
|
1158
|
+
? highlightPointerTarget(theme, BACK_TO_MAIN, visibleWidth(BACK_TO_MAIN)) : undefined;
|
|
1112
1159
|
let contentLines: string[];
|
|
1113
1160
|
if (detail?.transcript && deps.createTranscriptComponent) {
|
|
1114
1161
|
if (transcriptDetail !== detail || !transcriptComponent) {
|
|
@@ -1121,18 +1168,30 @@ function createOverlayComponent(
|
|
|
1121
1168
|
width,
|
|
1122
1169
|
deps.truncate,
|
|
1123
1170
|
fg,
|
|
1124
|
-
{ minRows: detailRows, maxRows: detailRows, bottomFooter: false, logTailRows },
|
|
1171
|
+
{ minRows: detailRows, maxRows: detailRows, bottomFooter: false, logTailRows, backControl },
|
|
1125
1172
|
);
|
|
1126
1173
|
} else {
|
|
1127
1174
|
transcriptDetail = null;
|
|
1128
1175
|
transcriptComponent = null;
|
|
1129
|
-
contentLines = buildDetailLines(detail, width, deps.truncate, fg, { expandedSections, logTailRows, minRows: detailRows, maxRows: detailRows, bottomFooter: false });
|
|
1176
|
+
contentLines = buildDetailLines(detail, width, deps.truncate, fg, { expandedSections, logTailRows, minRows: detailRows, maxRows: detailRows, bottomFooter: false, backControl });
|
|
1130
1177
|
}
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1178
|
+
const fittedContent = detailRows === undefined ? contentLines : contentLines.slice(0, detailRows);
|
|
1179
|
+
if (detailRows !== undefined) while (fittedContent.length < detailRows) fittedContent.push("");
|
|
1180
|
+
const contentTargets = fittedContent.map((_, index) => index === 1 ? BACK_TARGET : undefined);
|
|
1181
|
+
const allLines = [...fittedContent, ...bottomLines];
|
|
1182
|
+
const allTargets = [...contentTargets, ...rail.owners, ...editorLines.map(() => undefined)];
|
|
1183
|
+
// Match hit targets to the same viewport clipping as the visible lines.
|
|
1184
|
+
const start = overlayRows === undefined ? 0 : Math.max(0, allLines.length - Math.max(1, overlayRows));
|
|
1185
|
+
pointerTargets = allTargets.slice(start);
|
|
1186
|
+
pointerWidth = renderWidth(width);
|
|
1187
|
+
return allLines.slice(start).map((line, index) => {
|
|
1188
|
+
if (!pointer.hovered || pointerTargets[index] !== pointer.hovered) return line;
|
|
1189
|
+
if (pointer.hovered === BACK_TARGET) return line;
|
|
1190
|
+
return highlightPointerTarget(theme, line, width);
|
|
1191
|
+
});
|
|
1192
|
+
},
|
|
1193
|
+
handleMouse(event: WidgetMouseEvent): WidgetMouseResult | undefined {
|
|
1194
|
+
return pointer.handleMouse(event);
|
|
1136
1195
|
},
|
|
1137
1196
|
handleInput(data: string) {
|
|
1138
1197
|
if (closed) return;
|
|
@@ -1151,7 +1210,7 @@ function createOverlayComponent(
|
|
|
1151
1210
|
else if (deps.matchKey(data, "left")) {
|
|
1152
1211
|
const mainIdx = overlayState.rows.findIndex((row) => row.parentRow && row.id === "main");
|
|
1153
1212
|
if (mainIdx >= 0) selectOverlayRow(mainIdx);
|
|
1154
|
-
|
|
1213
|
+
backToMain();
|
|
1155
1214
|
}
|
|
1156
1215
|
else if (deps.matchKey(data, "enter")) {
|
|
1157
1216
|
const row = selectedRow();
|
|
@@ -1179,8 +1238,7 @@ function createOverlayComponent(
|
|
|
1179
1238
|
requestRender();
|
|
1180
1239
|
}
|
|
1181
1240
|
else if (deps.matchKey(data, "escape")) {
|
|
1182
|
-
|
|
1183
|
-
close();
|
|
1241
|
+
backToMain();
|
|
1184
1242
|
}
|
|
1185
1243
|
},
|
|
1186
1244
|
/** Show another row's detail in this overlay (the main list moved while it was unfocused). */
|
|
@@ -1195,8 +1253,10 @@ function createOverlayComponent(
|
|
|
1195
1253
|
},
|
|
1196
1254
|
/** Close the overlay (done), e.g. when the navigator is disposed. */
|
|
1197
1255
|
dismiss() { close(); },
|
|
1198
|
-
invalidate() { transcriptComponent?.invalidate(); },
|
|
1256
|
+
invalidate() { pointer.reset(); pointerTargets = []; transcriptComponent?.invalidate(); },
|
|
1199
1257
|
dispose() {
|
|
1258
|
+
pointer.reset();
|
|
1259
|
+
pointerTargets = [];
|
|
1200
1260
|
clearCloseArm();
|
|
1201
1261
|
detailScheduler.dispose();
|
|
1202
1262
|
},
|
|
@@ -1233,14 +1293,14 @@ function buildTranscriptDetailLines(
|
|
|
1233
1293
|
width: number,
|
|
1234
1294
|
truncate: (s: string, width: number) => string,
|
|
1235
1295
|
fg: (color: string, value: string) => string,
|
|
1236
|
-
options: { minRows?: number; maxRows?: number; bottomFooter?: boolean; logTailRows?: number } = {},
|
|
1296
|
+
options: { minRows?: number; maxRows?: number; bottomFooter?: boolean; logTailRows?: number; backControl?: string } = {},
|
|
1237
1297
|
): string[] {
|
|
1238
|
-
const actions = [...(detail.footerActions ?? ["x close"])
|
|
1298
|
+
const actions = [...(detail.footerActions ?? ["x close"])].join(" · ");
|
|
1239
1299
|
const tailRows = options.logTailRows ?? DEFAULT_LOG_TAIL_ROWS;
|
|
1240
1300
|
let shownTranscriptLines = transcriptLines.length ? transcriptLines.slice(-tailRows) : [" (no transcript yet)"];
|
|
1241
1301
|
const lines: string[] = [
|
|
1242
1302
|
fg("accent", rule(detail.title, width)),
|
|
1243
|
-
dim(`
|
|
1303
|
+
` ${options.backControl ?? fg("accent", BACK_TO_MAIN)}${dim(` · ${actions}`, fg)}`,
|
|
1244
1304
|
"",
|
|
1245
1305
|
` status ${fg(toneColor(detail.statusTone, detail.status), detail.status)}`,
|
|
1246
1306
|
];
|
|
@@ -1264,7 +1324,7 @@ function buildTranscriptDetailLines(
|
|
|
1264
1324
|
lines.push(...shownTranscriptLines);
|
|
1265
1325
|
lines.push("");
|
|
1266
1326
|
if (options.bottomFooter === false) return lines.map((line) => safeTruncate(line, width, truncate));
|
|
1267
|
-
const footerLines = [dim(`
|
|
1327
|
+
const footerLines = [dim(` ${BACK_TO_MAIN} · ${actions}`, fg), dim(rule("", width), fg)];
|
|
1268
1328
|
padBeforeFooter(lines, footerLines.length, options.minRows);
|
|
1269
1329
|
lines.push(...footerLines);
|
|
1270
1330
|
return lines.map((line) => safeTruncate(line, width, truncate));
|
|
@@ -1320,10 +1380,10 @@ function buildDetailLines(
|
|
|
1320
1380
|
width: number,
|
|
1321
1381
|
truncate: (s: string, width: number) => string,
|
|
1322
1382
|
fg: (color: string, value: string) => string,
|
|
1323
|
-
options: { expandedSections?: Set<string>; logTailRows?: number; minRows?: number; maxRows?: number; bottomFooter?: boolean } = {},
|
|
1383
|
+
options: { expandedSections?: Set<string>; logTailRows?: number; minRows?: number; maxRows?: number; bottomFooter?: boolean; backControl?: string } = {},
|
|
1324
1384
|
): string[] {
|
|
1325
1385
|
if (!detail) {
|
|
1326
|
-
const lines = [rule("Work unavailable", width),
|
|
1386
|
+
const lines = [rule("Work unavailable", width), ` ${options.backControl ?? fg("accent", BACK_TO_MAIN)}`, ""];
|
|
1327
1387
|
if (options.bottomFooter === false) return lines.map((line) => safeTruncate(line, width, truncate));
|
|
1328
1388
|
const footerLines = [dim(rule("", width), fg)];
|
|
1329
1389
|
padBeforeFooter(lines, footerLines.length, options.minRows);
|
|
@@ -1336,8 +1396,8 @@ function buildDetailLines(
|
|
|
1336
1396
|
const foldedAction = toggleableSectionId
|
|
1337
1397
|
? (options.expandedSections?.has(toggleableSectionId) ? "Enter collapse" : "Enter expand")
|
|
1338
1398
|
: null;
|
|
1339
|
-
const actions = [foldedAction, ...(detail.footerActions?.length ? detail.footerActions : ["x close"]), "l 10/25"
|
|
1340
|
-
lines.push(dim(`
|
|
1399
|
+
const actions = [foldedAction, ...(detail.footerActions?.length ? detail.footerActions : ["x close"]), "l 10/25"].filter(Boolean).join(" · ");
|
|
1400
|
+
lines.push(` ${options.backControl ?? fg("accent", BACK_TO_MAIN)}${dim(` · ${actions}`, fg)}`);
|
|
1341
1401
|
lines.push("");
|
|
1342
1402
|
lines.push(` status ${fg(toneColor(detail.statusTone, detail.status), detail.status)}`);
|
|
1343
1403
|
if (detail.subtitle) lines.push(` summary ${detail.subtitle}`);
|
|
@@ -1393,7 +1453,7 @@ function buildDetailLines(
|
|
|
1393
1453
|
}
|
|
1394
1454
|
lines.push("");
|
|
1395
1455
|
if (options.bottomFooter === false) return lines.map((line) => safeTruncate(line, width, truncate));
|
|
1396
|
-
const footerLines = [dim(`
|
|
1456
|
+
const footerLines = [dim(` ${BACK_TO_MAIN} · ${actions}`, fg), dim(rule("", width), fg)];
|
|
1397
1457
|
padBeforeFooter(lines, footerLines.length, options.minRows);
|
|
1398
1458
|
lines.push(...footerLines);
|
|
1399
1459
|
return lines.map((line) => safeTruncate(line, width, truncate));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-harness",
|
|
3
|
-
"version": "0.34.
|
|
3
|
+
"version": "0.34.2",
|
|
4
4
|
"description": "Pi extension bundle for a write sandbox, subagents, background tasks, SSH, goals, and Outcome & Todo.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -53,12 +53,13 @@
|
|
|
53
53
|
"test": "node --import tsx --import ../../scripts/isolate-registry.mjs --test test/*.test.mjs test/task-context/*.test.ts"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"pi-better-background-tasks": "0.12.
|
|
56
|
+
"pi-better-background-tasks": "0.12.1",
|
|
57
57
|
"pi-better-goal": "0.10.3",
|
|
58
58
|
"pi-better-sandbox": "0.14.0",
|
|
59
59
|
"pi-better-ssh": "0.1.3",
|
|
60
|
-
"pi-better-subagents": "0.22.
|
|
60
|
+
"pi-better-subagents": "0.22.2",
|
|
61
61
|
"proper-lockfile": "^4.1.2",
|
|
62
|
+
"shell-quote": "1.12.0",
|
|
62
63
|
"smol-toml": "1.9.0",
|
|
63
64
|
"yaml": "^2.9.1"
|
|
64
65
|
},
|