pi-open-agents 0.1.25 → 0.1.27

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 CHANGED
@@ -93,7 +93,7 @@ here — or native in pi itself:
93
93
  |---|---|
94
94
  | `AGENTS.md` adoption — anthropics/claude-code#31005 👍336, openai/codex#12115 👍109 | ✅ Native in pi: `AGENTS.md`/`CLAUDE.md` discovery with `AGENTS.override.md` layering |
95
95
  | Agent Skills standard (`.agents/skills/`) — agentskills/agentskills#15 👍183 | ✅ Native in pi; shared agents from `.agents/` (#7 aligns to the dotagents layout) |
96
- | Tool restrictions that actually reach subagents — anthropics/claude-code#18950 👍70 | ✅ Since v0.1.14, `tools:`/`permission:` restrict the child toolset via `--tools` (full parent inheritance → #8) |
96
+ | Tool restrictions that actually reach subagents — anthropics/claude-code#18950 👍70 | ✅ Since v0.1.14 via `--tools`; full **deny/ask inheritance** (effective-set ceiling + arg patterns) since v0.1.27 |
97
97
  | Model selection per subagent / per call — sst/opencode#6651 👍71 | ✅ Per-agent `model:` + per-call `model:` override in `subagent()` (since v0.1.19) |
98
98
  | Reasoning effort per agent — Kilo-Org/kilocode#13105 👍8 | ✅ `thinking: off…xhigh` per agent, forwarded to the child process |
99
99
  | Delegation guards — Kilo-Org/kilocode#9985 | ✅ `allowedAgents` allowlist + `maxDepth` recursion limit |
@@ -167,6 +167,31 @@ active agent's rules *before it runs* — not just used to filter the toolset:
167
167
  > blocks matching *individual calls* (patterns included), not just statically
