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.
@@ -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(4, ...plan.rows.map((row) => visibleWidth(planText(row.label)))));
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)) + 2);
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);
@@ -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));
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-goal",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "description": "Pi extension for goal tracking with background-aware continuation.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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 { OperationField, OperationList, OperationView } from "./agent-inspection.ts";
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
- export function catalogDetailLines(entry: OperationView, editable = false): string[] {
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.id,
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
- `Status ${stateLabel(entry)} · ${entry.launchabilityReason}`,
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 ? [fieldLine("Model", entry.fields.model), fieldLine("Effort", entry.fields.effort)] : []),
79
- fieldLine("Tier", entry.fields.tier),
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
- `Actual ${entry.actualModel ?? "not resolved"}${entry.actualEffort ? ` · ${entry.actualEffort}` : ""}`,
82
- ...(entry.validation.length ? ["", "Diagnostics", ...entry.validation.map((item) => `${item.blocking ? "!" : "·"} ${item.message}`)] : []),
83
- ...(entry.shadowed.length ? ["", "Shadowed sources", ...entry.shadowed.map((source) => `${source.scope} ${source.path}`)] : []),
84
- ...(entry.restrictions.length ? ["", "Execution restrictions", ...entry.restrictions.map((item) => `${item.honored ? "honored" : "not honored"} ${item.name}`)] : []),
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
- "Effective prompt",
87
- "Press p to read the complete resolved instructions.",
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
- let lastDetailWidth = 80;
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 controlRows = editor && mode === "detail" ? (safeWidth < 42 ? 5 : 3) : 0;
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
- lastDetailWidth = safeWidth;
219
+
185
220
  const line = (text: string) => truncateToWidth(text, safeWidth);
186
221
  const rows = filtered();
187
222
  const current = selected();
188
- const detailLines = current ? detailRows(current, safeWidth) : [];
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
- scroll = Math.max(0, Math.min(scroll, detailLines.length - detailSize));
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
- const value = `${field?.value ?? "(unset)"} ${origin}`;
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
- ...controls,
260
+
221
261
  ...detailLines.slice(scroll, scroll + detailSize).map((text, i) =>
222
- i === 0 && scroll === 0 ? theme.fg("accent", text) : 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 === 0 && (delta < 0 || settingRow === 0)) settingRow = Math.max(0, Math.min(1, settingRow + delta));
265
- else if (mode !== "list" && selected()) scroll = Math.max(0, Math.min(detailRows(selected()!, lastDetailWidth).length - 1, scroll + delta));
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
- if (!needsMonitoring(listMetasForParent(process.pid)) && !hasPendingFailureCallbacks()) stopHealthTicker();
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
- function hasPendingFailureCallbacks(): boolean {
952
- return listMetasForParent(process.pid).some((m) => ownedByThisParent(m) && m.callback !== false &&
953
- m.completionCallbackPendingAt !== undefined && m.completionCallbackSentAt === undefined && m.completionCallbackSuppressedAt === undefined &&
954
- !callbackSuppressionReason(m));
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
- const result = finalizeRunCore(id, code, {
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
- if (result.applied && hasPendingFailureCallbacks()) ensureHealthTicker();
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
- // The harness's own child control extension (no tools, no command I/O) rides along in every
1789
- // mode, including clean: it is how a soft-deadline steer reaches the child session.
1790
- const extArgs = [...resolvedExtArgs, ...trustedExtArgs, "--extension", childSteerExtensionPath()]
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 run has harness-owned stall detection: 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.",
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: renderWidget });
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
- // Resume supervision reconciliation + durable health-callback recovery
2479
- // across /reload while current-parent work still needs the ticker
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.0",
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
- 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));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-harness",
3
- "version": "0.34.0",
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.0",
57
- "pi-better-goal": "0.10.2",
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.0",
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
  },