@cr1ms0n/pi-subagent 0.8.7 → 0.8.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/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.8.8 — 2026-09-16
6
+
7
+ ### Remote Context tools are target-model gated
8
+
9
+ - Restrict `new_context`, `get_context_remaining`, `history`, and `notes` to Pi children whose target model is an exact `compaction.gatewayContextModels` entry of the operator-owned `pi-openai-toolkit` config while `compaction.contextManagement` is `remote`. The previous 0.8.6 behavior appended these tools to every Pi child whenever the parent exposed them, so a non-allowlisted child could receive context tools simply because the parent session had them.
10
+ - Matching is exact and case-sensitive on the full `provider/model` string; prefixes, bare model ids, and native `openai-codex` identity do not match. Explicit requests for a context tool on a non-eligible target are rejected as unavailable, inherited/default lists are filtered, and retry fallback attempts are re-filtered per attempt so a disallowed fallback cannot inherit them. The internally constructed synthesis child uses the same gate.
11
+ - Missing, unreadable, invalid, or non-`remote` toolkit configuration now fails closed to no context tools. Non-Pi backends, read-only/write classification, and model-policy routing are unchanged. The package reads the existing operator-owned allowlist and does not duplicate it in `~/.pi/subagent.json`.
12
+
5
13
  ## 0.8.7 — 2026-09-14
6
14
 
7
15
  ### Provider-safe tool schemas
package/README.md CHANGED
@@ -193,17 +193,30 @@ Set a persona's backend in agent frontmatter with `backend: codex`.
193
193
 
194
194
  | Profile | Tools | Writes |
195
195
  | ---------------------------- | ------------------------------------------------------- | ------------------------------------------- |
196
- | `explore` (parallel default) | read/grep/find/ls + safe extras + Pi context tools | no project-file writes |
196
+ | `explore` (parallel default) | read/grep/find/ls + safe extras + allowlisted context tools | no project-file writes |
197
197
  | `review` | same as explore | no project-file writes |
198
- | `general` | inherited active tools + Pi context tools | yes if tools include bash/edit/write |
198
+ | `general` | inherited active tools + allowlisted context tools | yes if tools include bash/edit/write |
199
199
 
200
200
  For the Pi backend, the context-management tools `new_context`,
201
201
  `get_context_remaining`, `history`, and `notes` are retained in child tool
202
- allowlists when the parent exposes them. They are control-plane tools: they may
203
- update continuity notes or the remote context window, but cannot modify the
204
- child checkout or run a shell command. This exception also applies when a task
205
- supplies a narrower tool list, so Pi's `contextManagement` remains usable for
206
- configured gateway models.
202
+ allowlists only when all three hold: the toolkit config at
203
+ `$PI_CODING_AGENT_DIR/extensions/pi-openai-toolkit/config.json` (default:
204
+ `~/.pi/agent/extensions/pi-openai-toolkit/config.json`) has
205
+ `compaction.contextManagement: "remote"`, the child's target model exactly
206
+ equals one `compaction.gatewayContextModels` entry, and the parent exposes the
207
+ tool. Eligibility is exact and case-sensitive on the full `provider/model`
208
+ string — prefixes, bare model ids, and native `openai-codex` identity do not
209
+ match. The package reads that operator-owned list and never duplicates it.
210
+
211
+ Context tools are control-plane tools: they may update continuity notes or the
212
+ remote context window, but cannot modify the child checkout or run a shell
213
+ command. For an eligible target they are appended even when the task supplies a
214
+ narrower tool list; for every other Pi target they are removed from inherited
215
+ tools, never passed to `--tools`, and an explicit request for one is rejected as
216
+ unavailable. Retry fallback attempts are filtered per attempt, so a disallowed
217
+ fallback cannot inherit them. If the toolkit file is missing, unreadable,
218
+ invalid, or not in Remote Context mode, the effective allowlist is empty (fail
219
+ closed). Non-Pi backends are unchanged.
207
220
 
208
221
  Parallel write-capable tasks sharing one checkout are rejected unless each uses
209
222
  `isolation: "worktree"`, distinct `cwd`, or explicit `allow_shared_writes: true`.
@@ -14,7 +14,14 @@
14
14
  PID; transcript joins happen only on message boundaries, not per-chunk ticks.
15
15
  - Retry with model fallback lives in `orchestrator.ts` (`isTransientFailure`): queue
16
16
  timeouts, stalls, spawn errors, and provider errors re-run the same spec on the next
17
- fallback model with accumulated usage; task-quality failures never retry.
17
+ fallback model with accumulated usage; task-quality failures never retry. Each attempt
18
+ re-derives its Pi context-tool membership from the attempt's target model via
19
+ `context-policy.ts`, so an ineligible fallback cannot inherit the primary's tools.
20
+ - `context-policy.ts`: sole reader/parser of the operator-owned `pi-openai-toolkit`
21
+ Remote Context allowlist (`<Pi agent dir>/extensions/pi-openai-toolkit/config.json`).
22
+ Exposes the exact `gatewayContextModels` match plus the four context-tool names, and
23
+ fails closed to an empty allowlist on any missing/unreadable/invalid/non-remote
24
+ configuration. The toolkit's native `openai-codex` rule is intentionally not mirrored.
18
25
  - `context: "fork"` spawns the child with `--fork <parent session file>` so it starts
19
26
  from a real branched copy of the parent conversation. Fail-fast when the parent
20
27
  session is not persisted; single-task only.
@@ -29,7 +36,7 @@
29
36
  - `persistence.ts`: versioned active-branch event folding and bounded child transcript metadata.
