pi-better-harness 0.16.0 → 0.17.1
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/node_modules/pi-better-plan/README.md +4 -2
- package/node_modules/pi-better-plan/package.json +1 -1
- package/node_modules/pi-better-plan/src/index.ts +24 -24
- package/node_modules/pi-better-plan/src/workflow-plan.ts +9 -7
- package/node_modules/pi-better-sandbox/README.md +15 -8
- package/node_modules/pi-better-sandbox/commands.ts +17 -4
- package/node_modules/pi-better-sandbox/index.ts +28 -9
- package/node_modules/pi-better-sandbox/package.json +1 -1
- package/node_modules/pi-better-sandbox/permissions-page.ts +12 -15
- package/node_modules/pi-better-sandbox/session-permissions.ts +24 -0
- package/node_modules/pi-better-sandbox/state.ts +4 -5
- package/node_modules/pi-better-subagents/README.md +2 -1
- package/node_modules/pi-better-subagents/agent-commands.ts +35 -7
- package/node_modules/pi-better-subagents/agent-session-settings.ts +89 -0
- package/node_modules/pi-better-subagents/agents-catalog-tool.ts +6 -2
- package/node_modules/pi-better-subagents/catalog-resolver.ts +10 -6
- package/node_modules/pi-better-subagents/catalog-runtime.ts +8 -2
- package/node_modules/pi-better-subagents/catalog-store.ts +3 -0
- package/node_modules/pi-better-subagents/catalog-ui.ts +115 -9
- package/node_modules/pi-better-subagents/docs/agent-catalog-lifecycle.md +23 -0
- package/node_modules/pi-better-subagents/docs/agent-catalog-operations.md +11 -1
- package/node_modules/pi-better-subagents/docs/usage.md +4 -2
- package/node_modules/pi-better-subagents/index.ts +18 -0
- package/node_modules/pi-better-subagents/package.json +1 -1
- package/node_modules/pi-better-subagents/settings-page.ts +34 -22
- package/package.json +4 -4
|
@@ -36,7 +36,7 @@ For a DAG, assign stable ids to prerequisite steps and list those ids in depende
|
|
|
36
36
|
|
|
37
37
|
When an explicitly invoked skill declares `workflow-role: coordinator` in its metadata, that skill's task plan takes precedence. The generic checklist stays persisted but is hidden, and `update_plan` refuses generic checklist updates until workflow ownership is released. `/plan` never opens a stale generic checklist during workflow ownership.
|
|
38
38
|
|
|
39
|
-
For `rush-issues
|
|
39
|
+
For any workflow using the shared task-plan contract (including `resolve-issues` and `rush-issues`), call `sync_workflow_plan` once with the absolute `.resolve-issues/rush/<run-id>/task-plan.json` path and its persisted `planRevision`. This binds the run to the session and shows its fleet stages and units in the widget, `/plan`, and `get_plan`; the binding survives Pi session resume, and ownership release restores the prior generic checklist. After that, record every transition with `update_plan` and a `workflow` object instead of editing `task-plan.json` or the profiling log by hand:
|
|
40
40
|
|
|
41
41
|
```json
|
|
42
42
|
{ "workflow": {
|
|
@@ -50,7 +50,9 @@ For `rush-issues`, call `sync_workflow_plan` once with the absolute `.resolve-is
|
|
|
50
50
|
} }
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
-
`changes` addresses units, components, and fleet stages by id (a change without an id sets run-level fields); all changes in one call are one transition. For a scope change, a change with `target` `unit` or `component` and an `add` row appends a new row; existing rows cannot be removed or renamed. The tool checks ids, dependencies (including cycles), worker slots, and status/stage values against the
|
|
53
|
+
`changes` addresses units, components, and fleet stages by id (a change without an id sets run-level fields); all changes in one call are one transition. For a scope change, a change with `target` `unit` or `component` and an `add` row appends a new row; existing rows cannot be removed or renamed. The tool checks ids, dependencies (including cycles), worker slots, and status/stage values against the shared issue-resolution contract (a fleet status of `n/a` is saved as `not-applicable`), refuses a stale `revision`, appends an optional `decision`, then saves the plan atomically with `planRevision + 1` and a new `updatedAt`, and appends one profiling event with the same revision to the log the run already uses (`profiling/run.jsonl` or `profiling.jsonl`). A rejected update changes nothing. The event is appended before the plan is renamed into place, so a crash between the two can leave the log one revision ahead; the next update notices, reuses that revision, and marks its event with `logAheadRevision`.
|
|
54
|
+
|
|
55
|
+
The binding records the active workflow's name, which is shown in the plan view. A different workflow cannot read or update that binding; a new invocation or ownership release invalidates it. The skill name is not an allowlist: explicit binding opts a workflow into the shared schema and transition rules, while path confinement to `.resolve-issues/rush/<run-id>/task-plan.json`, run identity, size limits, and revision checks still apply. Workflows using other schemas or storage locations retain their own planning and must not bind incompatible state.
|
|
54
56
|
|
|
55
57
|
## Plan Display
|
|
56
58
|
|
|
@@ -134,7 +134,7 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
134
134
|
const refresh = (force = false): void => refreshWidget?.(force);
|
|
135
135
|
|
|
136
136
|
const loadRushPlan = (ctx: ExtensionContext): RushPlan | null => {
|
|
137
|
-
if (!rushBinding || workflowPlanOwner(ctx) !==
|
|
137
|
+
if (!rushBinding || workflowPlanOwner(ctx) !== rushBinding.owner) return null;
|
|
138
138
|
try {
|
|
139
139
|
const plan = readRushPlan(rushBinding.path, ctx.cwd);
|
|
140
140
|
if (plan.runId !== rushBinding.runId) throw new Error("Rush run identity changed.");
|
|
@@ -181,7 +181,8 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
181
181
|
const state = reconstructPlanState(ctx.sessionManager.getBranch());
|
|
182
182
|
currentPlan = state.plan;
|
|
183
183
|
displayedWorkflowOwner = workflowPlanOwner(ctx);
|
|
184
|
-
|
|
184
|
+
const binding = workflowBinding(ctx.sessionManager.getBranch());
|
|
185
|
+
rushBinding = binding?.owner === displayedWorkflowOwner ? binding : null;
|
|
185
186
|
rushPlan = null;
|
|
186
187
|
rushError = null;
|
|
187
188
|
if (rushBinding) loadRushPlan(ctx);
|
|
@@ -213,13 +214,13 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
213
214
|
refreshWidget = localRefresh;
|
|
214
215
|
return {
|
|
215
216
|
render(width: number): string[] {
|
|
216
|
-
if (displayMode === "hidden") return [];
|
|
217
|
+
if (displayMode === "hidden" || displayedWorkflowOwner !== workflowPlanOwner(ctx)) return [];
|
|
217
218
|
if (displayedWorkflowOwner) {
|
|
218
|
-
if (
|
|
219
|
+
if (rushBinding?.owner !== displayedWorkflowOwner) return [];
|
|
219
220
|
const fg = typeof theme?.fg === "function"
|
|
220
221
|
? (color: string, value: string) => theme.fg(color as never, value)
|
|
221
222
|
: undefined;
|
|
222
|
-
return rushPlan ? ["", ...renderRushPlan(rushPlan, width, false, fg)] : [];
|
|
223
|
+
return rushPlan ? ["", ...renderRushPlan(rushPlan, width, false, fg, displayedWorkflowOwner)] : [];
|
|
223
224
|
}
|
|
224
225
|
if (!currentPlan) return [];
|
|
225
226
|
return ["", ...renderCompactPlan(currentPlan, width, theme as never)];
|
|
@@ -237,20 +238,20 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
237
238
|
const showFullPlan = async (ctx: ExtensionContext): Promise<void> => {
|
|
238
239
|
const owner = workflowPlanOwner(ctx);
|
|
239
240
|
if (owner) {
|
|
240
|
-
const plan =
|
|
241
|
+
const plan = loadRushPlan(ctx);
|
|
241
242
|
if (!plan) {
|
|
242
|
-
ctx.ui.notify(owner
|
|
243
|
+
ctx.ui.notify(`${owner} plan unavailable: ${rushError ?? "not bound"}`, "warning");
|
|
243
244
|
return;
|
|
244
245
|
}
|
|
245
246
|
if (ctx.mode !== "tui") {
|
|
246
|
-
ctx.ui.notify(renderRushPlan(plan, 160, true).join("\n"), "info");
|
|
247
|
+
ctx.ui.notify(renderRushPlan(plan, 160, true, undefined, owner).join("\n"), "info");
|
|
247
248
|
return;
|
|
248
249
|
}
|
|
249
250
|
await ctx.ui.custom<void>((tui, theme, _keys, done) => {
|
|
250
251
|
const fg = typeof theme?.fg === "function"
|
|
251
252
|
? (color: string, value: string) => theme.fg(color as never, value)
|
|
252
253
|
: undefined;
|
|
253
|
-
const component = createRushPlanComponent(plan, () => done(), fg);
|
|
254
|
+
const component = createRushPlanComponent(plan, () => done(), fg, owner);
|
|
254
255
|
return { render: (width) => component.render(width), handleInput(data) {
|
|
255
256
|
component.handleInput?.(data);
|
|
256
257
|
tui.requestRender();
|
|
@@ -284,13 +285,14 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
284
285
|
pi.registerTool({
|
|
285
286
|
name: "sync_workflow_plan",
|
|
286
287
|
label: "Sync Workflow Plan",
|
|
287
|
-
description: "Bind and display
|
|
288
|
+
description: "Bind and display an active workflow's compatible persisted task plan at its exact checkpoint revision. Record later transitions with update_plan's workflow field.",
|
|
288
289
|
parameters: Type.Object({
|
|
289
290
|
path: Type.String({ description: "Absolute path to .resolve-issues/rush/<run-id>/task-plan.json" }),
|
|
290
291
|
revision: Type.Integer({ minimum: 0, description: "Persisted planRevision to display" }),
|
|
291
292
|
}),
|
|
292
293
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
293
|
-
|
|
294
|
+
const owner = workflowPlanOwner(ctx);
|
|
295
|
+
if (!owner) throw new Error("Only an active workflow can sync its plan.");
|
|
294
296
|
const plan = readRushPlan(params.path, ctx.cwd);
|
|
295
297
|
if (plan.planRevision !== params.revision) {
|
|
296
298
|
rushPlan = null;
|
|
@@ -298,22 +300,23 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
298
300
|
refresh(true);
|
|
299
301
|
throw new Error(`Rush plan ${rushError}.`);
|
|
300
302
|
}
|
|
301
|
-
const binding: WorkflowPlanBinding = { owner
|
|
302
|
-
if (!rushBinding || rushBinding.path !== binding.path || rushBinding.runId !== binding.runId) {
|
|
303
|
+
const binding: WorkflowPlanBinding = { owner, path: params.path, runId: plan.runId };
|
|
304
|
+
if (!rushBinding || rushBinding.owner !== owner || rushBinding.path !== binding.path || rushBinding.runId !== binding.runId) {
|
|
303
305
|
pi.appendEntry(WORKFLOW_PLAN_ENTRY, { version: 1, kind: "set", ...binding });
|
|
304
306
|
}
|
|
307
|
+
displayedWorkflowOwner = owner;
|
|
305
308
|
rushBinding = binding;
|
|
306
309
|
rushPlan = plan;
|
|
307
310
|
rushError = null;
|
|
308
311
|
refresh(true);
|
|
309
|
-
return { content: [{ type: "text", text: `Showing
|
|
312
|
+
return { content: [{ type: "text", text: `Showing ${owner} rev ${plan.planRevision}: ${plan.issues.length} units.` }], details: { runId: plan.runId, revision: plan.planRevision } };
|
|
310
313
|
},
|
|
311
314
|
});
|
|
312
315
|
|
|
313
316
|
pi.registerTool({
|
|
314
317
|
name: "update_plan",
|
|
315
318
|
label: "Update Plan",
|
|
316
|
-
description: "Create or atomically replace the current structured execution plan and its step statuses. While
|
|
319
|
+
description: "Create or atomically replace the current structured execution plan and its step statuses. While a workflow owns a bound plan, send workflow instead of plan to save one task-plan transition.",
|
|
317
320
|
promptSnippet: "Create and update a persistent structured execution plan",
|
|
318
321
|
promptGuidelines: [
|
|
319
322
|
"Use update_plan for work with three or more meaningful steps unless a skill owns planning; update it immediately when a step completes, becomes blocked, or scope changes.",
|
|
@@ -322,7 +325,7 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
322
325
|
"For generic plans, follow the active delegation mode. Manual forbids proactive delegation even in plan mode; adaptive favors substantial independent work; coordinator delegates nontrivial role-owned tasks after agents_catalog discovery.",
|
|
323
326
|
"Use a generic plan as a milestone ledger only when no workflow owns planning. Otherwise follow the workflow's task plan and foreground role.",
|
|
324
327
|
"For generic plans, use separate steps for distinct deliverables, not one step per worker process. Before completing verification or the plan, inspect and integrate every relevant delegated result or failure.",
|
|
325
|
-
"While
|
|
328
|
+
"While a workflow owns the plan and it is bound with sync_workflow_plan, record every transition with update_plan's workflow field instead of editing task-plan.json or its profiling log; it saves the rows, the next planRevision, and the matching profiling event together.",
|
|
326
329
|
],
|
|
327
330
|
parameters: UpdatePlanSchema,
|
|
328
331
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
@@ -330,9 +333,8 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
330
333
|
const input = params as { explanation?: string; plan?: PlanStepInput[]; workflow?: RushPlanUpdate };
|
|
331
334
|
if (input.workflow !== undefined) {
|
|
332
335
|
if (!owner) throw new Error("No workflow owns the task plan; send plan instead of workflow.");
|
|
333
|
-
if (owner !== "rush-issues") throw new Error(`${owner} owns the task plan and does not accept update_plan workflow changes.`);
|
|
334
336
|
if (input.plan !== undefined) throw new Error("Send either plan or workflow, not both.");
|
|
335
|
-
if (!rushBinding) throw new Error(
|
|
337
|
+
if (!rushBinding || rushBinding.owner !== owner) throw new Error(`No ${owner} plan is bound; call sync_workflow_plan with the task-plan.json path first.`);
|
|
336
338
|
const result = applyRushPlanUpdate(rushBinding.path, ctx.cwd, rushBinding.runId, input.workflow);
|
|
337
339
|
rushPlan = result.plan;
|
|
338
340
|
rushError = null;
|
|
@@ -342,7 +344,7 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
342
344
|
const logNote = result.logAheadRevision === undefined ? ""
|
|
343
345
|
: ` The profiling log already held rev ${result.logAheadRevision} (an earlier write stopped after logging); this event supersedes it.`;
|
|
344
346
|
return {
|
|
345
|
-
content: [{ type: "text", text: `Saved
|
|
347
|
+
content: [{ type: "text", text: `Saved ${owner} rev ${result.revision} (${input.workflow.event.trim()})${rows.length ? `: ${rows.join(", ")}` : ""}${input.workflow.decision ? `${rows.length ? ";" : ":"} decision ${input.workflow.decision.id}` : ""}. Profiling: ${result.profilingPath}.${logNote}` }],
|
|
346
348
|
details: {
|
|
347
349
|
ok: true, runId: result.plan.runId, revision: result.revision, changed: result.changed, profilingPath: result.profilingPath,
|
|
348
350
|
...(result.logAheadRevision === undefined ? {} : { logAheadRevision: result.logAheadRevision }),
|
|
@@ -350,9 +352,7 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
350
352
|
};
|
|
351
353
|
}
|
|
352
354
|
if (owner) {
|
|
353
|
-
throw new Error(owner
|
|
354
|
-
? "rush-issues owns the task plan. Send workflow (bound with sync_workflow_plan) instead of plan."
|
|
355
|
-
: `${owner} owns the task plan. Update its workflow plan instead of update_plan.`);
|
|
355
|
+
throw new Error(`${owner} owns the task plan. Send workflow (bound with sync_workflow_plan) instead of plan.`);
|
|
356
356
|
}
|
|
357
357
|
if (input.plan === undefined) throw new Error("plan is required: send the full list of steps.");
|
|
358
358
|
const plan = replacePlan(currentPlan, input.plan, input.explanation);
|
|
@@ -387,10 +387,10 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
387
387
|
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
388
388
|
const owner = workflowPlanOwner(ctx);
|
|
389
389
|
if (owner) {
|
|
390
|
-
const workflowPlan =
|
|
390
|
+
const workflowPlan = loadRushPlan(ctx);
|
|
391
391
|
return {
|
|
392
392
|
content: [{ type: "text", text: workflowPlan
|
|
393
|
-
? renderRushPlan(workflowPlan, 160, true).join("\n")
|
|
393
|
+
? renderRushPlan(workflowPlan, 160, true, undefined, owner).join("\n")
|
|
394
394
|
: `${owner} owns the task plan; ${rushError ?? "consult its persisted workflow state"}.` }],
|
|
395
395
|
details: { hasPlan: !!workflowPlan, plan: workflowPlan, progress: null, workflowOwner: owner },
|
|
396
396
|
};
|
|
@@ -28,7 +28,7 @@ export interface RushPlan {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
export interface WorkflowPlanBinding {
|
|
31
|
-
owner:
|
|
31
|
+
owner: string;
|
|
32
32
|
path: string;
|
|
33
33
|
runId: string;
|
|
34
34
|
}
|
|
@@ -113,9 +113,9 @@ export function workflowBinding(entries: Iterable<{ type: string; customType?: s
|
|
|
113
113
|
const data = entry.data;
|
|
114
114
|
if (!isRecord(data) || data.version !== 1) continue;
|
|
115
115
|
if (data.kind === "clear") binding = null;
|
|
116
|
-
else if (data.kind === "set" && data.owner === "
|
|
116
|
+
else if (data.kind === "set" && typeof data.owner === "string" && data.owner.length > 0 &&
|
|
117
117
|
typeof data.path === "string" && typeof data.runId === "string") {
|
|
118
|
-
binding = { owner:
|
|
118
|
+
binding = { owner: data.owner, path: data.path, runId: data.runId };
|
|
119
119
|
}
|
|
120
120
|
}
|
|
121
121
|
return binding;
|
|
@@ -133,7 +133,7 @@ function workflowStatus(status: string): DisplayStatus {
|
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
function presentRushPlan(plan: RushPlan): PlanPresentation {
|
|
136
|
+
function presentRushPlan(plan: RushPlan, owner: string): PlanPresentation {
|
|
137
137
|
// The warehouse canary only applies to data work; hide it otherwise.
|
|
138
138
|
const stages = ["explore", "implement", "review", "ci", ...(plan.warehouseCanaryRequired ? ["canary"] : [])];
|
|
139
139
|
const fleet = stages.map((stage) => {
|
|
@@ -142,7 +142,7 @@ function presentRushPlan(plan: RushPlan): PlanPresentation {
|
|
|
142
142
|
const style = statusStyle(display);
|
|
143
143
|
return { color: style.color, text: ` ${stage} ${style.glyph}${display === "unknown" ? ` ${planText(status)}` : ""}` };
|
|
144
144
|
});
|
|
145
|
-
const metadata = [[{ color: "dim", text:
|
|
145
|
+
const metadata = [[{ color: "dim", text: `${planText(owner)} · rev ${plan.planRevision} ·` }, ...fleet]];
|
|
146
146
|
if (plan.spec?.title) metadata.push([{ color: "dim", text: plan.spec.title }]);
|
|
147
147
|
return {
|
|
148
148
|
metadata,
|
|
@@ -164,16 +164,18 @@ export function renderRushPlan(
|
|
|
164
164
|
width: number,
|
|
165
165
|
full = false,
|
|
166
166
|
fg?: (color: string, value: string) => string,
|
|
167
|
+
owner = "rush-issues",
|
|
167
168
|
): string[] {
|
|
168
169
|
const theme = fg ? { fg } : plainPlanTheme;
|
|
169
|
-
return renderPlanPresentation(presentRushPlan(plan), width, theme, full);
|
|
170
|
+
return renderPlanPresentation(presentRushPlan(plan, owner), width, theme, full);
|
|
170
171
|
}
|
|
171
172
|
|
|
172
173
|
export function createRushPlanComponent(
|
|
173
174
|
plan: RushPlan,
|
|
174
175
|
onClose: () => void,
|
|
175
176
|
fg?: (color: string, value: string) => string,
|
|
177
|
+
owner = "rush-issues",
|
|
176
178
|
): Component {
|
|
177
179
|
const theme = fg ? { fg } : plainPlanTheme;
|
|
178
|
-
return createPlanPresentationComponent(presentRushPlan(plan), theme, onClose);
|
|
180
|
+
return createPlanPresentationComponent(presentRushPlan(plan, owner), theme, onClose);
|
|
179
181
|
}
|
|
@@ -11,7 +11,8 @@ pi install npm:pi-better-sandbox
|
|
|
11
11
|
Either way you keep starting Pi the way you always have — `pi`. There is
|
|
12
12
|
no launcher or wrapper command. Main starts inactive; Subagents start confined.
|
|
13
13
|
Open `/sandbox` to change either column, or use `/sandbox on` for the current
|
|
14
|
-
Main session.
|
|
14
|
+
Main session. Edits apply immediately and are saved to the current session branch.
|
|
15
|
+
**Ctrl+S** saves both profiles as defaults for new sessions.
|
|
15
16
|
|
|
16
17
|
```text
|
|
17
18
|
Sandbox permissions Main Subagents
|
|
@@ -31,8 +32,7 @@ Network access - On
|
|
|
31
32
|
> [x] @juicesharp/rpiv-web-tools 2/2
|
|
32
33
|
> [ ] <other installed package> 0/1
|
|
33
34
|
|
|
34
|
-
↑↓ Select
|
|
35
|
-
Save as defaults
|
|
35
|
+
↑↓ Select · ←→ Column/fold · Space Toggle · ctrl+s Save default · Enter Fold · Esc Back
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
**Subagents · Tools** picks the extension tools a confined subagent may use:
|
|
@@ -56,7 +56,7 @@ are; the count is selected/total. Right expands a group. Left collapses a group,
|
|
|
56
56
|
or moves from a child to its parent and collapses it. Enter folds the selected
|
|
57
57
|
group. Space on a group selects all children when none or only some are selected,
|
|
58
58
|
or deselects them all when all are selected. Every toggle applies on one Space;
|
|
59
|
-
|
|
59
|
+
Ctrl+S saves defaults and reports anything loosened. Only individual tool
|
|
60
60
|
entries are saved, never a package/provider wildcard or the open/closed state.
|
|
61
61
|
|
|
62
62
|
Defaults: `apply_patch`, `web_fetch`, and `web_search` on; everything else off.
|
|
@@ -123,7 +123,7 @@ Write, those paths are also protected from removal and renaming.
|
|
|
123
123
|
Space applies every change immediately, including higher permission levels,
|
|
124
124
|
capabilities switched on, and a sandbox switched off. `ctrl+s` saves the
|
|
125
125
|
defaults from any row on the first press, and the note names anything that
|
|
126
|
-
loosened. Enter
|
|
126
|
+
loosened. Enter only folds tool groups; it never saves defaults. The focused
|
|
127
127
|
row has a full-width background and bold text; the selected Main/Subagents cell
|
|
128
128
|
also uses inverse styling. No model
|
|
129
129
|
tool can change these settings. Other rows toggle Off/On. Detail cells under an
|
|
@@ -319,7 +319,7 @@ in your rule set but is held out in that project, with a message saying so.
|
|
|
319
319
|
Nothing is migrated on disk. A saved `pi-better-sandbox-permissions.json` keeps
|
|
320
320
|
its values: a saved `read-write` is now shown as Write & delete, and a saved
|
|
321
321
|
Subagents Outside project = Read stays Read. Only a fresh configuration, or
|
|
322
|
-
**
|
|
322
|
+
**Ctrl+S** after you choose Write, uses the new Subagents default. To
|
|
323
323
|
adopt it, open `/sandbox`, set Subagents → Outside project to Write, and save.
|
|
324
324
|
|
|
325
325
|
`~/.pi/agent/sandbox.json` (`filesystem.allowWrite`, `allowRead`,
|
|
@@ -328,12 +328,19 @@ package never reads it, and it has no effect on the harness.
|
|
|
328
328
|
|
|
329
329
|
## Lifecycle
|
|
330
330
|
|
|
331
|
-
The foreground sandbox is inactive by default.
|
|
332
|
-
|
|
331
|
+
The foreground sandbox is inactive by default. Permission edits, including
|
|
332
|
+
Sandbox switches and `/sandbox on|off`, auto-persist as Pi custom entries on the
|
|
333
|
+
current session branch, not as global defaults. Resume, fork, reload and session
|
|
334
|
+
tree navigation restore the latest policy on the active branch. A branch with
|
|
335
|
+
no permission entry and a new session inherit saved global settings. Invalid
|
|
336
|
+
persisted session policy fails closed rather than falling back to defaults.
|
|
337
|
+
Ctrl+S writes both
|
|
333
338
|
profiles to `~/.pi/agent/extensions/pi-better-sandbox-permissions.json` (or the
|
|
334
339
|
corresponding `$PI_CODING_AGENT_DIR`). Existing activation preferences in
|
|
335
340
|
`pi-better-sandbox-preferences.json` migrate when no profile file exists.
|
|
336
341
|
`/sandbox default on|off` remains available and updates Main's saved switch.
|
|
342
|
+
Failed default saves leave the current session's edits intact. Write-deny rules
|
|
343
|
+
retain their separate global persistence through `/sandbox deny` and `/sandbox rules`.
|
|
337
344
|
|
|
338
345
|
Toggles apply to operations launched after the change. A command already running
|
|
339
346
|
keeps the policy it launched with.
|
|
@@ -55,8 +55,8 @@ const DISABLE_TITLE = "Disable the foreground write sandbox?";
|
|
|
55
55
|
|
|
56
56
|
const DISABLE_MESSAGE = [
|
|
57
57
|
"The built-in bash, write, and edit tools and user-entered ! / !! commands",
|
|
58
|
-
"will run with normal host write access
|
|
59
|
-
"
|
|
58
|
+
"will run with normal host write access on this session branch, including",
|
|
59
|
+
"after resume or reload. New sessions apply your saved sandbox default.",
|
|
60
60
|
].join("\n");
|
|
61
61
|
|
|
62
62
|
const NO_UI_REJECTION =
|
|
@@ -80,6 +80,8 @@ export type SandboxCommandDeps = {
|
|
|
80
80
|
onStateChange: (status: ForegroundSandboxStatus) => void;
|
|
81
81
|
/** Persist a default and apply it to the current session. */
|
|
82
82
|
setDefault: (enabled: boolean) => ForegroundSandboxStatus;
|
|
83
|
+
/** Persist the session change before mutating enforcement. */
|
|
84
|
+
setSessionEnabled?: (enabled: boolean) => ForegroundSandboxStatus;
|
|
83
85
|
openPermissions?: (ctx: ExtensionCommandContext) => Promise<void>;
|
|
84
86
|
};
|
|
85
87
|
|
|
@@ -89,6 +91,7 @@ export function createSandboxCommandHandler({
|
|
|
89
91
|
denyRules,
|
|
90
92
|
onStateChange,
|
|
91
93
|
setDefault,
|
|
94
|
+
setSessionEnabled,
|
|
92
95
|
openPermissions,
|
|
93
96
|
}: SandboxCommandDeps) {
|
|
94
97
|
return async function handleSandboxCommand(
|
|
@@ -112,7 +115,12 @@ export function createSandboxCommandHandler({
|
|
|
112
115
|
}
|
|
113
116
|
|
|
114
117
|
if (subcommand === "on") {
|
|
115
|
-
|
|
118
|
+
let status: ForegroundSandboxStatus;
|
|
119
|
+
try { status = setSessionEnabled ? setSessionEnabled(true) : controller.enable(); }
|
|
120
|
+
catch (error) {
|
|
121
|
+
ctx.ui.notify(`Sandbox state unchanged: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
116
124
|
onStateChange(status);
|
|
117
125
|
ctx.ui.notify(
|
|
118
126
|
status.state === "enabled"
|
|
@@ -140,7 +148,12 @@ export function createSandboxCommandHandler({
|
|
|
140
148
|
ctx.ui.notify("Foreground sandbox left on.", "info");
|
|
141
149
|
return;
|
|
142
150
|
}
|
|
143
|
-
|
|
151
|
+
let status: ForegroundSandboxStatus;
|
|
152
|
+
try { status = setSessionEnabled ? setSessionEnabled(false) : controller.disable(); }
|
|
153
|
+
catch (error) {
|
|
154
|
+
ctx.ui.notify(`Sandbox state unchanged: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
144
157
|
onStateChange(status);
|
|
145
158
|
ctx.ui.notify(
|
|
146
159
|
"Foreground sandbox OFF for this session. Shell commands can now write anywhere.",
|
|
@@ -38,6 +38,7 @@ import { writeSandboxDefault } from "./preferences.ts";
|
|
|
38
38
|
import { readPermissionSettings, writePermissionSettings } from "./permission-settings.ts";
|
|
39
39
|
import { defaultSandboxPermissions, describeLoosening, type SandboxPermissionSettings } from "./permissions.ts";
|
|
40
40
|
import { openPermissionsPage } from "./permissions-page.ts";
|
|
41
|
+
import { appendSessionPermissions, readSessionPermissions } from "./session-permissions.ts";
|
|
41
42
|
|
|
42
43
|
import { footerTone, formatFooterStatus } from "./status.ts";
|
|
43
44
|
import { ForegroundSandboxController, type ForegroundSandboxStatus } from "./state.ts";
|
|
@@ -98,9 +99,7 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
|
|
|
98
99
|
// `/sandbox deny ...` and the `/sandbox rules` page.
|
|
99
100
|
const denyRules = new DenyRuleManager({ controller, onStateChange: announce });
|
|
100
101
|
|
|
101
|
-
|
|
102
|
-
shellPath = resolveShellPath(ctx.cwd);
|
|
103
|
-
boundary.register(ctx.cwd);
|
|
102
|
+
const restorePermissions = (ctx: ExtensionContext): void => {
|
|
104
103
|
paintFooter = (status) => {
|
|
105
104
|
ctx.ui.setStatus(
|
|
106
105
|
FOOTER_KEY,
|
|
@@ -108,11 +107,14 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
|
|
|
108
107
|
);
|
|
109
108
|
};
|
|
110
109
|
|
|
111
|
-
//
|
|
112
|
-
//
|
|
110
|
+
// Session policy takes precedence over global defaults. Never skip an
|
|
111
|
+
// invalid latest entry to recover an older, potentially broader policy.
|
|
113
112
|
let settings = defaultSandboxPermissions();
|
|
113
|
+
let hasSessionPolicy = false;
|
|
114
114
|
try {
|
|
115
|
-
|
|
115
|
+
const sessionPermissions = readSessionPermissions(ctx);
|
|
116
|
+
hasSessionPolicy = sessionPermissions !== undefined;
|
|
117
|
+
settings = sessionPermissions ?? readPermissionSettings();
|
|
116
118
|
} catch (error) {
|
|
117
119
|
controller.beginSession(ctx.cwd, true);
|
|
118
120
|
const message = `Sandbox permissions could not be loaded: ${error instanceof Error ? error.message : String(error)}`;
|
|
@@ -130,7 +132,7 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
|
|
|
130
132
|
.map((tool) => tool.sourceInfo?.path).filter((path): path is string => typeof path === "string" && isAbsolute(path))
|
|
131
133
|
.map(runtimeCodeRoot));
|
|
132
134
|
controller.setPermissionSettings(settings);
|
|
133
|
-
controller.applyDefault(settings.main.enabled);
|
|
135
|
+
if (!hasSessionPolicy) controller.applyDefault(settings.main.enabled);
|
|
134
136
|
|
|
135
137
|
// Then the rules are re-read and re-resolved, because the same global
|
|
136
138
|
// template set means different absolute paths in a different project.
|
|
@@ -154,7 +156,14 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
|
|
|
154
156
|
status.state === "disabled" ? "info" : "warning",
|
|
155
157
|
);
|
|
156
158
|
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
pi.on("session_start", (_event, ctx) => {
|
|
162
|
+
shellPath = resolveShellPath(ctx.cwd);
|
|
163
|
+
boundary.register(ctx.cwd);
|
|
164
|
+
restorePermissions(ctx);
|
|
157
165
|
});
|
|
166
|
+
pi.on("session_tree", (_event, ctx) => restorePermissions(ctx));
|
|
158
167
|
|
|
159
168
|
pi.on("session_shutdown", () => {
|
|
160
169
|
controller.dispose();
|
|
@@ -167,11 +176,18 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
|
|
|
167
176
|
controller,
|
|
168
177
|
denyRules,
|
|
169
178
|
onStateChange: announce,
|
|
179
|
+
setSessionEnabled: (enabled) => {
|
|
180
|
+
const settings = controller.permissionSettings() ?? defaultSandboxPermissions();
|
|
181
|
+
settings.main.enabled = enabled;
|
|
182
|
+
appendSessionPermissions(pi, settings);
|
|
183
|
+
return enabled ? controller.enable() : controller.disable();
|
|
184
|
+
},
|
|
170
185
|
setDefault: (enabled) => {
|
|
171
186
|
const settings = controller.permissionSettings() ?? defaultSandboxPermissions();
|
|
172
187
|
settings.main.enabled = enabled;
|
|
173
188
|
writePermissionSettings(settings);
|
|
174
189
|
writeSandboxDefault(enabled ? "on" : "off");
|
|
190
|
+
appendSessionPermissions(pi, settings);
|
|
175
191
|
return controller.setPermissionSettings(settings);
|
|
176
192
|
},
|
|
177
193
|
openPermissions: async (ctx) => {
|
|
@@ -179,9 +195,12 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
|
|
|
179
195
|
getConfig: () => controller.permissionSettings() ?? defaultSandboxPermissions(),
|
|
180
196
|
// Trusted-tool candidates: what this Pi has actually registered, by owning package.
|
|
181
197
|
discoverTools: () => discoverTrustedTools(pi.getAllTools?.() ?? []),
|
|
182
|
-
change: (settings) =>
|
|
198
|
+
change: (settings) => {
|
|
199
|
+
appendSessionPermissions(pi, settings);
|
|
200
|
+
announce(controller.setPermissionSettings(settings));
|
|
201
|
+
},
|
|
183
202
|
save: (settings) => writePermissionSettings(settings),
|
|
184
|
-
//
|
|
203
|
+
// Report every capability loosened by saving these defaults.
|
|
185
204
|
loosening: (settings) => {
|
|
186
205
|
let previous: SandboxPermissionSettings;
|
|
187
206
|
try { previous = readPermissionSettings(); } catch { previous = defaultSandboxPermissions(); }
|
|
@@ -124,7 +124,7 @@ export function createPermissionsPage(
|
|
|
124
124
|
const itemKey = (item: ToolItem) => item.kind === "group" ? item.id
|
|
125
125
|
: item.kind === "guarded" ? "apply_patch" : JSON.stringify([item.name, item.package]);
|
|
126
126
|
let items = toolItems();
|
|
127
|
-
const
|
|
127
|
+
const lastRow = () => rows.length + items.length - 1;
|
|
128
128
|
let row = 0;
|
|
129
129
|
let column = 0;
|
|
130
130
|
let busy = false;
|
|
@@ -136,7 +136,7 @@ export function createPermissionsPage(
|
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
async function change(): Promise<void> {
|
|
139
|
-
if (!settings || busy
|
|
139
|
+
if (!settings || busy) return;
|
|
140
140
|
const next = snapshot(settings);
|
|
141
141
|
if (row >= rows.length) {
|
|
142
142
|
const item = items[row - rows.length]!;
|
|
@@ -173,15 +173,14 @@ export function createPermissionsPage(
|
|
|
173
173
|
busy = true;
|
|
174
174
|
try {
|
|
175
175
|
await handlers.change(snapshot(next));
|
|
176
|
-
const selected = row >= rows.length
|
|
177
|
-
const wasSave = row === saveRow();
|
|
176
|
+
const selected = row >= rows.length ? items[row - rows.length] : undefined;
|
|
178
177
|
const successors = selected ? items.slice(row - rows.length + 1).map(itemKey) : [];
|
|
179
178
|
settings = next;
|
|
180
179
|
items = toolItems();
|
|
181
180
|
const index = selected ? items.findIndex((item) => itemKey(item) === itemKey(selected)) : -1;
|
|
182
181
|
const successor = successors.map((key) => items.findIndex((item) => itemKey(item) === key)).find((at) => at >= 0);
|
|
183
|
-
row =
|
|
184
|
-
: successor !== undefined ? rows.length + successor : Math.min(row,
|
|
182
|
+
row = index >= 0 ? rows.length + index
|
|
183
|
+
: successor !== undefined ? rows.length + successor : Math.min(row, lastRow());
|
|
185
184
|
message = "";
|
|
186
185
|
isError = false;
|
|
187
186
|
requestRender();
|
|
@@ -215,7 +214,7 @@ export function createPermissionsPage(
|
|
|
215
214
|
}
|
|
216
215
|
|
|
217
216
|
function fold(open?: boolean): void {
|
|
218
|
-
const item = row >= rows.length
|
|
217
|
+
const item = row >= rows.length ? items[row - rows.length] : undefined;
|
|
219
218
|
const id = item?.kind === "group" ? item.id : item?.kind === "trusted" && open === false ? item.group : undefined;
|
|
220
219
|
if (!id) return;
|
|
221
220
|
if (open ?? !expanded.has(id)) expanded.add(id);
|
|
@@ -234,22 +233,20 @@ export function createPermissionsPage(
|
|
|
234
233
|
row = Math.max(0, row - 1);
|
|
235
234
|
requestRender();
|
|
236
235
|
} else if (matchesKey(data, Key.down)) {
|
|
237
|
-
row = Math.min(
|
|
236
|
+
row = Math.min(lastRow(), row + 1);
|
|
238
237
|
requestRender();
|
|
239
238
|
} else if (matchesKey(data, Key.left)) {
|
|
240
|
-
if (row >= rows.length
|
|
239
|
+
if (row >= rows.length) fold(false);
|
|
241
240
|
else column = 0;
|
|
242
241
|
requestRender();
|
|
243
242
|
} else if (matchesKey(data, Key.right)) {
|
|
244
|
-
if (row >= rows.length
|
|
243
|
+
if (row >= rows.length) fold(true);
|
|
245
244
|
else column = 1;
|
|
246
245
|
requestRender();
|
|
247
246
|
} else if (matchesKey(data, Key.space)) {
|
|
248
247
|
void change();
|
|
249
248
|
} else if (matchesKey(data, "ctrl+s")) {
|
|
250
249
|
void save();
|
|
251
|
-
} else if (matchesKey(data, Key.enter) && row === saveRow()) {
|
|
252
|
-
void save();
|
|
253
250
|
} else if (matchesKey(data, Key.enter)) {
|
|
254
251
|
fold();
|
|
255
252
|
}
|
|
@@ -315,13 +312,13 @@ export function createPermissionsPage(
|
|
|
315
312
|
output.push(theme.fg("dim", truncateToWidth(`${prefix ? " " : ""} Trusted (runs outside the file rules)`, w, "")),
|
|
316
313
|
theme.fg("dim", truncateToWidth(`${prefix ? " " : ""} No other extension tools are installed.`, w, "")));
|
|
317
314
|
}
|
|
318
|
-
output.push(""
|
|
315
|
+
output.push("");
|
|
319
316
|
const selected = row < rows.length ? rows[row]!.key : undefined;
|
|
320
|
-
const tool = row >= rows.length
|
|
317
|
+
const tool = row >= rows.length ? items[row - rows.length] : undefined;
|
|
321
318
|
const contextual = tool ? (tool.kind === "guarded" ? GUARDED_HINT : TRUSTED_HINT)
|
|
322
319
|
: selected && settings ? cellHint(selected, settings[columns[column]!]) : undefined;
|
|
323
320
|
for (const hint of [
|
|
324
|
-
"↑↓ Select · ←→ Column/fold · Space Toggle · ctrl+s Save · Enter Fold · Esc Back",
|
|
321
|
+
"↑↓ Select · ←→ Column/fold · Space Toggle · ctrl+s Save default · Enter Fold · Esc Back",
|
|
325
322
|
"Changes apply to new launches. Background tasks follow their launcher.",
|
|
326
323
|
"Stored credentials: known files only; excludes OS vaults and environment tokens.",
|
|
327
324
|
"Trusted tools run outside the file rules.",
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { parseSandboxPermissions, type SandboxPermissionSettings } from "./permissions.ts";
|
|
3
|
+
|
|
4
|
+
export const SESSION_PERMISSION_ENTRY = "pi-better-sandbox-permissions";
|
|
5
|
+
|
|
6
|
+
/** Only the active branch contributes policy; the newest entry must validate. */
|
|
7
|
+
export function readSessionPermissions(ctx: ExtensionContext): SandboxPermissionSettings | undefined {
|
|
8
|
+
const entry = [...ctx.sessionManager.getBranch()].reverse().find((entry) =>
|
|
9
|
+
entry.type === "custom" && entry.customType === SESSION_PERMISSION_ENTRY);
|
|
10
|
+
if (!entry || entry.type !== "custom") return undefined;
|
|
11
|
+
const data = entry.data as { version?: unknown; permissions?: unknown } | undefined;
|
|
12
|
+
if (!data || data.version !== 1) throw new Error("Unsupported sandbox session permissions version.");
|
|
13
|
+
// Session snapshots have always included Tools. Unlike old global files,
|
|
14
|
+
// an incomplete snapshot must not gain the default trusted-tool grants.
|
|
15
|
+
if (!data.permissions || typeof data.permissions !== "object" ||
|
|
16
|
+
(data.permissions as { subagentTools?: unknown }).subagentTools === undefined) {
|
|
17
|
+
throw new Error("Sandbox session permissions require explicit subagent tool settings.");
|
|
18
|
+
}
|
|
19
|
+
return parseSandboxPermissions(data.permissions);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function appendSessionPermissions(pi: ExtensionAPI, settings: SandboxPermissionSettings): void {
|
|
23
|
+
pi.appendEntry(SESSION_PERMISSION_ENTRY, { version: 1, permissions: parseSandboxPermissions(settings) });
|
|
24
|
+
}
|
|
@@ -6,9 +6,8 @@
|
|
|
6
6
|
* the *effective* status derived from live runtime evidence (which backend this
|
|
7
7
|
* platform actually resolves, not what the package intended).
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* default and clears the previous override.
|
|
9
|
+
* The extension persists permission snapshots in Pi custom entries and restores
|
|
10
|
+
* the active branch after `beginSession` resets this in-memory controller.
|
|
12
11
|
*/
|
|
13
12
|
|
|
14
13
|
import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent";
|
|
@@ -132,7 +131,7 @@ export class ForegroundSandboxController {
|
|
|
132
131
|
|
|
133
132
|
/**
|
|
134
133
|
* Capture the canonical launch directory and apply the persisted default.
|
|
135
|
-
*
|
|
134
|
+
* The extension then restores any permissions on the active session branch.
|
|
136
135
|
*/
|
|
137
136
|
beginSession(cwd: string, defaultEnabled = false): ForegroundSandboxStatus {
|
|
138
137
|
const projectRoot = canonicalizePath(cwd, this.#seams);
|
|
@@ -156,7 +155,7 @@ export class ForegroundSandboxController {
|
|
|
156
155
|
return this.status();
|
|
157
156
|
}
|
|
158
157
|
|
|
159
|
-
/** Turn protection off for this session
|
|
158
|
+
/** Turn protection off for this session; persistence belongs to the extension. */
|
|
160
159
|
disable(): ForegroundSandboxStatus {
|
|
161
160
|
this.#sessionOverride = false;
|
|
162
161
|
this.#policyProblem = undefined;
|