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.
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from "pi-better-subagents/agent-catalog.mjs";
3
+
4
+ process.exitCode = await runCli();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- return lineOwners[event.y];
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
- pressedRow = undefined;
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
- // Only an unmodified left press is ours; every other gesture reaches the host untouched.
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
- pressedRow = undefined;
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
- pressedRow = undefined;
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 railLines = buildMainListLines(overlayState.rows, width, deps.truncate, fg, {
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
- if (overlayRows === undefined || detailRows === undefined) return [...contentLines, ...bottomLines];
1132
- const fittedContent = contentLines.slice(0, detailRows);
1133
- while (fittedContent.length < detailRows) fittedContent.push("");
1134
- // A terminal shorter than rail + editor keeps the bottom rows (the input frame) and drops the top.
1135
- return [...fittedContent, ...bottomLines].slice(-Math.max(1, overlayRows));
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
- close();
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
- unfocusMainList();
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"]), "Esc close"].join(" · ");
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(` ← main · ${actions}`, fg),
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(` ← main · ${actions}`, fg), dim(rule("", width), fg)];
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), dim(" ← back · Esc close", fg), ""];
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", "Esc close"].filter(Boolean).join(" · ");
1340
- lines.push(dim(` ← back · ${actions}`, fg));
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(` ← back · ${actions}`, fg), dim(rule("", width), fg)];
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, a Codex runtime, or a second launch path.
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` and `tier-policy.ts` do not spawn a child and do not edit the catalog. Lifecycle calls them before `spawnSubagentRun`.
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.