pi-jar 0.2.7 → 0.2.8
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 +1 -1
- package/docs/INTEGRATIONS.md +1 -1
- package/docs/SUBAGENT-TOOLS.md +24 -4
- package/extensions/index.ts +4 -1
- package/package.json +1 -1
- package/src/delegate.ts +107 -26
- package/src/subagent-resources.ts +152 -0
- package/src/subagent-tools.ts +67 -1
package/README.md
CHANGED
|
@@ -309,7 +309,7 @@ Providers cache a prompt as an ordered prefix: tools, then the system prompt, th
|
|
|
309
309
|
- **`jar_shell`** — `start` (optional `name`, `watch` regex, `notify`, task/service `purpose`, `waitMs` up to 30 s), `list`, `peek`, bounded `wait`, `output`, `kill`. Use plain `bash` for finite commands expected to finish in about two minutes; `jar_shell` is for services, watchers and long jobs. `start` with `waitMs` returns the exit code and output tail in one call when the job finishes in time (acknowledged, so no later notification), otherwise the output so far; Esc cancels only the wait. `output` returns only lines new since your last read (with counts of trimmed or skipped lines); `all: true` repeats the trailing window. ANSI-stripped logs have per-job caps and a 4 MiB aggregate budget; surfaced finished jobs compact to a 32 KiB tail. Live jobs retain useful tails and are never killed to reclaim logs. Watch/exit notifications are compact and coalesced; reads acknowledge observed events. Active model responses cannot be interrupted by a completion event: the UI previews status without consuming it, and model delivery waits for a safe boundary. When no useful work remains, use bounded `wait` for the relevant finite jobs rather than waiting for an automatic wake-up. Each running shell gets a footer row and an activity view (`/jar shells`) with `x` kill and `f` follow. All shells stop when the session ends.
|
|
310
310
|
- **`jar_delegate`** — spawns retained, session-scoped subagents up to **Max subagents** in `/jar settings` (2, 4, 6, 8 or 16; default 2). Idle, paused and hibernated agents count toward this pool; over-cap batches are rejected. `/profiles` → **Create Swarm profile** copies the current settings with a pool of 4. `scout` is fresh/read-only, `fork` inherits conversation read-only, and `worktree` forks context into a path-guarded Git worktree. Settled retained agents close their Pi process after 30 seconds of quiet while keeping their private session and workspace; `resume`/`ask` relaunch that same session. Launch returns before startup or the first turn completes, including legacy one-shot `write: true` mode. Turn and stop events carry a summary of at most 400 characters plus changed files; all events pending at one boundary arrive as one `pi-jar.subagent` message, together with the moderator's unread discussion mail. `/roles` warns when the `scout` or `reviewer` role would run on the main model while a cheaper model is available.
|
|
311
311
|
- **`jar_subagent`** — `peek` reports progress, including the workspace path and changed files for recovery records; `steer` redirects a running worker; `resume` relaunches an idle/paused/hibernated agent; `report` returns an agent's latest full report (a synchronous `jar_delegate` result shares a 16k-character report budget across its runs and points here when it truncates). `ask`, `pause` and `stop` return accepted receipts immediately; answers and final handoffs arrive as `pi-jar.subagent` events. `stop` reconciles safe worktree changes; conflicts remain private and can be retried with `stop` after resolving parent drift. `discard` removes an unresolved recovery worktree without applying it. At most four active/recovery worktrees are admitted; resolve or discard one before starting another. Session shutdown reports paths of any preserved worktrees. Controls do not poll the child.
|
|
312
|
-
- **Subagent capabilities** — each task can set `tools`
|
|
312
|
+
- **Subagent capabilities** — each task can set `tools` (`name` exact list / `-name` subtract; any active tool, e.g. MCP `mom_search`; worktrees still refuse shells), `inheritTools: true`, and `skills` (same `+`/`-` syntax). `~/.pi/agent/pi-jar-subagents.json` holds user `+`/`-` rules for tools, extensions (`npm:@scope/pkg`, names, paths) and skills; user disallows always win. See [tool capabilities](docs/SUBAGENT-TOOLS.md).
|
|
313
313
|
- **Worktree finalization** — edits do not touch the parent merely because a worker settles or hibernates. On `jar_subagent stop`, pi-jar checks parent drift and reviewability, applies safe changes and adds them to `/diff`; conflicts remain isolated for retry or explicit discard. Failed worktree transcripts are compacted independently of finished history. Process closure is awaited before finalization, and failed multi-file application rolls back rather than leaving a partial result.
|
|
314
314
|
- **`jar_council`** — **exceptional only**, for a persistent super-complex issue with several evidence-backed options after at least two distinct failed approaches. The moderator opens 2–8 options, resumes relevant idle/paused/hibernated fresh-context read-only scouts or spawns new scouts within the pool, and collects one private ballot per voter. A strict majority of invited voters wins; ties, plurality or missing/invalid ballots require user choice. Voting never implements its recommendation. A round has a 10-minute deadline and bounded rationales.
|
|
315
315
|
- **`jar_discuss`** — session-local, parent-owned discussion broker over authenticated local IPC; no shared paper, lock or polling. `ask`/`answer` return stable IDs without echoing your text. `list` reads only relevant unread questions/answers and advances your cursor; `thread` (or `list` with `questionId`) reads one thread; `pending` shows counts without message bodies. `since: "d0"` rereads relevant mail. At most 128 messages, 1600 characters each and 128 KiB are retained; unread/unanswered questions block new posts instead of being silently lost. Messages do not wake idle agents. Unread mail (never your own messages) arrives once, inline, at your next natural turn start, so no `list` call is needed; the moderator's also rides along with subagent events.
|
package/docs/INTEGRATIONS.md
CHANGED
|
@@ -62,7 +62,7 @@ Hidden context messages (`pi-jar.plan-context`, `pi-jar.plan-reminder`, `pi-jar.
|
|
|
62
62
|
| `jar_goal` | `get`, `complete` (with evidence, audit phase only), `block`; active only while a goal runs |
|
|
63
63
|
| `jar_suggest` | one next-prompt suggestion shown as composer ghost text; active only when suggestions are enabled |
|
|
64
64
|
| `jar_shell` | background shells: `start` (`command`, `name`, `watch`, `notify`, `purpose`, `waitMs`), `list`, `peek`, `wait`, `output` (new lines; `all`), `kill` |
|
|
65
|
-
| `jar_delegate` | retained subagents within the configured pool (`tasks: [{task, name?, role?, mode?, tools?, inheritTools?}]`) |
|
|
65
|
+
| `jar_delegate` | retained subagents within the configured pool (`tasks: [{task, name?, role?, mode?, tools?, inheritTools?, skills?}]`) |
|
|
66
66
|
| `jar_subagent` | moderator control: `peek`, `report`, `steer`, `ask`, `pause`, `resume`, `stop`, `discard` |
|
|
67
67
|
| `jar_council` | exceptional scout vote with strict majority; never implements |
|
|
68
68
|
| `jar_discuss` | parent-brokered Q/A between moderator and subagents |
|
package/docs/SUBAGENT-TOOLS.md
CHANGED
|
@@ -12,10 +12,11 @@ Subagents use Pi's standard `--tools` allowlist. Each `jar_delegate` task can cu
|
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
- With neither option, existing mode defaults are unchanged: `scout` and `fork` are read-only; `worktree` gets the path-guarded file tools; legacy `direct` retains Pi's default allowlist.
|
|
15
|
-
- `tools`
|
|
16
|
-
- `
|
|
17
|
-
- `
|
|
18
|
-
- `
|
|
15
|
+
- `tools` entries are `name`/`+name` (allow) or `-name` (subtract). Any allow entry makes an exact list that replaces the mode defaults and is the moderator's deliberate grant: any tool active in the parent is honored, including MCP/extension tools such as `mom_search` or `punakawan_project`. Include the basics you still need (`read`, `jar_todo`, ...). Only `-name` entries subtract from the mode defaults (or inherited tools), e.g. `["-jar_discuss"]`. A scout or fork granted a mutating tool is no longer strictly read-only — grant narrowly.
|
|
16
|
+
- `skills` (per task) uses the same syntax for Pi skills, by name or path: `["mom", "punakawan"]` (or `[]`) is exact, `["-lavish"]` removes from the defaults; omit it to keep the defaults. Children then run `--no-skills --skill <path>...`.
|
|
17
|
+
- `inheritTools: true` snapshots the parent's currently active tool names, then filters them for mode safety plus the trusted extras from `pi-jar-subagents.json`. It does not grant tools registered later. Pi hosts must expose `getActiveTools()` (or the integration must supply an active-name getter); otherwise inheritance/configurable lists fail with an actionable error.
|
|
18
|
+
- Inherited `scout`/`fork` tools are known read-only/discovery tools, named web tools and trusted extras.
|
|
19
|
+
- `worktree` rejects shell tools even when explicitly requested (a shell would escape the path guard). Inherited worktree tools are path-guarded file tools, named web tools and trusted extras. `multi_file_edit` is included in defaults only when active in the parent; each effective edit target, default path and recognized alternate path field is validated, including traversal and existing symlink escapes.
|
|
19
20
|
- Named web capabilities are `web_enable`, `web_search`, `fetch_content`, `get_search_content` and `source_check`. Newly enabled tools must still be in the child's original capability list.
|
|
20
21
|
- These are tool-call restrictions, not an OS sandbox. Extensions execute trusted code; named web tools can access external services, and legacy direct-write mode is not workspace-confined.
|
|
21
22
|
- `jar_delegate`, `jar_subagent`, and `jar_council` are always removed from inherited tools and rejected in explicit lists. A child cannot recursively spawn more subagents.
|
|
@@ -23,4 +24,23 @@ Subagents use Pi's standard `--tools` allowlist. Each `jar_delegate` task can cu
|
|
|
23
24
|
|
|
24
25
|
Retained scout/fork/worktree children hibernate after 30 seconds idle: their Pi process exits, but the private child session and worktree remain for `jar_subagent resume` or `ask`. Hibernated records count against Max subagents. Unresolved worktree recovery records do not occupy a pool slot, but at most four active/recovery worktrees are admitted. `jar_subagent stop` retries reconciliation; `discard` explicitly removes an unresolved recovery workspace without applying it. On session shutdown pi-jar reports paths for worktrees it preserves on disk, including still-active worktrees that might contain edits.
|
|
25
26
|
|
|
27
|
+
## `~/.pi/agent/pi-jar-subagents.json`
|
|
28
|
+
|
|
29
|
+
User-owned and profile-independent; read on every launch. A missing file means no rules; malformed content fails the launch with an error. Each list takes `+entry` (or bare `entry`) to allow and `-entry` to disallow; a disallow wins over an allow, and user disallows always win over the moderator's task requests (which are then rejected with an error).
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"tools": ["+mom_search", "+mom_get", "-punakawan_connector_call"],
|
|
34
|
+
"extensions": ["+npm:@gotgenes/pi-anthropic-auth", "-pi-web-access"],
|
|
35
|
+
"skills": ["+antislop", "-lavish"]
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
- `tools` — allow: trusted names added to `inheritTools` in every mode. Disallow: removed from defaults/inheritance and rejected in explicit task lists. Recursive delegation tools can never be allowed.
|
|
40
|
+
- `extensions` — entries are Pi package sources (`npm:@scope/name`, `git:host/owner/repo`, URLs), short names (npm package, git repo, a top-level extension file/dir name, or a builtin such as `mcp`), or paths (`~/`, absolute, `./`, or `dir/file` relative to the agent dir). Sources and names resolve to the installed paths from the parent's own Pi resolution (settings filters included); an allowed source that is not installed is passed to Pi as-is (Pi installs it temporarily, slower).
|
|
41
|
+
- allow: loaded by every child. Default read-only scouts/forks run `--no-extensions` plus pi-jar for fast startup, so a provider extension such as Anthropic OAuth compatibility must be allowed here or those children fail authentication. Children with normal discovery get it added only when it is not already loaded.
|
|
42
|
+
- disallow: children that would use normal discovery instead get `--no-extensions` plus the parent's enabled extensions (top-level, packages and enabled builtins) minus the disallowed ones. A parent started with `--no-extensions` never gains discovered extensions.
|
|
43
|
+
- `skills` — names or paths. allow: added to children's default skills, even when disabled in the parent. Disallow: never loaded by children; a task asking for one is rejected. When any skill rule (config or task) applies, children get an exact `--no-skills --skill ...` list; otherwise Pi's normal discovery is unchanged.
|
|
44
|
+
- The running pi-jar is always loaded explicitly; another installed pi-jar copy is never loaded twice.
|
|
45
|
+
|
|
26
46
|
If configurable tools are unavailable on a Pi host, use mode defaults or upgrade to a host exposing its active tool list. Avoid inheriting every tool for convenience; grant the narrowest capability needed for the assignment.
|
package/extensions/index.ts
CHANGED
|
@@ -54,7 +54,8 @@ import { ContextSampler, formatPerf, LARGE_SESSION_ENTRIES, processRss, RenderSc
|
|
|
54
54
|
import { registerChangeReview } from "../src/diff-view.ts";
|
|
55
55
|
import { registerShells, ShellManager, type ShellEvent, type ShellJob } from "../src/shells.ts";
|
|
56
56
|
import { registerShellNotifications } from "../src/shell-notifications.ts";
|
|
57
|
-
import {
|
|
57
|
+
import { loadResourceCatalog } from "../src/subagent-resources.ts";
|
|
58
|
+
import { CHILD_TOOLS_ENV, childToolAllowlist, loadSubagentConfig, RECURSIVE_TOOLS, worktreeToolViolation } from "../src/subagent-tools.ts";
|
|
58
59
|
import { hopefulWelcomeMessage, welcomeHit, welcomeLines, type WelcomeAction } from "../src/welcome.ts";
|
|
59
60
|
|
|
60
61
|
const WELCOME_KEY = "pi-jar.welcome";
|
|
@@ -351,6 +352,8 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
351
352
|
discussionRetire: (key) => discussion?.retire(key),
|
|
352
353
|
discussionUnread: (key) => discussion?.unreadFor(key) ?? 0,
|
|
353
354
|
discussionDigest: () => discussion?.digest(),
|
|
355
|
+
getSubagentConfig: () => loadSubagentConfig(getAgentDir()),
|
|
356
|
+
loadResources: (cwd) => loadResourceCatalog(cwd, getAgentDir()),
|
|
354
357
|
getMaxSubagents: () => visualSettings.maxSubagents, onController: (controller) => { delegateController = controller; } });
|
|
355
358
|
if (!delegatedChild) registerCouncil(pi, () => delegateController, () => visualSettings.maxSubagents);
|
|
356
359
|
const planMode = new PlanMode(pi, () => todos, modelRoles, updateTaskWidget);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-jar",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
4
4
|
"description": "A warm, role-aware Pi terminal workspace with switchable profiles, animated UI, plan and goal workflows, tasks, change review, model roles, background shells and subagents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/delegate.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
2
|
import { signalProcessTree } from "./async-process.ts";
|
|
3
|
-
import { CHILD_TOOLS_ENV, RECURSIVE_TOOLS, WEB_TOOLS } from "./subagent-tools.ts";
|
|
3
|
+
import { CHILD_TOOLS_ENV, EMPTY_SUBAGENT_CONFIG, parseRules, RECURSIVE_TOOLS, SHELL_TOOLS, SUBAGENT_CONFIG_FILE, WEB_TOOLS, type RuleList, type SubagentConfig } from "./subagent-tools.ts";
|
|
4
|
+
import { planExtensions, planSkills, type ExtensionPlan, type ResourceCatalog } from "./subagent-resources.ts";
|
|
4
5
|
import { existsSync, mkdtempSync, realpathSync } from "node:fs";
|
|
5
6
|
import { rm } from "node:fs/promises";
|
|
6
7
|
import { tmpdir } from "node:os";
|
|
@@ -37,8 +38,9 @@ export const READ_ONLY_TOOLS = ["read", "grep", "find", "ls", "jar_todo", "jar_d
|
|
|
37
38
|
export const WORKTREE_TOOLS = ["read", "edit", "write", "multi_file_edit", "grep", "find", "ls", "jar_todo", "jar_discuss"] as const;
|
|
38
39
|
const READ_ONLY_SAFE_TOOLS = new Set<string>([...READ_ONLY_TOOLS, ...WEB_TOOLS, "ffgrep", "fffind", "lsp_diagnostics", "obs_recall"]);
|
|
39
40
|
const WORKTREE_SAFE_TOOLS = new Set<string>([...WORKTREE_TOOLS, ...WEB_TOOLS]);
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
/** Inheritance filter: mode-safe tools plus the user's trusted extras from pi-jar-subagents.json. */
|
|
42
|
+
const allowedInMode = (mode: DelegateExecutionMode, name: string, trusted: ReadonlySet<string> = new Set()) => !RECURSIVE_TOOLS.has(name)
|
|
43
|
+
&& (mode === "direct" || trusted.has(name) || (mode === "worktree" ? WORKTREE_SAFE_TOOLS.has(name) : READ_ONLY_SAFE_TOOLS.has(name)));
|
|
42
44
|
export type DelegateMode = "scout" | "fork" | "worktree";
|
|
43
45
|
type DelegateExecutionMode = DelegateMode | "direct";
|
|
44
46
|
const MAX_OUTPUT_CHARS = 12_000;
|
|
@@ -169,24 +171,52 @@ export interface DelegateSessionOptions {
|
|
|
169
171
|
tools?: readonly string[];
|
|
170
172
|
}
|
|
171
173
|
|
|
174
|
+
/** The running pi-jar entry point; children always load this copy explicitly. */
|
|
175
|
+
export const OWN_EXTENSION = fileURLToPath(new URL("../extensions/index.ts", import.meta.url));
|
|
176
|
+
|
|
177
|
+
/** Extension and skill choices for one child, planned from pi-jar-subagents.json and the task. */
|
|
178
|
+
export interface ChildResources {
|
|
179
|
+
extensions?: ExtensionPlan;
|
|
180
|
+
/** Exact skill paths (`--no-skills --skill ...`); undefined keeps Pi's normal skill discovery. */
|
|
181
|
+
skills?: readonly string[];
|
|
182
|
+
}
|
|
183
|
+
|
|
172
184
|
/** CLI arguments for one subagent: an ephemeral one-shot, a private resumable session, or a real Pi session fork. */
|
|
173
185
|
export function delegateArgs(model: string | undefined, thinking: string | undefined, write: boolean, argv = process.argv,
|
|
174
|
-
session?: DelegateSessionOptions, toolOverride?: readonly string[]): string[] {
|
|
186
|
+
session?: DelegateSessionOptions, toolOverride?: readonly string[], resources: ChildResources = {}): string[] {
|
|
175
187
|
const sessionArgs = !session ? ["--no-session"]
|
|
176
188
|
: session.resume ? ["--session", session.resume, "--session-dir", session.sessionDir]
|
|
177
189
|
: session.source ? ["--fork", session.source, "--session-dir", session.sessionDir]
|
|
178
190
|
: ["--session-dir", session.sessionDir];
|
|
179
191
|
const extensions = extensionFlags(argv);
|
|
180
192
|
const tools = session?.tools ?? toolOverride ?? (!write ? READ_ONLY_TOOLS : undefined);
|
|
181
|
-
const ownExtension =
|
|
193
|
+
const ownExtension = OWN_EXTENSION;
|
|
194
|
+
const plan = resources.extensions;
|
|
195
|
+
const load = (path: string) => { if (!extensions.includes(path)) extensions.push("--extension", path); };
|
|
196
|
+
const parentDiscovers = !extensions.some(flag => flag === "-ne" || flag === "--no-extensions");
|
|
182
197
|
// Default read-only children need only core file tools and pi-jar. Avoid importing every
|
|
183
198
|
// personal extension (and opening unused MCP/provider connections) for each scout.
|
|
184
199
|
// Explicit extension flags and optional inherited tools retain the parent's full setup.
|
|
185
200
|
const lean = !write && extensions.length === 0 && tools?.every(tool => (READ_ONLY_TOOLS as readonly string[]).includes(tool));
|
|
186
|
-
if (lean)
|
|
187
|
-
|
|
188
|
-
|
|
201
|
+
if (lean) {
|
|
202
|
+
extensions.push("--no-extensions", "--extension", ownExtension);
|
|
203
|
+
// User-allowed essentials (e.g. a provider auth extension) that lean children still need.
|
|
204
|
+
for (const path of plan?.lean ?? []) load(path);
|
|
205
|
+
} else if (plan?.explicit && parentDiscovers) {
|
|
206
|
+
// Something is disallowed: replace discovery with the parent's resolved list minus the disallowed entries.
|
|
207
|
+
extensions.push("--no-extensions");
|
|
208
|
+
load(ownExtension);
|
|
209
|
+
for (const path of plan.explicit) load(path);
|
|
210
|
+
} else {
|
|
211
|
+
// Worktree cwd/session forks (resumed ones too) do not inherit project extension discovery from the parent.
|
|
212
|
+
if (session?.source) load(ownExtension);
|
|
213
|
+
for (const path of plan?.add ?? []) load(path);
|
|
214
|
+
}
|
|
189
215
|
const args = ["--mode", "rpc", ...sessionArgs, ...extensions];
|
|
216
|
+
if (resources.skills) {
|
|
217
|
+
args.push("--no-skills");
|
|
218
|
+
for (const path of resources.skills) args.push("--skill", path);
|
|
219
|
+
}
|
|
190
220
|
if (model) args.push("--model", model);
|
|
191
221
|
if (thinking) args.push("--thinking", thinking);
|
|
192
222
|
if (tools) args.push("--tools", tools.join(","));
|
|
@@ -1186,8 +1216,10 @@ const Parameters = Type.Object({
|
|
|
1186
1216
|
role: Type.Optional(Type.String({ description: "pi-jar role override; defaults by mode (scout→scout, fork→reviewer, worktree→worker), then role fallbacks/current model." })),
|
|
1187
1217
|
mode: Type.Optional(DelegateModeSchema),
|
|
1188
1218
|
tools: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 80 }), { minItems: 1, maxItems: 64, uniqueItems: true,
|
|
1189
|
-
description: "
|
|
1190
|
-
inheritTools: Type.Optional(Type.Boolean({ description: "Inherit your active tools, filtered by mode safety; recursive delegation tools are always removed." }))
|
|
1219
|
+
description: "Child tools. `name`/`+name` entries form an exact list (replaces mode defaults; include read etc.); any tool active here is honored, e.g. MCP mom_search; mutating tools bypass scout/fork read-only. Only `-name` entries subtract from the defaults (or inherited tools). User-disallowed tools and worktree shells are rejected." })),
|
|
1220
|
+
inheritTools: Type.Optional(Type.Boolean({ description: "Inherit your active tools, filtered by mode safety plus user-trusted extras; recursive delegation tools are always removed." })),
|
|
1221
|
+
skills: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 300 }), { maxItems: 64, uniqueItems: true,
|
|
1222
|
+
description: "Skills the child may load, by name or path. `name`/`+name` entries (or []) form an exact list; only `-name` entries remove from the defaults. Omit to keep defaults. User-disallowed skills are rejected." }))
|
|
1191
1223
|
}), { minItems: 1, maxItems: 16 }),
|
|
1192
1224
|
mode: Type.Optional(DelegateModeSchema),
|
|
1193
1225
|
write: Type.Optional(Type.Boolean({ description: "Deprecated: true = legacy shared-workspace editing; prefer mode=\"worktree\"." }))
|
|
@@ -1219,6 +1251,10 @@ export interface DelegateController {
|
|
|
1219
1251
|
export interface DelegateOptions {
|
|
1220
1252
|
/** Snapshot names of tools active in the parent session. */
|
|
1221
1253
|
getActiveToolNames?: () => readonly string[];
|
|
1254
|
+
/** Tool/extension/skill allow (+) and disallow (-) rules (pi-jar-subagents.json); read on every launch, may throw on bad config. */
|
|
1255
|
+
getSubagentConfig?: () => SubagentConfig;
|
|
1256
|
+
/** The parent's resolved extensions and skills; needed only when extension/skill rules apply. */
|
|
1257
|
+
loadResources?: (cwd: string) => Promise<ResourceCatalog>;
|
|
1222
1258
|
/** Allowed sizes: 2, 4, 6, 8, 16; defaults to MAX_DELEGATES. Read on every launch. */
|
|
1223
1259
|
getMaxSubagents?: () => number;
|
|
1224
1260
|
/** Shares the delegate lifecycle and capacity with scout orchestration. */
|
|
@@ -1472,32 +1508,77 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1472
1508
|
} catch { return undefined; }
|
|
1473
1509
|
};
|
|
1474
1510
|
const activeNames = parentToolNames();
|
|
1511
|
+
let config: SubagentConfig;
|
|
1512
|
+
try { config = options.getSubagentConfig?.() ?? EMPTY_SUBAGENT_CONFIG; }
|
|
1513
|
+
catch (error) {
|
|
1514
|
+
return { content: [{ type: "text", text: `Cannot read subagent config: ${(error as Error).message}` }], details: { runs: [], write: false }, isError: true };
|
|
1515
|
+
}
|
|
1516
|
+
const trusted = new Set(config.tools.allow);
|
|
1517
|
+
const disallowed = new Set(config.tools.deny);
|
|
1475
1518
|
const defaultWorktreeTools = WORKTREE_TOOLS.filter(name => name !== "multi_file_edit" || activeNames?.includes(name));
|
|
1519
|
+
const toolPlans: Array<readonly string[] | undefined> = [];
|
|
1520
|
+
const skillRules: Array<RuleList | undefined> = [];
|
|
1476
1521
|
for (const [index, item] of requested.entries()) {
|
|
1477
1522
|
const mode = item.mode ?? params.mode ?? (params.write === true ? "direct" : "scout");
|
|
1478
|
-
|
|
1479
|
-
|
|
1523
|
+
let rules: RuleList | undefined;
|
|
1524
|
+
try {
|
|
1525
|
+
rules = item.tools ? parseRules(item.tools, `Task ${index + 1}: tools`) : undefined;
|
|
1526
|
+
skillRules.push(item.skills ? parseRules(item.skills, `Task ${index + 1}: skills`) : undefined);
|
|
1527
|
+
} catch (error) {
|
|
1528
|
+
return { content: [{ type: "text", text: (error as Error).message }], details: { runs: [], write: false }, isError: true };
|
|
1529
|
+
}
|
|
1480
1530
|
if ((item.inheritTools || item.tools) && !activeNames) {
|
|
1481
1531
|
return { content: [{ type: "text", text: `Task ${index + 1}: this Pi host does not expose active tool names; configurable tools cannot be validated. Use default tools or a host with getActiveTools().` }], details: { runs: [], write: false }, isError: true };
|
|
1482
1532
|
}
|
|
1483
|
-
|
|
1484
|
-
|
|
1533
|
+
const inherited = item.inheritTools && activeNames ? activeNames.filter(name => allowedInMode(mode, name, trusted) && !disallowed.has(name)) : undefined;
|
|
1534
|
+
const defaults = inherited ?? (mode === "worktree" ? defaultWorktreeTools : mode === "direct" ? undefined : [...READ_ONLY_TOOLS]);
|
|
1535
|
+
const explicit = rules?.allow.length ? rules.allow : undefined;
|
|
1536
|
+
let selected: readonly string[] | undefined;
|
|
1537
|
+
if (explicit) selected = explicit;
|
|
1538
|
+
else if (rules) {
|
|
1539
|
+
if (!defaults) return { content: [{ type: "text", text: `Task ${index + 1}: direct mode has no default tool list to subtract from; list the tools to grant.` }], details: { runs: [], write: false }, isError: true };
|
|
1540
|
+
selected = defaults.filter(name => !rules!.deny.includes(name));
|
|
1541
|
+
} else selected = inherited ?? (defaults?.some(name => disallowed.has(name)) ? defaults.filter(name => !disallowed.has(name)) : undefined);
|
|
1542
|
+
if ((item.inheritTools || rules) && !selected?.length) {
|
|
1543
|
+
return { content: [{ type: "text", text: `Task ${index + 1}: no tools remain after applying ${mode} safety filters and disallow rules. Choose explicit tools or use the mode defaults.` }], details: { runs: [], write: false }, isError: true };
|
|
1485
1544
|
}
|
|
1486
1545
|
if (selected) {
|
|
1487
|
-
|
|
1488
|
-
|
|
1546
|
+
const blocked = selected.filter(name => disallowed.has(name));
|
|
1547
|
+
if (blocked.length) return { content: [{ type: "text", text: `Task ${index + 1}: tools disallowed by ${SUBAGENT_CONFIG_FILE}: ${blocked.join(", ")}.` }], details: { runs: [], write: false }, isError: true };
|
|
1548
|
+
if (explicit && activeNames) {
|
|
1549
|
+
const missing = explicit.filter(name => !activeNames.includes(name));
|
|
1489
1550
|
if (missing.length) return { content: [{ type: "text", text: `Task ${index + 1}: tools are not currently active in the parent: ${missing.join(", ")}. Enable their extensions/tools first.` }], details: { runs: [], write: false }, isError: true };
|
|
1490
|
-
} else if (item.inheritTools) {
|
|
1491
|
-
return { content: [{ type: "text", text: `Task ${index + 1}: cannot resolve inherited tools from this Pi host.` }], details: { runs: [], write: false }, isError: true };
|
|
1492
1551
|
}
|
|
1493
1552
|
const recursive = selected.filter(name => RECURSIVE_TOOLS.has(name));
|
|
1494
1553
|
if (recursive.length) return { content: [{ type: "text", text: `Task ${index + 1}: recursive delegation tools are never available to subagents: ${recursive.join(", ")}.` }], details: { runs: [], write: false }, isError: true };
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
}
|
|
1554
|
+
// Explicit `tools` are the moderator's deliberate grant: any active non-recursive tool is honored.
|
|
1555
|
+
// Only the worktree sandbox keeps its hard boundary, since a shell would escape the path guard.
|
|
1498
1556
|
if (mode === "worktree") {
|
|
1499
|
-
const unsafe = selected.filter(name =>
|
|
1500
|
-
if (unsafe.length) return { content: [{ type: "text", text: `Task ${index + 1}: worktree cannot
|
|
1557
|
+
const unsafe = selected.filter(name => SHELL_TOOLS.has(name));
|
|
1558
|
+
if (unsafe.length) return { content: [{ type: "text", text: `Task ${index + 1}: worktree subagents cannot run shell tools (${unsafe.join(", ")}); edits stay inside path-guarded file tools.` }], details: { runs: [], write: false }, isError: true };
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
toolPlans.push(selected);
|
|
1562
|
+
}
|
|
1563
|
+
// Extension/skill rules need the parent's resolved resources; plain launches skip that work.
|
|
1564
|
+
let extensionPlan: ExtensionPlan | undefined;
|
|
1565
|
+
const skillPlans: Array<readonly string[] | undefined> = requested.map(() => undefined);
|
|
1566
|
+
const ruled = (rules: RuleList) => rules.allow.length > 0 || rules.deny.length > 0;
|
|
1567
|
+
if (ruled(config.extensions) || ruled(config.skills) || skillRules.some(Boolean)) {
|
|
1568
|
+
if (!options.loadResources) {
|
|
1569
|
+
return { content: [{ type: "text", text: "This Pi host cannot resolve extensions/skills for subagents; remove extension/skill rules." }], details: { runs: [], write: false }, isError: true };
|
|
1570
|
+
}
|
|
1571
|
+
let catalog: ResourceCatalog;
|
|
1572
|
+
try {
|
|
1573
|
+
catalog = await options.loadResources(ctx.cwd);
|
|
1574
|
+
if (ruled(config.extensions)) extensionPlan = planExtensions(config.extensions, catalog, OWN_EXTENSION);
|
|
1575
|
+
} catch (error) {
|
|
1576
|
+
return { content: [{ type: "text", text: `Cannot plan subagent extensions: ${(error as Error).message}` }], details: { runs: [], write: false }, isError: true };
|
|
1577
|
+
}
|
|
1578
|
+
for (const [index, rules] of skillRules.entries()) {
|
|
1579
|
+
try { skillPlans[index] = planSkills(config.skills, rules, catalog, SUBAGENT_CONFIG_FILE); }
|
|
1580
|
+
catch (error) {
|
|
1581
|
+
return { content: [{ type: "text", text: `Task ${index + 1}: ${(error as Error).message}` }], details: { runs: [], write: false }, isError: true };
|
|
1501
1582
|
}
|
|
1502
1583
|
}
|
|
1503
1584
|
}
|
|
@@ -1519,8 +1600,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1519
1600
|
const toolsByRun = new Map<number, readonly string[] | undefined>();
|
|
1520
1601
|
const runs: DelegateRun[] = requested.map((item, index) => {
|
|
1521
1602
|
const mode: DelegateExecutionMode = item.mode ?? defaultMode;
|
|
1522
|
-
toolsByRun.set(index + 1,
|
|
1523
|
-
allowedInMode(mode, name)) : mode === "worktree" ? defaultWorktreeTools : undefined));
|
|
1603
|
+
toolsByRun.set(index + 1, toolPlans[index] ?? (mode === "worktree" ? defaultWorktreeTools : undefined));
|
|
1524
1604
|
const role = cleanText(item.role ?? roleForMode(mode), 32) || roleForMode(mode);
|
|
1525
1605
|
const candidates = [...roleCandidates(role), ...roleCandidates("default")];
|
|
1526
1606
|
const selected = candidates.find((candidate) => available.has(candidate.provider + "/" + candidate.model));
|
|
@@ -1684,7 +1764,8 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1684
1764
|
// Read at every spawn, so a relaunch after hibernation reaches the current discussion broker.
|
|
1685
1765
|
hooks.env = () => ({ ...env, ...discussionEnv(key, run.name) });
|
|
1686
1766
|
const argsFor = (resume?: string) => delegateArgs(run.model, thinkingByRun.get(run.index), write, process.argv,
|
|
1687
|
-
session && resume ? { ...session, resume } : session, run.mode === "scout" ? toolsByRun.get(run.index) : undefined
|
|
1767
|
+
session && resume ? { ...session, resume } : session, run.mode === "scout" ? toolsByRun.get(run.index) : undefined,
|
|
1768
|
+
{ ...(extensionPlan ? { extensions: extensionPlan } : {}), ...(skillPlans[run.index - 1] ? { skills: skillPlans[run.index - 1] } : {}) });
|
|
1688
1769
|
if (session) {
|
|
1689
1770
|
const own = session.sessionDir;
|
|
1690
1771
|
// Only ever continue this child's own private session, never a file it reports elsewhere.
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, extname, isAbsolute, resolve, sep } from "node:path";
|
|
3
|
+
import type { RuleList } from "./subagent-tools.ts";
|
|
4
|
+
|
|
5
|
+
/** One extension or skill the parent's Pi configuration resolves, enabled or filtered out. */
|
|
6
|
+
export interface CatalogResource { path: string; enabled: boolean; source: string; packageRoot?: string }
|
|
7
|
+
export interface ResourceCatalog { extensions: readonly CatalogResource[]; skills: readonly CatalogResource[] }
|
|
8
|
+
|
|
9
|
+
/** Pi's built-in extensions; resolved as `builtin:<name>` so an explicit child list keeps the enabled ones. */
|
|
10
|
+
export const BUILTIN_EXTENSIONS = ["llama.cpp", "codemode", "tool-search", "mcp"] as const;
|
|
11
|
+
|
|
12
|
+
/** The parent's resources exactly as Pi resolves them (settings filters, packages, top-level dirs); never installs. */
|
|
13
|
+
export async function loadResourceCatalog(cwd: string, agentDir: string): Promise<ResourceCatalog> {
|
|
14
|
+
const { DefaultPackageManager, SettingsManager } = await import("@earendil-works/pi-coding-agent");
|
|
15
|
+
const manager = new DefaultPackageManager({ cwd, agentDir, settingsManager: SettingsManager.create(cwd, agentDir),
|
|
16
|
+
builtinExtensions: [...BUILTIN_EXTENSIONS] });
|
|
17
|
+
const resolved = await manager.resolve(async () => "skip");
|
|
18
|
+
const map = (item: { path: string; enabled: boolean; metadata: { source: string; packageRoot?: string } }): CatalogResource =>
|
|
19
|
+
({ path: item.path, enabled: item.enabled, source: item.metadata.source, ...(item.metadata.packageRoot ? { packageRoot: item.metadata.packageRoot } : {}) });
|
|
20
|
+
return { extensions: resolved.extensions.map(map), skills: resolved.skills.map(map) };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** `npm:@a/b@1` → `npm:@a/b`; git/URL forms → `git:host/path` without ref or `.git`; others unchanged. */
|
|
24
|
+
export function normalizeSource(spec: string): string {
|
|
25
|
+
const value = spec.trim();
|
|
26
|
+
if (value.startsWith("npm:")) {
|
|
27
|
+
const name = value.slice(4);
|
|
28
|
+
const at = name.indexOf("@", 1);
|
|
29
|
+
return "npm:" + (at > 0 ? name.slice(0, at) : name);
|
|
30
|
+
}
|
|
31
|
+
let git: string;
|
|
32
|
+
if (value.startsWith("git:")) git = value.slice(4);
|
|
33
|
+
else if (/^https?:\/\//.test(value)) git = value.replace(/^https?:\/\//, "");
|
|
34
|
+
else if (/^git@[^:]+:/.test(value)) git = value.slice(4).replace(":", "/");
|
|
35
|
+
else return value;
|
|
36
|
+
return "git:" + git.replace(/@[^/]*$/, "").replace(/\.git$/, "").replace(/\/+$/, "");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const stem = (path: string) => basename(path, extname(path));
|
|
40
|
+
/** Short name: npm package, git repo, builtin name, or a top-level file/directory name. */
|
|
41
|
+
export function extensionName(resource: CatalogResource): string {
|
|
42
|
+
if (resource.path.startsWith("builtin:")) return resource.path.slice(8);
|
|
43
|
+
const source = normalizeSource(resource.source);
|
|
44
|
+
if (source.startsWith("npm:")) return source.slice(4);
|
|
45
|
+
if (source.startsWith("git:")) return source.slice(source.lastIndexOf("/") + 1);
|
|
46
|
+
const file = stem(resource.path);
|
|
47
|
+
return file === "index" ? basename(dirname(resource.path)) : file;
|
|
48
|
+
}
|
|
49
|
+
/** Agent Skills names match their directory; standalone Markdown skills use the file name. */
|
|
50
|
+
export function skillName(resource: CatalogResource): string {
|
|
51
|
+
return basename(resource.path) === "SKILL.md" ? basename(dirname(resource.path)) : stem(resource.path);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const isPathSpec = (spec: string) => isAbsolute(spec);
|
|
55
|
+
const within = (path: string, root: string) => path === root || path.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
56
|
+
|
|
57
|
+
function extensionMatches(resource: CatalogResource, spec: string): boolean {
|
|
58
|
+
if (spec === resource.path) return true;
|
|
59
|
+
if (isPathSpec(spec)) {
|
|
60
|
+
const target = resolve(spec);
|
|
61
|
+
return within(resource.path, target) || (!!resource.packageRoot && resolve(resource.packageRoot) === target);
|
|
62
|
+
}
|
|
63
|
+
if (spec.includes(":")) return normalizeSource(spec) === normalizeSource(resource.source);
|
|
64
|
+
return extensionName(resource) === spec;
|
|
65
|
+
}
|
|
66
|
+
function skillMatches(resource: CatalogResource, spec: string): boolean {
|
|
67
|
+
if (spec === resource.path) return true;
|
|
68
|
+
if (isPathSpec(spec)) return within(resource.path, resolve(spec));
|
|
69
|
+
return skillName(resource) === spec;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const packageNames = new Map<string, string | undefined>();
|
|
73
|
+
const packageName = (root: string): string | undefined => {
|
|
74
|
+
if (!packageNames.has(root)) {
|
|
75
|
+
let name: string | undefined;
|
|
76
|
+
try { name = (JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")) as { name?: unknown }).name as string | undefined; }
|
|
77
|
+
catch { name = undefined; } // a package root without a readable manifest simply is not pi-jar
|
|
78
|
+
packageNames.set(root, typeof name === "string" ? name : undefined);
|
|
79
|
+
}
|
|
80
|
+
return packageNames.get(root);
|
|
81
|
+
};
|
|
82
|
+
/** Children always load the running pi-jar explicitly; never a second (possibly older) pi-jar copy. */
|
|
83
|
+
const isPiJar = (resource: CatalogResource, ownExtension: string) => resource.path === ownExtension
|
|
84
|
+
|| (!!resource.packageRoot && packageName(resource.packageRoot) === "pi-jar");
|
|
85
|
+
|
|
86
|
+
const unique = (paths: Iterable<string>) => [...new Set(paths)];
|
|
87
|
+
|
|
88
|
+
export interface ExtensionPlan {
|
|
89
|
+
/** Loaded by lean (`--no-extensions`) children besides pi-jar. */
|
|
90
|
+
lean: string[];
|
|
91
|
+
/** Added with `--extension` to children that keep normal discovery. */
|
|
92
|
+
add: string[];
|
|
93
|
+
/** Set when something is disallowed: the complete list for `--no-extensions` children (pi-jar excluded). */
|
|
94
|
+
explicit?: string[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Applies `+`/`-` extension rules to the parent's resolved extensions. Unknown allowed specs pass through to Pi. */
|
|
98
|
+
export function planExtensions(rules: RuleList, catalog: ResourceCatalog, ownExtension: string): ExtensionPlan {
|
|
99
|
+
const denied = (resource: CatalogResource) => rules.deny.some(spec => extensionMatches(resource, spec));
|
|
100
|
+
const allowed: string[] = [];
|
|
101
|
+
const added: string[] = [];
|
|
102
|
+
for (const spec of rules.allow) {
|
|
103
|
+
const matched = catalog.extensions.filter(resource => extensionMatches(resource, spec) && !isPiJar(resource, ownExtension) && !denied(resource));
|
|
104
|
+
if (!matched.length) {
|
|
105
|
+
if (!rules.deny.some(deny => deny === spec)) { allowed.push(spec); added.push(spec); }
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const enabled = matched.filter(resource => resource.enabled);
|
|
109
|
+
allowed.push(...(enabled.length ? enabled : matched).map(resource => resource.path));
|
|
110
|
+
if (!enabled.length) added.push(...matched.map(resource => resource.path));
|
|
111
|
+
}
|
|
112
|
+
const plan: ExtensionPlan = { lean: unique(allowed), add: unique(added) };
|
|
113
|
+
if (rules.deny.length) {
|
|
114
|
+
plan.explicit = unique([...catalog.extensions.filter(resource => resource.enabled && !denied(resource) && !isPiJar(resource, ownExtension))
|
|
115
|
+
.map(resource => resource.path), ...allowed]);
|
|
116
|
+
}
|
|
117
|
+
return plan;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Child skill paths for `--no-skills --skill ...`, or undefined to keep Pi's normal discovery.
|
|
122
|
+
* Defaults are the parent's enabled skills plus config allows minus config denies. A task list with any
|
|
123
|
+
* allowed entry (or an empty list) is exact; a list of only `-` entries subtracts from the defaults.
|
|
124
|
+
* Config denies always win: a task that explicitly asks for one is rejected.
|
|
125
|
+
*/
|
|
126
|
+
export function planSkills(config: RuleList, task: RuleList | undefined, catalog: ResourceCatalog, configLabel: string): string[] | undefined {
|
|
127
|
+
if (!config.allow.length && !config.deny.length && !task) return undefined;
|
|
128
|
+
const find = (spec: string, label: string): string[] => {
|
|
129
|
+
const resources = catalog.skills.filter(resource => skillMatches(resource, spec));
|
|
130
|
+
// A name picks one skill (enabled copy first), like Pi's own first-wins collision rule; a directory path keeps all.
|
|
131
|
+
const matched = (isPathSpec(spec) ? resources : resources.filter(resource => resource.enabled).concat(resources).slice(0, 1))
|
|
132
|
+
.map(resource => resource.path);
|
|
133
|
+
if (matched.length) return matched;
|
|
134
|
+
if (isPathSpec(spec)) return [spec];
|
|
135
|
+
throw new Error(`${label}: unknown skill "${spec}"`);
|
|
136
|
+
};
|
|
137
|
+
const configDenied = (path: string) => config.deny.some(spec => {
|
|
138
|
+
const resource = catalog.skills.find(item => item.path === path) ?? { path, enabled: true, source: "explicit" };
|
|
139
|
+
return skillMatches(resource, spec);
|
|
140
|
+
});
|
|
141
|
+
const defaults = unique([...catalog.skills.filter(resource => resource.enabled).map(resource => resource.path),
|
|
142
|
+
...config.allow.flatMap(spec => find(spec, configLabel))]).filter(path => !configDenied(path));
|
|
143
|
+
if (!task) return defaults;
|
|
144
|
+
const subtract = new Set(task.deny.flatMap(spec => find(spec, "skills")));
|
|
145
|
+
if (task.allow.length || !task.deny.length) {
|
|
146
|
+
const requested = unique(task.allow.flatMap(spec => find(spec, "skills")));
|
|
147
|
+
const blocked = requested.filter(configDenied);
|
|
148
|
+
if (blocked.length) throw new Error(`skills disallowed by ${configLabel}: ${blocked.map(path => skillName({ path, enabled: true, source: "" })).join(", ")}`);
|
|
149
|
+
return requested.filter(path => !subtract.has(path));
|
|
150
|
+
}
|
|
151
|
+
return defaults.filter(path => !subtract.has(path));
|
|
152
|
+
}
|
package/src/subagent-tools.ts
CHANGED
|
@@ -1,12 +1,78 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
1
4
|
import { workspacePathAllowed } from "./delegate-worktree.ts";
|
|
2
5
|
|
|
3
6
|
export const CHILD_TOOLS_ENV = "PI_JAR_CHILD_TOOLS";
|
|
4
7
|
export const WEB_TOOLS = ["web_enable", "web_search", "fetch_content", "get_search_content", "source_check"] as const;
|
|
5
8
|
export const RECURSIVE_TOOLS = new Set(["jar_delegate", "jar_subagent", "jar_council"]);
|
|
9
|
+
export const SHELL_TOOLS = new Set(["bash", "powershell", "jar_shell"]);
|
|
10
|
+
|
|
11
|
+
/** User-owned, profile-independent subagent capabilities in the agent directory. */
|
|
12
|
+
export const SUBAGENT_CONFIG_FILE = "pi-jar-subagents.json";
|
|
13
|
+
/** `+name` (or bare `name`) allows, `-name` disallows. */
|
|
14
|
+
export interface RuleList { allow: readonly string[]; deny: readonly string[] }
|
|
15
|
+
export interface SubagentConfig {
|
|
16
|
+
/** allow: trusted for `inheritTools` in every mode (e.g. mom_search); deny: never granted to any subagent. */
|
|
17
|
+
tools: RuleList;
|
|
18
|
+
/** allow: always loaded by children (npm:/git:/URL sources, paths, or names); deny: never loaded by children. */
|
|
19
|
+
extensions: RuleList;
|
|
20
|
+
/** allow: added to children's default skills (even if disabled in the parent); deny: never loaded by children. */
|
|
21
|
+
skills: RuleList;
|
|
22
|
+
}
|
|
23
|
+
export const EMPTY_RULES: RuleList = { allow: [], deny: [] };
|
|
24
|
+
export const EMPTY_SUBAGENT_CONFIG: SubagentConfig = { tools: EMPTY_RULES, extensions: EMPTY_RULES, skills: EMPTY_RULES };
|
|
25
|
+
|
|
26
|
+
/** Splits `+x`/`x`/`-x` entries; throws `${label}: ...` on empty or non-string entries. */
|
|
27
|
+
export function parseRules(value: unknown, label: string, map: (entry: string) => string = entry => entry): RuleList {
|
|
28
|
+
if (value === undefined) return EMPTY_RULES;
|
|
29
|
+
if (!Array.isArray(value) || value.length > 64 || value.some(item => typeof item !== "string"))
|
|
30
|
+
throw new Error(`${label} must be an array of at most 64 strings`);
|
|
31
|
+
const allow = new Set<string>();
|
|
32
|
+
const deny = new Set<string>();
|
|
33
|
+
for (const raw of value as string[]) {
|
|
34
|
+
const text = raw.trim();
|
|
35
|
+
const negative = text.startsWith("-");
|
|
36
|
+
const entry = (negative || text.startsWith("+") ? text.slice(1) : text).trim();
|
|
37
|
+
if (!entry) throw new Error(`${label}: empty entry "${raw}"`);
|
|
38
|
+
(negative ? deny : allow).add(map(entry));
|
|
39
|
+
}
|
|
40
|
+
for (const entry of deny) allow.delete(entry); // disallow wins
|
|
41
|
+
return { allow: [...allow], deny: [...deny] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `~/x`, absolute, `./x` and `dir/x` are paths (resolved from `directory`); `npm:`/`git:`/URLs and bare names stay as specs. */
|
|
45
|
+
export function expandSpec(spec: string, directory: string, home = homedir()): string {
|
|
46
|
+
if (spec === "~" || spec.startsWith("~/")) return join(home, spec.slice(1));
|
|
47
|
+
if (isAbsolute(spec)) return spec;
|
|
48
|
+
if (spec.includes(":") || !spec.includes("/") && !spec.startsWith(".")) return spec;
|
|
49
|
+
return resolve(directory, spec);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Missing file means no extras; a malformed file is an error, never silently ignored. */
|
|
53
|
+
export function loadSubagentConfig(directory: string, home = homedir()): SubagentConfig {
|
|
54
|
+
let text: string;
|
|
55
|
+
try { text = readFileSync(join(directory, SUBAGENT_CONFIG_FILE), "utf8"); }
|
|
56
|
+
catch (error) {
|
|
57
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return EMPTY_SUBAGENT_CONFIG;
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
let raw: unknown;
|
|
61
|
+
try { raw = JSON.parse(text); } catch (error) { throw new Error(`${SUBAGENT_CONFIG_FILE}: invalid JSON: ${(error as Error).message}`); }
|
|
62
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`${SUBAGENT_CONFIG_FILE}: expected a JSON object`);
|
|
63
|
+
const value = raw as Record<string, unknown>;
|
|
64
|
+
const label = (key: string) => `${SUBAGENT_CONFIG_FILE}: "${key}"`;
|
|
65
|
+
const tools = parseRules(value.tools, label("tools"));
|
|
66
|
+
const recursive = tools.allow.filter(name => RECURSIVE_TOOLS.has(name));
|
|
67
|
+
if (recursive.length) throw new Error(`${SUBAGENT_CONFIG_FILE}: recursive delegation tools are never available to subagents: ${recursive.join(", ")}`);
|
|
68
|
+
const extensions = parseRules(value.extensions, label("extensions"), spec => expandSpec(spec, directory, home));
|
|
69
|
+
const skills = parseRules(value.skills, label("skills"), spec => expandSpec(spec, directory, home));
|
|
70
|
+
return { tools, extensions, skills };
|
|
71
|
+
}
|
|
6
72
|
|
|
7
73
|
/** Multi-file tools must validate each effective target, not just the top-level default path. */
|
|
8
74
|
export function worktreeToolViolation(root: string, cwd: string, name: string, args: unknown): string | undefined {
|
|
9
|
-
if (
|
|
75
|
+
if (SHELL_TOOLS.has(name)) return "Sandboxed worktree subagents cannot run shell tools.";
|
|
10
76
|
const pathTools = new Set(["read", "edit", "write", "grep", "find", "ls", "multi_file_edit"]);
|
|
11
77
|
if (!pathTools.has(name)) return;
|
|
12
78
|
if (!workspacePathAllowed(root, cwd, ".")) return "Sandboxed worktree subagent working directory is outside its workspace.";
|