pi-better-harness 0.34.1 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/pi-agent-catalog.mjs +4 -0
- 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 +23 -1
- package/node_modules/pi-better-subagents/agent-catalog.mjs +29 -0
- package/node_modules/pi-better-subagents/child-ownership.ts +130 -0
- package/node_modules/pi-better-subagents/docs/agent-catalog.md +1 -1
- package/node_modules/pi-better-subagents/docs/agent-model-resolution.md +1 -1
- package/node_modules/pi-better-subagents/docs/worker-catalog.md +116 -0
- package/node_modules/pi-better-subagents/index.ts +4 -3
- package/node_modules/pi-better-subagents/model-resolution-core.ts +1074 -0
- package/node_modules/pi-better-subagents/model-resolution.ts +17 -1056
- package/node_modules/pi-better-subagents/package.json +14 -1
- package/node_modules/pi-better-subagents/roles/role.developer.md +1 -0
- package/node_modules/pi-better-subagents/shared-navigator.ts +107 -47
- package/node_modules/pi-better-subagents/worker-catalog.ts +162 -0
- package/package.json +7 -4
|
@@ -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
|
|
@@ -93,13 +110,18 @@ line says so, for example
|
|
|
93
110
|
|
|
94
111
|
## When To Use
|
|
95
112
|
|
|
113
|
+
Skills can use the same role definitions on Pi and Codex through the standalone
|
|
114
|
+
`pi-agent-catalog` JSON CLI, without starting Pi. The CLI checks supplied host
|
|
115
|
+
capabilities and returns a worker plan; it never launches workers or substitutes
|
|
116
|
+
prompt text for model selection. See [shared worker catalog](docs/worker-catalog.md).
|
|
117
|
+
|
|
96
118
|
Use this package for independent coding, review, research, or verification work that can finish later. Do not use it for steps that need immediate foreground interaction or user clarification.
|
|
97
119
|
|
|
98
120
|
## Compatibility
|
|
99
121
|
|
|
100
122
|
| Requirement | Support |
|
|
101
123
|
|-------------|---------|
|
|
102
|
-
| Pi | Required |
|
|
124
|
+
| Pi | Required for worker execution through this extension; not for standalone catalog planning |
|
|
103
125
|
| Install method | `pi install npm:pi-better-subagents` |
|
|
104
126
|
| macOS sandboxing | Supported when enabled |
|
|
105
127
|
| Linux sandboxing | Requires usable `bubblewrap` when enabled |
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
|
|
5
|
+
export async function runCli(args = process.argv.slice(2), input = () => readFileSync(0, 'utf8')) {
|
|
6
|
+
if (args.includes('--help')) {
|
|
7
|
+
console.log('pi-agent-catalog [resolve-workers] < request.json\nResolves shared agent roles against explicit host model and effort capabilities. No workers are launched.');
|
|
8
|
+
return 0;
|
|
9
|
+
}
|
|
10
|
+
if (args.length > 1 || (args.length === 1 && args[0] !== 'resolve-workers')) {
|
|
11
|
+
console.error('Usage: pi-agent-catalog [resolve-workers] < request.json');
|
|
12
|
+
return 2;
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
// Register only for this import. The portable resolver never loads Pi extensions or credentials.
|
|
16
|
+
const { tsImport } = await import('tsx/esm/api');
|
|
17
|
+
const { planWorkers } = await tsImport('./worker-catalog.ts', import.meta.url);
|
|
18
|
+
const text = input();
|
|
19
|
+
if (Buffer.byteLength(text) > 1024 * 1024) throw new Error('worker request exceeds 1 MiB');
|
|
20
|
+
console.log(JSON.stringify(planWorkers(JSON.parse(text))));
|
|
21
|
+
return 0;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
console.error(`pi-agent-catalog: ${error.message}`);
|
|
24
|
+
console.log(JSON.stringify({ version: 1, ok: false, code: 'worker-catalog-error', reason: error.message }));
|
|
25
|
+
return 2;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) process.exitCode = await runCli();
|
|
@@ -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
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Native agent catalog (`pi-agent/v1`)
|
|
2
2
|
|
|
3
|
-
Portable role and named-agent definitions for `pi-better-subagents`. This is the local contract for DEV-23 / GitHub #289. It is not an Agentier importer
|
|
3
|
+
Portable role and named-agent definitions for `pi-better-subagents`. This is the local contract for DEV-23 / GitHub #289. It is not an Agentier importer or a second launch path. The [shared worker catalog](worker-catalog.md) exposes these definitions to skills on Pi and Codex through host-specific planning, without launching workers itself.
|
|
4
4
|
|
|
5
5
|
Normative behavior is DEV-23 and the accepted ADR `docs/adr/0003-native-agent-catalog-live-inheritance.md`. Field names below are the engineering schema those decisions require.
|
|
6
6
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Agent model resolution
|
|
2
2
|
|
|
3
|
-
Resolver for DEV-23 model and effort choices. `model-resolution.ts`
|
|
3
|
+
Resolver for DEV-23 model and effort choices. `model-resolution-core.ts` owns portable resolution; `model-resolution.ts` supplies Pi's effort-support helper. Neither spawns a child or edits the catalog. Lifecycle calls the Pi adapter before `spawnSubagentRun`. Skills on other hosts use the [shared worker catalog](worker-catalog.md).
|
|
4
4
|
|
|
5
5
|
Normative rules are R3, R4, R8, and the resolver half of R10. Prose parsing and real Pi delegation stay with the acceptance unit.
|
|
6
6
|
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Shared worker catalog
|
|
2
|
+
|
|
3
|
+
Skills request roles; the harness catalog owns their instructions, preferred
|
|
4
|
+
models, and effort. Skills must not turn a model recommendation into an explicit
|
|
5
|
+
launch override. This interface plans workers; it does not launch them.
|
|
6
|
+
|
|
7
|
+
`pi-agent-catalog [resolve-workers]` reads one JSON request from stdin and emits
|
|
8
|
+
one JSON plan. Install the harness or subagents npm package to expose the binary,
|
|
9
|
+
or run its `agent-catalog.mjs` directly. No Pi session, credentials, provider
|
|
10
|
+
discovery, extensions, or network connection is needed. The CLI uses the packaged
|
|
11
|
+
`tsx` loader; the portable TypeScript API is `pi-better-subagents/catalog`.
|
|
12
|
+
|
|
13
|
+
## Request
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"version": 1,
|
|
18
|
+
"harness": "codex",
|
|
19
|
+
"cwd": "/workspace/project",
|
|
20
|
+
"projectTrusted": false,
|
|
21
|
+
"available": [
|
|
22
|
+
{
|
|
23
|
+
"model": "openai/gpt-6.1-sol",
|
|
24
|
+
"nativeModel": "gpt-6.1-sol",
|
|
25
|
+
"supportedEfforts": ["low", "medium", "high"]
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"model": "openai/gpt-6-luna",
|
|
29
|
+
"nativeModel": "gpt-6-luna",
|
|
30
|
+
"supportedEfforts": ["low", "medium", "high"]
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"model": "openai/gpt-6-astra",
|
|
34
|
+
"nativeModel": "gpt-6-astra",
|
|
35
|
+
"supportedEfforts": ["low", "medium", "high"]
|
|
36
|
+
}
|
|
37
|
+
],
|
|
38
|
+
"launchCapabilities": {
|
|
39
|
+
"model": true,
|
|
40
|
+
"effort": true,
|
|
41
|
+
"instructions": true
|
|
42
|
+
},
|
|
43
|
+
"hostPreferences": {
|
|
44
|
+
"developer": {"model": "openai/gpt-6.1-sol", "effort": "high"}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
This is an example shape, not evidence that these models or controls are available
|
|
50
|
+
in any Codex installation. The caller supplies capabilities from its current host.
|
|
51
|
+
Do not copy an availability list from Pi, infer supported effort from a model name,
|
|
52
|
+
or treat a configured model as available without runtime evidence. A native model
|
|
53
|
+
name maps to exactly one canonical `provider/model`; ambiguous mappings fail.
|
|
54
|
+
|
|
55
|
+
The catalog still uses project > personal > bundled definitions. Personal storage
|
|
56
|
+
is `userRoot`, then `PI_CODING_AGENT_DIR`, then `~/.pi/agent`, shared across hosts.
|
|
57
|
+
The CLI never scans `.codex/agents` or writes user configuration. Project catalog
|
|
58
|
+
files contribute only when `projectTrusted` is explicitly true. Host preferences
|
|
59
|
+
are supplied configuration, not another copied catalog or a write to a role.
|
|
60
|
+
|
|
61
|
+
Optional `overrides` selects `implementer`, `explorer`, `diagnoser`, or `reviewer`:
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
{"overrides": {"implementer": {"model": "deepseek/deepseek-flash@max"}}}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Model and effort resolve independently: task override > host preference > catalog
|
|
68
|
+
default. A model-only override keeps the resolved effort. Unsupported explicit
|
|
69
|
+
model/effort choices block; inherited effort can use the catalog's documented
|
|
70
|
+
nearest-level adjustment. Pi retains its existing tier/foreground fallback policy.
|
|
71
|
+
Codex and Claude Code require an explicit configured `tiers` policy or
|
|
72
|
+
`fallbackToForeground: true` to permit those default-model substitutions. An
|
|
73
|
+
explicit task or host model is never silently substituted.
|
|
74
|
+
|
|
75
|
+
## Plan And Execution
|
|
76
|
+
|
|
77
|
+
The plan maps implementer and diagnoser to Developer, explorer to Explorer, and
|
|
78
|
+
reviewer to Reviewer. Every worker reports `role`, `model`, `nativeModel`, `effort`,
|
|
79
|
+
`instructions`, `selection`, `catalogDigest`, `diagnostics`,
|
|
80
|
+
and `launch`. All four use the same immutable catalog snapshot. A reviewer that
|
|
81
|
+
resolves to the implementer's model blocks preflight; the skill must not invent
|
|
82
|
+
another model to make the check pass.
|
|
83
|
+
|
|
84
|
+
- **Pi:** `launch` is `{role: "developer"}` by default. Only explicit task or host
|
|
85
|
+
model/effort choices add `model` or `thinking`. The coordinator adds its task
|
|
86
|
+
prompt, alias, workspace, and authorized controls to the existing spawn tool.
|
|
87
|
+
Live Pi launch resolution remains authoritative, including branch-local session
|
|
88
|
+
preferences that an out-of-process CLI cannot read. Record the actual launch
|
|
89
|
+
provenance, not just the preflight prediction.
|
|
90
|
+
- **Codex / Claude Code:** `launch` contains the verified native `model`, `effort`,
|
|
91
|
+
and resolved role `instructions`. The adapter uses actual launch/configuration
|
|
92
|
+
controls supported by its current surface and appends the task instructions.
|
|
93
|
+
If the surface cannot apply a required field, do not launch. Saying a model name
|
|
94
|
+
in a worker prompt is not model selection. This API is a plan, not a claim that
|
|
95
|
+
every native `spawn_agent` surface supports per-call model or effort fields.
|
|
96
|
+
|
|
97
|
+
The catalog grants no sandbox, tool, filesystem, credential, or delegation
|
|
98
|
+
permissions. Existing host launch controls still apply.
|
|
99
|
+
|
|
100
|
+
On a blocked plan, `ok` is false and **every** `launch` is null. A model recorded
|
|
101
|
+
in an unsuccessful decision is diagnostic evidence, not launch authorization.
|
|
102
|
+
The CLI returns status 0 for a valid plan, including a blocked plan, and status 2
|
|
103
|
+
for malformed input or an invocation error. Consumers must check `ok`, not just
|
|
104
|
+
the process exit status. No automatic retry, install, or post-start substitution
|
|
105
|
+
is performed.
|
|
106
|
+
|
|
107
|
+
## Modules
|
|
108
|
+
|
|
109
|
+
- `catalog-schema.ts`, `catalog-store.ts`, `catalog-resolver.ts`: existing shared
|
|
110
|
+
definitions and live inheritance, unchanged storage format.
|
|
111
|
+
- `model-resolution-core.ts`: portable availability and effort resolution, with
|
|
112
|
+
host-provided supported effort levels or an injected effort-support function.
|
|
113
|
+
- `model-resolution.ts`: Pi wrapper, supplying `getSupportedThinkingLevels` from
|
|
114
|
+
Pi's registry. Existing Pi callers retain their API and semantics.
|
|
115
|
+
- `worker-catalog.ts`: workflow slots, host adaptation, independence, and a single
|
|
116
|
+
batch snapshot. It does not import a Pi SDK.
|