pi-open-agents 0.1.26 → 0.1.28
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 +29 -4
- package/package.json +1 -1
- package/src/index.ts +12 -7
- package/src/permission/gate.ts +13 -3
- package/src/subagent/env.ts +35 -0
- package/src/subagent/executor.ts +140 -8
- package/src/subagent/tool.ts +80 -2
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
|
|
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
|
|
@@ -359,8 +384,8 @@ six sessions, zero chaos: that's the exact workstation setup documented in
|
|
|
359
384
|
|
|
360
385
|
## Roadmap
|
|
361
386
|
|
|
362
|
-
-
|
|
363
|
-
-
|
|
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)
|
|
364
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)
|
|
365
390
|
- **Parallel subagent limit** — #11
|
|
366
391
|
|
|
@@ -373,7 +398,7 @@ opening an issue or pull request.
|
|
|
373
398
|
|
|
374
399
|
```bash
|
|
375
400
|
npm install
|
|
376
|
-
npm test #
|
|
401
|
+
npm test # 191 tests
|
|
377
402
|
npm run typecheck # tsc --noEmit
|
|
378
403
|
```
|
|
379
404
|
|
package/package.json
CHANGED
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
|
-
//
|
|
146
|
-
|
|
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 }) =>
|
|
@@ -256,8 +261,8 @@ export { matchPattern, isWildcardPattern } from "./permission/matcher.ts";
|
|
|
256
261
|
export { evaluate, toolToPermission, getDisabledTools, filterDisabledTools } from "./permission/evaluator.ts";
|
|
257
262
|
|
|
258
263
|
// Subagent re-exports
|
|
259
|
-
export { runSubagent, resolveSubagentSession, resolvePiEntryPoint } from "./subagent/executor.ts";
|
|
260
|
-
export type { AgentProgress, AgentResult, RunSubagentOptions, SubagentSessionInfo, SubagentSessionMode } from "./subagent/executor.ts";
|
|
264
|
+
export { runSubagent, resolveSubagentSession, resolvePiEntryPoint, isVirtualBunPath, resolveCompiledCommand } from "./subagent/executor.ts";
|
|
265
|
+
export type { AgentProgress, AgentResult, RunSubagentOptions, SubagentSessionInfo, SubagentSessionMode, HostProcessProbe } from "./subagent/executor.ts";
|
|
261
266
|
export { resolveSkills } from "./subagent/skills.ts";
|
|
262
267
|
export type { ResolvedSkill } from "./subagent/skills.ts";
|
|
263
268
|
export { buildSubagentPrompt } from "./subagent/prompt.ts";
|
package/src/permission/gate.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/src/subagent/env.ts
CHANGED
|
@@ -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
|
}
|
package/src/subagent/executor.ts
CHANGED
|
@@ -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"),
|
|
@@ -196,9 +227,95 @@ export function subagentSessionDir(
|
|
|
196
227
|
return path.join(agentDir, "sessions", safeProject, "subagents");
|
|
197
228
|
}
|
|
198
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Detect Bun's in-memory virtual paths (issue #28).
|
|
232
|
+
*
|
|
233
|
+
* Inside a `bun build --compile` single-file binary, `process.argv[1]` (and on
|
|
234
|
+
* some platforms `process.execPath`) resolves to a compile-time virtual entry
|
|
235
|
+
* such as `/$bunfs/root/pi`. That path does not exist on disk — any
|
|
236
|
+
* `realpathSync`/`lstat` on it throws ENOENT. A real on-disk executable path
|
|
237
|
+
* never contains these markers, so the check has no false positives.
|
|
238
|
+
*/
|
|
239
|
+
export function isVirtualBunPath(candidate: string | undefined | null): boolean {
|
|
240
|
+
if (!candidate) return false;
|
|
241
|
+
return (
|
|
242
|
+
candidate.includes("$bunfs") || candidate.includes("~BUN") || candidate.includes("%7EBUN")
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Injectable host-process values for {@link resolvePiEntryPoint}.
|
|
248
|
+
*
|
|
249
|
+
* The production default reads `process.execPath` / `process.argv` /
|
|
250
|
+
* `/proc/self/exe` directly. Tests inject exact strings (including the
|
|
251
|
+
* virtual `/$bunfs/...` paths a compiled host reports) without spawning
|
|
252
|
+
* a real compiled binary.
|
|
253
|
+
*/
|
|
254
|
+
export interface HostProcessProbe {
|
|
255
|
+
execPath?: string;
|
|
256
|
+
argv0?: string;
|
|
257
|
+
argv1?: string;
|
|
258
|
+
readProcSelfExe?: () => string;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Pick the real executable for a compiled single-file host (issue #28).
|
|
263
|
+
*
|
|
264
|
+
* Priority order (most reliable first):
|
|
265
|
+
* 1. `/proc/self/exe` (kernel truth on Linux),
|
|
266
|
+
* 2. `process.execPath` (real in every observed compiled binary),
|
|
267
|
+
* 3. `process.argv[0]` — ONLY when absolute. A compiled Bun binary reports
|
|
268
|
+
* the bare word `"bun"` here, which must never become the spawn command
|
|
269
|
+
* (it would resolve via PATH to the Bun runtime instead of pi).
|
|
270
|
+
*
|
|
271
|
+
* Returns `undefined` when every candidate is missing or virtual — the caller
|
|
272
|
+
* turns that into a descriptive error instead of spawning a `$bunfs` path.
|
|
273
|
+
*/
|
|
274
|
+
export function resolveCompiledCommand(probe: {
|
|
275
|
+
execPath?: string;
|
|
276
|
+
argv0?: string;
|
|
277
|
+
procSelfExe?: string | undefined;
|
|
278
|
+
}): string | undefined {
|
|
279
|
+
for (const candidate of [probe.procSelfExe, probe.execPath]) {
|
|
280
|
+
if (candidate && !isVirtualBunPath(candidate)) return candidate;
|
|
281
|
+
}
|
|
282
|
+
const argv0 = probe.argv0;
|
|
283
|
+
if (argv0 && path.isAbsolute(argv0) && !isVirtualBunPath(argv0)) return argv0;
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
|
|
199
287
|
export function resolvePiEntryPoint(
|
|
200
288
|
resolveSelf: (specifier: string) => string = (specifier) => import.meta.resolve(specifier),
|
|
289
|
+
hostProbe?: HostProcessProbe,
|
|
201
290
|
): PiResolution {
|
|
291
|
+
const execPath = hostProbe?.execPath ?? process.execPath;
|
|
292
|
+
const argv0 = hostProbe?.argv0 ?? process.argv[0];
|
|
293
|
+
const argv1 = hostProbe?.argv1 ?? process.argv[1];
|
|
294
|
+
|
|
295
|
+
// Compiled single-file host (`bun build --compile`): the entry "file" is a
|
|
296
|
+
// virtual `$bunfs` path that does not exist on disk. The binary IS the CLI,
|
|
297
|
+
// so resolve the real executable and return an empty entry point — the args
|
|
298
|
+
// builder omits it and the child receives only real CLI flags.
|
|
299
|
+
if (isVirtualBunPath(execPath) || isVirtualBunPath(argv1)) {
|
|
300
|
+
let procSelfExe: string | undefined;
|
|
301
|
+
try {
|
|
302
|
+
procSelfExe = hostProbe?.readProcSelfExe
|
|
303
|
+
? hostProbe.readProcSelfExe()
|
|
304
|
+
: realpathSync("/proc/self/exe");
|
|
305
|
+
} catch {
|
|
306
|
+
procSelfExe = undefined;
|
|
307
|
+
}
|
|
308
|
+
const command = resolveCompiledCommand({ execPath, argv0, procSelfExe });
|
|
309
|
+
if (!command) {
|
|
310
|
+
throw new Error(
|
|
311
|
+
"pi-open-agents: running inside a compiled single-file pi binary but the real " +
|
|
312
|
+
"executable path could not be determined (only virtual paths available). " +
|
|
313
|
+
"Subagent delegation is unavailable in this environment.",
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
return { command, entryPoint: "" };
|
|
317
|
+
}
|
|
318
|
+
|
|
202
319
|
let packageEntryPoint: string;
|
|
203
320
|
try {
|
|
204
321
|
packageEntryPoint = fileURLToPath(resolveSelf("@earendil-works/pi-coding-agent"));
|
|
@@ -208,14 +325,21 @@ export function resolvePiEntryPoint(
|
|
|
208
325
|
// resolution which cannot see the host package from the plugin's install
|
|
209
326
|
// tree (peer dependencies are never installed next to it). Fall back to
|
|
210
327
|
// the running pi entry itself — the child must run the same pi anyway.
|
|
328
|
+
if (!argv1) {
|
|
329
|
+
throw new Error(
|
|
330
|
+
"pi-open-agents: cannot resolve the pi CLI entry point (host package " +
|
|
331
|
+
"self-resolution failed and process.argv[1] is missing). " +
|
|
332
|
+
"Subagent delegation is unavailable in this environment.",
|
|
333
|
+
);
|
|
334
|
+
}
|
|
211
335
|
return {
|
|
212
|
-
command:
|
|
213
|
-
entryPoint: realpathSync(
|
|
336
|
+
command: execPath,
|
|
337
|
+
entryPoint: realpathSync(argv1),
|
|
214
338
|
};
|
|
215
339
|
}
|
|
216
340
|
const packageRoot = path.dirname(path.dirname(packageEntryPoint));
|
|
217
341
|
return {
|
|
218
|
-
command:
|
|
342
|
+
command: execPath,
|
|
219
343
|
entryPoint: path.join(packageRoot, "dist", "cli.js"),
|
|
220
344
|
};
|
|
221
345
|
}
|
|
@@ -511,8 +635,11 @@ export async function runSubagent(options: RunSubagentOptions): Promise<AgentRes
|
|
|
511
635
|
const modelRegistry = buildModelRegistry(options.agentDir);
|
|
512
636
|
|
|
513
637
|
// ── Build CLI args ──
|
|
638
|
+
// A compiled single-file host IS the CLI: its PiResolution carries an
|
|
639
|
+
// empty entryPoint (issue #28) and the child receives only real CLI
|
|
640
|
+
// flags. Non-compiled hosts keep runtime + script + flags, unchanged.
|
|
514
641
|
const args = [
|
|
515
|
-
pi.entryPoint,
|
|
642
|
+
...(pi.entryPoint ? [pi.entryPoint] : []),
|
|
516
643
|
"--mode", "json",
|
|
517
644
|
"-p",
|
|
518
645
|
"--no-skills",
|
|
@@ -533,10 +660,12 @@ export async function runSubagent(options: RunSubagentOptions): Promise<AgentRes
|
|
|
533
660
|
// Thinking level
|
|
534
661
|
args.push("--thinking", options.agent.thinking);
|
|
535
662
|
|
|
536
|
-
// Tools whitelist
|
|
537
|
-
const
|
|
538
|
-
if (
|
|
539
|
-
args.push("--tools"
|
|
663
|
+
// Tools whitelist, intersected with the parent's ceiling (#8)
|
|
664
|
+
const childTools = composeChildTools(deriveToolsWhitelist(options.agent), options.parentCeiling);
|
|
665
|
+
if (childTools === "none") {
|
|
666
|
+
args.push("--no-tools");
|
|
667
|
+
} else if (childTools) {
|
|
668
|
+
args.push("--tools", childTools.join(","));
|
|
540
669
|
}
|
|
541
670
|
|
|
542
671
|
// System prompt file (append or replace)
|
|
@@ -564,6 +693,9 @@ export async function runSubagent(options: RunSubagentOptions): Promise<AgentRes
|
|
|
564
693
|
systemPromptMode: options.agent.systemPrompt,
|
|
565
694
|
session: options.session?.effective ?? "none",
|
|
566
695
|
allowedAgents: availableSubagentsForAgent(options.agent, options.availableAgents),
|
|
696
|
+
parentRules: options.parentRules && options.parentRules.length > 0
|
|
697
|
+
? JSON.stringify(options.parentRules)
|
|
698
|
+
: undefined,
|
|
567
699
|
debug: false,
|
|
568
700
|
};
|
|
569
701
|
const childEnv: NodeJS.ProcessEnv = {
|
package/src/subagent/tool.ts
CHANGED
|
@@ -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 {
|
|
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
|
|