30
37
  - `maintenance.ts`: filesystem GC (session files) and abort-race helpers; kept out of persistence.
31
38
  - `usage.ts`: provider-reported root/subagent/combined accounting.
32
- - `policy.ts` / `schema.ts`: discriminated request validation and safe capability profiles. `schema.ts` retains the canonical TypeBox validators and derives provider-safe tool-schema projections; `extension.ts` registers those projections while validating calls with the originals. Pi context-management control-plane tools remain available to child allowlists without granting project-file write access.
39
+ - `policy.ts` / `schema.ts`: discriminated request validation and safe capability profiles. `schema.ts` retains the canonical TypeBox validators and derives provider-safe tool-schema projections; `extension.ts` registers those projections while validating calls with the originals. Pi context-management control-plane tools are granted per target model: `context-policy.ts` reads the operator-owned `pi-openai-toolkit` Remote Context allowlist (`compaction.gatewayContextModels` with `contextManagement: "remote"`), `policy.ts` removes the four context names from every non-eligible Pi child, `orchestrator.ts` re-filters them per fallback attempt, and both fail closed to no context tools when the config is missing or invalid.
33
40
  - `config.ts`: defaults ← `~/.pi/subagent.json` ← `PI_SUBAGENT_*` env overrides.
34
41
  - `structured.ts`: structured-output contract (dependency-free JSON-Schema subset
35
42
  validation, fenced json:result extraction, contract/repair prompts) and
package/docs/SECURITY.md CHANGED
@@ -8,9 +8,17 @@ and can use tools according to their capability profile.
8
8
 
9
9
  | Profile | Default tools | Writes? |
10
10
  |---------|---------------|---------|
11
- | `explore` | `read`, `grep`, `find`, `ls` (+ safe extras and Pi context tools) | No project-file writes |
11
+ | `explore` | `read`, `grep`, `find`, `ls` (+ safe extras and allowlisted Pi context tools) | No project-file writes |
12
12
  | `review` | same as explore | No project-file writes |
13
- | `general` | inherited active tools (+ Pi context tools) | Yes if `bash`/`edit`/`write` are active |
13
+ | `general` | inherited active tools (+ allowlisted Pi context tools) | Yes if `bash`/`edit`/`write` are active |
14
+
15
+ Pi context tools are allowlisted by **target model**, not by parent session: a
16
+ child receives `new_context`, `get_context_remaining`, `history`, or `notes`
17
+ only when the toolkit config has `compaction.contextManagement: "remote"`, the
18
+ child's target model exactly equals a `compaction.gatewayContextModels` entry,
19
+ and the parent exposes the tool. Missing/unreadable/invalid/disabled toolkit
20
+ configuration yields no context tools. Retry fallback attempts are re-filtered
21
+ per attempt, so a disallowed fallback cannot inherit them.
14
22
 
15
23
  Parallel mode defaults to `explore` to avoid concurrent shared writes.
16
24
 
@@ -19,8 +27,9 @@ Parallel mode defaults to `explore` to avoid concurrent shared writes.
19
27
  1. **Read-only means no project-file mutation.** `bash` can rewrite the disk and is never part of
20
28
  an explore/review profile. Pi context-management tools (`new_context`,
21
29
  `get_context_remaining`, `history`, `notes`) are an explicit control-plane
22
- exception: they may update continuity notes/window state but cannot access
23
- the project write tools.
30
+ exception, but only for the exact target models allowlisted above: they may
31
+ update continuity notes/window state but cannot access the project write
32
+ tools, and they are rejected as unavailable for every other target.
24
33
  2. **Parallel writers** require `isolation: "worktree"`, distinct `cwd` values,
25
34
  or an explicit `allow_shared_writes: true` opt-in.
26
35
  3. **Depth is capped** (`maxDepth`, default 2). Nested children at the ceiling do
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cr1ms0n/pi-subagent",
3
- "version": "0.8.7",
3
+ "version": "0.8.8",
4
4
  "description": "Community fork of Luke Parke's pi-subagent with explicit model policy and model visibility for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -59,15 +59,23 @@ const routeModel = "<exact model from current modelPolicy route>";
59
59
 
60
60
  | Profile | Tools | Writes |
61
61
  | --------- | --------------------------------------------------------- | ------------------------------------------- |
62
- | `explore` | read/search/ls (+safe) + Pi context tools | no project-file writes |
62
+ | `explore` | read/search/ls (+safe) + allowlisted context tools | no project-file writes |
63
63
  | `review` | same as explore | no project-file writes |
64
- | `general` | inherited active tools + Pi context tools | yes if tools include bash/edit/write |
64
+ | `general` | inherited active tools + allowlisted context tools | yes if tools include bash/edit/write |
65
65
 
66
66
  For Pi children, `new_context`, `get_context_remaining`, `history`, and
67
- `notes` are control-plane tools. When available in the parent they remain in
68
- the child allowlist—even if a narrower tool list was requested—so Pi's remote
69
- `contextManagement` can stay active. They may update context notes/window
70
- state, but never grant `bash`, `edit`, or `write` access.
67
+ `notes` are control-plane tools. They are kept in the child allowlist only when
68
+ the target model exactly matches a `compaction.gatewayContextModels` entry in
69
+ the Remote Context toolkit config
70
+ (`$PI_CODING_AGENT_DIR/extensions/pi-openai-toolkit/config.json`, default:
71
+ `~/.pi/agent/extensions/pi-openai-toolkit/config.json`, with
72
+ `contextManagement: "remote"`), and only when the parent exposes them. For an
73
+ eligible target they stay even if a narrower tool list was requested; for any
74
+ other Pi target they are dropped from inherited tools, never passed to
75
+ `--tools`, and an explicit request for one is rejected as unavailable. Fallback
76
+ attempts are filtered per attempt. Missing/unreadable/invalid/disabled toolkit
77
+ configuration means no context tools at all. They may update context
78
+ notes/window state, but never grant `bash`, `edit`, or `write` access.
71
79
 
