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.
Files changed (26) hide show
  1. package/node_modules/pi-better-plan/README.md +4 -2
  2. package/node_modules/pi-better-plan/package.json +1 -1
  3. package/node_modules/pi-better-plan/src/index.ts +24 -24
  4. package/node_modules/pi-better-plan/src/workflow-plan.ts +9 -7
  5. package/node_modules/pi-better-sandbox/README.md +15 -8
  6. package/node_modules/pi-better-sandbox/commands.ts +17 -4
  7. package/node_modules/pi-better-sandbox/index.ts +28 -9
  8. package/node_modules/pi-better-sandbox/package.json +1 -1
  9. package/node_modules/pi-better-sandbox/permissions-page.ts +12 -15
  10. package/node_modules/pi-better-sandbox/session-permissions.ts +24 -0
  11. package/node_modules/pi-better-sandbox/state.ts +4 -5
  12. package/node_modules/pi-better-subagents/README.md +2 -1
  13. package/node_modules/pi-better-subagents/agent-commands.ts +35 -7
  14. package/node_modules/pi-better-subagents/agent-session-settings.ts +89 -0
  15. package/node_modules/pi-better-subagents/agents-catalog-tool.ts +6 -2
  16. package/node_modules/pi-better-subagents/catalog-resolver.ts +10 -6
  17. package/node_modules/pi-better-subagents/catalog-runtime.ts +8 -2
  18. package/node_modules/pi-better-subagents/catalog-store.ts +3 -0
  19. package/node_modules/pi-better-subagents/catalog-ui.ts +115 -9
  20. package/node_modules/pi-better-subagents/docs/agent-catalog-lifecycle.md +23 -0
  21. package/node_modules/pi-better-subagents/docs/agent-catalog-operations.md +11 -1
  22. package/node_modules/pi-better-subagents/docs/usage.md +4 -2
  23. package/node_modules/pi-better-subagents/index.ts +18 -0
  24. package/node_modules/pi-better-subagents/package.json +1 -1
  25. package/node_modules/pi-better-subagents/settings-page.ts +34 -22
  26. 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`, 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:
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 Rush 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`. Other workflow-owned skills retain their own planning and have no generic plan view.
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
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-plan",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "Structured execution plans with persistent progress for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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) !== "rush-issues") return null;
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
- rushBinding = displayedWorkflowOwner === "rush-issues" ? workflowBinding(ctx.sessionManager.getBranch()) : null;
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 (displayedWorkflowOwner !== "rush-issues") return [];
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 = owner === "rush-issues" ? loadRushPlan(ctx) : null;
241
+ const plan = loadRushPlan(ctx);
241
242
  if (!plan) {
242
- ctx.ui.notify(owner === "rush-issues" ? `Rush plan unavailable: ${rushError ?? "not bound"}` : `${owner} owns the task plan.`, "warning");
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 the persisted rush-issues task plan at its exact checkpoint revision. Record later transitions with update_plan's workflow field.",
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
- if (workflowPlanOwner(ctx) !== "rush-issues") throw new Error("Only an active rush-issues workflow can sync its plan.");
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: "rush-issues", path: params.path, runId: plan.runId };
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 rush-issues rev ${plan.planRevision}: ${plan.issues.length} units.` }], details: { runId: plan.runId, revision: plan.planRevision } };
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 rush-issues owns a bound plan, send workflow instead of plan to save one task-plan transition.",
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 rush-issues 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.",
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("No rush-issues plan is bound; call sync_workflow_plan with the task-plan.json path first.");
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 rush-issues 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}` }],
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 === "rush-issues"
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 = owner === "rush-issues" ? loadRushPlan(ctx) : null;
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: "rush-issues";
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 === "rush-issues" &&
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: "rush-issues", path: data.path, runId: data.runId };
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: `rush-issues · rev ${plan.planRevision} ·` }, ...fleet]];
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. **Save as defaults** persists both profiles.
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 row ←→ Select column / expand / collapse Enter Fold Space Change
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
- saving looser defaults still needs a second Enter. Only individual tool
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 on the Save row still saves. The focused
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
- **Save as defaults** after you choose Write, uses the new Subagents default. To
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. Session overrides do not survive
332
- startup, new session, resume, fork, or reload. Save as defaults writes both
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 for the rest of this session. The",
59
- "next session applies your persisted foreground sandbox default.",
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
- const status = controller.enable();
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
- const status = controller.disable();
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
- pi.on("session_start", (_event, ctx: ExtensionContext) => {
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
- // Re-read saved profiles on each session. Malformed policy blocks protected
112
- // operations rather than silently widening a restricted session.
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
- settings = readPermissionSettings();
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) => announce(controller.setPermissionSettings(settings)),
198
+ change: (settings) => {
199
+ appendSessionPermissions(pi, settings);
200
+ announce(controller.setPermissionSettings(settings));
201
+ },
183
202
  save: (settings) => writePermissionSettings(settings),
184
- // Persisting a looser default needs a second, explicit Enter.
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(); }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-sandbox",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "Pi extension with independent Main and Subagents permission profiles, file guards, and OS sandbox enforcement.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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 saveRow = () => rows.length + items.length;
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 || row === saveRow()) return;
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 && row < saveRow() ? items[row - rows.length] : undefined;
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 = wasSave ? saveRow() : index >= 0 ? rows.length + index
184
- : successor !== undefined ? rows.length + successor : Math.min(row, saveRow());
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 && row < saveRow() ? items[row - rows.length] : undefined;
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(saveRow(), row + 1);
236
+ row = Math.min(lastRow(), row + 1);
238
237
  requestRender();
239
238
  } else if (matchesKey(data, Key.left)) {
240
- if (row >= rows.length && row < saveRow()) fold(false);
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 && row < saveRow()) fold(true);
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("", line("Save as defaults", "", "", row === saveRow()));
315
+ output.push("");
319
316
  const selected = row < rows.length ? rows[row]!.key : undefined;
320
- const tool = row >= rows.length && row < saveRow() ? items[row - rows.length] : undefined;
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
- * Session overrides are deliberately in-memory only. Every session start —
10
- * startup, new, resume, fork, reload — calls `beginSession` with the persisted
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
- * A session override never survives a new, resumed, forked, or reloaded session.
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 only. Never persisted. */
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;