pi-better-harness 0.34.0 → 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/extensions/task-context/plan-presentation.ts +2 -2
- 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-goal/package.json +1 -1
- package/node_modules/pi-better-goal/src/index.ts +1 -1
- package/node_modules/pi-better-subagents/README.md +23 -0
- package/node_modules/pi-better-subagents/catalog-ui.ts +89 -43
- package/node_modules/pi-better-subagents/child-ownership.ts +130 -0
- package/node_modules/pi-better-subagents/index.ts +19 -16
- package/node_modules/pi-better-subagents/package.json +2 -1
- package/node_modules/pi-better-subagents/shared-navigator.ts +107 -47
- package/package.json +5 -4
|
@@ -86,7 +86,7 @@ export function renderPlanPresentation(
|
|
|
86
86
|
for (const line of wrapTextWithAnsi(content, Math.max(1, size - 2))) lines.push(` ${line}`);
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
const labelWidth = Math.min(12, Math.max(
|
|
89
|
+
const labelWidth = Math.min(12, Math.max(1, ...plan.rows.map((row) => visibleWidth(planText(row.label)))));
|
|
90
90
|
const statusWidth = Math.min(24, Math.max(0, ...plan.rows.map((row) =>
|
|
91
91
|
visibleWidth(planText(row.statusLabel ?? statusStyle(row.status).label)))));
|
|
92
92
|
const indices = full || expanded ? plan.rows.map((_, index) => index) : compactRowIndices(plan.rows);
|
|
@@ -102,7 +102,7 @@ export function renderPlanPresentation(
|
|
|
102
102
|
const label = truncateToWidth(planText(row.label), labelWidth);
|
|
103
103
|
const prefix = (index === selectedIndex ? theme.fg("accent", "› ") : " ") +
|
|
104
104
|
theme.fg(style.color, style.glyph) + " " + theme.fg("dim", label) +
|
|
105
|
-
" ".repeat(Math.max(0, labelWidth - visibleWidth(label)) +
|
|
105
|
+
" ".repeat(Math.max(0, labelWidth - visibleWidth(label)) + 1);
|
|
106
106
|
const titleColor = row.status === "blocked" ? "warning" : row.status === "failed" ? "error" :
|
|
107
107
|
row.status === "in_progress" || row.status === "implemented" || row.status === "reviewed" ? "text" : "muted";
|
|
108
108
|
const badge = planText(row.statusLabel ?? style.label);
|
|
@@ -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));
|
|
@@ -890,7 +890,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
890
890
|
const fg = typeof theme?.fg === "function"
|
|
891
891
|
? (color: string, value: string) => theme.fg(color as never, value)
|
|
892
892
|
: undefined;
|
|
893
|
-
return [renderGoalClockLine(goal, width, undefined, fg)
|
|
893
|
+
return [renderGoalClockLine(goal, width, undefined, fg)];
|
|
894
894
|
},
|
|
895
895
|
};
|
|
896
896
|
},
|
|
@@ -19,6 +19,29 @@ Use `pi-better-subagents` when you want Pi to launch independent agent work with
|
|
|
19
19
|
- Live background-work navigator for active runs.
|
|
20
20
|
- Harness-owned run timing: every run gets a no-progress wake (10 min), while wall-clock soft deadlines and hard ceilings are opt-in so productive agents are not stopped solely for taking a long time. Configure per spawn with `deadline_minutes`, `grace_minutes`, `max_minutes`, `stuck_minutes`, or globally in `config.json` / `PI_SUBAGENT_*_MINUTES`. See [usage notes](docs/usage.md#run-timing-deadline-ceiling-stuck).
|
|
21
21
|
|
|
22
|
+
Every single or batch launch automatically joins one shared supervision watcher in the
|
|
23
|
+
foreground Pi process. The last active run's exit or stop tears it down; a later
|
|
24
|
+
launch starts it again. No separate background task or coordinator heartbeat is
|
|
25
|
+
needed. Completion delivery retries use the callback batcher's own timer. Lost-run
|
|
26
|
+
alerts retain supervision until their handoff is handled, so lost work is not silent.
|
|
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
|
+
|
|
22
45
|
## Install
|
|
23
46
|
|
|
24
47
|
```sh
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Input, SelectList, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
-
import type {
|
|
1
|
+
import { Input, SelectList, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { OperationList, OperationView } from "./agent-inspection.ts";
|
|
3
3
|
import { EFFORT_LEVELS } from "./catalog-schema.ts";
|
|
4
4
|
import type { AgentSessionOverrides, AgentSettingKey } from "./agent-session-settings.ts";
|
|
5
5
|
|
|
@@ -19,9 +19,6 @@ function stateLabel(entry: OperationView): string {
|
|
|
19
19
|
return "unresolved";
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
function fieldLine(label: string, field: OperationField): string {
|
|
23
|
-
return `${label.padEnd(8)} ${field.value ?? "(unset)"} ${field.source === "role-default" ? "inherited" : field.source === "agent-override" ? "override" : "absent"}`;
|
|
24
|
-
}
|
|
25
22
|
|
|
26
23
|
function modelLabel(entry: OperationView): string {
|
|
27
24
|
return entry.actualModel ?? entry.requestedModel ?? entry.fields?.model.value ?? "not resolved";
|
|
@@ -49,6 +46,7 @@ export function catalogColumns(entry: OperationView | null, width: number): stri
|
|
|
49
46
|
}
|
|
50
47
|
|
|
51
48
|
function wrapLine(text: string, width: number): string[] {
|
|
49
|
+
if (text.includes("\x1b")) return wrapTextWithAnsi(text, width);
|
|
52
50
|
const rows: string[] = [];
|
|
53
51
|
let row = "";
|
|
54
52
|
for (const { segment } of new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text)) {
|
|
@@ -62,31 +60,67 @@ function wrapLine(text: string, width: number): string[] {
|
|
|
62
60
|
return rows;
|
|
63
61
|
}
|
|
64
62
|
|
|
65
|
-
|
|
63
|
+
function settingLines(label: string, value: string, origin: string, width: number, marker = " ", theme?: Theme): string[] {
|
|
64
|
+
const title = (text: string) => theme?.fg(marker === "> " ? "accent" : "muted", text) ?? text;
|
|
65
|
+
const source = (text: string) => theme?.fg("dim", text) ?? text;
|
|
66
|
+
if (width < 54) return [title(`${marker}${label}`), ...wrapLine(`${value} ${source(origin)}`, Math.max(1, width - 4)).map((row) => ` ${row}`)];
|
|
67
|
+
const valueWidth = Math.max(1, width - 34);
|
|
68
|
+
return wrapLine(value, valueWidth).map((row, index) =>
|
|
69
|
+
`${index === 0 ? title(marker + label.padEnd(10)) : " ".repeat(12)}${cell(row, valueWidth)} ${index === 0 ? source(origin) : ""}`.trimEnd());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function catalogDetailLines(entry: OperationView, editable = false, width = 80, controls: string[] = [], theme?: Theme): string[] {
|
|
73
|
+
const label = (text: string) => theme?.fg("muted", text) ?? text;
|
|
74
|
+
const heading = (text: string) => label(theme?.bold?.(text) ?? text);
|
|
75
|
+
const contentWidth = Math.max(1, width - 4);
|
|
76
|
+
const columnWidth = width >= 90 ? Math.floor((contentWidth - 4) / 2) : contentWidth;
|
|
77
|
+
const fact = (title: string, value: string) => wrapLine(value, Math.max(1, columnWidth - 13))
|
|
78
|
+
.map((text, index) => `${index === 0 ? label(title.padEnd(13)) : " ".repeat(13)}${text}`);
|
|
79
|
+
const identity = [
|
|
80
|
+
...fact("ID", entry.id),
|
|
81
|
+
...fact("Kind", entry.identity.kind ?? "unknown"),
|
|
82
|
+
...(entry.role ? fact("Base role", `${entry.role.id ?? "(missing)"}${entry.role.found ? "" : " (missing)"}`) : []),
|
|
83
|
+
];
|
|
84
|
+
const state = [
|
|
85
|
+
...fact("Status", stateLabel(entry)),
|
|
86
|
+
...fact("Scope", entry.identity.scope ?? "unknown"),
|
|
87
|
+
...(entry.instructionMode ? fact("Instructions", entry.instructionMode) : []),
|
|
88
|
+
];
|
|
89
|
+
const overview = width >= 90
|
|
90
|
+
? Array.from({ length: Math.max(identity.length, state.length) }, (_, i) =>
|
|
91
|
+
`${cell(identity[i] ?? "", columnWidth)} ${state[i] ?? ""}`.trimEnd())
|
|
92
|
+
: [...identity, ...state];
|
|
66
93
|
const lines = [
|
|
67
|
-
`${entry.identity.name || entry.id} · ${entry.identity.kind ?? "unknown"}`,
|
|
68
|
-
entry.
|
|
94
|
+
theme?.fg("accent", `${entry.identity.name || entry.id} · ${entry.identity.kind ?? "unknown"}`) ?? `${entry.identity.name || entry.id} · ${entry.identity.kind ?? "unknown"}`,
|
|
95
|
+
...(entry.identity.description ? wrapLine(entry.identity.description, contentWidth) : []),
|
|
69
96
|
"",
|
|
70
|
-
|
|
71
|
-
`Source ${entry.identity.scope ?? "unknown"} · ${entry.identity.path ?? "(none)"}`,
|
|
72
|
-
...(entry.identity.description ? [`About ${entry.identity.description}`] : []),
|
|
73
|
-
...(entry.role ? [`Base role ${entry.role.id ?? "(missing)"}${entry.role.found ? "" : " · missing"}`] : []),
|
|
74
|
-
...(entry.instructionMode ? [`Instructions ${entry.instructionMode}`] : []),
|
|
97
|
+
...overview,
|
|
75
98
|
"",
|
|
76
|
-
"Configuration",
|
|
99
|
+
heading("Configuration"),
|
|
100
|
+
...(contentWidth >= 54 ? [label(settingLines("SETTING", "VALUE", "ORIGIN", contentWidth)[0])] : []),
|
|
101
|
+
...controls,
|
|
77
102
|
...(entry.fields ? [
|
|
78
|
-
...(!editable ? [
|
|
79
|
-
|
|
103
|
+
...(!editable ? ["model", "effort"].flatMap((key) => {
|
|
104
|
+
const field = entry.fields![key as "model" | "effort"];
|
|
105
|
+
return settingLines(key === "model" ? "Model" : "Effort", field.value ?? "(unset)", field.inherited || field.source === "role-default" ? "inherited" : field.source === "agent-override" ? "override" : "absent", contentWidth, " ", theme);
|
|
106
|
+
}) : []),
|
|
107
|
+
...settingLines("Tier", entry.fields.tier.value ?? "(unset)", entry.fields.tier.source === "role-default" ? "inherited" : entry.fields.tier.source === "agent-override" ? "override" : "absent", contentWidth, " ", theme),
|
|
80
108
|
] : []),
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
...(entry.
|
|
109
|
+
`${label("Actual")} ${entry.actualModel ?? "not resolved"}${entry.actualEffort ? ` · ${entry.actualEffort}` : ""}`,
|
|
110
|
+
"",
|
|
111
|
+
heading("Effective prompt"),
|
|
112
|
+
...(entry.instructions?.trim() ? entry.instructions.split(/\r\n|\r|\n/) : ["(No resolved instructions)"]),
|
|
85
113
|
"",
|
|
86
|
-
"
|
|
87
|
-
|
|
114
|
+
heading("Launch resolution"),
|
|
115
|
+
entry.launchabilityReason,
|
|
116
|
+
"",
|
|
117
|
+
heading("Definition source"),
|
|
118
|
+
entry.identity.path ?? "(none)",
|
|
119
|
+
...(entry.validation.length ? ["", heading("Diagnostics"), ...entry.validation.map((item) => `${item.blocking ? "!" : "·"} ${item.message}`)] : []),
|
|
120
|
+
...(entry.shadowed.length ? ["", heading("Shadowed sources"), ...entry.shadowed.map((source) => `${source.scope} ${source.path}`)] : []),
|
|
121
|
+
...(entry.restrictions.length ? ["", heading("Execution restrictions"), ...entry.restrictions.map((item) => `${item.honored ? "honored" : "not honored"} ${item.name}`)] : []),
|
|
88
122
|
];
|
|
89
|
-
return lines;
|
|
123
|
+
return lines.flatMap((text) => text ? wrapLine(text, contentWidth).map((row) => ` ${row}`) : [""]);
|
|
90
124
|
}
|
|
91
125
|
|
|
92
126
|
export function createCatalogComponent(
|
|
@@ -103,6 +137,8 @@ export function createCatalogComponent(
|
|
|
103
137
|
let query = "";
|
|
104
138
|
let scroll = 0;
|
|
105
139
|
let settingRow = 0;
|
|
140
|
+
let settingPositions: number[] = [];
|
|
141
|
+
let revealSetting = false;
|
|
106
142
|
let picker: SelectList | undefined;
|
|
107
143
|
let filterPicker: ((filter: string) => void) | undefined;
|
|
108
144
|
let pickerKey: AgentSettingKey | undefined;
|
|
@@ -159,11 +195,11 @@ export function createCatalogComponent(
|
|
|
159
195
|
[entry.id, entry.identity.name, entry.identity.description, entry.role?.id]
|
|
160
196
|
.some((value) => value?.toLowerCase().includes(query.toLowerCase())));
|
|
161
197
|
const selected = () => filtered().find((entry) => entry.id === selectedId);
|
|
162
|
-
const detailRows = (entry: OperationView, width: number) => (mode === "prompt"
|
|
198
|
+
const detailRows = (entry: OperationView, width: number, controls: string[] = []) => (mode === "prompt"
|
|
163
199
|
? [`${entry.identity.name || entry.id} · effective instructions`, "", ...(entry.instructions?.split("\n") ?? ["(none)"])]
|
|
164
|
-
: catalogDetailLines(entry, !!editor))
|
|
200
|
+
: catalogDetailLines(entry, !!editor, width, controls, theme))
|
|
165
201
|
.flatMap((text) => text ? wrapLine(text, Math.max(1, width - 2)) : [""]);
|
|
166
|
-
|
|
202
|
+
|
|
167
203
|
|
|
168
204
|
return {
|
|
169
205
|
get focused() { return focused; },
|
|
@@ -174,21 +210,20 @@ export function createCatalogComponent(
|
|
|
174
210
|
const footerRows = (editor && mode !== "prompt" ? 2 : 0) + (message ? 2 : 0);
|
|
175
211
|
const diagnosticRows = Math.min(catalog.diagnostics.length, 3);
|
|
176
212
|
const listSize = Math.max(1, height - 5 - footerRows - diagnosticRows - 1);
|
|
177
|
-
const
|
|
178
|
-
const detailSize = Math.max(1, height - 5 - footerRows - controlRows);
|
|
213
|
+
const detailSize = Math.max(1, height - 5 - footerRows);
|
|
179
214
|
const fit = (lines: string[]) => {
|
|
180
215
|
const result = lines.slice(0, height).map(line);
|
|
181
216
|
while (result.length < height) result.push("");
|
|
182
217
|
return result;
|
|
183
218
|
};
|
|
184
|
-
|
|
219
|
+
|
|
185
220
|
const line = (text: string) => truncateToWidth(text, safeWidth);
|
|
186
221
|
const rows = filtered();
|
|
187
222
|
const current = selected();
|
|
188
|
-
|
|
223
|
+
|
|
189
224
|
const selectedIndex = rows.findIndex((entry) => entry.id === selectedId);
|
|
190
225
|
const start = Math.max(0, Math.min(selectedIndex - Math.floor(listSize / 2), rows.length - listSize));
|
|
191
|
-
|
|
226
|
+
|
|
192
227
|
if (picker) return fit([
|
|
193
228
|
theme.fg("accent", `Agents / ${pickerKey}`), "",
|
|
194
229
|
...(pickerKey === "model" ? search.render(safeWidth) : []),
|
|
@@ -203,23 +238,29 @@ export function createCatalogComponent(
|
|
|
203
238
|
const ownSession = Object.hasOwn(own ?? {}, key);
|
|
204
239
|
const roleSession = field?.inherited && Object.hasOwn(editor?.settings()[current.role?.id ?? ""] ?? {}, key);
|
|
205
240
|
const origin = ownSession && own?.[key] === null ? "inherit / session" : ownSession ? "session" : roleSession ? "inherited / session" : field?.inherited ? "inherited" : "catalog";
|
|
206
|
-
|
|
207
|
-
const text = `${settingRow === index ? "> " : " "}${label.padEnd(18)} ${value}`;
|
|
208
|
-
if (safeWidth < 42) {
|
|
209
|
-
const title = `${settingRow === index ? "> " : " "}${label}`;
|
|
210
|
-
return [settingRow === index ? highlight(title, safeWidth) : title, ` ${truncateToWidth(value, Math.max(1, safeWidth - 2))}`];
|
|
211
|
-
}
|
|
212
|
-
return [settingRow === index ? highlight(text, safeWidth) : text];
|
|
241
|
+
return settingLines(label, field?.value ?? "(unset)", origin, Math.max(1, safeWidth - 4), settingRow === index ? "> " : " ", theme);
|
|
213
242
|
}),
|
|
214
|
-
"",
|
|
215
243
|
] : [];
|
|
244
|
+
const detailLines = current ? detailRows(current, safeWidth, controls) : [];
|
|
245
|
+
if (editor && mode === "detail") {
|
|
246
|
+
settingPositions = ["Model", "Effort"].map((label) => detailLines.findIndex((text) =>
|
|
247
|
+
new RegExp(`^ [> ] ${label}(?:\\s|$)`).test(text.replace(/\x1b\[[0-9;]*m/g, ""))));
|
|
248
|
+
if (revealSetting && settingPositions[settingRow] >= 0) {
|
|
249
|
+
const position = settingPositions[settingRow];
|
|
250
|
+
if (position < scroll) scroll = position;
|
|
251
|
+
else if (position >= scroll + detailSize) scroll = position - detailSize + 1;
|
|
252
|
+
}
|
|
253
|
+
revealSetting = false;
|
|
254
|
+
}
|
|
255
|
+
scroll = Math.max(0, Math.min(scroll, detailLines.length - detailSize));
|
|
216
256
|
const lines = mode !== "list" && current ? [
|
|
217
257
|
theme.fg("accent", mode === "prompt" ? "Agents / prompt" : editor ? `Agents / ${current.identity.name || current.id}` : "Agents / inspect"),
|
|
218
258
|
theme.fg("dim", mode === "prompt" ? "← details · ↑↓ scroll · Esc back" : `${editor ? "Enter change · " : ""}p prompt · ← list · ↑↓ ${editor ? "select / scroll" : "scroll"} · Esc back`),
|
|
219
259
|
"",
|
|
220
|
-
|
|
260
|
+
|
|
221
261
|
...detailLines.slice(scroll, scroll + detailSize).map((text, i) =>
|
|
222
|
-
i === 0 && scroll === 0 ? theme.fg("accent", text)
|
|
262
|
+
i === 0 && scroll === 0 ? theme.fg("accent", text)
|
|
263
|
+
: text),
|
|
223
264
|
"",
|
|
224
265
|
theme.fg("dim", `Showing ${scroll + 1}-${Math.min(scroll + detailSize, detailLines.length)} of ${detailLines.length} lines`),
|
|
225
266
|
] : [
|
|
@@ -261,8 +302,12 @@ export function createCatalogComponent(
|
|
|
261
302
|
else { close(); return; }
|
|
262
303
|
} else if (matchesKey(data, "up") || matchesKey(data, "down")) {
|
|
263
304
|
const delta = matchesKey(data, "down") ? 1 : -1;
|
|
264
|
-
if (mode === "detail" && editor && scroll
|
|
265
|
-
|
|
305
|
+
if (mode === "detail" && editor && scroll <= (settingPositions[1] ?? 0)
|
|
306
|
+
&& ((delta > 0 && settingRow === 0) || (delta < 0 && settingRow === 1))) {
|
|
307
|
+
settingRow += delta;
|
|
308
|
+
revealSetting = true;
|
|
309
|
+
}
|
|
310
|
+
else if (mode !== "list" && selected()) scroll = Math.max(0, scroll + delta);
|
|
266
311
|
else if (rows.length) selectedId = rows[Math.max(0, Math.min(rows.length - 1, rows.findIndex((entry) => entry.id === selectedId) + delta))]?.id;
|
|
267
312
|
} else if (matchesKey(data, "enter") && mode === "list" && selected()) {
|
|
268
313
|
mode = "detail";
|
|
@@ -274,6 +319,7 @@ export function createCatalogComponent(
|
|
|
274
319
|
} else if (editor && (data === "e" || data === "E") && mode === "detail" && selected()) {
|
|
275
320
|
settingRow = 0;
|
|
276
321
|
scroll = 0;
|
|
322
|
+
revealSetting = true;
|
|
277
323
|
} else if (editor && mode === "detail" && (matchesKey(data, "enter") || data === " ")) {
|
|
278
324
|
try { edit(); } catch (cause) { report(cause instanceof Error ? cause.message : String(cause), true); }
|
|
279
325
|
} else if (mode === "list" && matchesKey(data, "backspace")) {
|
|
@@ -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
|
+
}
|
|
@@ -879,7 +879,7 @@ function reconcileHealth(): void {
|
|
|
879
879
|
if (meta && meta.completionCallbackPendingAt !== undefined && meta.completionCallbackSentAt === undefined && meta.completionCallbackSuppressedAt === undefined) enqueueCompletionCallback(pi!, meta.id);
|
|
880
880
|
}
|
|
881
881
|
pruneProgressCache();
|
|
882
|
-
|
|
882
|
+
syncHealthTicker();
|
|
883
883
|
}
|
|
884
884
|
|
|
885
885
|
/** Release progress state for runs that are no longer live (lost, stopped, or finished by any path). */
|
|
@@ -948,10 +948,10 @@ function reconcileAbandonedRuns(now: number = Date.now()): number {
|
|
|
948
948
|
return adopted;
|
|
949
949
|
}
|
|
950
950
|
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
951
|
+
/** Completion batches own their retry timer; only supervision keeps this one alive. */
|
|
952
|
+
function syncHealthTicker(): void {
|
|
953
|
+
if (needsMonitoring(listMetasForParent(process.pid))) ensureHealthTicker();
|
|
954
|
+
else stopHealthTicker();
|
|
955
955
|
}
|
|
956
956
|
|
|
957
957
|
/** Start the reconciliation loop if it isn't already running. */
|
|
@@ -1099,6 +1099,7 @@ function navigatorCloseRun(id: string) {
|
|
|
1099
1099
|
healthLogCache.delete(id);
|
|
1100
1100
|
resetChildEventLogCursor(id);
|
|
1101
1101
|
resetParseRunCursor(id);
|
|
1102
|
+
syncHealthTicker();
|
|
1102
1103
|
return outcome;
|
|
1103
1104
|
}
|
|
1104
1105
|
|
|
@@ -1451,14 +1452,14 @@ function finalizeRun(pi: ExtensionAPI, ctx: ExtensionContext, id: string, code:
|
|
|
1451
1452
|
// Host-facing wrapper around first-party finalizer (finalization.ts).
|
|
1452
1453
|
// Coherent child-exit evidence may supersede provisional orphaned/lost
|
|
1453
1454
|
// reconciliation; finalization.ts enforces canExitFinalize + lifecycle authority.
|
|
1454
|
-
|
|
1455
|
+
finalizeRunCore(id, code, {
|
|
1455
1456
|
renderWidget,
|
|
1456
1457
|
notify: (message, level) => {
|
|
1457
1458
|
try { ctx.ui.notify(message, level); } catch { /* ignore */ }
|
|
1458
1459
|
},
|
|
1459
1460
|
sendMessage: () => enqueueCompletionCallback(pi, id),
|
|
1460
1461
|
});
|
|
1461
|
-
|
|
1462
|
+
syncHealthTicker();
|
|
1462
1463
|
}
|
|
1463
1464
|
|
|
1464
1465
|
function timingSchemaFields() {
|
|
@@ -1785,9 +1786,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1785
1786
|
const { args: resolvedExtArgs, missing } = extensionArgs(resolution, resolveExtensionPath);
|
|
1786
1787
|
const trustedExtArgs = [...new Set(taskTools?.trusted.map((tool) => tool.loadPath) ?? [])]
|
|
1787
1788
|
.filter((path) => !resolvedExtArgs.includes(path)).flatMap((path) => ["--extension", path]);
|
|
1788
|
-
//
|
|
1789
|
-
//
|
|
1790
|
-
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")]
|
|
1791
1793
|
.map((value, index, all) => sandboxEnabled && all[index - 1] === "--extension" ? canonicalizePath(value) : value);
|
|
1792
1794
|
if (sandboxEnabled && resolution.mode === "inherit") {
|
|
1793
1795
|
throw new Error("Task confinement requires explicit extensions; inheritExtensions is unsupported while sandboxing is enabled.");
|
|
@@ -1938,7 +1940,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1938
1940
|
"The tools param is both the tool allowlist AND what determines which extensions load in the child (e.g. tools='read,bash,web_fetch' loads only the web-tools package). Ask for the tools the task needs and nothing more; clean:true gives a built-ins-only child. Without an agent or role, pick a model with the model param (e.g. 'xai/grok-4.5@high'); providerless model patterns are resolved by Pi, while provider/model is deterministic and loads mapped provider extensions.",
|
|
1939
1941
|
...CATALOG_GUIDELINES,
|
|
1940
1942
|
"Subagent sandboxing defaults to off. Human settings in /sandbox control file, credential-file, command, and network permissions; sandbox:false cannot override an enabled human profile. Set sandbox:true to request kernel confinement, including without published settings. Set callback:false to finish quietly — then read the result on demand via subagent_result.",
|
|
1941
|
-
"Every
|
|
1943
|
+
"Every launch automatically joins one shared harness-owned watcher, including batch launches; it stops when the last active run ends. Do not start a separate heartbeat or background watcher for subagents. No progress for stuck_minutes (default 10) wakes you once without stopping the run. Elapsed-time limits are opt-in: deadline_minutes steers the child to wrap up and later stops it after grace_minutes, while max_minutes stops it without grace. Do not set elapsed-time limits merely because a task may take a while; use them only when the work needs a real wall-clock bound.",
|
|
1942
1944
|
"Use git_clone_workspace:true when the subagent will mutate Git in a sandbox. The parent prepares a disposable, self-contained clone with a real .git/ directory inside the sandbox root, so linked-worktree metadata outside the sandbox cannot stall the child.",
|
|
1943
1945
|
],
|
|
1944
1946
|
parameters: Type.Object({
|
|
@@ -2263,7 +2265,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
2263
2265
|
const acceptanceResultTool = subagentResultTool(Type, toolSession);
|
|
2264
2266
|
pi.registerTool(acceptanceResultTool);
|
|
2265
2267
|
publishAcceptanceHooks(acceptanceResultTool);
|
|
2266
|
-
const acceptanceStopTool = subagentStopTool(Type, { onStopped:
|
|
2268
|
+
const acceptanceStopTool = subagentStopTool(Type, { onStopped: () => {
|
|
2269
|
+
renderWidget();
|
|
2270
|
+
syncHealthTicker();
|
|
2271
|
+
} });
|
|
2267
2272
|
acceptanceStopToolRef = acceptanceStopTool;
|
|
2268
2273
|
pi.registerTool(acceptanceStopTool);
|
|
2269
2274
|
publishAcceptanceHooks();
|
|
@@ -2475,10 +2480,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
2475
2480
|
// Clear the retired legacy widget so the shared navigator is the only
|
|
2476
2481
|
// list surface for running/orphaned subagents.
|
|
2477
2482
|
renderWidget();
|
|
2478
|
-
//
|
|
2479
|
-
|
|
2480
|
-
// (running/orphaned, or unmarked lost); it stops itself when idle.
|
|
2481
|
-
if (needsMonitoring(listMetasForParent(process.pid)) || hasPendingFailureCallbacks()) ensureHealthTicker();
|
|
2483
|
+
// Reattach one watcher after reload; ordinary completion retries belong to the batcher.
|
|
2484
|
+
syncHealthTicker();
|
|
2482
2485
|
});
|
|
2483
2486
|
|
|
2484
2487
|
pi.on("session_before_switch", () => {
|
|
@@ -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.
|
|
57
|
-
"pi-better-goal": "0.10.
|
|
56
|
+
"pi-better-background-tasks": "0.12.1",
|
|
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
|
},
|