72
80
  Parallel write-capable tasks sharing one checkout are rejected unless each uses
73
81
  `isolation: "worktree"`, a distinct `cwd`, or `allow_shared_writes: true`.
package/src/agents.ts CHANGED
@@ -64,7 +64,13 @@ export interface AgentDefinition {
64
64
  const MAX_AGENT_FILE_BYTES = 64 * 1024;
65
65
  const NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
66
66
 
67
- function agentDir(): string {
67
+ /**
68
+ * Resolve the Pi agent directory: `$PI_CODING_AGENT_DIR` (with `~` expansion)
69
+ * else `~/.pi/agent`. Exported so other configuration readers (for example the
70
+ * Remote Context allowlist in `context-policy.ts`) share one path convention
71
+ * instead of inventing a second one.
72
+ */
73
+ export function piAgentDir(): string {
68
74
  const envDir = process.env.PI_CODING_AGENT_DIR?.trim();
69
75
  if (envDir) {
70
76
  return envDir.startsWith("~") ? path.join(os.homedir(), envDir.slice(1)) : envDir;
@@ -76,7 +82,7 @@ export function discoveryRoots(cwd: string): Array<{ dir: string; scope: AgentDe
76
82
  return [
77
83
  { dir: path.join(cwd, ".pi", "agents"), scope: "project" },
78
84
  { dir: path.join(cwd, ".agents", "agents"), scope: "shared" },
79
- { dir: path.join(agentDir(), "agents"), scope: "global" },
85
+ { dir: path.join(piAgentDir(), "agents"), scope: "global" },
80
86
  ];
81
87
  }
82
88
 
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Remote Context eligibility boundary for the four Pi context-management tools.
3
+ *
4
+ * `pi-subagent` does not own the Remote Context model list: the operator-owned
5
+ * `pi-openai-toolkit` configuration owns it. This module reads that file once
6
+ * per dispatch and fails closed to an empty allowlist whenever the file is
7
+ * missing, unreadable, malformed, or not in `contextManagement: "remote"` mode.
8
+ *
9
+ * Matching is exact and case-sensitive on the complete `provider/model` string:
10
+ * entries are never trimmed, prefix-matched, or inferred from a bare model id.
11
+ * The toolkit's separate native `openai-codex` provider rule is intentionally
12
+ * not mirrored here — only configured `gatewayContextModels` entries are
13
+ * eligible, because the boundary is "models outside the allowlist receive no
14
+ * context-manager tools".
15
+ *
16
+ * Nothing in this module writes to stdout: that channel is the child RPC
17
+ * protocol and the parent TUI.
18
+ */
19
+
20
+ import * as fs from "node:fs/promises";
21
+ import * as path from "node:path";
22
+ import { piAgentDir } from "./agents.js";
23
+
24
+ /**
25
+ * Pi context-management tools are control-plane capabilities: they may update
26
+ * continuity notes or the remote context window, but they cannot modify the
27
+ * child checkout. Keep them separate from ordinary source-inspection tools so
28
+ * the read-only profile's exception remains explicit.
29
+ */
30
+ export const CONTEXT_MANAGEMENT_TOOLS: ReadonlySet<string> = new Set([
31
+ "new_context",
32
+ "get_context_remaining",
33
+ "history",
34
+ "notes",
35
+ ]);
36
+
37
+ /** Canonical enumeration order when appending context tools to a child allowlist. */
38
+ export const CONTEXT_MANAGEMENT_TOOL_NAMES: readonly string[] = Object.freeze([
39
+ ...CONTEXT_MANAGEMENT_TOOLS,
40
+ ]);
41
+
42
+ /** Toolkit config path relative to the Pi agent directory. */
43
+ export const TOOLKIT_CONFIG_PATH_PARTS: readonly string[] = Object.freeze([
44
+ "extensions",
45
+ "pi-openai-toolkit",
46
+ "config.json",
47
+ ]);
48
+
49
+ /** Immutable per-dispatch snapshot of the operator-owned Remote Context allowlist. */
50
+ export interface ContextManagementPolicy {
51
+ /** Exact `provider/model` strings eligible for Remote Context. */
52
+ readonly gatewayModels: readonly string[];
53
+ }
54
+
55
+ /** Fail-closed policy used for missing/unreadable/invalid/disabled configuration. */
56
+ export const EMPTY_CONTEXT_MANAGEMENT_POLICY: ContextManagementPolicy = Object.freeze({
57
+ gatewayModels: Object.freeze([]),
58
+ });
59
+
60
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
61
+ return value !== null && typeof value === "object" && !Array.isArray(value)
62
+ ? (value as Record<string, unknown>)
63
+ : undefined;
64
+ }
65
+
66
+ /**
67
+ * Pure parser over the toolkit config JSON.
68
+ *
69
+ * Returns the exact gateway model set only when `compaction.contextManagement`
70
+ * is `"remote"` and every `compaction.gatewayContextModels` entry is a non-empty
71
+ * string. Any other shape — including a partly malformed list — yields the
72
+ * empty policy so a compromised/edited file cannot widen the boundary.
73
+ */
74
+ export function parseContextManagementPolicy(raw: unknown): ContextManagementPolicy {
75
+ const compaction = asRecord(asRecord(raw)?.compaction);
76
+ if (!compaction || compaction.contextManagement !== "remote") return EMPTY_CONTEXT_MANAGEMENT_POLICY;
77
+ const listed = compaction.gatewayContextModels;
78
+ if (!Array.isArray(listed)) return EMPTY_CONTEXT_MANAGEMENT_POLICY;
79
+ const gatewayModels: string[] = [];
80
+ for (const entry of listed) {
81
+ if (typeof entry !== "string" || entry.trim() === "") return EMPTY_CONTEXT_MANAGEMENT_POLICY;
82
+ if (!gatewayModels.includes(entry)) gatewayModels.push(entry);
83
+ }
84
+ return Object.freeze({ gatewayModels: Object.freeze(gatewayModels) });
85
+ }
86
+
87
+ /** Default toolkit config path, using the shared Pi agent-directory precedence. */
88
+ export function contextManagementConfigPath(agentDir = piAgentDir()): string {
89
+ return path.join(agentDir, ...TOOLKIT_CONFIG_PATH_PARTS);
90
+ }
91
+
92
+ /**
93
+ * Read one Remote Context snapshot. The injectable path keeps checks offline.
94
+ * Every failure mode (missing, unreadable, invalid JSON, wrong shape) returns
95
+ * the empty policy, so an ineligible target never inherits context tools.
96
+ */
97
+ export async function readContextManagementPolicy(
98
+ file = contextManagementConfigPath(),
99
+ ): Promise<ContextManagementPolicy> {
100
+ try {
101
+ return parseContextManagementPolicy(JSON.parse(await fs.readFile(file, "utf8")));
102
+ } catch {
103
+ return EMPTY_CONTEXT_MANAGEMENT_POLICY; // fail closed; stdout is the RPC channel
104
+ }
105
+ }
106
+
107
+ /** True when `model` exactly equals a configured `gatewayContextModels` entry. */
108
+ export function isGatewayContextModel(
109
+ policy: ContextManagementPolicy | undefined,
110
+ model: string | undefined,
111
+ ): boolean {
112
+ if (!policy || !model) return false;
113
+ return policy.gatewayModels.includes(model);
114
+ }
115
+
116
+ /**
117
+ * Context-tool names a target model may receive: the parent-exposed subset,
118
+ * and only for an exact allowlisted model. Empty for every other target.
119
+ */
120
+ export function contextToolsForModel(
121
+ policy: ContextManagementPolicy | undefined,
122
+ model: string | undefined,
123
+ parentExposed: readonly string[],
124
+ ): string[] {
125
+ if (!isGatewayContextModel(policy, model)) return [];
126
+ const exposed = new Set(parentExposed);
127
+ return CONTEXT_MANAGEMENT_TOOL_NAMES.filter((tool) => exposed.has(tool));
128
+ }
129
+
130
+ /** Tool list for the internally constructed read-only synthesis child. */
131
+ export function synthesisToolsForModel(
132
+ policy: ContextManagementPolicy | undefined,
133
+ model: string | undefined,
134
+ parentExposed: readonly string[],
135
+ ): string[] {
136
+ return ["read", ...contextToolsForModel(policy, model, parentExposed)];
137
+ }
138
+
139
+ /**
140
+ * Re-derive a child tool allowlist for one target model (primary request or a
141
+ * fallback attempt). Non-context names keep their existing order/deduplication;
142
+ * all four context names are removed first and re-appended only when the
143
+ * effective backend is Pi and the attempt model is exactly allowlisted.
144
+ *
145
+ * Non-Pi backends are returned unchanged. Absent a dispatch policy, a Pi list
146
+ * loses every context name (fail closed) — low-level `runTasks` callers that
147
+ * provide a validated `TaskSpec` but no snapshot cannot reintroduce them. A Pi
148
+ * task with no explicit list becomes `--no-tools` at the backend boundary
149
+ * rather than silently falling back to Pi's unrestricted default tool set.
150
+ */
151
+ export function filterContextToolsForModel(
152
+ tools: readonly string[] | undefined,
153
+ options: {
154
+ backend: string;
155
+ model?: string;
156
+ policy?: ContextManagementPolicy;
157
+ parentExposed?: readonly string[];
158
+ },
159
+ ): string[] | undefined {
160
+ if (tools === undefined) return options.backend === "pi" ? [] : undefined;
161
+ if (options.backend !== "pi") return [...tools];
162
+ const kept = tools.filter((tool) => !CONTEXT_MANAGEMENT_TOOLS.has(tool));
163
+ return [
164
+ ...new Set([
165
+ ...kept,
166
+ ...contextToolsForModel(options.policy, options.model, options.parentExposed ?? []),
167
+ ]),
168
+ ];
169
+ }
package/src/extension.ts CHANGED
@@ -22,9 +22,15 @@ import {
22
22
  import { createGetPiCommand, getLaunchResolution } from "./launch.js";
23
23
  import { abortAsPromise } from "./maintenance.js";
24
24
  import { sweepSessionsLifecycle } from "./distill.js";
25
- import { runTasks } from "./orchestrator.js";
25
+ import { runTasksWithContextPolicy } from "./orchestrator.js";
26
26
  import { OutputManager } from "./output.js";
27
- import { CONTEXT_MANAGEMENT_TOOLS, parseDepth, parseSpawnPolicy, SPAWNS_ENV_VAR, validateSubagentRequest, type ResolvedTask } from "./policy.js";
27
+ import {
28
+ CONTEXT_MANAGEMENT_TOOL_NAMES,
29
+ readContextManagementPolicy,
30
+ synthesisToolsForModel,
31
+ type ContextManagementPolicy,
32
+ } from "./context-policy.js";
33
+ import { parseDepth, parseSpawnPolicy, SPAWNS_ENV_VAR, validateSubagentRequest, type ResolvedTask } from "./policy.js";
28
34
  import type { ChildRunner } from "./runner.js";
29
35
  import { ProcessLockManager } from "./process-lock.js";
30
36
  import { SessionScopedRunRegistry, snapshotFromLiveRun } from "./registry.js";
@@ -523,7 +529,13 @@ async function runSynthesis(
523
529
  runtime: SessionRuntime,
524
530
  instruction: string,
525
531
  results: TaskResult[],
526
- options: { runId: string; modelPolicy: ModelPolicySnapshot; signal: AbortSignal },
532
+ options: {
533
+ runId: string;
534
+ modelPolicy: ModelPolicySnapshot;
535
+ contextPolicy: ContextManagementPolicy;
536
+ parentContextTools: readonly string[];
537
+ signal: AbortSignal;
538
+ },
527
539
  ): Promise<TaskResult | undefined> {
528
540
  const sections = results.map((result, index) => {
529
541
  // Typed handoff: validated structured results feed the synthesis child
@@ -570,7 +582,9 @@ async function runSynthesis(
570
582
  label: "synthesis",
571
583
  profile: "review",
572
584
  canWrite: false,
573
- tools: ["read", ...CONTEXT_MANAGEMENT_TOOLS],
585
+ // Same exact target-model gate as ordinary children: a disallowed
586
+ // synthesis model receives no context-manager tools at all.
587
+ tools: synthesisToolsForModel(options.contextPolicy, approved.route.model, options.parentContextTools),
574
588
  model: approved.route.model,
575
589
  fallbackModels: [...approved.route.fallbackModels],
576
590
  thinking: approved.route.thinking ?? "low",
@@ -976,12 +990,19 @@ export default function registerSubagent(pi: ExtensionAPI): void {
976
990
  // restarting the parent session; no provider catalog or credentials are
977
991
  // loaded by this path.
978
992
  const dispatchConfig = loadConfig(await readConfigFile());
993
+ // One Remote Context snapshot per dispatch: the same operator-owned
994
+ // gateway allowlist gates initial validation, every fallback attempt,
995
+ // and the internally constructed synthesis child. Missing/invalid/off
996
+ // toolkit configuration fails closed to an empty allowlist.
997
+ const contextPolicy = await readContextManagementPolicy();
998
+ const parentToolNames = pi.getAllTools().map((tool) => tool.name);
999
+ const parentContextTools = CONTEXT_MANAGEMENT_TOOL_NAMES.filter((tool) => parentToolNames.includes(tool));
979
1000
  const model = ctx.model;
980
1001
  const validated = validateSubagentRequest(params, {
981
1002
  cwd: ctx.cwd,
982
1003
  model: model ? `${model.provider}/${model.id}` : undefined,
983
1004
  thinking: pi.getThinkingLevel() as TaskSpec["thinking"],
984
- availableTools: pi.getAllTools().map((tool) => tool.name),
1005
+ availableTools: parentToolNames,
985
1006
  activeTools: pi.getActiveTools(),
986
1007
  depth: parseDepth(),
987
1008
  sessionFile: ctx.sessionManager.getSessionFile() ?? undefined,
@@ -993,6 +1014,7 @@ export default function registerSubagent(pi: ExtensionAPI): void {
993
1014
  agents: agentCatalog(runtime),
994
1015
  modelPolicy: dispatchConfig.modelPolicy,
995
1016
  modelPolicyError: dispatchConfig.modelPolicyError,
1017
+ contextPolicy,
996
1018
  });
997
1019
  if (!validated.ok) fail(validated.error);
998
1020
 
@@ -1174,6 +1196,7 @@ export default function registerSubagent(pi: ExtensionAPI): void {
1174
1196
  }
1175
1197
 
1176
1198
  const specs: TaskSpec[] = validated.tasks.map((task: ResolvedTask) => ({
1199
+ backend: task.backend,
1177
1200
  task: task.task,
1178
1201
  label: task.label,
1179
1202
  systemPrompt: task.systemPrompt,
@@ -1262,7 +1285,7 @@ export default function registerSubagent(pi: ExtensionAPI): void {
1262
1285
 
1263
1286
  const work = (async () => {
1264
1287
  try {
1265
- const result = await runTasks(specs, {
1288
+ const result = await runTasksWithContextPolicy(specs, {
1266
1289
  semaphore: runtime.semaphore,
1267
1290
  getPiCommand: runtime.getPiCommand,
1268
1291
  sessionDir: runtime.config.sessionDir,
@@ -1276,6 +1299,8 @@ export default function registerSubagent(pi: ExtensionAPI): void {
1276
1299
  stallAfterMs: runtime.config.stallAfterMs,
1277
1300
  stallKillAfterMs: runtime.config.stallKillAfterMs,
1278
1301
  maxRetries: runtime.config.maxRetries,
1302
+ contextPolicy,
1303
+ parentContextTools,
1279
1304
  onRunnerCreated: (index, runner) => {
1280
1305
  let runners = runtime.liveRunners.get(runId);
1281
1306
  if (!runners) runtime.liveRunners.set(runId, (runners = new Map()));
@@ -1322,6 +1347,8 @@ export default function registerSubagent(pi: ExtensionAPI): void {
1322
1347
  const synthesized = await runSynthesis(runtime, validated.synthesis, result.results, {
1323
1348
  runId,
1324
1349
  modelPolicy: dispatchConfig.modelPolicy!,
1350
+ contextPolicy,
1351
+ parentContextTools,
1325
1352
  signal: controller.signal,
1326
1353
  });
1327
1354
  if (synthesized) result.results = [synthesized, ...result.results];
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import { defaultConfig } from "./config.js";
4
+ import { filterContextToolsForModel, type ContextManagementPolicy } from "./context-policy.js";
4
5
  import { createGetPiCommand } from "./launch.js";
5
6
  import type { ProcessLockManager } from "./process-lock.js";
6
7
  import { ChildRunner, type GetPiCommand } from "./runner.js";
@@ -30,6 +31,40 @@ export interface OrchestratorDeps {
30
31
  maxRetries?: number;
31
32
  }
32
33
 
34
+ /** Internal-only capability snapshot; deliberately absent from the stable SDK type. */
35
+ interface ContextOrchestratorDeps {
36
+ /** Remote Context allowlist captured once for the parent dispatch. */
37
+ contextPolicy?: ContextManagementPolicy;
38
+ /** Parent-exposed context-manager tool names in canonical order. */
39
+ parentContextTools?: readonly string[];
40
+ }
41
+
42
+ type RunTasksOptions = OrchestratorDeps & { signal?: AbortSignal };
43
+ type InternalRunTasksOptions = RunTasksOptions & ContextOrchestratorDeps;
44
+
45
+ /** Internal projection used by the stable public entry and offline checks. */
46
+ export function stripContextOrchestratorOptions(options: RunTasksOptions): InternalRunTasksOptions {
47
+ return {
48
+ ...options,
49
+ contextPolicy: undefined,
50
+ parentContextTools: undefined,
51
+ };
52
+ }
53
+
54
+ /** Resolve the actual tool list that one retry attempt will receive. */
55
+ export function toolsForAttempt(
56
+ spec: Pick<TaskSpec, "backend" | "tools">,
57
+ model: string | undefined,
58
+ options: ContextOrchestratorDeps,
59
+ ): string[] | undefined {
60
+ return filterContextToolsForModel(spec.tools, {
61
+ backend: spec.backend ?? "pi",
62
+ model,
63
+ policy: options.contextPolicy,
64
+ parentExposed: options.parentContextTools,
65
+ });
66
+ }
67
+
33
68
  /**
34
69
  * Transient failures are infrastructure problems, not task problems: the same
35
70
  * spec is safe to retry without duplicating side effects because no meaningful
@@ -88,7 +123,29 @@ async function writeArtifact(spec: TaskSpec, result: TaskResult): Promise<void>
88
123
 
89
124
  export async function runTasks(
90
125
  specs: TaskSpec[],
91
- options: OrchestratorDeps & { signal?: AbortSignal } = {},
126
+ options: RunTasksOptions = {},
127
+ ): Promise<OrchestratedRun> {
128
+ // Deliberately erase any runtime-only fields supplied by JavaScript callers
129
+ // or `as any` casts. Only the extension-only entry below may carry the
130
+ // operator-owned context snapshot into the attempt loop.
131
+ return runTasksInternal(specs, stripContextOrchestratorOptions(options));
132
+ }
133
+
134
+ /**
135
+ * Extension-only orchestration entry that carries the trusted, per-dispatch
136
+ * Remote Context snapshot. It is intentionally not re-exported from src/index.ts;
137
+ * stable SDK callers cannot forge operator-owned context authorization.
138
+ */
139
+ export async function runTasksWithContextPolicy(
140
+ specs: TaskSpec[],
141
+ options: InternalRunTasksOptions,
142
+ ): Promise<OrchestratedRun> {
143
+ return runTasksInternal(specs, options);
144
+ }
145
+
146
+ async function runTasksInternal(
147
+ specs: TaskSpec[],
148
+ options: InternalRunTasksOptions,
92
149
  ): Promise<OrchestratedRun> {
93
150
  const semaphore = options.semaphore ?? new Semaphore(defaultConfig.maxActiveProcesses, defaultConfig.maxQueuedTasks);
94
151
  const worktrees = options.worktrees ?? new WorktreeManager();
@@ -163,7 +220,15 @@ export async function runTasks(
163
220
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
164
221
  const model = attempt === 1 ? spec.model : (fallbacks[attempt - 2] ?? spec.model);
165
222
  if (model) attemptedModels.push(model);
166
- const attemptSpec: TaskSpec = { ...spec, model };
223
+ const attemptSpec: TaskSpec = {
224
+ ...spec,
225
+ model,
226
+ // Retry safety: a fallback model may differ in Remote Context
227
+ // eligibility, so context tools are re-derived per attempt from the
228
+ // original validated list. Without a dispatch snapshot this removes
229
+ // them entirely (fail closed) instead of leaking the primary's.
230
+ tools: toolsForAttempt(spec, model, options),
231
+ };
167
232
  const runner = new ChildRunner(
168
233
  semaphore,
169
234
  options.getPiCommand ?? createGetPiCommand(),
package/src/policy.ts CHANGED
@@ -9,6 +9,13 @@ import { BACKEND_NAMES, checkCapabilities, type BackendName } from "./backend.js
9
9
  import { resolveBackend } from "./backends/index.js";
10
10
  import { validateModelRequest, type ModelPolicySnapshot } from "./model-policy.js";
11
11
  import { isThinkingLevel } from "./thinking.js";
12
+ import {
13
+ CONTEXT_MANAGEMENT_TOOLS,
14
+ EMPTY_CONTEXT_MANAGEMENT_POLICY,
15
+ contextToolsForModel,
16
+ isGatewayContextModel,
17
+ type ContextManagementPolicy,
18
+ } from "./context-policy.js";
12
19
 
13
20
  export const DEPTH_ENV_VAR = "PI_SUBAGENT_DEPTH";
14
21
  export const SPAWNS_ENV_VAR = "PI_SUBAGENT_SPAWNS";
@@ -27,19 +34,8 @@ export const READ_ONLY_TOOLS = new Set([
27
34
  "web_search",
28
35
  "web_fetch",
29
36
  ]);
30
- /**
31
- * Pi context-management tools are control-plane capabilities: they may update
32
- * continuity notes or the remote context window, but they cannot modify the
33
- * child checkout. Keep them separate from ordinary source-inspection tools so
34
- * the read-only profile's exception remains explicit.
35
- */
36
- export const CONTEXT_MANAGEMENT_TOOLS = new Set([
37
- "new_context",
38
- "get_context_remaining",
39
- "history",
40
- "notes",
41
- ]);
42
37
  const NON_WRITING_TOOLS = new Set([...READ_ONLY_TOOLS, ...CONTEXT_MANAGEMENT_TOOLS]);
38
+ export { CONTEXT_MANAGEMENT_TOOLS };
43
39
  export const KNOWN_WRITE_TOOLS = new Set(["bash", "edit", "write"]);
44
40
  /** Backward-compatible export; policy uses fail-closed classification above. */
45
41
  export const WRITE_TOOLS = KNOWN_WRITE_TOOLS;
@@ -89,24 +85,43 @@ function resolveTools(
89
85
  availableTools: string[],
90
86
  activeTools: string[],
91
87
  backend: BackendName,
88
+ contextPolicy: ContextManagementPolicy = EMPTY_CONTEXT_MANAGEMENT_POLICY,
89
+ targetModel: string | undefined = undefined,
92
90
  ): { tools?: string[]; canWrite?: boolean; error?: string } {
93
- const available = new Set(availableTools);
94
- const contextTools = backend === "pi"
95
- ? [...CONTEXT_MANAGEMENT_TOOLS].filter((tool) => available.has(tool))
96
- : [];
97
- const nonWritingTools = backend === "pi" ? NON_WRITING_TOOLS : READ_ONLY_TOOLS;
98
- // Keep Pi's context-management control plane available to every child when
99
- // the parent exposes it, even if the task requested a narrower tool subset.
91
+ const isPi = backend === "pi";
92
+ // Remote Context eligibility belongs to the exact target model, never to the
93
+ // parent session: a parent that exposes context tools is not authorization
94
+ // for a different child model.
95
+ const eligible = isPi && isGatewayContextModel(contextPolicy, targetModel);
96
+ const contextTools = eligible ? contextToolsForModel(contextPolicy, targetModel, availableTools) : [];
97
+ const nonWritingTools = isPi ? NON_WRITING_TOOLS : READ_ONLY_TOOLS;
98
+ // A non-eligible Pi target must not see context names at all: not inherited
99
+ // from the parent's active tools, not in the read-only default set, and not
100
+ // as a valid explicit request.
101
+ const availableForTarget = new Set(availableTools);
102
+ if (isPi && !eligible) {
103
+ for (const tool of CONTEXT_MANAGEMENT_TOOLS) availableForTarget.delete(tool);
104
+ }
100
105
  const addContextTools = (tools: readonly string[]): string[] =>
101
106
  [...new Set([...tools, ...contextTools])];
107
+ const dropContextTools = (tools: readonly string[]): string[] => {
108
+ if (!isPi || eligible) return [...tools];
109
+ return tools.filter((tool) => !CONTEXT_MANAGEMENT_TOOLS.has(tool));
110
+ };
102
111
 
103
112
  if (requested) {
104
- const unknown = requested.filter((tool) => !available.has(tool));
113
+ // An explicit context name on a non-eligible target is unavailable, not
114
+ // silently dropped — the caller gets the existing rejection channel.
115
+ const unknown = requested.filter((tool) => !availableForTarget.has(tool));
105
116
  if (unknown.length) return { error: `Unknown or unavailable tools: ${unknown.join(", ")}` };
106
117
  }
107
118
 
108
119
  if (profile === "explore" || profile === "review") {
109
- const source = addContextTools(requested ?? [...nonWritingTools].filter((tool) => available.has(tool)));
120
+ // Non-writing defaults come from READ_ONLY_TOOLS; context tools are added
121
+ // back only through the eligibility-gated addContextTools above.
122
+ const source = addContextTools(
123
+ requested ?? [...READ_ONLY_TOOLS].filter((tool) => availableForTarget.has(tool)),
124
+ );
110
125
  const unsafe = source.filter((tool) => !nonWritingTools.has(tool));
111
126
  if (unsafe.length) {
112
127
  return {
@@ -116,8 +131,8 @@ function resolveTools(
116
131
  return { tools: source, canWrite: false };
117
132
  }
118
133
 
119
- const source = addContextTools(requested ?? activeTools);
120
- const unknown = source.filter((tool) => !available.has(tool));
134
+ const source = dropContextTools(addContextTools(requested ?? activeTools));
135
+ const unknown = source.filter((tool) => !availableForTarget.has(tool));
121
136
  if (unknown.length) return { error: `Active tools are unavailable: ${unknown.join(", ")}` };
122
137
  // General-profile custom tools are conservatively write-capable unless explicitly known non-writing.
123
138
  return {
@@ -158,7 +173,7 @@ function normalizeTask(
158
173
  index: number,
159
174
  parent: ParentContext,
160
175
  defaultProfile: TaskProfile,
161
- defaults: { timeoutMs?: number; taskDefaults?: TaskDefaultsByProfile; agents?: Map<string, AgentDefinition>; modelPolicy?: ModelPolicySnapshot; modelPolicyError?: string } = {},
176
+ defaults: { timeoutMs?: number; taskDefaults?: TaskDefaultsByProfile; agents?: Map<string, AgentDefinition>; modelPolicy?: ModelPolicySnapshot; modelPolicyError?: string; contextPolicy?: ContextManagementPolicy } = {},
162
177
  ): { task?: ResolvedTask; error?: string } {
163
178
  if (!item.task?.trim()) return { error: `Task ${index + 1} must not be blank` };
164
179
 
@@ -222,8 +237,20 @@ function normalizeTask(
222
237
  }
223
238
  const profile = item.profile ?? agent?.profile ?? defaultProfile;
224
239
  const requestedTools = item.tools ?? agent?.tools;
225
- const resolved = resolveTools(profile, requestedTools, parent.availableTools, parent.activeTools ?? parent.availableTools, backend);
226
- if (resolved.error || !resolved.tools || resolved.canWrite === undefined) return { error: resolved.error ?? "Tool resolution failed" };
240
+ const resolved = resolveTools(
241
+ profile,
242
+ requestedTools,
243
+ parent.availableTools,
244
+ parent.activeTools ?? parent.availableTools,
245
+ backend,
246
+ defaults.contextPolicy ?? EMPTY_CONTEXT_MANAGEMENT_POLICY,
247
+ // The target model is the policy-routed model; the parent's model is not
248
+ // authorization for a different child.
249
+ modelRoute.model,
250
+ );
251
+ if (resolved.error || !resolved.tools || resolved.canWrite === undefined) {
252
+ return { error: `Task ${index + 1}: ${resolved.error ?? "Tool resolution failed"}` };
253
+ }
227
254
  const cwd = resolvePath(parent.cwd, item.cwd);
228
255
  const output = item.output ? resolvePath(cwd, item.output) : undefined;
229
256
  // Non-model fields retain the existing precedence: explicit request > agent
@@ -403,6 +430,8 @@ export function validateSubagentRequest(
403
430
  agents?: Map<string, AgentDefinition>;
404
431
  modelPolicy?: ModelPolicySnapshot;
405
432
  modelPolicyError?: string;
433
+ /** Remote Context allowlist snapshot; omitted means fail closed (no context tools). */
434
+ contextPolicy?: ContextManagementPolicy;
406
435
  } = {},
407
436
  ): ValidationResult {
408
437
  const defaults = {
@@ -411,6 +440,7 @@ export function validateSubagentRequest(
411
440
  agents: options.agents,
412
441
  modelPolicy: options.modelPolicy,
413
442
  modelPolicyError: options.modelPolicyError,
443
+ contextPolicy: options.contextPolicy,
414
444
  };
415
445
  const depth = parent.depth ?? parseDepth();
416
446
  const maxDepth = options.maxDepth ?? defaultConfig.maxDepth;
package/src/schema.ts CHANGED
@@ -51,8 +51,8 @@ export const TaskFields = {
51
51
  system_prompt: Type.Optional(Type.String({ description: "Extra system prompt appended to the child's prompt (does not replace it)." })),
52
52
  model: Type.Optional(Type.String({ description: "**REQUIRED for every spawn call (task/tasks).** Exact model id from modelPolicy, in provider/model-id form. Calls without an explicit model are rejected; agent-file model, taskDefaults, and parent-session inheritance are ignored. Management actions (status/wait/cancel/steer/diff/apply/discard) do not need it." })),
53
53
  thinking: Type.Optional({ ...ThinkingLevel, description: "Opaque Pi thinking level for the child. Values such as max are passed through unchanged; Pi/model support decides validity. Defaults to agent thinking, profile taskDefaults.thinking, modelPolicy route thinking, then the parent's level." }),
54
- tools: Type.Optional(Type.Array(Type.String(), { description: "Optional tool allowlist. explore/review profiles reject project-writing tools; Pi context-management tools remain available for context continuity." })),
55
- profile: Type.Optional({ ...Profile, description: "Capability profile: explore/review cannot write project files but retain Pi context-management tools; general inherits the parent's active tools and may write." }),
54
+ tools: Type.Optional(Type.Array(Type.String(), { description: "Optional tool allowlist. explore/review profiles reject project-writing tools. Pi context-management tools are added only when the target model is an exact `compaction.gatewayContextModels` entry of the Remote Context toolkit config and the parent exposes them; requesting one for any other target is rejected as unavailable." })),
55
+ profile: Type.Optional({ ...Profile, description: "Capability profile: explore/review cannot write project files; Pi context-management tools are retained only for an exact allowlisted Remote Context target and never grant bash/edit/write. general inherits the parent's active tools and may write." }),
56
56
  cwd: Type.Optional(Type.String({ description: "Working directory for the child process." })),
57
57
  timeout_ms: Type.Optional(Type.Number({ minimum: 1, maximum: 24 * 60 * 60_000, description: "Total budget in milliseconds including queue time. Timed-out runs report which phase timed out." })),
58
58
  max_turns: Type.Optional(Type.Number({ minimum: 1, maximum: 500, description: "Budget: at this many turns the child is steered to wrap up and given grace turns for a final answer; ends as 'partial' with output preserved." })),