168
168
  > hiding whole tools. (#20)
169
169
 
170
+ **Inheritance down the delegation chain (#8).** A subagent can never exceed
171
+ the authority of the agent that delegated to it — in both directions of the
172
+ rule language:
173
+
174
+ - **Tool ceiling:** at delegation the parent's *live* tool set is snapshotted
175
+ (`pi.getActiveTools()`), minus tools it blanket-`deny`s or blanket-`ask`s
176
+ (`ask` is not authority: headless children have no approval bridge). The
177
+ child's own whitelist is intersected with that ceiling and passed via
178
+ `--tools` — an empty intersection becomes `--no-tools`, never "all tools".
179
+ - **Argument patterns:** the parent's `deny`/`ask` rules (patterns included)
180
+ travel to the child's gate and are evaluated *last*, so the child cannot
181
+ widen them (`write: {"**/*.env": deny}` upstream blocks `.env` writes two
182
+ levels deep, whatever the child declares).
183
+ - **Transitive:** the ceiling the child launches with *is* the child's active
184
+ set — a grandchild cannot regain any tool removed at an earlier hop.
185
+
186
+ ### Iterating on agents: `/reload`
187
+
188
+ Edit a `.agent.md` file (or add/remove one) and run pi's built-in **`/reload`**
189
+ — no session restart (#9). Agent definitions are re-discovered, the active
190
+ agent is **kept** (your mid-session switch survives; `--agent` is a
191
+ startup-only default), and a deleted/disabled active agent falls back cleanly
192
+ with a warning. New agents appear in `/agents` and the `subagent` tool list
193
+ immediately.
194
+
170
195
  ### Auto-injected delegation guidance
171
196
 
172
197
  If an agent has the `subagent` tool, the plugin automatically appends a
@@ -191,6 +216,12 @@ This means:
191
216
  - Each subagent runs with exactly the model and tools it declares
192
217
  - Skills load per-agent, with wildcard support (`security-*`, `git-*`)
193
218
 
219
+ > **`skills:` is subagent-only (#19).** pi discovers skills once at session
220
+ > startup and exposes no per-agent filter, so a **primary** cannot role-scope
221
+ > them. Declaring `skills:` on a primary (or `all`) agent warns on apply and
222
+ > the field is ignored; the subagent child receives exactly its listed skills,
223
+ > injected into its system prompt.
224
+
194
225
  **Per-call model override:** the caller can override the agent's default for a
195
226
  single call — `subagent({ agent: "deep-research", task: "...", model: "zai/glm-5.3-flash" })`.
196
227
  Handy for "run this one task on the cheap model" without forking an agent
@@ -353,8 +384,8 @@ six sessions, zero chaos: that's the exact workstation setup documented in
353
384
 
354
385
  ## Roadmap
355
386
 
356
- - **Parent permission inheritance** for subagents — #8
357
- - **Agent hot-reload** (or `/reload-agents`) — #9
387
+ - ~~**Parent permission inheritance** for subagents~~ — ✅ shipped in [0.1.27](https://github.com/andrea-tomassi/pi-open-agents/releases/tag/v0.1.27) (#8)
388
+ - ~~**Agent hot-reload**~~ — ✅ works via pi's built-in `/reload`, shipped in [0.1.27](https://github.com/andrea-tomassi/pi-open-agents/releases/tag/v0.1.27) (#9)
358
389
  - ~~**Per-call model override** in `subagent()`~~ — ✅ shipped in [0.1.19](https://github.com/andrea-tomassi/pi-open-agents/releases/tag/v0.1.19) (#10)
359
390
  - **Parallel subagent limit** — #11
360
391
 
@@ -367,7 +398,7 @@ opening an issue or pull request.
367
398
 
368
399
  ```bash
369
400
  npm install
370
- npm test # 117 tests
401
+ npm test # 178 tests
371
402
  npm run typecheck # tsc --noEmit
372
403
  ```
373
404
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-open-agents",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "description": "Unified agent and subagent management for pi coding agent, with OpenCode-compatible agent definitions.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/index.ts CHANGED
@@ -26,7 +26,7 @@ import { setShowAgentDescription, updateBanner } from "./tui/banner.ts";
26
26
  import { registerTuiControls } from "./tui/selector.ts";
27
27
  import { registerAgentTools } from "./tui/tools.ts";
28
28
  import { registerSubagentTool } from "./subagent/tool.ts";
29
- import { allowedAgentNames, isSubagentProcess } from "./subagent/env.ts";
29
+ import { allowedAgentNames, isSubagentProcess, parentInheritedRules } from "./subagent/env.ts";
30
30
  import { registerPermissionGate } from "./permission/gate.ts";
31
31
 
32
32
  // ─── Settings ────────────────────────────────────────────────────────────────
@@ -111,6 +111,8 @@ export default function piOpenAgents(pi: ExtensionAPI): void {
111
111
  if (childName) return manager.getAgent(childName);
112
112
  return manager.getActive();
113
113
  },
114
+ // #8: restrictive rules handed down by the delegating parent (env JSON).
115
+ inheritedRules: () => parentInheritedRules(process.env),
114
116
  });
115
117
 
116
118
  // Subagent child processes are fully configured by the executor via CLI flags
@@ -131,9 +133,11 @@ export default function piOpenAgents(pi: ExtensionAPI): void {
131
133
  return;
132
134
  }
133
135
 
134
- // Check for --agent CLI flag first (highest priority)
136
+ // Check for --agent CLI flag first (highest priority at startup).
137
+ // Skipped on reload: the flag is a startup default — a mid-session agent
138
+ // switch must survive /reload, so the session entry below wins instead.
135
139
  const agentFlag = pi.getFlag("agent");
136
- if (typeof agentFlag === "string" && agentFlag) {
140
+ if (typeof agentFlag === "string" && agentFlag && event.reason !== "reload") {
137
141
  const agent = manager.getAgent(agentFlag);
138
142
  if (agent) {
139
143
  await manager.apply(agentFlag, pi, ctx);
@@ -142,8 +146,9 @@ export default function piOpenAgents(pi: ExtensionAPI): void {
142
146
  return;
143
147
  }
144
148
 
145
- // Try to restore from session state (if resuming)
146
- if (event.reason === "resume" || event.reason === "fork") {
149
+ // Restore from session state (resume, fork, or /reload — all must keep
150
+ // the agent the session was actually using, not re-apply startup defaults)
151
+ if (event.reason === "resume" || event.reason === "fork" || event.reason === "reload") {
147
152
  const entries = ctx.sessionManager.getEntries();
148
153
  const agentEntry = entries
149
154
  .filter((e: { type: string; customType?: string }) =>
@@ -140,22 +140,32 @@ export interface PermissionGateOptions {
140
140
  resolveAgent: () => AgentDefinition | undefined;
141
141
  /** Session-scoped "always allow" memo (per process). Injectable for tests. */
142
142
  alwaysAllow?: Map<string, boolean>;
143
+ /**
144
+ * Rules inherited from the delegating parent (#8). Appended AFTER the
145
+ * agent's own rules — last-match-wins, so an inherited deny/ask always
146
+ * beats the child's own allow for the same call (authority ceiling).
147
+ */
148
+ inheritedRules?: () => PermissionRule[];
143
149
  }
144
150
 
145
151
  /**
146
152
  * Register the permission gate on the `tool_call` event.
147
153
  *
148
- * Does nothing for agents without permission rules.
154
+ * Does nothing when neither the agent nor the chain contributes rules.
149
155
  */
150
156
  export function registerPermissionGate(pi: ExtensionAPI, options: PermissionGateOptions): void {
151
157
  const alwaysAllow = options.alwaysAllow ?? new Map<string, boolean>();
152
158
 
153
159
  pi.on("tool_call", async (event, ctx: ExtensionContext) => {
154
160
  const agent = options.resolveAgent();
155
- if (!agent?.permission) return undefined;
161
+ const ownRules = agent?.permission ? parsePermissionRules(agent.permission) : [];
162
+ const inherited = options.inheritedRules?.() ?? [];
163
+ if (ownRules.length === 0 && inherited.length === 0) return undefined;
156
164
 
157
165
  const input = (event as { input?: Record<string, unknown> }).input ?? {};
158
- const rules = parsePermissionRules(agent.permission);
166
+ // Parent rules last: last-match-wins makes an inherited deny/ask beat
167
+ // the child's own allow — the authority ceiling cannot be widened (#8).
168
+ const rules = [...ownRules, ...inherited];
159
169
  const decision = decideToolCall(event.toolName, input, rules);
160
170
 
161
171
  if (!decision.gated || decision.action === "allow") return undefined;
@@ -5,11 +5,28 @@
5
5
  * and coordinates with the executor for model/thinking/tools/prompt.
6
6
  */
7
7
 
8
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
+ import type { ExtensionAPI, ExtensionContext, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
9
9
  import type { Model } from "@earendil-works/pi-ai";
10
10
  import type { AgentDefinition, ThinkingLevel } from "../types.ts";
11
11
  import { applyAgentConfig } from "./executor.ts";
12
12
 
13
+ /**
14
+ * Warn when an agent that will act as PRIMARY declares `skills:`.
15
+ *
16
+ * `skills:` is honored only for subagent children (the executor injects them
17
+ * into the child's system prompt). pi loads session skills at startup and
18
+ * exposes no per-agent filter, so a primary cannot role-scope them — warn
19
+ * instead of silently ignoring (#19).
20
+ */
21
+ export function warnAgentSkillsPrimary(agent: AgentDefinition | undefined, ui: ExtensionUIContext): void {
22
+ if (agent?.skills && agent.skills.length > 0 && agent.mode !== "subagent") {
23
+ ui.notify(
24
+ `Agent "${agent.name}": skills: (${agent.skills.join(", ")}) applies to subagent delegation only — primaries use pi's session-wide skill set (#19)`,
25
+ "warning",
26
+ );
27
+ }
28
+ }
29
+
13
30
  interface OriginalState {
14
31
  model: Model<any> | undefined;
15
32
  thinkingLevel: ThinkingLevel;
@@ -56,6 +73,8 @@ export class AgentManager {
56
73
  const agent = this.agents.get(name);
57
74
  if (!agent) return false;
58
75
 
76
+ warnAgentSkillsPrimary(agent, ctx.ui);
77
+
59
78
  // Snapshot state on first apply (for restore on clear)
60
79
  if (this.activeName === undefined) {
61
80
  this.originalState = {
@@ -19,6 +19,7 @@ export type RecursionEnv = Partial<
19
19
  | "PI_OPEN_AGENTS_SYSTEM_PROMPT_MODE"
20
20
  | "PI_OPEN_AGENTS_ALLOWED"
21
21
  | "PI_OPEN_AGENTS_SESSION"
22
+ | "PI_OPEN_AGENTS_PARENT_RULES"
22
23
  | "PI_OPEN_AGENTS_DEBUG",
23
24
  string
24
25
  >
@@ -65,6 +66,35 @@ export function isSubagentProcess(env: RecursionEnv): boolean {
65
66
  return depth !== undefined && depth > 0;
66
67
  }
67
68
 
69
+ /**
70
+ * Get the parent's inherited deny/ask rules from env (issue #8).
71
+ *
72
+ * The delegating parent serializes the restrictive subset of its permission
73
+ * rules (deny/ask, pattern-level included) as JSON. The child's permission
74
+ * gate appends them after its own rules (last-match-wins → parent wins).
75
+ * Malformed or absent → no inheritance (empty list).
76
+ */
77
+ export function parentInheritedRules(
78
+ env: RecursionEnv,
79
+ ): Array<{ permission: string; pattern: string; action: "deny" | "ask" }> {
80
+ const raw = env?.PI_OPEN_AGENTS_PARENT_RULES;
81
+ if (!raw) return [];
82
+ try {
83
+ const parsed: unknown = JSON.parse(raw);
84
+ if (!Array.isArray(parsed)) return [];
85
+ return parsed.filter(
86
+ (r): r is { permission: string; pattern: string; action: "deny" | "ask" } =>
87
+ typeof r === "object" &&
88
+ r !== null &&
89
+ typeof (r as any).permission === "string" &&
90
+ typeof (r as any).pattern === "string" &&
91
+ ((r as any).action === "deny" || (r as any).action === "ask"),
92
+ );
93
+ } catch {
94
+ return [];
95
+ }
96
+ }
97
+
68
98
  /**
69
99
  * Get the system prompt mode from env.
70
100
  */
@@ -91,6 +121,7 @@ export interface SubagentEnvVars {
91
121
  systemPromptMode: SystemPromptMode;
92
122
  session: "none" | "fork";
93
123
  allowedAgents?: string[];
124
+ parentRules?: string;
94
125
  debug: boolean;
95
126
  }
96
127
 
@@ -111,5 +142,9 @@ export function buildSubagentEnv(vars: SubagentEnvVars): Record<string, string>
111
142
  env.PI_OPEN_AGENTS_ALLOWED = vars.allowedAgents.join(",");
112
143
  }
113
144
 
145
+ if (vars.parentRules) {
146
+ env.PI_OPEN_AGENTS_PARENT_RULES = vars.parentRules;
147
+ }
148
+
114
149
  return env;
115
150
  }
@@ -116,6 +116,17 @@ export interface RunSubagentOptions {
116
116
  session?: SubagentSessionInfo;
117
117
  /** Per-call model override ("provider/model-id"). Takes precedence over agent.model. */
118
118
  modelOverride?: string;
119
+ /**
120
+ * Parent's effective tool authority at delegation time (issue #8).
121
+ * The child's representable toolset is intersected with it; the result
122
+ * travels to the grandchild through pi's own active set (CLI ceiling),
123
+ * so authority can only shrink along the delegation chain.
124
+ * Undefined = no ceiling (unrestricted parent or host without
125
+ * getActiveTools). Empty array = hard zero-tool ceiling.
126
+ */
127
+ parentCeiling?: string[];
128
+ /** Parent's restrictive (deny/ask) permission rules, inherited into the child's gate via env. */
129
+ parentRules?: Array<{ permission: string; pattern: string; action: "deny" | "ask" }>;
119
130
  resolvePi?: () => Promise<PiResolution> | PiResolution;
120
131
  runner?: ProcessRunner;
121
132
  fs?: ExecutorFs;
@@ -185,6 +196,26 @@ function emptyUsage(): AgentUsage {
185
196
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0 };
186
197
  }
187
198
 
199
+ /**
200
+ * Compose the child's `--tools` value under the parent ceiling (#8).
201
+ *
202
+ * - No ceiling → child's own whitelist as-is (undefined when unrestricted).
203
+ * - Ceiling + own whitelist → own ∩ ceiling, preserving own's order.
204
+ * - Ceiling only (child unrestricted) → the ceiling itself.
205
+ * - Empty result → "none": the caller must pass `--no-tools` — omitting
206
+ * `--tools` would mean ALL tools and fail open.
207
+ */
208
+ export function composeChildTools(
209
+ own: string[] | undefined,
210
+ ceiling: string[] | undefined,
211
+ ): string[] | "none" | undefined {
212
+ if (ceiling === undefined) return own && own.length > 0 ? own : undefined;
213
+ const base = own ?? ceiling;
214
+ const allowed = new Set(ceiling);
215
+ const result = base.filter((t) => allowed.has(t));
216
+ return result.length > 0 ? result : "none";
217
+ }
218
+
188
219
  export function subagentSessionDir(
189
220
  cwd: string,
190
221
  agentDir = path.join(os.homedir(), ".pi", "agent"),
@@ -533,10 +564,12 @@ export async function runSubagent(options: RunSubagentOptions): Promise<AgentRes
533
564
  // Thinking level
534
565
  args.push("--thinking", options.agent.thinking);
535
566
 
536
- // Tools whitelist
537
- const toolsWhitelist = deriveToolsWhitelist(options.agent);
538
- if (toolsWhitelist && toolsWhitelist.length > 0) {
539
- args.push("--tools", toolsWhitelist.join(","));
567
+ // Tools whitelist, intersected with the parent's ceiling (#8)
568
+ const childTools = composeChildTools(deriveToolsWhitelist(options.agent), options.parentCeiling);
569
+ if (childTools === "none") {
570
+ args.push("--no-tools");
571
+ } else if (childTools) {
572
+ args.push("--tools", childTools.join(","));
540
573
  }
541
574
 
542
575
  // System prompt file (append or replace)
@@ -564,6 +597,9 @@ export async function runSubagent(options: RunSubagentOptions): Promise<AgentRes
564
597
  systemPromptMode: options.agent.systemPrompt,
565
598
  session: options.session?.effective ?? "none",
566
599
  allowedAgents: availableSubagentsForAgent(options.agent, options.availableAgents),
600
+ parentRules: options.parentRules && options.parentRules.length > 0
601
+ ? JSON.stringify(options.parentRules)
602
+ : undefined,
567
603
  debug: false,
568
604
  };
569
605
  const childEnv: NodeJS.ProcessEnv = {
@@ -21,8 +21,10 @@ import { getMarkdownTheme, keyHint } from "@earendil-works/pi-coding-agent";
21
21
  import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
22
22
  import { Type } from "typebox";
23
23
 
24
- import type { AgentDefinition } from "../types.ts";
24
+ import type { AgentDefinition, PermissionRule } from "../types.ts";
25
25
  import { spawnableAgents } from "../discovery/loader.ts";
26
+ import { matchPermissionKey, toolToPermission } from "../permission/evaluator.ts";
27
+ import { parsePermissionRules } from "../permission/parser.ts";
26
28
  import {
27
29
  availableSubagentsForAgent,
28
30
  resolveSubagentSession,
@@ -37,7 +39,12 @@ import {
37
39
  formatResultLines,
38
40
  formatUsage,
39
41
  } from "./render.ts";
40
- import { allowedAgentNames, isPastMaxDepth, type RecursionEnv } from "./env.ts";
42
+ import {
43
+ allowedAgentNames,
44
+ isPastMaxDepth,
45
+ parentInheritedRules,
46
+ type RecursionEnv,
47
+ } from "./env.ts";
41
48
  import { preview, shortenPath, stringArg } from "../utils/format.ts";
42
49
 
43
50
  // ─── Types ───────────────────────────────────────────────────────────────────
@@ -78,6 +85,66 @@ function isValidModelSpec(spec: string): boolean {
78
85
  return slash > 0 && slash < spec.length - 1 && !/\s/.test(spec);
79
86
  }
80
87
 
88
+ // ─── Permission Inheritance (#8) ─────────────────────────────────────────────
89
+
90
+ type InheritedRule = { permission: string; pattern: string; action: "deny" | "ask" };
91
+
92
+ function isRestrictive(
93
+ rule: PermissionRule,
94
+ ): rule is PermissionRule & { action: "deny" | "ask" } {
95
+ return rule.action === "deny" || rule.action === "ask";
96
+ }
97
+
98
+ /**
99
+ * Compute the parent's effective tool ceiling at delegation time (#8).
100
+ *
101
+ * Base = pi's live active set (the authority snapshot the child cannot
102
+ * exceed — and, in a child process, what the child's own delegates will
103
+ * see: the ceiling composes transitively down the chain). Tools the
104
+ * parent's own rules deny or ask over are REMOVED from the ceiling: `ask`
105
+ * is not authority — a headless child has no approval bridge, and an
106
+ * ask-only tool must not become a silently usable one down the chain.
107
+ *
108
+ * Returns undefined when the host exposes no active-set API (then no
109
+ * ceiling is applied and behavior matches pre-#8).
110
+ */
111
+ export function computeDelegationCeiling(
112
+ activeTools: string[] | undefined,
113
+ rules: PermissionRule[],
114
+ ): string[] | undefined {
115
+ if (activeTools === undefined) return undefined;
116
+ const blanket = rules.filter(
117
+ (r) => r.pattern === "*" && (r.action === "deny" || r.action === "ask"),
118
+ );
119
+ return activeTools.filter((toolName) => {
120
+ const category = toolToPermission(toolName);
121
+ return !blanket.some((r) => matchPermissionKey(r.permission, category));
122
+ });
123
+ }
124
+
125
+ /**
126
+ * The restrictive rule set to hand to the child's gate (#8): the parent's
127
+ * own deny/ask rules PLUS rules inherited further up the chain (so
128
+ * patterns survive every hop). Deduped; later (own) entries win on overlap
129
+ * via last-match-wins — both are restrictive, so ordering only affects
130
+ * identical-pattern conflicts.
131
+ */
132
+ export function composeInheritedRules(
133
+ own: PermissionRule[],
134
+ fromEnv: InheritedRule[],
135
+ ): InheritedRule[] {
136
+ const restrictiveOwn: InheritedRule[] = own.filter(isRestrictive);
137
+ const seen = new Set<string>();
138
+ const out: InheritedRule[] = [];
139
+ for (const rule of [...fromEnv, ...restrictiveOwn]) {
140
+ const key = `${rule.permission}\u0000${rule.pattern}\u0000${rule.action}`;
141
+ if (seen.has(key)) continue;
142
+ seen.add(key);
143
+ out.push(rule);
144
+ }
145
+ return out;
146
+ }
147
+
81
148
  function toToolResult(result: AgentResult) {
82
149
  return {
83
150
  content: [{ type: "text" as const, text: result.output || "(no output)" }],
@@ -270,6 +337,15 @@ export function registerSubagentTool(
270
337
  options.agentDir,
271
338
  );
272
339
 
340
+ // Permission inheritance (#8): snapshot the parent's live authority,
341
+ // strip blanket deny/ask tools, and carry restrictive patterns (own +
342
+ // inherited) into the child's gate via env.
343
+ const getActive = (pi as { getActiveTools?: () => string[] }).getActiveTools;
344
+ const activeTools = typeof getActive === "function" ? getActive.call(pi) : undefined;
345
+ const ownRules = activeAgent?.permission ? parsePermissionRules(activeAgent.permission) : [];
346
+ const parentCeiling = computeDelegationCeiling(activeTools, ownRules.filter(isRestrictive));
347
+ const parentRules = composeInheritedRules(ownRules, parentInheritedRules(env));
348
+
273
349
  const result = await runner({
274
350
  agent,
275
351
  task: params.task,
@@ -280,6 +356,8 @@ export function registerSubagentTool(
280
356
  agentDir: options.agentDir,
281
357
  session,
282
358
  modelOverride: params.model,
359
+ parentCeiling,
360
+ parentRules: parentRules.length > 0 ? parentRules : undefined,
283
361
  onProgress: (progress) => onUpdate?.(toProgressResult(progress)),
284
362
  });
285
363