pi-better-harness 0.35.0 → 0.35.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/extensions/task-context/index.ts +0 -3
- package/node_modules/pi-better-background-tasks/package.json +1 -1
- package/node_modules/pi-better-background-tasks/src/shared-navigator.ts +11 -19
- package/node_modules/pi-better-goal/README.md +1 -1
- package/node_modules/pi-better-goal/package.json +1 -1
- package/node_modules/pi-better-goal/src/index.ts +1 -43
- package/node_modules/pi-better-ssh/README.md +1 -1
- package/node_modules/pi-better-ssh/package.json +1 -1
- package/node_modules/pi-better-ssh/src/index.ts +6 -37
- package/node_modules/pi-better-subagents/agent-commands.ts +17 -0
- package/node_modules/pi-better-subagents/catalog-editor.ts +125 -0
- package/node_modules/pi-better-subagents/catalog-ui.ts +20 -3
- package/node_modules/pi-better-subagents/docs/agent-catalog-lifecycle.md +3 -1
- package/node_modules/pi-better-subagents/docs/agent-catalog-operations.md +3 -1
- package/node_modules/pi-better-subagents/docs/agent-catalog.md +4 -0
- package/node_modules/pi-better-subagents/docs/usage.md +6 -9
- package/node_modules/pi-better-subagents/index.ts +4 -28
- package/node_modules/pi-better-subagents/navigator.mjs +5 -21
- package/node_modules/pi-better-subagents/package.json +1 -1
- package/node_modules/pi-better-subagents/shared-navigator.ts +11 -19
- package/node_modules/pi-better-subagents/task-guard.ts +10 -3
- package/node_modules/pi-better-subagents/task-runtime.mjs +5 -2
- package/node_modules/pi-better-subagents/task-tool-owner.ts +21 -0
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -55,8 +55,8 @@ optional interface is off. See the [usage guide](docs/usage.md#outcome--todo).
|
|
|
55
55
|
|
|
56
56
|
Subagent and background-task rows can be clicked to open their existing navigator
|
|
57
57
|
details. Enter and keyboard controls remain available, and editor input stays
|
|
58
|
-
passive.
|
|
59
|
-
|
|
58
|
+
passive. Harness packages do not write to Pi's status bar. Goal holds and resume
|
|
59
|
+
instructions remain available through `/goal`; permission checks are unchanged.
|
|
60
60
|
|
|
61
61
|
## Screenshots
|
|
62
62
|
|
|
@@ -90,7 +90,6 @@ const UpdatePlanSchema = Type.Object({
|
|
|
90
90
|
|
|
91
91
|
type WorkflowPlanInput = Omit<RushPlanUpdate, "event"> & { path?: string; event?: string };
|
|
92
92
|
|
|
93
|
-
const LEGACY_PLAN_NAV_STATUS_KEY = "pi-better-plan-nav";
|
|
94
93
|
const goalHeaderRuntime = (goal: GoalSnapshot) => ({ status: goal.status, clock: formatClockDuration(goalTiming(goal).activeSeconds) });
|
|
95
94
|
const PLAN_ACTIONS: readonly AutocompleteItem[] = [
|
|
96
95
|
{ value: "clear", label: "clear", description: "Remove the current Todo without changing its outcome" },
|
|
@@ -289,7 +288,6 @@ export default function taskContextExtension(pi: ExtensionAPI): void {
|
|
|
289
288
|
if ((enabled || display) && rushBinding) loadRushPlan(ctx);
|
|
290
289
|
displayMode = state.displayMode;
|
|
291
290
|
expanded = state.expanded;
|
|
292
|
-
if (ctx.hasUI) ctx.ui.setStatus(LEGACY_PLAN_NAV_STATUS_KEY, undefined);
|
|
293
291
|
refresh(true);
|
|
294
292
|
if (enabled) scheduleCompletedPlanClear();
|
|
295
293
|
};
|
|
@@ -695,7 +693,6 @@ export default function taskContextExtension(pi: ExtensionAPI): void {
|
|
|
695
693
|
refreshWidget = undefined;
|
|
696
694
|
widgetContext = undefined;
|
|
697
695
|
try {
|
|
698
|
-
ctx.ui.setStatus(LEGACY_PLAN_NAV_STATUS_KEY, undefined);
|
|
699
696
|
ctx.ui.setWidget(EXTENSION_NAME, undefined);
|
|
700
697
|
} catch { /* best-effort cleanup */ }
|
|
701
698
|
legacyPlanMask?.dispose();
|
|
@@ -175,18 +175,15 @@ export function isNavigatorUiAvailable(ctx: ExtensionContext | undefined): boole
|
|
|
175
175
|
}
|
|
176
176
|
|
|
177
177
|
export function navigatorFooterHint(_count: number): string | null {
|
|
178
|
-
// Retain the compatibility helper
|
|
178
|
+
// Retain the compatibility helper without publishing a badge.
|
|
179
179
|
return null;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
export function applyNavigatorFooter(ui: { setStatus(key: string, value: string | undefined): void }, count: number): string | null {
|
|
183
|
-
|
|
184
|
-
ui.setStatus(NAVIGATOR_STATUS_KEY, hint ?? undefined);
|
|
185
|
-
return hint;
|
|
183
|
+
return navigatorFooterHint(count);
|
|
186
184
|
}
|
|
187
185
|
|
|
188
186
|
export function applyCloseConfirmFooter(ui: { setStatus(key: string, value: string | undefined): void }, hint: string | null | undefined): string | null {
|
|
189
|
-
ui.setStatus(CLOSE_CONFIRM_STATUS_KEY, hint ?? undefined);
|
|
190
187
|
return hint ?? null;
|
|
191
188
|
}
|
|
192
189
|
|
|
@@ -219,8 +216,6 @@ export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext): void {
|
|
|
219
216
|
s.uiCtx = undefined;
|
|
220
217
|
if (activeCtx && isNavigatorUiAvailable(activeCtx)) {
|
|
221
218
|
const ui = activeCtx.ui;
|
|
222
|
-
try { applyNavigatorFooter(ui as any, 0); } catch { /* ignore */ }
|
|
223
|
-
try { applyCloseConfirmFooter(ui as any, null); } catch { /* ignore */ }
|
|
224
219
|
try { (ui as any).setWidget?.(MAIN_LIST_WIDGET_KEY, undefined); } catch { /* ignore */ }
|
|
225
220
|
}
|
|
226
221
|
s.lastHint = undefined;
|
|
@@ -241,12 +236,6 @@ export function refreshBackgroundWorkNavigator(ctx?: ExtensionContext): void {
|
|
|
241
236
|
const activeCtx = isNavigatorUiAvailable(ctx) ? ctx : s.uiCtx;
|
|
242
237
|
if (!isNavigatorUiAvailable(activeCtx)) return;
|
|
243
238
|
try {
|
|
244
|
-
const count = visibleCount();
|
|
245
|
-
const hint = navigatorFooterHint(count);
|
|
246
|
-
if (hint !== s.lastHint) {
|
|
247
|
-
applyNavigatorFooter(activeCtx!.ui as any, count);
|
|
248
|
-
s.lastHint = hint;
|
|
249
|
-
}
|
|
250
239
|
refreshMainListWidget();
|
|
251
240
|
} catch { /* ignore */ }
|
|
252
241
|
}
|
|
@@ -271,7 +260,6 @@ function clearMainListCloseArm(): void {
|
|
|
271
260
|
s.mainListCloseArmTimer = undefined;
|
|
272
261
|
if (!s.mainListCloseArm) return;
|
|
273
262
|
s.mainListCloseArm = undefined;
|
|
274
|
-
try { applyCloseConfirmFooter((s.uiCtx as any).ui, null); } catch { /* ignore */ }
|
|
275
263
|
}
|
|
276
264
|
|
|
277
265
|
function refreshMainListWidget(): void {
|
|
@@ -293,6 +281,7 @@ function refreshMainListWidget(): void {
|
|
|
293
281
|
const nextSignature = rows.length ? mainListSignature(rows, {
|
|
294
282
|
selectedId: s.mainListFocused ? s.mainListSelectedId : undefined,
|
|
295
283
|
focused: s.mainListFocused === true,
|
|
284
|
+
closeHint: closeHintFor(rows.find((row) => row.navigatorId === s.mainListCloseArm?.id)),
|
|
296
285
|
}) : undefined;
|
|
297
286
|
const changed = nextSignature !== s.lastMainListSignature;
|
|
298
287
|
s.lastMainListSignature = nextSignature;
|
|
@@ -416,6 +405,9 @@ function createMainListWidget(tui: { requestRender?(): void }, theme: unknown, d
|
|
|
416
405
|
selectedId: state().mainListFocused ? state().mainListSelectedId : undefined,
|
|
417
406
|
focused: state().mainListFocused === true,
|
|
418
407
|
});
|
|
408
|
+
const armedRow = rows.find((row) => row.navigatorId === state().mainListCloseArm?.id);
|
|
409
|
+
const hint = closeHintFor(armedRow);
|
|
410
|
+
if (hint) { layout.lines.push(deps.truncate(` ${hint}`, renderWidth(width))); layout.owners.push(undefined); }
|
|
419
411
|
lineWidth = renderWidth(width);
|
|
420
412
|
// The leading gap line belongs to the widget too, so its slot stays ownerless.
|
|
421
413
|
lineOwners = [undefined, ...layout.owners];
|
|
@@ -630,9 +622,10 @@ function statusIndicator(row: InternalRow, now = Date.now()): { glyph: string; c
|
|
|
630
622
|
return { glyph: RUNNING_DOT_GLYPH, color: "accent" };
|
|
631
623
|
}
|
|
632
624
|
|
|
633
|
-
function mainListSignature(rows: InternalRow[], options: { selectedId?: string; focused?: boolean } = {}): string {
|
|
625
|
+
function mainListSignature(rows: InternalRow[], options: { selectedId?: string; focused?: boolean; closeHint?: string | null } = {}): string {
|
|
634
626
|
return JSON.stringify({
|
|
635
627
|
focused: options.focused === true,
|
|
628
|
+
closeHint: options.closeHint ?? null,
|
|
636
629
|
selectedId: options.focused === true ? options.selectedId ?? null : null,
|
|
637
630
|
rows: rows.map((row) => ({
|
|
638
631
|
navigatorId: row.navigatorId,
|
|
@@ -863,7 +856,6 @@ function handleMainListCloseKey(): void {
|
|
|
863
856
|
}
|
|
864
857
|
clearMainListCloseArm();
|
|
865
858
|
s.mainListCloseArm = { id: row.navigatorId, armedAt: now };
|
|
866
|
-
try { applyCloseConfirmFooter((s.uiCtx as any).ui, closeHintFor(row)); } catch { /* ignore */ }
|
|
867
859
|
s.mainListCloseArmTimer = setTimeout(() => {
|
|
868
860
|
clearMainListCloseArm();
|
|
869
861
|
refreshMainListWidget();
|
|
@@ -1034,7 +1026,6 @@ function createOverlayComponent(
|
|
|
1034
1026
|
closeArmTimer = undefined;
|
|
1035
1027
|
if (closeArm) {
|
|
1036
1028
|
closeArm = undefined;
|
|
1037
|
-
try { applyCloseConfirmFooter((state().uiCtx as any).ui, null); } catch { /* ignore */ }
|
|
1038
1029
|
}
|
|
1039
1030
|
}
|
|
1040
1031
|
|
|
@@ -1108,11 +1099,9 @@ function createOverlayComponent(
|
|
|
1108
1099
|
}
|
|
1109
1100
|
clearCloseArm();
|
|
1110
1101
|
closeArm = { id: row.navigatorId, armedAt: now };
|
|
1111
|
-
try { applyCloseConfirmFooter((state().uiCtx as any).ui, closeHintFor(row)); } catch { /* ignore */ }
|
|
1112
1102
|
closeArmTimer = setTimeout(() => {
|
|
1113
1103
|
closeArmTimer = undefined;
|
|
1114
1104
|
closeArm = undefined;
|
|
1115
|
-
try { applyCloseConfirmFooter((state().uiCtx as any).ui, null); } catch { /* ignore */ }
|
|
1116
1105
|
requestRender();
|
|
1117
1106
|
}, CLOSE_ARM_MS);
|
|
1118
1107
|
closeArmTimer.unref?.();
|
|
@@ -1149,6 +1138,9 @@ function createOverlayComponent(
|
|
|
1149
1138
|
selectedId: selectedRow()?.navigatorId,
|
|
1150
1139
|
focused: true,
|
|
1151
1140
|
});
|
|
1141
|
+
const armedRow = overlayState.rows.find((row) => row.navigatorId === closeArm?.id);
|
|
1142
|
+
const hint = closeHintFor(armedRow);
|
|
1143
|
+
if (hint) { rail.lines.push(deps.truncate(` ${hint}`, renderWidth(width))); rail.owners.push(undefined); }
|
|
1152
1144
|
const railLines = rail.lines;
|
|
1153
1145
|
const editorLines = renderEditorLines(width);
|
|
1154
1146
|
const bottomLines = [...railLines, ...editorLines];
|
|
@@ -14,7 +14,7 @@ Use `pi-better-goal` when a Pi session should keep an explicit objective visible
|
|
|
14
14
|
|
|
15
15
|
- `/goal` runtime for starting, pausing, resuming, completing, and clearing the current objective.
|
|
16
16
|
- `/goal settings` opens an interactive settings page in the TUI. Select a row with Up/Down and toggle with Space/Enter; changes save immediately, and Escape closes the page. All three controls default to `on`. Automatic continuation controls idle and background-drain wakes; conversational resume controls the agent's Escape-pause resume tool; **Pause on Esc** controls whether editor Escape pauses the active goal. Goal kickoff, `/goal resume`, and `alt+g` remain available with these controls off. For scripts or non-interactive sessions, use `/goal settings auto-continue on|off`, `/goal settings conversational-resume on|off`, and `/goal settings pause-on-escape on|off`; bare `/goal settings` prints the saved values outside the TUI.
|
|
17
|
-
- `escape` pauses the active goal by default, and it stays paused while you talk to the agent. With **Pause on Esc** off, observed editor Escape leaves the goal active while preserving native streaming interruption and menu/dialog cancellation. Automatic continuation remains independent. Hosts without terminal/focus inspection and unknown custom editors retain generic abort-to-pause behavior because they cannot identify Escape reliably. With conversational resume enabled, say "go" (the agent then calls `goal_resume`), or use `/goal resume` or `alt+g`. `/goal pause` resumes only through `/goal resume` or `alt+g`.
|
|
17
|
+
- `escape` pauses the active goal by default, and it stays paused while you talk to the agent. With **Pause on Esc** off, observed editor Escape leaves the goal active while preserving native streaming interruption and menu/dialog cancellation. Automatic continuation remains independent. Hosts without terminal/focus inspection and unknown custom editors retain generic abort-to-pause behavior because they cannot identify Escape reliably. With conversational resume enabled, say "go" (the agent then calls `goal_resume`), or use `/goal resume` or `alt+g`. `/goal pause` resumes only through `/goal resume` or `alt+g`. Paused goals are never poked. On a macOS terminal without Option-as-Meta, `alt+g` types `©`; use `/goal resume` there.
|
|
18
18
|
- Background work that finishes while an `ask_user_question` is pending is handed to the agent right after the answer.
|
|
19
19
|
- A compact goal widget that does not replace Pi's footer.
|
|
20
20
|
- Background activity tracking for subagents and other registered providers.
|
|
@@ -374,7 +374,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
374
374
|
* Chat `notify` appends lines above the Working/bash status region. While the
|
|
375
375
|
* agent is streaming, that height change desyncs Pi main-screen differential
|
|
376
376
|
* rendering and stacks "Working..." / "Elapsed Xs" into scrollback. Keep
|
|
377
|
-
* mid-stream feedback out of the transcript
|
|
377
|
+
* mid-stream non-error feedback out of the transcript.
|
|
378
378
|
*/
|
|
379
379
|
const notifyGoal = (
|
|
380
380
|
ctx: ExtensionContext,
|
|
@@ -385,11 +385,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
385
385
|
return;
|
|
386
386
|
}
|
|
387
387
|
if (isForegroundBusy(ctx) && type !== "error") {
|
|
388
|
-
try {
|
|
389
|
-
ctx.ui.setStatus(EXTENSION_NAME, message);
|
|
390
|
-
} catch {
|
|
391
|
-
// Footer status is best-effort only.
|
|
392
|
-
}
|
|
393
388
|
return;
|
|
394
389
|
}
|
|
395
390
|
ctx.ui.notify(message, type);
|
|
@@ -424,7 +419,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
424
419
|
clearIdleContinuation();
|
|
425
420
|
syncPollingState();
|
|
426
421
|
syncResumeTool(goal);
|
|
427
|
-
applyStatus(ctx);
|
|
428
422
|
// Force a full redraw when the dock height changes (absent ↔ visible clock).
|
|
429
423
|
refreshGoalWidget?.(!wasVisible || !isGoalClockVisible(goal));
|
|
430
424
|
};
|
|
@@ -443,7 +437,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
443
437
|
clearIdleContinuation();
|
|
444
438
|
syncPollingState();
|
|
445
439
|
syncResumeTool(null);
|
|
446
|
-
applyStatus(ctx);
|
|
447
440
|
refreshGoalWidget?.(wasVisible);
|
|
448
441
|
};
|
|
449
442
|
|
|
@@ -726,41 +719,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
726
719
|
pi.events.emit(EVENT_TERMINAL_ATTENTION, snapshot);
|
|
727
720
|
if (execution !== executionGeneration) return null;
|
|
728
721
|
}
|
|
729
|
-
applyStatus(ctx);
|
|
730
|
-
|
|
731
722
|
return snapshot;
|
|
732
723
|
};
|
|
733
724
|
|
|
734
|
-
/** Footer status from the goal state and the latest activity snapshot. */
|
|
735
|
-
const statusText = (ctx: ExtensionContext, snapshot: ActivitySnapshot | null): string | undefined => {
|
|
736
|
-
const goal = getGoal(ctx);
|
|
737
|
-
let waitingOnAnswer = 0;
|
|
738
|
-
if (snapshot) {
|
|
739
|
-
for (const activeAtStart of pendingQuestions.values()) {
|
|
740
|
-
waitingOnAnswer += finishedSinceQuestion(activeAtStart, snapshot).length;
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
if (waitingOnAnswer > 0) return `${waitingOnAnswer} background done; waiting on your answer`;
|
|
744
|
-
if (goal?.status === "paused") return pausedGoalStatus(goal, preferences.conversationalResume);
|
|
745
|
-
if (snapshot?.backgroundRunning) return undefined;
|
|
746
|
-
const continuation = goal ? currentContinuationState(ctx, goal.goalId) : null;
|
|
747
|
-
if (continuation?.blocked) return "waiting: no progress";
|
|
748
|
-
const goalStall = observeGoalStall(goal, continuation, {
|
|
749
|
-
foregroundRunning,
|
|
750
|
-
backgroundRunning: snapshot?.backgroundRunning ?? false,
|
|
751
|
-
});
|
|
752
|
-
return goalStall?.state === "stalled" ? "goal stalled" : undefined;
|
|
753
|
-
};
|
|
754
|
-
|
|
755
|
-
function applyStatus(ctx: ExtensionContext): void {
|
|
756
|
-
if (!ctx.hasUI) return;
|
|
757
|
-
try {
|
|
758
|
-
ctx.ui.setStatus(EXTENSION_NAME, statusText(ctx, latestSnapshot));
|
|
759
|
-
} catch {
|
|
760
|
-
// UI status is best-effort only.
|
|
761
|
-
}
|
|
762
|
-
}
|
|
763
|
-
|
|
764
725
|
/**
|
|
765
726
|
* `goal_resume` is in the model's tool list only while an escape-paused goal
|
|
766
727
|
* waits. Pi activates newly registered tools by default, so this also removes
|
|
@@ -918,7 +879,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
918
879
|
preferences = writeGoalPreference(key, enabled);
|
|
919
880
|
if (previous.autoContinue !== preferences.autoContinue) clearIdleContinuation();
|
|
920
881
|
syncResumeTool(getGoal(ctx));
|
|
921
|
-
applyStatus(ctx);
|
|
922
882
|
if (!previous.autoContinue && preferences.autoContinue) {
|
|
923
883
|
const snapshot = await publishSnapshot(ctx);
|
|
924
884
|
const goal = getGoal(ctx);
|
|
@@ -1578,7 +1538,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1578
1538
|
if (blocked) {
|
|
1579
1539
|
refreshGoalWidget?.();
|
|
1580
1540
|
if (ctx.hasUI) {
|
|
1581
|
-
ctx.ui.setStatus(EXTENSION_NAME, "waiting: no progress");
|
|
1582
1541
|
ctx.ui.notify(
|
|
1583
1542
|
`Goal automatic continuation is waiting after ${noProgressRetries} identical retries (${evidence.summary}). Use /goal resume or alt+g to resume it.`,
|
|
1584
1543
|
"warning",
|
|
@@ -1630,7 +1589,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1630
1589
|
latestSnapshot = null;
|
|
1631
1590
|
collectionPending = false;
|
|
1632
1591
|
try {
|
|
1633
|
-
ctx.ui.setStatus(EXTENSION_NAME, undefined);
|
|
1634
1592
|
ctx.ui.setWidget(EXTENSION_NAME, undefined);
|
|
1635
1593
|
} catch {
|
|
1636
1594
|
// Best-effort cleanup.
|
|
@@ -93,7 +93,7 @@ A profile sets defaults for later `remote_bash` calls. `use` accepts a configure
|
|
|
93
93
|
}
|
|
94
94
|
```
|
|
95
95
|
|
|
96
|
-
Call `ssh_profile` with `status` to inspect the active profile or `clear` to remove it.
|
|
96
|
+
Call `ssh_profile` with `status` to inspect the active profile or `clear` to remove it. An explicit `host`, `workdir`, or environment entry on `remote_bash` takes precedence over the corresponding profile default.
|
|
97
97
|
|
|
98
98
|
## ControlMaster
|
|
99
99
|
|
|
@@ -21,8 +21,6 @@ import { executeRemoteBash, resolveRemoteBashConnection } from "./remote-bash.js
|
|
|
21
21
|
import type { RemoteBashDependencies, RemoteBashResult } from "./remote-bash.js";
|
|
22
22
|
import { resolveSshCommand } from "./shared-ssh-core/index.js";
|
|
23
23
|
|
|
24
|
-
const STATUS_KEY = "pi-better-ssh";
|
|
25
|
-
|
|
26
24
|
const RemoteBashParams = Type.Object({
|
|
27
25
|
command: Type.String({ description: "Bash command string to execute remotely." }),
|
|
28
26
|
host: Type.Optional(Type.String({ description: "SSH Host alias or user@host target. Optional when an active SSH profile supplies the host." })),
|
|
@@ -114,11 +112,7 @@ export function registerSshExtension(pi: ExtensionAPI, dependencies: RegisterSsh
|
|
|
114
112
|
...dependencies,
|
|
115
113
|
muxRegistry,
|
|
116
114
|
getActiveProfile: () => activeProfile,
|
|
117
|
-
onResult: (
|
|
118
|
-
if (activeProfile && params.host === undefined) {
|
|
119
|
-
setFooter(ctx, activeProfile, result.mux.state);
|
|
120
|
-
}
|
|
121
|
-
},
|
|
115
|
+
onResult: () => {},
|
|
122
116
|
});
|
|
123
117
|
|
|
124
118
|
const resolveMuxTarget = (params: {
|
|
@@ -158,22 +152,11 @@ export function registerSshExtension(pi: ExtensionAPI, dependencies: RegisterSsh
|
|
|
158
152
|
}
|
|
159
153
|
};
|
|
160
154
|
|
|
161
|
-
const
|
|
162
|
-
try {
|
|
163
|
-
ctx.ui.setStatus(STATUS_KEY, profile ? formatSshProfileChip(profile, muxState ?? "down") : undefined);
|
|
164
|
-
} catch {
|
|
165
|
-
// Footer status is best-effort in non-interactive and partial harness contexts.
|
|
166
|
-
}
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
const refreshFooter = async (ctx: ExtensionContext) => {
|
|
155
|
+
const refreshMux = async (ctx: ExtensionContext) => {
|
|
170
156
|
if (!activeProfile) {
|
|
171
|
-
setFooter(ctx, undefined);
|
|
172
157
|
return undefined;
|
|
173
158
|
}
|
|
174
|
-
|
|
175
|
-
setFooter(ctx, activeProfile, mux.state);
|
|
176
|
-
return mux;
|
|
159
|
+
return queryMux(activeProfile, ctx.sessionManager.getSessionId());
|
|
177
160
|
};
|
|
178
161
|
|
|
179
162
|
pi.registerTool({
|
|
@@ -192,19 +175,18 @@ export function registerSshExtension(pi: ExtensionAPI, dependencies: RegisterSsh
|
|
|
192
175
|
const aliases = listSshHostAliases(dependencies.sshConfigPath);
|
|
193
176
|
activeProfile = createSshProfile(params, aliases, dependencies.sshConfigPath);
|
|
194
177
|
pi.appendEntry(SSH_PROFILE_ENTRY_TYPE, profileStateEntry(activeProfile));
|
|
195
|
-
const mux = await
|
|
178
|
+
const mux = await refreshMux(ctx);
|
|
196
179
|
const details = { action: "use" as const, active: activeProfile, mux };
|
|
197
180
|
return { content: [{ type: "text" as const, text: `Active SSH profile: ${formatSshProfileChip(activeProfile, mux?.state ?? "down")}` }], details };
|
|
198
181
|
}
|
|
199
182
|
if (params.action === "clear") {
|
|
200
183
|
activeProfile = undefined;
|
|
201
184
|
pi.appendEntry(SSH_PROFILE_ENTRY_TYPE, profileStateEntry(undefined));
|
|
202
|
-
setFooter(ctx, undefined);
|
|
203
185
|
const details = { action: "clear" as const, active: null };
|
|
204
186
|
return { content: [{ type: "text" as const, text: "Active SSH profile cleared." }], details };
|
|
205
187
|
}
|
|
206
188
|
|
|
207
|
-
const mux = activeProfile ? await
|
|
189
|
+
const mux = activeProfile ? await refreshMux(ctx) : undefined;
|
|
208
190
|
const details = { action: "status" as const, active: activeProfile ?? null, ...(mux ? { mux } : {}) };
|
|
209
191
|
return {
|
|
210
192
|
content: [{
|
|
@@ -235,12 +217,6 @@ export function registerSshExtension(pi: ExtensionAPI, dependencies: RegisterSsh
|
|
|
235
217
|
? await muxRegistry.statusAll(sessionScope)
|
|
236
218
|
: await muxRegistry.stopAll(sessionScope);
|
|
237
219
|
const masters = results.map(muxResultDetails);
|
|
238
|
-
if (activeProfile) {
|
|
239
|
-
const activeTarget = resolveMuxTarget({ host: activeProfile.host }).identity.target;
|
|
240
|
-
const activeMux = masters.find((master) => master.target === activeTarget);
|
|
241
|
-
if (params.action === "stop") setFooter(ctx, activeProfile, "down");
|
|
242
|
-
else if (activeMux) setFooter(ctx, activeProfile, activeMux.state === "up" ? "up" : "down");
|
|
243
|
-
}
|
|
244
220
|
const details = { action: params.action, scope: "all" as const, masters };
|
|
245
221
|
return { content: [{ type: "text" as const, text: formatMuxResults(masters) }], details };
|
|
246
222
|
}
|
|
@@ -250,12 +226,6 @@ export function registerSshExtension(pi: ExtensionAPI, dependencies: RegisterSsh
|
|
|
250
226
|
? await muxRegistry.statusTarget(resolved, sessionScope)
|
|
251
227
|
: await muxRegistry.stopTarget(resolved, sessionScope);
|
|
252
228
|
const masters = [muxResultDetails(result)];
|
|
253
|
-
if (activeProfile) {
|
|
254
|
-
const activeTarget = resolveMuxTarget({ host: activeProfile.host }).identity.target;
|
|
255
|
-
if (resolved.identity.target === activeTarget) {
|
|
256
|
-
setFooter(ctx, activeProfile, result.state === "up" ? "up" : "down");
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
229
|
const details = { action: params.action, scope: "target" as const, masters };
|
|
260
230
|
return { content: [{ type: "text" as const, text: formatMuxResults(masters) }], details };
|
|
261
231
|
},
|
|
@@ -263,11 +233,10 @@ export function registerSshExtension(pi: ExtensionAPI, dependencies: RegisterSsh
|
|
|
263
233
|
|
|
264
234
|
const restore = async (ctx: ExtensionContext): Promise<void> => {
|
|
265
235
|
activeProfile = restoreSshProfile(ctx.sessionManager.getBranch());
|
|
266
|
-
await
|
|
236
|
+
await refreshMux(ctx);
|
|
267
237
|
};
|
|
268
238
|
pi.on("session_start", async (_event, ctx) => restore(ctx));
|
|
269
239
|
pi.on("session_tree", async (_event, ctx) => restore(ctx));
|
|
270
|
-
pi.on("session_shutdown", async (_event, ctx) => setFooter(ctx, undefined));
|
|
271
240
|
}
|
|
272
241
|
|
|
273
242
|
export default function sshExtension(pi: ExtensionAPI): void {
|
|
@@ -11,6 +11,7 @@ import { isAbsolute, resolve } from "node:path";
|
|
|
11
11
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import { presentCatalog, presentCatalogEntry, type LaunchEnricher, type OperationView } from "./agent-inspection.ts";
|
|
13
13
|
import { createCatalogComponent } from "./catalog-ui.ts";
|
|
14
|
+
import type { ScopedModelLike } from "./catalog-editor.ts";
|
|
14
15
|
import {
|
|
15
16
|
CATALOG_SCHEMA_VERSION,
|
|
16
17
|
DiagnosticCodes,
|
|
@@ -95,6 +96,11 @@ export interface AgentCommandHost {
|
|
|
95
96
|
hasUI: boolean;
|
|
96
97
|
mode?: "tui" | "rpc" | "json" | "print";
|
|
97
98
|
isProjectTrusted(): boolean;
|
|
99
|
+
/**
|
|
100
|
+
* Session model scope from `ctx.scopedModels`. Empty or absent means no
|
|
101
|
+
* scope, which Pi treats as "all available models are usable".
|
|
102
|
+
*/
|
|
103
|
+
scopedModels?: readonly ScopedModelLike[];
|
|
98
104
|
ui: AgentCommandUi;
|
|
99
105
|
}
|
|
100
106
|
|
|
@@ -150,10 +156,20 @@ export function executeAgentsCommandContext(args: string, ctx: ExtensionCommandC
|
|
|
150
156
|
hasUI: ctx.hasUI,
|
|
151
157
|
mode: ctx.mode,
|
|
152
158
|
isProjectTrusted: () => ctx.isProjectTrusted(),
|
|
159
|
+
scopedModels: scopedModelsFromContext(ctx),
|
|
153
160
|
ui: ctx.ui,
|
|
154
161
|
}, deps);
|
|
155
162
|
}
|
|
156
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Older Pi versions do not expose `ctx.scopedModels`, so they retain the
|
|
166
|
+
* unscoped picker. Current hosts use an empty array when no scope is configured.
|
|
167
|
+
*/
|
|
168
|
+
function scopedModelsFromContext(ctx: ExtensionCommandContext): readonly ScopedModelLike[] {
|
|
169
|
+
const scoped = (ctx as unknown as { scopedModels?: unknown }).scopedModels;
|
|
170
|
+
return Array.isArray(scoped) ? scoped as readonly ScopedModelLike[] : [];
|
|
171
|
+
}
|
|
172
|
+
|
|
157
173
|
export async function executeAgentsCommand(args: string, host: AgentCommandHost, deps: AgentCommandDeps = {}): Promise<AgentCommandResult> {
|
|
158
174
|
const parsed = parseCommandArgs(args);
|
|
159
175
|
if (parsed.positionals.length === 0 && parsed.flags.size === 0 && host.mode === "tui" && host.hasUI && host.ui.custom) parsed.positionals.push("list");
|
|
@@ -729,6 +745,7 @@ async function finishInspection(host: AgentCommandHost, commandResult: AgentComm
|
|
|
729
745
|
createCatalogComponent(entries, theme, () => tui.requestRender(), () => done(), view?.id, deps.sessionSettings ? {
|
|
730
746
|
get: () => presentCatalog(readCatalog(location(host, deps)), deps.enrich),
|
|
731
747
|
models: () => deps.editableModels?.() ?? [],
|
|
748
|
+
scopedModels: () => host.scopedModels ?? [],
|
|
732
749
|
settings: () => deps.sessionSettings!.snapshot(),
|
|
733
750
|
change: (id, key, value) => deps.sessionSettings!.change(id, key, value),
|
|
734
751
|
save(id) {
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model choices for the agent catalog's session-settings picker.
|
|
3
|
+
*
|
|
4
|
+
* Pi scopes model selection with `--models` / `enabledModels` and exposes the
|
|
5
|
+
* resolved set to extensions as `ctx.scopedModels`. Pi's contract for that
|
|
6
|
+
* field (pi-coding-agent extension types): "Empty when no scoping is
|
|
7
|
+
* configured (all available models are usable)." Pi's own `/model` selector
|
|
8
|
+
* applies the same rule: it starts in the `scoped` view when the resolved set
|
|
9
|
+
* is non-empty and in the `all` view when it is empty.
|
|
10
|
+
*
|
|
11
|
+
* The catalog picker follows those semantics, because an agent's model is a
|
|
12
|
+
* per-session preference rather than a second model policy:
|
|
13
|
+
*
|
|
14
|
+
* - non-empty scope: offer exactly the scoped models, in the order the session
|
|
15
|
+
* configured them;
|
|
16
|
+
* - empty scope: nothing is restricted, so fall back to the foreground
|
|
17
|
+
* registry's available models, which is what the picker offered before
|
|
18
|
+
* scoping existed.
|
|
19
|
+
*
|
|
20
|
+
* A saved preference outside the scope is never rewritten: it stays on the
|
|
21
|
+
* definition and in the detail view, but the picker does not offer it, so the
|
|
22
|
+
* scope is the only source of selectable models. Only in the no-scope fallback
|
|
23
|
+
* does the current value stay in the list when it is no longer available, which
|
|
24
|
+
* is what the picker did before scoping existed. Nothing here validates or
|
|
25
|
+
* restricts launches: catalog and launch resolution keep using the value they
|
|
26
|
+
* already had.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Structural view of one `ctx.scopedModels` entry (`{ model, thinkingLevel? }`). */
|
|
30
|
+
export interface ScopedModelLike {
|
|
31
|
+
readonly model?: { readonly provider?: unknown; readonly id?: unknown } | null;
|
|
32
|
+
readonly provider?: unknown;
|
|
33
|
+
readonly id?: unknown;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type CatalogModelScope = "scoped" | "all";
|
|
37
|
+
|
|
38
|
+
export interface CatalogModelChoices {
|
|
39
|
+
/** Values the picker offers, in display order. */
|
|
40
|
+
values: string[];
|
|
41
|
+
/** `scoped` when the session restricts models; `all` when the fallback applies. */
|
|
42
|
+
scope: CatalogModelScope;
|
|
43
|
+
/** True when no session scope was configured and available models were used. */
|
|
44
|
+
fallback: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Saved value the offered set does not contain. Scoped mode leaves it out of
|
|
47
|
+
* `values`; the no-scope fallback keeps it in `values` so it stays selectable.
|
|
48
|
+
*/
|
|
49
|
+
retained?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
53
|
+
return typeof value === "object" && value !== null ? value as Record<string, unknown> : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function referencePart(value: unknown): string | undefined {
|
|
57
|
+
if (typeof value !== "string") return undefined;
|
|
58
|
+
const trimmed = value.trim();
|
|
59
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Canonical `provider/model` reference for a scoped model entry.
|
|
64
|
+
* Accepts `{ model: { provider, id } }` (Pi's shape) and a bare
|
|
65
|
+
* `{ provider, id }` so callers can pass either.
|
|
66
|
+
*/
|
|
67
|
+
export function modelReference(entry: unknown): string | undefined {
|
|
68
|
+
const record = asRecord(entry);
|
|
69
|
+
if (!record) return undefined;
|
|
70
|
+
const scoped = asRecord(record.model) ?? record;
|
|
71
|
+
const provider = referencePart(scoped.provider);
|
|
72
|
+
const id = referencePart(scoped.id);
|
|
73
|
+
return provider && id ? `${provider}/${id}` : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Scoped model references, in session order, with malformed and duplicate entries dropped. */
|
|
77
|
+
export function scopedModelReferences(scoped: readonly unknown[] | null | undefined): string[] {
|
|
78
|
+
const seen = new Set<string>();
|
|
79
|
+
const references: string[] = [];
|
|
80
|
+
for (const entry of scoped ?? []) {
|
|
81
|
+
const reference = modelReference(entry);
|
|
82
|
+
if (!reference || seen.has(reference)) continue;
|
|
83
|
+
seen.add(reference);
|
|
84
|
+
references.push(reference);
|
|
85
|
+
}
|
|
86
|
+
return references;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Picker choices for the model field.
|
|
91
|
+
*
|
|
92
|
+
* `scoped` is the session scope (`ctx.scopedModels`); `available` is the
|
|
93
|
+
* foreground registry's available models used only when the scope is empty;
|
|
94
|
+
* `current` is the saved catalog/session value, reported through `retained`
|
|
95
|
+
* when the offered set does not contain it.
|
|
96
|
+
*/
|
|
97
|
+
export function catalogModelChoices(input: {
|
|
98
|
+
scoped?: readonly unknown[] | null;
|
|
99
|
+
available?: readonly string[] | null;
|
|
100
|
+
current?: string | null;
|
|
101
|
+
}): CatalogModelChoices {
|
|
102
|
+
const scoped = scopedModelReferences(input.scoped);
|
|
103
|
+
const fallback = scoped.length === 0;
|
|
104
|
+
const values = fallback
|
|
105
|
+
? [...new Set((input.available ?? []).map((value) => value.trim()).filter((value) => value.length > 0))].sort()
|
|
106
|
+
: [...scoped];
|
|
107
|
+
const current = referencePart(input.current);
|
|
108
|
+
const retained = current && !values.includes(current) ? current : undefined;
|
|
109
|
+
if (retained && fallback) values.push(retained);
|
|
110
|
+
return { values, scope: fallback ? "all" : "scoped", fallback, ...(retained ? { retained } : {}) };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** One-line note above the model list explaining where the options come from. */
|
|
114
|
+
export function catalogModelScopeLabel(choices: CatalogModelChoices): string {
|
|
115
|
+
if (choices.fallback) return "All available models \u00b7 no session scope";
|
|
116
|
+
const count = choices.values.length;
|
|
117
|
+
const scope = `Session model scope \u00b7 ${count} model${count === 1 ? "" : "s"}`;
|
|
118
|
+
return choices.retained ? `${scope} \u00b7 saved value is outside this scope` : scope;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Option label; only the retained saved value of the no-scope fallback is annotated. */
|
|
122
|
+
export function catalogModelOptionLabel(choices: CatalogModelChoices, value: string): string {
|
|
123
|
+
if (choices.scope !== "all" || !choices.retained || value !== choices.retained) return value;
|
|
124
|
+
return `${value} \u00b7 saved, unavailable`;
|
|
125
|
+
}
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { Input, SelectList, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
2
|
import type { OperationList, OperationView } from "./agent-inspection.ts";
|
|
3
3
|
import { EFFORT_LEVELS } from "./catalog-schema.ts";
|
|
4
|
+
import { catalogModelChoices, catalogModelOptionLabel, catalogModelScopeLabel, type ScopedModelLike } from "./catalog-editor.ts";
|
|
4
5
|
import type { AgentSessionOverrides, AgentSettingKey } from "./agent-session-settings.ts";
|
|
5
6
|
|
|
6
7
|
type Theme = { fg(color: string, text: string): string; bg?(color: string, text: string): string; bold?(text: string): string; inverse?(text: string): string };
|
|
7
8
|
|
|
8
9
|
export interface CatalogEditor {
|
|
9
10
|
get(): OperationList;
|
|
11
|
+
/** Available foreground models, used only when the session scope is empty. */
|
|
10
12
|
models(): readonly string[];
|
|
13
|
+
/** Session model scope (`ctx.scopedModels`). Empty or absent means no scope. */
|
|
14
|
+
scopedModels?(): readonly ScopedModelLike[];
|
|
11
15
|
settings(): AgentSessionOverrides;
|
|
12
16
|
change(id: string, key: AgentSettingKey, value: string | null): void;
|
|
13
17
|
save(id: string): { ok: boolean; message: string };
|
|
@@ -142,6 +146,7 @@ export function createCatalogComponent(
|
|
|
142
146
|
let picker: SelectList | undefined;
|
|
143
147
|
let filterPicker: ((filter: string) => void) | undefined;
|
|
144
148
|
let pickerKey: AgentSettingKey | undefined;
|
|
149
|
+
let modelScopeNote: string | undefined;
|
|
145
150
|
const search = new Input();
|
|
146
151
|
let focused = false;
|
|
147
152
|
let message = "";
|
|
@@ -162,8 +167,15 @@ export function createCatalogComponent(
|
|
|
162
167
|
if (!current || !editor) return;
|
|
163
168
|
if (!current.definitionValid) { report("Repair the catalog definition before editing.", true); return; }
|
|
164
169
|
const key: AgentSettingKey = settingRow === 0 ? "model" : "effort";
|
|
165
|
-
|
|
166
|
-
|
|
170
|
+
// Pi treats an empty scope as unrestricted. Scoped choices never include
|
|
171
|
+
// an out-of-scope saved value; the detail view still displays it.
|
|
172
|
+
const choices = key === "model"
|
|
173
|
+
? catalogModelChoices({ scoped: editor.scopedModels?.(), available: editor.models(), current: current.fields?.model.value })
|
|
174
|
+
: undefined;
|
|
175
|
+
modelScopeNote = choices ? catalogModelScopeLabel(choices) : undefined;
|
|
176
|
+
const items = [{ value: "", label: "Inherit" }, ...(choices
|
|
177
|
+
? choices.values.map((value) => ({ value, label: catalogModelOptionLabel(choices, value) }))
|
|
178
|
+
: EFFORT_LEVELS.map((value) => ({ value, label: value })))];
|
|
167
179
|
pickerKey = key;
|
|
168
180
|
search.setValue("");
|
|
169
181
|
search.focused = focused && key === "model";
|
|
@@ -189,7 +201,11 @@ export function createCatalogComponent(
|
|
|
189
201
|
picker.onSelect = onSelect;
|
|
190
202
|
};
|
|
191
203
|
filterPicker("");
|
|
192
|
-
|
|
204
|
+
// A saved value the scope does not offer is not in the list. Highlight
|
|
205
|
+
// the first offered model instead of "Inherit" so Enter does not clear
|
|
206
|
+
// the saved preference by accident.
|
|
207
|
+
const currentIndex = items.findIndex((item) => item.value === current.fields?.[key].value);
|
|
208
|
+
picker!.setSelectedIndex(Math.max(0, currentIndex >= 0 ? currentIndex : choices && choices.scope === "scoped" ? 1 : 0));
|
|
193
209
|
}
|
|
194
210
|
const filtered = () => catalog.entries.filter((entry) =>
|
|
195
211
|
[entry.id, entry.identity.name, entry.identity.description, entry.role?.id]
|
|
@@ -226,6 +242,7 @@ export function createCatalogComponent(
|
|
|
226
242
|
|
|
227
243
|
if (picker) return fit([
|
|
228
244
|
theme.fg("accent", `Agents / ${pickerKey}`), "",
|
|
245
|
+
...(pickerKey === "model" && modelScopeNote ? [theme.fg("dim", modelScopeNote), ""] : []),
|
|
229
246
|
...(pickerKey === "model" ? search.render(safeWidth) : []),
|
|
230
247
|
...picker.render(safeWidth),
|
|
231
248
|
...(message ? [theme.fg(error ? "error" : "muted", message)] : []),
|
|
@@ -51,7 +51,9 @@ new sessions start from catalog defaults.
|
|
|
51
51
|
The catalog host supplies a live `getSessionSettings` getter so inspection
|
|
52
52
|
reflects edits made while the overlay is open. `loadLaunchSnapshot` reads that
|
|
53
53
|
getter once and freezes the overlay for each batch. Later edits cannot alter
|
|
54
|
-
already-admitted jobs. Model choices come from the
|
|
54
|
+
already-admitted jobs. Model choices come from the session's Pi model scope
|
|
55
|
+
(`ctx.scopedModels`); an empty scope is no restriction, so they fall back to the
|
|
56
|
+
current Pi model registry's available models.
|
|
55
57
|
|
|
56
58
|
Ctrl+S saves only the selected definition's own preferences, never flattened
|
|
57
59
|
inherited settings. Bundled roles are copied to user scope; existing user and
|
|
@@ -10,7 +10,9 @@ In an interactive TUI, `/agents`, `/agents list`, and `/agents show` open a focu
|
|
|
10
10
|
|
|
11
11
|
### Model and effort edits
|
|
12
12
|
|
|
13
|
-
When lifecycle supplies `sessionSettings`, press `e` in detail to edit Model or Effort. Up/Down selects the setting and Enter opens its choices. Model choices
|
|
13
|
+
When lifecycle supplies `sessionSettings`, press `e` in detail to edit Model or Effort. Up/Down selects the setting and Enter opens its choices. Model choices follow the session's model scope (`ctx.scopedModels`, the same resolved set `/scoped-models` shows), with a header row naming the source, and type filters the names. A saved value outside the scope stays on the definition and in the detail view but is not offered by the picker; the header says so instead. An empty scope means no restriction in Pi, so the picker falls back to the foreground registry's available models, keeping a current preference that is no longer available labelled `saved`. Effort choices use the existing catalog effort levels. Escape cancels an unselected choice. Only these two fields are editable; instructions, base role, tier, and execution controls are unchanged.
|
|
14
|
+
|
|
15
|
+
The scope affects only the picker. Launch and runtime model resolution keep reading the catalog/session preference as before, and the package imposes no global model restriction.
|
|
14
16
|
|
|
15
17
|
A selected value immediately appends a custom entry to the current session branch. It does not write a catalog file. Reloading the extension or session restores the active branch's edits. Branches share their common ancestor settings but subsequent edits do not cross to siblings. Role edits flow into named agents that inherit that field. Choosing `Inherit` removes the selected definition's own value, including a saved agent override, instead of storing an explicit null preference. For a role with no parent, that removes its default. Later role/file edits remain live.
|
|
16
18
|
|
|
@@ -74,6 +74,10 @@ Credential-like keys (`token`, `apiKey`, `password`, `connectionString`, and sim
|
|
|
74
74
|
|
|
75
75
|
## Sources
|
|
76
76
|
|
|
77
|
+
For external consumers, the repo-root `agent-catalog.yaml` publishes the shared
|
|
78
|
+
definitions through a [Git subscription contract](catalog-subscription.md).
|
|
79
|
+
This publication mechanism does not change local source precedence below.
|
|
80
|
+
|
|
77
81
|
Precedence is project, then personal, then bundled. The higher source replaces the whole definition. Omitted agent fields inherit from the effective role, not from the shadowed agent.
|
|
78
82
|
|
|
79
83
|
| Scope | Path |
|
|
@@ -615,10 +615,8 @@ is unchanged in every mode.
|
|
|
615
615
|
still running, press `←` to focus the main-window subagent list above the
|
|
616
616
|
input line.
|
|
617
617
|
- If the editor contains text, `←` keeps normal cursor-left behavior.
|
|
618
|
-
-
|
|
619
|
-
|
|
620
|
-
for terminals that do not render the default footer status. The hint clears
|
|
621
|
-
when no non-dismissed current-parent run is still running.
|
|
618
|
+
- The work list shows a navigation hint while running work is visible. The
|
|
619
|
+
subagent navigator does not write to the status bar.
|
|
622
620
|
- While the main-window list is focused, the title hint changes to
|
|
623
621
|
`Enter to view · x to stop`; the selected row is marked with `›`. Press
|
|
624
622
|
`↓` from the bottom row to return to the input line.
|
|
@@ -660,7 +658,7 @@ is unchanged in every mode.
|
|
|
660
658
|
### Two-press `x` stop/dismiss
|
|
661
659
|
|
|
662
660
|
- First `x` on the selected (list) or viewed (detail) run arms the action for three
|
|
663
|
-
seconds and shows a
|
|
661
|
+
seconds and shows a hint in the navigator: `x again to stop <name>` while running, or
|
|
664
662
|
`x again to dismiss <name>` when terminal.
|
|
665
663
|
- Second `x` within the window, on the **same** run, acts:
|
|
666
664
|
- **Running** — stop the process group (shared `subagent_stop` semantics),
|
|
@@ -671,7 +669,7 @@ is unchanged in every mode.
|
|
|
671
669
|
|
|
672
670
|
### Dismissal is navigator-only
|
|
673
671
|
|
|
674
|
-
Dismissed runs leave the navigator list
|
|
672
|
+
Dismissed runs leave the navigator list. Logs, prompt, session
|
|
675
673
|
data, metadata, and id-based tool access stay intact. `subagent_list`,
|
|
676
674
|
`subagent_output`, `subagent_result`, and `subagent_stop` still resolve dismissed
|
|
677
675
|
run ids. Dismissal survives `/reload` as dismissed in the navigator.
|
|
@@ -679,9 +677,8 @@ run ids. Dismissal survives `/reload` as dismissed in the navigator.
|
|
|
679
677
|
### Reload and teardown
|
|
680
678
|
|
|
681
679
|
`/reload` and session restart reinstall the empty-editor wrapper without stacking
|
|
682
|
-
duplicate handlers
|
|
683
|
-
|
|
684
|
-
and clears navigator footer statuses (TUI only).
|
|
680
|
+
duplicate handlers and clear any leftover overlay timers or close-confirm
|
|
681
|
+
state. Session shutdown disposes open navigator timers.
|
|
685
682
|
|
|
686
683
|
## Design notes
|
|
687
684
|
|
|
@@ -20,8 +20,6 @@ import { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
|
20
20
|
import { matchesKey, Key, truncateToWidth, type AutocompleteItem } from "@earendil-works/pi-tui";
|
|
21
21
|
import { Type } from "@earendil-works/pi-ai";
|
|
22
22
|
import {
|
|
23
|
-
CLOSE_CONFIRM_STATUS_KEY,
|
|
24
|
-
NAVIGATOR_STATUS_KEY,
|
|
25
23
|
disposeBackgroundWorkNavigator,
|
|
26
24
|
ensureBackgroundWorkNavigator,
|
|
27
25
|
isNavigatorUiAvailable,
|
|
@@ -986,13 +984,11 @@ export function setIdentityProbeForTests(probe: ProcessProbe | undefined): void
|
|
|
986
984
|
//
|
|
987
985
|
// Human-facing TUI surface. Glue points, all gated on isNavigatorUiAvailable so
|
|
988
986
|
// print/RPC sessions never see any of it:
|
|
989
|
-
// 1.
|
|
990
|
-
// (setStatus — the full footer is never replaced);
|
|
991
|
-
// 2. an editor wrapper that intercepts bare ← only when the editor is empty
|
|
987
|
+
// 1. an editor wrapper that intercepts bare ← only when the editor is empty
|
|
992
988
|
// and at least one non-dismissed current-parent run is running,
|
|
993
989
|
// delegating everything else to the wrapped
|
|
994
990
|
// editor (composition via navigator.mjs, tested with fakes);
|
|
995
|
-
//
|
|
991
|
+
// 2. a focused overlay (ctx.ui.custom(..., { overlay: true })) listing the
|
|
996
992
|
// #44 navigatorVisibleRuns newest first, with Enter → live detail view
|
|
997
993
|
// that refreshes once per second (#46) and two-press `x` Close (#47).
|
|
998
994
|
// Detail + close-arm timers dispose on back, Escape, overlay close,
|
|
@@ -1103,14 +1099,6 @@ function navigatorCloseRun(id: string) {
|
|
|
1103
1099
|
return outcome;
|
|
1104
1100
|
}
|
|
1105
1101
|
|
|
1106
|
-
/** Publish/clear the Close confirmation footer hint (TUI only). */
|
|
1107
|
-
function publishCloseConfirmHint(ctx: ExtensionContext, hint: string | null): void {
|
|
1108
|
-
if (!isNavigatorUiAvailable(ctx)) return;
|
|
1109
|
-
try {
|
|
1110
|
-
ctx.ui.setStatus(CLOSE_CONFIRM_STATUS_KEY, hint ?? undefined);
|
|
1111
|
-
} catch { /* ignore */ }
|
|
1112
|
-
}
|
|
1113
|
-
|
|
1114
1102
|
function statusTone(status: string): BackgroundWorkRow["statusTone"] {
|
|
1115
1103
|
switch (status) {
|
|
1116
1104
|
case "running": return "running";
|
|
@@ -1409,7 +1397,7 @@ function openNavigator(ctx: ExtensionContext, initialDetailId?: string): void {
|
|
|
1409
1397
|
ensureNavigator(ctx);
|
|
1410
1398
|
}
|
|
1411
1399
|
|
|
1412
|
-
/**
|
|
1400
|
+
/** Refresh the shared navigator after run changes. */
|
|
1413
1401
|
function updateNavigatorFooter(ctx: ExtensionContext | undefined): void {
|
|
1414
1402
|
refreshBackgroundWorkNavigator(ctx);
|
|
1415
1403
|
}
|
|
@@ -2460,8 +2448,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
2460
2448
|
// - Drop any leftover overlay timers/confirm state from a prior session
|
|
2461
2449
|
// (defensive if the host skipped session_shutdown before re-start).
|
|
2462
2450
|
// - Reinstall the editor wrapper without stacking (marked factory).
|
|
2463
|
-
// - Clear + republish footer statuses (pi clears extension statuses on
|
|
2464
|
-
// session switch/reload; dirty-check only dedupes within a session).
|
|
2465
2451
|
// - Repaint the widget even if its last in-memory lines match; the host
|
|
2466
2452
|
// may have dropped extension UI during reload/session replacement.
|
|
2467
2453
|
ensureSubagentProvider();
|
|
@@ -2471,9 +2457,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
2471
2457
|
disposeBackgroundWorkNavigator(ctx);
|
|
2472
2458
|
widgetNavActive = false;
|
|
2473
2459
|
widgetNavSelectedId = undefined;
|
|
2474
|
-
if (isNavigatorUiAvailable(ctx)) {
|
|
2475
|
-
try { ctx.ui.setStatus(CLOSE_CONFIRM_STATUS_KEY, undefined); } catch { /* ignore */ }
|
|
2476
|
-
}
|
|
2477
2460
|
ensureNavigator(ctx);
|
|
2478
2461
|
publishAcceptanceHooks();
|
|
2479
2462
|
updateNavigatorFooter(ctx);
|
|
@@ -2522,14 +2505,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2522
2505
|
// Always dispose navigator detail timers (no UI call — just clearInterval).
|
|
2523
2506
|
// Safe in every mode; the dispose hook is only set when a TUI overlay opened.
|
|
2524
2507
|
disposeBackgroundWorkNavigator(ctx);
|
|
2525
|
-
// Widget clear is intentional in every mode that exposes ui
|
|
2526
|
-
// — pi docs: setWidget works in both TUI and RPC). Navigator cleanup
|
|
2527
|
-
// is TUI-only: the footer hint is never published outside TUI, so
|
|
2528
|
-
// clearing it in RPC would be a pure UI leak (setStatus subagents-nav).
|
|
2508
|
+
// Widget clear is intentional in every mode that exposes ui, including RPC.
|
|
2529
2509
|
try { ctx.ui.setWidget("subagents", WIDGET_CLEAR); } catch { /* ignore */ }
|
|
2530
|
-
if (isNavigatorUiAvailable(ctx)) {
|
|
2531
|
-
try { ctx.ui.setStatus(NAVIGATOR_STATUS_KEY, undefined); } catch { /* ignore */ }
|
|
2532
|
-
try { ctx.ui.setStatus(CLOSE_CONFIRM_STATUS_KEY, undefined); } catch { /* ignore */ }
|
|
2533
|
-
}
|
|
2534
2510
|
});
|
|
2535
2511
|
}
|
|
@@ -37,29 +37,18 @@ export const DETAIL_TICK_MS = 1000;
|
|
|
37
37
|
/** Two-press Close confirmation window (ms). Exclusive at the boundary. */
|
|
38
38
|
export const CLOSE_ARM_MS = 3000;
|
|
39
39
|
|
|
40
|
-
/**
|
|
40
|
+
/** Legacy status keys retained for compatibility. */
|
|
41
41
|
export const NAVIGATOR_STATUS_KEY = "subagents-nav";
|
|
42
42
|
|
|
43
|
-
/** Footer status key for the Close confirmation hint (coexists with count hint). */
|
|
44
43
|
export const CLOSE_CONFIRM_STATUS_KEY = "subagents-close";
|
|
45
44
|
|
|
46
|
-
/**
|
|
47
|
-
* Footer hint text: `← subagents · N` while at least one visible
|
|
48
|
-
* current-parent run exists, null when none (caller clears the status).
|
|
49
|
-
*/
|
|
45
|
+
/** Legacy footer helpers no longer write status. */
|
|
50
46
|
export function navigatorFooterHint(count) {
|
|
51
|
-
return
|
|
47
|
+
return null;
|
|
52
48
|
}
|
|
53
49
|
|
|
54
|
-
/**
|
|
55
|
-
* Publish the footer hint through pi's default footer status mechanism.
|
|
56
|
-
* Sets `← subagents · N` for count ≥ 1, clears the status at 0. Returns the
|
|
57
|
-
* hint (or null) so callers can dirty-check. NEVER touches the footer itself.
|
|
58
|
-
*/
|
|
59
50
|
export function applyNavigatorFooter(ui, count) {
|
|
60
|
-
|
|
61
|
-
ui.setStatus(NAVIGATOR_STATUS_KEY, hint ?? undefined);
|
|
62
|
-
return hint;
|
|
51
|
+
return navigatorFooterHint(count);
|
|
63
52
|
}
|
|
64
53
|
|
|
65
54
|
// ---------------------------------------------------------------------------
|
|
@@ -79,13 +68,8 @@ export function closeConfirmHint(row) {
|
|
|
79
68
|
return stoppable ? `x again to stop ${label}` : `x again to dismiss ${label}`;
|
|
80
69
|
}
|
|
81
70
|
|
|
82
|
-
/**
|
|
83
|
-
* Publish/clear the Close confirmation hint through pi's default footer status
|
|
84
|
-
* mechanism (a dedicated key so it coexists with `← subagents · N`).
|
|
85
|
-
* Pass null/undefined to clear.
|
|
86
|
-
*/
|
|
71
|
+
/** Preserve the hint for compatibility without writing status. */
|
|
87
72
|
export function applyCloseConfirmFooter(ui, hint) {
|
|
88
|
-
ui.setStatus(CLOSE_CONFIRM_STATUS_KEY, hint ?? undefined);
|
|
89
73
|
return hint ?? null;
|
|
90
74
|
}
|
|
91
75
|
|
|
@@ -175,18 +175,15 @@ export function isNavigatorUiAvailable(ctx: ExtensionContext | undefined): boole
|
|
|
175
175
|
}
|
|
176
176
|
|
|
177
177
|
export function navigatorFooterHint(_count: number): string | null {
|
|
178
|
-
// Retain the compatibility helper
|
|
178
|
+
// Retain the compatibility helper without publishing a badge.
|
|
179
179
|
return null;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
export function applyNavigatorFooter(ui: { setStatus(key: string, value: string | undefined): void }, count: number): string | null {
|
|
183
|
-
|
|
184
|
-
ui.setStatus(NAVIGATOR_STATUS_KEY, hint ?? undefined);
|
|
185
|
-
return hint;
|
|
183
|
+
return navigatorFooterHint(count);
|
|
186
184
|
}
|
|
187
185
|
|
|
188
186
|
export function applyCloseConfirmFooter(ui: { setStatus(key: string, value: string | undefined): void }, hint: string | null | undefined): string | null {
|
|
189
|
-
ui.setStatus(CLOSE_CONFIRM_STATUS_KEY, hint ?? undefined);
|
|
190
187
|
return hint ?? null;
|
|
191
188
|
}
|
|
192
189
|
|
|
@@ -219,8 +216,6 @@ export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext): void {
|
|
|
219
216
|
s.uiCtx = undefined;
|
|
220
217
|
if (activeCtx && isNavigatorUiAvailable(activeCtx)) {
|
|
221
218
|
const ui = activeCtx.ui;
|
|
222
|
-
try { applyNavigatorFooter(ui as any, 0); } catch { /* ignore */ }
|
|
223
|
-
try { applyCloseConfirmFooter(ui as any, null); } catch { /* ignore */ }
|
|
224
219
|
try { (ui as any).setWidget?.(MAIN_LIST_WIDGET_KEY, undefined); } catch { /* ignore */ }
|
|
225
220
|
}
|
|
226
221
|
s.lastHint = undefined;
|
|
@@ -241,12 +236,6 @@ export function refreshBackgroundWorkNavigator(ctx?: ExtensionContext): void {
|
|
|
241
236
|
const activeCtx = isNavigatorUiAvailable(ctx) ? ctx : s.uiCtx;
|
|
242
237
|
if (!isNavigatorUiAvailable(activeCtx)) return;
|
|
243
238
|
try {
|
|
244
|
-
const count = visibleCount();
|
|
245
|
-
const hint = navigatorFooterHint(count);
|
|
246
|
-
if (hint !== s.lastHint) {
|
|
247
|
-
applyNavigatorFooter(activeCtx!.ui as any, count);
|
|
248
|
-
s.lastHint = hint;
|
|
249
|
-
}
|
|
250
239
|
refreshMainListWidget();
|
|
251
240
|
} catch { /* ignore */ }
|
|
252
241
|
}
|
|
@@ -271,7 +260,6 @@ function clearMainListCloseArm(): void {
|
|
|
271
260
|
s.mainListCloseArmTimer = undefined;
|
|
272
261
|
if (!s.mainListCloseArm) return;
|
|
273
262
|
s.mainListCloseArm = undefined;
|
|
274
|
-
try { applyCloseConfirmFooter((s.uiCtx as any).ui, null); } catch { /* ignore */ }
|
|
275
263
|
}
|
|
276
264
|
|
|
277
265
|
function refreshMainListWidget(): void {
|
|
@@ -293,6 +281,7 @@ function refreshMainListWidget(): void {
|
|
|
293
281
|
const nextSignature = rows.length ? mainListSignature(rows, {
|
|
294
282
|
selectedId: s.mainListFocused ? s.mainListSelectedId : undefined,
|
|
295
283
|
focused: s.mainListFocused === true,
|
|
284
|
+
closeHint: closeHintFor(rows.find((row) => row.navigatorId === s.mainListCloseArm?.id)),
|
|
296
285
|
}) : undefined;
|
|
297
286
|
const changed = nextSignature !== s.lastMainListSignature;
|
|
298
287
|
s.lastMainListSignature = nextSignature;
|
|
@@ -416,6 +405,9 @@ function createMainListWidget(tui: { requestRender?(): void }, theme: unknown, d
|
|
|
416
405
|
selectedId: state().mainListFocused ? state().mainListSelectedId : undefined,
|
|
417
406
|
focused: state().mainListFocused === true,
|
|
418
407
|
});
|
|
408
|
+
const armedRow = rows.find((row) => row.navigatorId === state().mainListCloseArm?.id);
|
|
409
|
+
const hint = closeHintFor(armedRow);
|
|
410
|
+
if (hint) { layout.lines.push(deps.truncate(` ${hint}`, renderWidth(width))); layout.owners.push(undefined); }
|
|
419
411
|
lineWidth = renderWidth(width);
|
|
420
412
|
// The leading gap line belongs to the widget too, so its slot stays ownerless.
|
|
421
413
|
lineOwners = [undefined, ...layout.owners];
|
|
@@ -630,9 +622,10 @@ function statusIndicator(row: InternalRow, now = Date.now()): { glyph: string; c
|
|
|
630
622
|
return { glyph: RUNNING_DOT_GLYPH, color: "accent" };
|
|
631
623
|
}
|
|
632
624
|
|
|
633
|
-
function mainListSignature(rows: InternalRow[], options: { selectedId?: string; focused?: boolean } = {}): string {
|
|
625
|
+
function mainListSignature(rows: InternalRow[], options: { selectedId?: string; focused?: boolean; closeHint?: string | null } = {}): string {
|
|
634
626
|
return JSON.stringify({
|
|
635
627
|
focused: options.focused === true,
|
|
628
|
+
closeHint: options.closeHint ?? null,
|
|
636
629
|
selectedId: options.focused === true ? options.selectedId ?? null : null,
|
|
637
630
|
rows: rows.map((row) => ({
|
|
638
631
|
navigatorId: row.navigatorId,
|
|
@@ -863,7 +856,6 @@ function handleMainListCloseKey(): void {
|
|
|
863
856
|
}
|
|
864
857
|
clearMainListCloseArm();
|
|
865
858
|
s.mainListCloseArm = { id: row.navigatorId, armedAt: now };
|
|
866
|
-
try { applyCloseConfirmFooter((s.uiCtx as any).ui, closeHintFor(row)); } catch { /* ignore */ }
|
|
867
859
|
s.mainListCloseArmTimer = setTimeout(() => {
|
|
868
860
|
clearMainListCloseArm();
|
|
869
861
|
refreshMainListWidget();
|
|
@@ -1034,7 +1026,6 @@ function createOverlayComponent(
|
|
|
1034
1026
|
closeArmTimer = undefined;
|
|
1035
1027
|
if (closeArm) {
|
|
1036
1028
|
closeArm = undefined;
|
|
1037
|
-
try { applyCloseConfirmFooter((state().uiCtx as any).ui, null); } catch { /* ignore */ }
|
|
1038
1029
|
}
|
|
1039
1030
|
}
|
|
1040
1031
|
|
|
@@ -1108,11 +1099,9 @@ function createOverlayComponent(
|
|
|
1108
1099
|
}
|
|
1109
1100
|
clearCloseArm();
|
|
1110
1101
|
closeArm = { id: row.navigatorId, armedAt: now };
|
|
1111
|
-
try { applyCloseConfirmFooter((state().uiCtx as any).ui, closeHintFor(row)); } catch { /* ignore */ }
|
|
1112
1102
|
closeArmTimer = setTimeout(() => {
|
|
1113
1103
|
closeArmTimer = undefined;
|
|
1114
1104
|
closeArm = undefined;
|
|
1115
|
-
try { applyCloseConfirmFooter((state().uiCtx as any).ui, null); } catch { /* ignore */ }
|
|
1116
1105
|
requestRender();
|
|
1117
1106
|
}, CLOSE_ARM_MS);
|
|
1118
1107
|
closeArmTimer.unref?.();
|
|
@@ -1149,6 +1138,9 @@ function createOverlayComponent(
|
|
|
1149
1138
|
selectedId: selectedRow()?.navigatorId,
|
|
1150
1139
|
focused: true,
|
|
1151
1140
|
});
|
|
1141
|
+
const armedRow = overlayState.rows.find((row) => row.navigatorId === closeArm?.id);
|
|
1142
|
+
const hint = closeHintFor(armedRow);
|
|
1143
|
+
if (hint) { rail.lines.push(deps.truncate(` ${hint}`, renderWidth(width))); rail.owners.push(undefined); }
|
|
1152
1144
|
const railLines = rail.lines;
|
|
1153
1145
|
const editorLines = renderEditorLines(width);
|
|
1154
1146
|
const bottomLines = [...railLines, ...editorLines];
|
|
@@ -7,6 +7,8 @@ import { SANDBOX_POLICY_CHANNEL, SANDBOX_POLICY_REQUEST_CHANNEL } from "./permis
|
|
|
7
7
|
import { failureDispositionTool, intentBashDefinition } from "./child-incidents.ts";
|
|
8
8
|
import { DISPOSITION_TOOL } from "./incident-model.ts";
|
|
9
9
|
import { SandboxDiagnostics, diagnosticPackageVersion } from "./shared-sandbox-diagnostics.ts";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { claimTaskToolOwner } from "./task-tool-owner.ts";
|
|
10
12
|
|
|
11
13
|
const TRUSTED_INLINE_SOURCE = "<inline:task-sandbox>";
|
|
12
14
|
|
|
@@ -31,7 +33,7 @@ export function trustedToolRefusal(policy: TaskPolicy, name: string, sourcePath:
|
|
|
31
33
|
return undefined;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
export default function taskGuard(pi: ExtensionAPI, input: unknown, fatal: (error: unknown) => never): void {
|
|
36
|
+
export default function taskGuard(pi: ExtensionAPI, input: unknown, fatal: (error: unknown) => never, ownedTools = false): void {
|
|
35
37
|
const policy = parseTaskPolicy(input);
|
|
36
38
|
const support = describeSandboxSupport();
|
|
37
39
|
if (!support.supported) throw new Error(`Task sandbox unavailable: ${support.reason}`);
|
|
@@ -54,9 +56,10 @@ export default function taskGuard(pi: ExtensionAPI, input: unknown, fatal: (erro
|
|
|
54
56
|
diagnosticsWarned = true;
|
|
55
57
|
},
|
|
56
58
|
});
|
|
57
|
-
const
|
|
59
|
+
const toolApi = ownedTools ? { ...pi, registerTool: claimTaskToolOwner(pi) } : pi;
|
|
60
|
+
const installBoundary = () => installTaskTools(toolApi, { controller, cwd: process.cwd(), shellPath: () => shellPath,
|
|
58
61
|
diagnostics,
|
|
59
|
-
trustedSources: [TRUSTED_INLINE_SOURCE],
|
|
62
|
+
trustedSources: [ownedTools ? fileURLToPath(new URL("./task-tool-owner.ts", import.meta.url)) : TRUSTED_INLINE_SOURCE],
|
|
60
63
|
// Structured command intent (#315): validated before the confined command runs.
|
|
61
64
|
bashDefinition: (cwd, operations) => intentBashDefinition(cwd, operations) as any,
|
|
62
65
|
applyPatch: policy.applyPatch,
|
|
@@ -66,6 +69,9 @@ export default function taskGuard(pi: ExtensionAPI, input: unknown, fatal: (erro
|
|
|
66
69
|
admitExtensionTool: (name, _input, sourcePath) => (name === DISPOSITION_TOOL && sourcePath === TRUSTED_INLINE_SOURCE) ||
|
|
67
70
|
trustedToolRefusal(policy, name, sourcePath) === undefined,
|
|
68
71
|
});
|
|
72
|
+
// Defer owned registrations until after SDK duplicate-name diagnostics.
|
|
73
|
+
// The final session_start handler still installs and verifies them before activation.
|
|
74
|
+
let boundary = ownedTools ? undefined : installBoundary();
|
|
69
75
|
pi.registerTool(failureDispositionTool(() => currentCwd) as any);
|
|
70
76
|
const profile = Object.freeze({ enabled: true, ...policy.permissions });
|
|
71
77
|
const status = Object.freeze({
|
|
@@ -80,6 +86,7 @@ export default function taskGuard(pi: ExtensionAPI, input: unknown, fatal: (erro
|
|
|
80
86
|
try {
|
|
81
87
|
shellPath = SettingsManager.create(ctx.cwd, policy.agentDir, { projectTrusted: false }).getShellPath();
|
|
82
88
|
currentCwd = ctx.cwd;
|
|
89
|
+
boundary ??= installBoundary();
|
|
83
90
|
boundary.register(ctx.cwd);
|
|
84
91
|
const builtins: readonly string[] = policy.applyPatch ? [...TASK_BUILTINS, ...GUARDED_TASK_TOOLS] : TASK_BUILTINS;
|
|
85
92
|
const known = new Set<string>(builtins);
|
|
@@ -29,9 +29,12 @@ try {
|
|
|
29
29
|
const guard = await load.import(fileURLToPath(new URL("./task-guard.ts", import.meta.url)), { default: true });
|
|
30
30
|
if (typeof guard !== "function") throw new Error("Task guard has no extension factory.");
|
|
31
31
|
const { main } = await import(pathToFileURL(join(dirname(sdkEntry), "main.js")).href);
|
|
32
|
-
|
|
32
|
+
// SDK tool names belong to the first extension registering them. Reserve a
|
|
33
|
+
// source before provider/trusted packages, but keep activation in the final guard.
|
|
34
|
+
const ownerPath = fileURLToPath(new URL("./task-tool-owner.ts", import.meta.url));
|
|
35
|
+
await main(["--extension", ownerPath, ...args], {
|
|
33
36
|
extensionFactories: [{ name: "task-sandbox", factory: async (pi) => {
|
|
34
|
-
try { await guard(pi, policy, fail); } catch (error) { fail(error); }
|
|
37
|
+
try { await guard(pi, policy, fail, true); } catch (error) { fail(error); }
|
|
35
38
|
} }],
|
|
36
39
|
});
|
|
37
40
|
} catch (error) {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Loaded before third-party extensions so guarded tools win SDK name resolution. */
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
const CLAIM_CHANNEL = "pi-better-subagents:claim-task-tool-owner";
|
|
5
|
+
type RegisterTool = ExtensionAPI["registerTool"];
|
|
6
|
+
|
|
7
|
+
export default function taskToolOwner(pi: ExtensionAPI): void {
|
|
8
|
+
const unsubscribe = pi.events.on(CLAIM_CHANNEL, (claim) => {
|
|
9
|
+
if (typeof claim !== "function") return;
|
|
10
|
+
unsubscribe();
|
|
11
|
+
claim(pi.registerTool.bind(pi));
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Hand registration to the final guard without changing the SDK's source attribution. */
|
|
16
|
+
export function claimTaskToolOwner(pi: ExtensionAPI): RegisterTool {
|
|
17
|
+
let register: RegisterTool | undefined;
|
|
18
|
+
pi.events.emit(CLAIM_CHANNEL, (owner: RegisterTool) => { register = owner; });
|
|
19
|
+
if (!register) throw new Error("Task tool owner was not installed before extension loading.");
|
|
20
|
+
return register;
|
|
21
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-harness",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.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",
|
|
@@ -54,11 +54,11 @@
|
|
|
54
54
|
"test": "node --import tsx --import ../../scripts/isolate-registry.mjs --test test/*.test.mjs test/task-context/*.test.ts"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"pi-better-background-tasks": "0.12.
|
|
58
|
-
"pi-better-goal": "0.10.
|
|
57
|
+
"pi-better-background-tasks": "0.12.2",
|
|
58
|
+
"pi-better-goal": "0.10.4",
|
|
59
59
|
"pi-better-sandbox": "0.14.0",
|
|
60
|
-
"pi-better-ssh": "0.1.
|
|
61
|
-
"pi-better-subagents": "0.23.
|
|
60
|
+
"pi-better-ssh": "0.1.4",
|
|
61
|
+
"pi-better-subagents": "0.23.2",
|
|
62
62
|
"proper-lockfile": "^4.1.2",
|
|
63
63
|
"shell-quote": "1.12.0",
|
|
64
64
|
"smol-toml": "1.9.0",
|