@arhen/pi-core-subagent 1.3.62 → 1.3.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -317,6 +317,21 @@ Background (default) + intercom — the run returns a runId immediately; you sta
317
317
 
318
318
  > **Ask urgency:** `ask_parent` takes `urgent` (default `false`). Both variants steer into the leader's current turn so the question is never deferred to the end of a long turn. `[URGENT]` tells the leader to answer before its next step; `[not urgent]` tells it that the child keeps waiting, so it may finish its current step first. Failures steer for the same reason; completions and aborts queue as follow-ups.
319
319
 
320
+ ## Tool exposure and opt-in codemode
321
+
322
+ Default: **legacy direct calls**, even when the codemode tool is active. Active helpers remain
323
+ model-visible and retain native script-call compatibility. Explicit global `codemode.mode: "only"`
324
+ still applies Pi\'s global script-only policy; this extension does not override it.
325
+
326
+ - `/subagents mode` — show preference and effective profile.
327
+ - `/subagents mode auto` — opt in to script-based routing while codemode is active; direct otherwise.
328
+ - `/subagents mode codemode` — explicitly select script-based routing (direct fallback when unavailable).
329
+ - `/subagents mode direct` — return to legacy direct behavior.
330
+
331
+ Choice is stored per session branch. New sessions/branches without a preference start direct;
332
+ existing explicit choices remain intact. Published 1.3.63 defaulted to `auto`: this compatibility
333
+ correction requires the corrected source/package, not a retroactive change to that npm release.
334
+
320
335
  ## Commands
321
336
 
322
337
  - `/subagents` — list runs; `/subagents peek` (or `ctrl+shift+a`) — browsable pane
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arhen/pi-core-subagent",
3
- "version": "1.3.62",
3
+ "version": "1.3.64",
4
4
  "type": "module",
5
5
  "description": "pi extension: fast in-process subagents with a dependency-graph scheduler (needs edges gate tasks and carry upstream output into dependent prompts), plus background runs, intercom and agent-to-agent mailbox. Leader defines agents inline.",
6
6
  "license": "MIT",
package/src/format.ts CHANGED
@@ -248,8 +248,8 @@ export function makeTaskNotice(run: RunSnapshot, task: TaskSnapshot, kind: strin
248
248
  isStartupFailure(task, kind)
249
249
  ? "Never started — stop and diagnose before spawning anything else: a config-level error (model, plan, auth, agent file) fails identically on every respawn."
250
250
  : kind === "completed"
251
- ? `Use subagent_result(runId: "${run.id}", taskId: "${task.id}") for full output.`
252
- : `Session file kept — resume_subagent(runId: "${run.id}", taskId: "${task.id}", model?: ...) revives it with full context. subagent_result for what it produced so far.`,
251
+ ? `Use subagent_result({ runId: "${run.id}", taskId: "${task.id}" }) for full output.`
252
+ : `Session file kept — resume_subagent({ runId: "${run.id}", taskId: "${task.id}", model?: ... }) revives it with full context. subagent_result for what it produced so far.`,
253
253
  ].join("\n");
254
254
  }
255
255
  export function makeAskNotice(
@@ -257,7 +257,7 @@ export function makeAskNotice(
257
257
  extra: { taskId?: string; agent?: string; question?: string; urgent?: boolean },
258
258
  ): string {
259
259
  const who = extra.agent ? `${extra.agent} (${extra.taskId ?? "task"})` : (extra.taskId ?? "a subagent");
260
- const reply = `reply_subagent(runId: "${run.id}", taskId: "${extra.taskId ?? ""}", message: ...)`;
260
+ const reply = `reply_subagent({ runId: "${run.id}", taskId: "${extra.taskId ?? ""}", message: ... })`;
261
261
  return extra.urgent
262
262
  ? `[URGENT] Subagent ${who} is blocked and cannot continue until you answer: ${extra.question ?? ""}\nAnswer now, before your next step, with ${reply}.`
263
263
  : `[not urgent] Subagent ${who} asks: ${extra.question ?? ""}\nIt waits while you keep working — finish your current step first if you want, then answer with ${reply}.`;
@@ -270,7 +270,7 @@ export function makeNotice(run: RunSnapshot, kind: string): string {
270
270
  for (const task of run.tasks) {
271
271
  lines.push(`- ${task.agent}: ${task.status}${task.error ? ` — ${truncateText(task.error, 200)}` : ""}`);
272
272
  }
273
- lines.push(`Use subagent_result(runId: "${run.id}") for full output.`);
273
+ lines.push(`Use subagent_result({ runId: "${run.id}" }) for full output.`);
274
274
  return lines.join("\n");
275
275
  }
276
276
 
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
2
2
  import { Text, truncateToWidth } from "@earendil-works/pi-tui";
3
+ import type { TSchema } from "typebox";
3
4
  import {
4
5
  compactLines,
5
6
  formatUsage,
@@ -13,6 +14,7 @@ import { waveNotation } from "./graph.ts";
13
14
  import { cloneRun, type ParkedMsg, SubagentManager } from "./manager.ts";
14
15
  import { listSelectableModels } from "./models.ts";
15
16
  import { createPeekPane, type PeekTask } from "./peek.ts";
17
+ import { type AnyToolDefinition, createPresentation, isSubagentMode } from "./presentation.ts";
16
18
  import {
17
19
  AwaitParam,
18
20
  ModelsParam,
@@ -29,6 +31,13 @@ import { cleanupMerged, ownerAlive, reapDeadWorktrees, repoRoot, sweepStale } fr
29
31
 
30
32
  export default function (pi: ExtensionAPI) {
31
33
  const manager = new SubagentManager(pi);
34
+ const definitions: AnyToolDefinition[] = [];
35
+ const presentation = createPresentation(pi, definitions);
36
+ const defineTool = <TParams extends TSchema, TDetails = unknown, TState = any>(
37
+ definition: ToolDefinition<TParams, TDetails, TState>,
38
+ ): void => {
39
+ definitions.push(definition as AnyToolDefinition);
40
+ };
32
41
 
33
42
  const openPeek = async (ctx: ExtensionContext) => {
34
43
  if (!ctx.hasUI) return;
@@ -65,11 +74,30 @@ export default function (pi: ExtensionAPI) {
65
74
  };
66
75
  pi.registerCommand("subagents", {
67
76
  description:
68
- "List subagent runs. `/subagents peek` opens the browsable pane; `/subagents auto-limit on|off` toggles the 1 h default runtime ceiling (default off = 6 h).",
77
+ "List subagent runs. `/subagents peek` opens the browsable pane; `/subagents mode [auto|direct|codemode]` reports or switches the tool exposure profile; `/subagents auto-limit on|off` toggles the 1 h default runtime ceiling (default off = 6 h).",
69
78
  handler: async (args, ctx) => {
70
79
  const arg = String(args ?? "")
71
80
  .trim()
72
81
  .toLowerCase();
82
+ if (arg === "mode" || arg.startsWith("mode ")) {
83
+ const value = arg.split(/\s+/)[1];
84
+ if (value === undefined) {
85
+ ctx.ui.notify(presentation.describe(), "info");
86
+ } else if (isSubagentMode(value)) {
87
+ presentation.setPreference(value);
88
+ // A switch during a streaming turn or an executing script would rewrite the loadout under
89
+ // a live call; defer it to the next request boundary in that case.
90
+ const idle = typeof ctx.isIdle === "function" ? ctx.isIdle() : true;
91
+ if (idle) presentation.sync();
92
+ ctx.ui.notify(
93
+ `Subagent exposure mode set to ${value}${idle ? "." : " — applies at the next request boundary."} ${presentation.describe()}`,
94
+ "info",
95
+ );
96
+ } else {
97
+ ctx.ui.notify(`Unknown subagent mode "${value}". Use \`/subagents mode auto|direct|codemode\`.`, "warning");
98
+ }
99
+ return;
100
+ }
73
101
  if (arg === "peek") return openPeek(ctx);
74
102
  if (arg === "auto-limit" || arg.startsWith("auto-limit ")) {
75
103
  const value = arg.split(/\s+/)[1];
@@ -103,8 +131,19 @@ export default function (pi: ExtensionAPI) {
103
131
  manager.turnActivity = false;
104
132
  });
105
133
 
134
+ pi.on("before_agent_start", () => {
135
+ presentation.sync();
136
+ });
137
+ // Successive model turns of the same run are safe boundaries too: a codemode availability or
138
+ // helper-selection change made between turns must reach the next request without a new prompt.
139
+ pi.on("turn_start", () => {
140
+ presentation.sync();
141
+ });
142
+
106
143
  pi.on("session_start", async (_event, ctx) => {
107
144
  await manager.restoreFromSidecar(ctx);
145
+ presentation.restore(ctx);
146
+ presentation.sync();
108
147
 
109
148
  const roots = new Set<string>();
110
149
  const cwdRoot = repoRoot(ctx.cwd);
@@ -125,6 +164,11 @@ export default function (pi: ExtensionAPI) {
125
164
  } catch {}
126
165
  }
127
166
  });
167
+ pi.on("session_tree", (_event, ctx) => {
168
+ presentation.restore(ctx);
169
+ presentation.sync();
170
+ });
171
+
128
172
  pi.on("session_shutdown", async (_event, ctx) => {
129
173
  if (ctx?.hasUI) {
130
174
  try {
@@ -134,11 +178,11 @@ export default function (pi: ExtensionAPI) {
134
178
  manager.clearRuns();
135
179
  });
136
180
 
137
- pi.registerTool<typeof ModelsParam, ModelCatalog>({
181
+ defineTool<typeof ModelsParam, ModelCatalog>({
138
182
  name: "subagent_models",
139
183
  label: "Subagent Models",
140
184
  description:
141
- "List the models a subagent task may name, each with the exact `model` value to pass. The list is scoped to this session's enabled models when scoping is configured (the same set `/scoped-models` shows); when no scoping is configured, every model with usable credentials is listed and the output says so. Each entry includes its context window and pi catalog price per million tokens, and the thinking levels the runtime honors — a level shown here is not silently clamped. References shown here are safe to pass; ambiguous ones are called out. Naming a model is optional: omit `model` to inherit the session model, or call this when choosing one.",
185
+ "List the models a subagent task may name: the exact `model` value to pass, the thinking levels the runtime honors, the context window and catalog price. Scoped to this session's models when scoping is configured, else every usable model; ambiguous references are called out. Full reference: `describeNamespace('subagents')`.",
142
186
  promptSnippet: "List the models a subagent task can name (reference, thinking levels, context, price).",
143
187
  parameters: ModelsParam,
144
188
  async execute(_id, _params, _signal, _onUpdate, ctx) {
@@ -146,26 +190,24 @@ export default function (pi: ExtensionAPI) {
146
190
  },
147
191
  });
148
192
 
149
- pi.registerTool<typeof SubagentParams, RunDetails>({
193
+ defineTool<typeof SubagentParams, RunDetails>({
150
194
  name: "subagent",
151
195
  label: "Subagent",
152
196
 
153
197
  description:
154
- "Run isolated subagents (own context, own session) in the background: returns a runId immediately, completion notifies you. One call = one agent (`agent`+`task`) or many (`tasks`, or `chain` with `{previous}`). `needs` edges gate tasks and prepend upstream outputs to their prompts. A user agent file (`.agents/agents`, `.claude/agents`, `.pi/agents`; project dirs, then home) whose `description` matches the goal is authoritative: body = system prompt, frontmatter `model`/`tools` apply, but explicit per-call `tools`/`write` override the file's tools. Write agents get an isolated git worktree; the result reports the branch. Children always carry talk tools (ask/notify the leader, message siblings).",
198
+ "Run isolated subagents (own context/session) in the background; returns a runId and completion notifies you. One call = one agent (`agent`+`task`) or many (`tasks`, `chain`, or `needs` edges that gate tasks and prepend upstream output). Write tasks use an isolated git worktree and report a branch; a matching agent file is authoritative (matched by description, body/model win). Full reference: `describeNamespace('subagents')`.",
155
199
  promptSnippet: "Define and delegate work to specialized subagents.",
156
200
  promptGuidelines: [
157
201
  "`model` is optional: omit it to inherit your current session model, or name one (agent-file `model` frontmatter wins) to pin the run. Call subagent_models for the exact references, thinking levels, and prices this session may use.",
158
202
  "Use subagent when independent review, testing, research, or parallel analysis improves quality.",
159
203
  "Batch every sub-task in ONE call: subagent({ tasks: [...] }) — never multiple parallel subagent calls.",
160
204
  "Declare ordering with `needs` edges on the tasks, never by splitting into separate calls; dependents receive upstream outputs automatically — do not restate them. Prefer flat `tasks` (plain parallel); add `needs` only when ordering genuinely matters.",
205
+ "Define each agent inline: invented name, focused system prompt, read-only by default (write:true to edit). Agent files are matched by description/goal, not name; a match is authoritative (body/model), and only per-call tools/write override its tools.",
206
+ "Write agents work in an isolated git worktree; review the branch diff and merge with `git merge --no-ff <branch>` when done.",
207
+ "After spawning, call subagent_status({ runId }) ONCE to confirm the tasks started; fix or respawn a task that died on spawn.",
161
208
  "End each task with a runnable check, e.g. 'Verify: bun test'. A subagent's claim of success is not evidence.",
162
- "Write agents work in an isolated git worktree; their changes land on a branch — review the diff, then merge with `git merge --no-ff <branch>`. Never leave a worktree branch unmerged at the end of the task.",
163
- "Define each agent inline: invented name, focused system prompt, read-only by default (write:true to edit). A matched agent file takes over (see description); matching is by description, not name — name the agent whatever fits the goal.",
164
- "Right after spawning, call subagent_status(runId) ONCE before any other work — a child that died on spawn (or never started) is invisible until far later otherwise. If it shows a task failed/never started, fix or respawn immediately.",
165
- "Never block with nothing to do: if you have no work left after spawning, end your turn — completion notifies you and wakes a fresh turn with the results. await_subagent/autoAwait while idle only burns time and tokens.",
166
- "A failed task interrupts you immediately as a steering message — handle it in the same turn (resume, swap model, re-dispatch) instead of finishing the plan on a broken intermediate result. Completes and aborts queue as follow-ups.",
167
- "autoAwait:true only when this SAME turn must consume the result immediately. await_subagent is for syncing with your own parallel work — not the default follow-up to a spawn.",
168
- "A task that failed mid-work (provider error, rate limit, timeout) keeps its session file and branch: resume_subagent(runId, taskId, model?) revives it with full context — prefer that over respawning. Respawn only when it never started (no session file).",
209
+ "When you have no work left, end your turn — completion notifies you. await_subagent/autoAwait only when this turn must consume the result immediately.",
210
+ "A failed task keeps its session file and branch: resume_subagent({ runId, taskId }) revives it; respawn only when it never started.",
169
211
  ],
170
212
  parameters: SubagentParams,
171
213
  executionMode: "parallel",
@@ -195,7 +237,7 @@ export default function (pi: ExtensionAPI) {
195
237
  ? `\n${asks.length} child(ren) waiting for your answer:\n${asks
196
238
  .map(
197
239
  (a) =>
198
- `- ${a.agent} (${a.taskId}): ${a.text}\n reply_subagent(runId: "${run.id}", taskId: "${a.taskId}", message: ...)`,
240
+ `- ${a.agent} (${a.taskId}): ${a.text}\n reply_subagent({ runId: "${run.id}", taskId: "${a.taskId}", message: ... })`,
199
241
  )
200
242
  .join("\n")}\nAnswer each, then await_subagent again for the result.`
201
243
  : "",
@@ -208,7 +250,7 @@ export default function (pi: ExtensionAPI) {
208
250
  content: [
209
251
  {
210
252
  type: "text",
211
- text: `Background run started: ${details.run.id} (${details.run.mode}, ${details.run.tasks.length} task${details.run.tasks.length > 1 ? "s" : ""}).\nNext: call subagent_status("${details.run.id}") now to confirm the tasks actually started before doing anything else.\nAfter that, completion will notify you — if you have no other work, end your turn instead of waiting.\nOther tools: subagent_result / reply_subagent / steer_subagent / resume_subagent / subagent_cancel.`,
253
+ text: `Background run started: ${details.run.id} (${details.run.mode}, ${details.run.tasks.length} task${details.run.tasks.length > 1 ? "s" : ""}).\nNext: call subagent_status({ runId: "${details.run.id}" }) now to confirm the tasks actually started before doing anything else.\nAfter that, completion will notify you — if you have no other work, end your turn instead of waiting.\nOther tools: subagent_result / reply_subagent / steer_subagent / resume_subagent / subagent_cancel.`,
212
254
  },
213
255
  ],
214
256
  details,
@@ -282,11 +324,11 @@ export default function (pi: ExtensionAPI) {
282
324
  },
283
325
  });
284
326
 
285
- pi.registerTool<typeof RunIdParam, { run?: RunSnapshot }>({
327
+ defineTool<typeof RunIdParam, { run?: RunSnapshot }>({
286
328
  name: "subagent_status",
287
329
  label: "Subagent Status",
288
330
  description:
289
- "Live per-task status of a subagent run (non-blocking), incl. each child's session file path (JSONL) to `tail -f` from outside. Call once right after spawning to verify children actually started.",
331
+ "Live per-task status of a subagent run (non-blocking), incl. each child's session file path to `tail -f`. Call once right after spawning to verify children actually started.",
290
332
  promptSnippet: "Check progress of a subagent run; use right after spawn as a health check.",
291
333
  parameters: RunIdParam,
292
334
  async execute(_id, params) {
@@ -303,7 +345,7 @@ export default function (pi: ExtensionAPI) {
303
345
  },
304
346
  });
305
347
 
306
- pi.registerTool<typeof ResultParam, { run?: RunSnapshot }>({
348
+ defineTool<typeof ResultParam, { run?: RunSnapshot }>({
307
349
  name: "subagent_result",
308
350
  label: "Subagent Result",
309
351
  description: "Full result (finalText + usage) of a run or one task. Non-blocking.",
@@ -330,11 +372,11 @@ export default function (pi: ExtensionAPI) {
330
372
  },
331
373
  });
332
374
 
333
- pi.registerTool<typeof AwaitParam, { run?: RunSnapshot }>({
375
+ defineTool<typeof AwaitParam, { run?: RunSnapshot }>({
334
376
  name: "await_subagent",
335
377
  label: "Await Subagent",
336
378
  description:
337
- "Block until a run finishes (or timeoutMs elapses). Only when you have your own work to sync — otherwise end your turn; completion notifies you. While parked, child→leader messages (asks, notifies, completions) wake the wait and arrive inside the result.",
379
+ "Block until a run finishes (or timeoutMs elapses). Only when you have your own work to sync; otherwise end your turn. Child messages that arrive while parked wake the wait and are returned.",
338
380
  parameters: AwaitParam,
339
381
  async execute(_id, params) {
340
382
  const { runId, timeoutMs } = params as { runId: string; timeoutMs?: number };
@@ -352,7 +394,7 @@ export default function (pi: ExtensionAPI) {
352
394
  },
353
395
  });
354
396
 
355
- pi.registerTool<typeof ReplyParam, { run?: RunSnapshot }>({
397
+ defineTool<typeof ReplyParam, { run?: RunSnapshot }>({
356
398
  name: "reply_subagent",
357
399
  label: "Reply Subagent",
358
400
  description: "Answer a child's ask_parent question; resumes its run.",
@@ -373,7 +415,7 @@ export default function (pi: ExtensionAPI) {
373
415
  },
374
416
  });
375
417
 
376
- pi.registerTool<typeof SteerParam, { steered?: string[] }>({
418
+ defineTool<typeof SteerParam, { steered?: string[] }>({
377
419
  name: "steer_subagent",
378
420
  label: "Steer Subagent",
379
421
  description:
@@ -400,11 +442,11 @@ export default function (pi: ExtensionAPI) {
400
442
  },
401
443
  });
402
444
 
403
- pi.registerTool<typeof ResumeParam, { run?: RunSnapshot }>({
445
+ defineTool<typeof ResumeParam, { run?: RunSnapshot }>({
404
446
  name: "resume_subagent",
405
447
  label: "Resume Subagent",
406
448
  description:
407
- "Revive a failed/aborted task in its original session (full context + worktree branch preserved). Optional `model` swaps provider (e.g. after a rate limit); optional `thinking` sets the effort — the stored level is clamped to what the target model accepts, so a resume never dies on an unsupported effort; optional `message` replaces the default 'recap and continue' prompt. Refuses tasks that never started — respawn those.",
449
+ "Revive a failed/aborted task in its original session (full context + worktree branch preserved). Optional `model` swaps provider after e.g. a rate limit; `thinking` is clamped to the target model; `message` replaces the default recap prompt. Refuses tasks that never started — respawn those.",
408
450
  parameters: ResumeParam,
409
451
  async execute(_id, params, _signal, _onUpdate, ctx) {
410
452
  const { runId, taskId, message, model, thinking } = params as {
@@ -421,7 +463,7 @@ export default function (pi: ExtensionAPI) {
421
463
  content: [
422
464
  {
423
465
  type: "text",
424
- text: `Resumed ${runId}/${taskId} (${res.task.agent})${model ? ` on ${model}` : ""} from ${res.task.sessionFile}${res.task.branch ? `, branch ${res.task.branch}` : ""}.${res.note ? ` Adjusted ${res.note}.` : ""}\nNext: subagent_status("${runId}") to confirm it is running; completion will notify you.`,
466
+ text: `Resumed ${runId}/${taskId} (${res.task.agent})${model ? ` on ${model}` : ""} from ${res.task.sessionFile}${res.task.branch ? `, branch ${res.task.branch}` : ""}.${res.note ? ` Adjusted ${res.note}.` : ""}\nNext: subagent_status({ runId: "${runId}" }) to confirm it is running; completion will notify you.`,
425
467
  },
426
468
  ],
427
469
  details: { run: run ? cloneRun(run) : undefined },
@@ -429,7 +471,7 @@ export default function (pi: ExtensionAPI) {
429
471
  },
430
472
  });
431
473
 
432
- pi.registerTool<typeof RunIdParam, { aborted?: number }>({
474
+ defineTool<typeof RunIdParam, { aborted?: number }>({
433
475
  name: "subagent_cancel",
434
476
  label: "Subagent Cancel",
435
477
  description: "Abort a running/queued subagent run. Children are killed; run becomes aborted.",
@@ -446,4 +488,6 @@ export default function (pi: ExtensionAPI) {
446
488
  };
447
489
  },
448
490
  });
491
+
492
+ presentation.registerInitial();
449
493
  }
package/src/manager.ts CHANGED
@@ -63,6 +63,9 @@ import {
63
63
  type Worktree,
64
64
  } from "./worktree.ts";
65
65
 
66
+ /** Legacy callers import the resolver from here; its owner is `models.ts`. */
67
+ export { resolveChildModel };
68
+
66
69
  export const DEFAULT_CONCURRENCY = 3;
67
70
  export const MAX_CONCURRENCY = 8;
68
71
  const DEFAULT_RUNTIME_MS = 3_600_000;
@@ -0,0 +1,209 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ ToolDefinition,
5
+ ToolExposure,
6
+ ToolLoadout,
7
+ ToolLoadoutChanges,
8
+ ToolNamespace,
9
+ } from "@earendil-works/pi-coding-agent";
10
+
11
+ /**
12
+ * Presentation preference for the subagent toolset.
13
+ * - auto: native codemode presentation when the codemode tool is active, direct otherwise.
14
+ * - direct (default): legacy native declarations and active-tool script callability.
15
+ * - codemode: subagent tools are callable from codemode scripts but not declared or listed.
16
+ */
17
+ export type SubagentMode = "auto" | "direct" | "codemode";
18
+ export type EffectiveMode = "direct" | "codemode";
19
+
20
+ /** Branch-scoped custom entry holding the preference, so resume/tree keep it. */
21
+ export const MODE_ENTRY_TYPE = "subagent-mode";
22
+ export const CODEMODE_TOOL_NAME = "codemode";
23
+
24
+ export const SUBAGENT_NAMESPACE: ToolNamespace = {
25
+ name: "subagents",
26
+ description: "Isolated background subagents with a dependency-graph scheduler, git-worktree isolation and intercom.",
27
+ instructions: `Isolated subagents with their own context, session and optional git worktree. Delegate independent review, testing, research or parallel analysis.
28
+
29
+ ## Modes
30
+ - direct (default): legacy native tool declarations and active-tool script callability; activating codemode alone does not switch profiles.
31
+ - auto (opt-in): codemode presentation while the \`codemode\` tool is active, direct otherwise.
32
+ - codemode: active tools are callable from scripts but not declared; inactive tools stay model-only and are not callable at all.
33
+ Switch with \`/subagents mode auto|direct|codemode\`. The preference is stored on the session branch and restored on reload, resume and tree navigation.
34
+
35
+ ## Operations
36
+ Object arguments, exactly as validated when issued by the model:
37
+ - \`subagent({ agent, task, prompt?, write?, tools?, model?, thinking?, cwd?, maxRuntimeMs?, autoAwait?, notifyPerTask? })\` runs one agent; \`tasks: [...]\`, \`chain: [...]\` with \`{previous}\` and \`needs\` edges gate tasks and prepend upstream output.
38
+ - \`subagent_models()\` lists the models a task may name: the exact \`model\` value to pass, the thinking levels the runtime honors, the context window and pi catalog price per million tokens. The list follows this session's scoped models when scoping is configured, else every usable model; the output states which case applies, and references that would resolve to another model are called out as ambiguous.
39
+ - \`subagent_status({ runId })\` returns live per-task status and session file paths; call once right after spawning.
40
+ - \`subagent_result({ runId, taskId? })\` returns final text, usage and the worktree branch/diff summary.
41
+ - \`await_subagent({ runId, timeoutMs? })\` blocks only when this turn must consume the result.
42
+ - \`reply_subagent({ runId, taskId, message })\` answers a child \`ask_parent\` question and resumes it.
43
+ - \`steer_subagent({ runId, taskId?, message })\` injects a message into running tasks.
44
+ - \`resume_subagent({ runId, taskId, message?, model?, thinking? })\` revives a failed/aborted task with its context and branch.
45
+ - \`subagent_cancel({ runId })\` aborts the run and kills its children.
46
+
47
+ ## Agents
48
+ - Define each agent inline: invented name, focused system prompt. Agent files (\`.agents/agents\`, \`.claude/agents\`, \`.pi/agents\`; project dirs, then home) are matched by \`description\` against the goal, never by name. A match is authoritative: its body is the system prompt, its \`model\` frontmatter wins over the inline value, and its \`tools\` apply — only the per-call \`tools\` and \`write\` override them.
49
+ - Agents are read-only by default; \`write: true\` gives an isolated git worktree branch. Review the diff and merge with \`git merge --no-ff <branch>\`.
50
+ - \`model\` is optional: omit it to inherit the session model, or name one from \`subagent_models\` to pin the run.
51
+
52
+ ## Rules
53
+ - Batch every sub-task in ONE call with \`tasks\`/\`needs\`; do not split parallel work into separate calls.
54
+ - After spawning, call \`subagent_status({ runId })\` once to confirm the tasks started; fix or respawn a task that died on spawn.
55
+ - A task that failed mid-work keeps its session file and branch: \`resume_subagent({ runId, taskId })\`, model swap included, revives it; respawn only a task that never started.
56
+ - Completion and failures notify you: end the turn when you have no work left; use \`autoAwait\` only when the same turn must consume the result.
57
+ - End each task with a runnable check, e.g. 'Verify: bun test'. A subagent's claim of success is not evidence.
58
+
59
+ ## Codemode
60
+ When the codemode profile is explicitly selected (or selected by opt-in auto), these tools are not declared. Call an active operation as \`tools.<name>(args)\`; spawning uses \`await tools.subagent(args)\`. If its signature is unknown, inspect only \`text(await describeTool('subagent'))\` once and reuse it. For another operation, inspect only its exact name when needed. Broad search and tool-list dumps are unnecessary for these known names; this namespace is the optional full reference. Inactive tools remain unavailable. Arguments are validated exactly like model-issued calls.`,
61
+ };
62
+
63
+ /** Adds the script-call path to the upfront rules, but only in the codemode profile. */
64
+ export const CODEMODE_DISCOVERY_GUIDELINE =
65
+ "Codemode subagents: call active tools as tools.<name>(args); spawn with await tools.subagent(args). If its signature is unknown, inspect only text(await describeTool('subagent')) once, then reuse it. Other helpers: describeTool(exactName) only when needed. No broad search or tool-list dump required; describeNamespace('subagents') is optional full reference.";
66
+
67
+ /** The full-reference pointer only resolves while codemode scripts can reach `describeNamespace`. */
68
+ const NAMESPACE_REFERENCE = / Full reference: `describeNamespace\('subagents'\)`\./;
69
+
70
+ export function isSubagentMode(value: unknown): value is SubagentMode {
71
+ return value === "auto" || value === "direct" || value === "codemode";
72
+ }
73
+
74
+ export type AnyToolDefinition = ToolDefinition<any, any, any>;
75
+
76
+ export interface Presentation {
77
+ readonly preference: SubagentMode;
78
+ /** The profile currently registered, or undefined before the first registration. */
79
+ readonly applied: EffectiveMode | undefined;
80
+ /** Effective mode for the given active set, defaulting to the session's live active tools. */
81
+ effective(active?: readonly string[]): EffectiveMode;
82
+ /** Report preference, effective mode and codemode availability. */
83
+ describe(): string;
84
+ setPreference(mode: SubagentMode): void;
85
+ /** Restore the latest preference stored on the session branch. */
86
+ restore(ctx: ExtensionContext): void;
87
+ /** Register the tools for the current effective mode; no-op when already applied. */
88
+ sync(): boolean;
89
+ /** Initial registration during extension load, before any session state exists. */
90
+ registerInitial(): void;
91
+ }
92
+
93
+ /**
94
+ * Owns the subagent exposure profile. Tools stay registered while only their exposure changes, so
95
+ * the active selection (and the running manager) is preserved across mode switches.
96
+ */
97
+ export function createPresentation(pi: ExtensionAPI, definitions: readonly AnyToolDefinition[]): Presentation {
98
+ const ownNames = (): string[] => definitions.map((definition) => definition.name);
99
+ let preference: SubagentMode = "direct";
100
+ let applied: EffectiveMode | undefined;
101
+ let appliedOwn = new Set<string>();
102
+
103
+ const codemodeActive = (active: readonly string[]): boolean => active.includes(CODEMODE_TOOL_NAME);
104
+ const effectiveFrom = (active: readonly string[]): EffectiveMode =>
105
+ preference !== "direct" && codemodeActive(active) ? "codemode" : "direct";
106
+ const sameSet = (a: ReadonlySet<string>, b: ReadonlySet<string>): boolean =>
107
+ a.size === b.size && [...a].every((name) => b.has(name));
108
+
109
+ // Hides only the declarations of active subagent tools; other tools keep their loadout. Runs on
110
+ // every loadout recompute, so it follows the registered profile: a preference that is only
111
+ // pending (mid-stream) must not rewrite a live loadout.
112
+ const hideDeclarations = (loadout: ToolLoadout): ToolLoadoutChanges | undefined => {
113
+ if (applied !== "codemode") return undefined;
114
+ const active = new Set(loadout.declared.map((tool) => tool.name));
115
+ const hidden = definitions.map((definition) => definition.name).filter((name) => active.has(name));
116
+ return hidden.length > 0 ? { hiddenDeclarations: hidden } : undefined;
117
+ };
118
+
119
+ // Opted-in codemode defers only active own tools; inactive tools stay model-only. Legacy direct
120
+ // callability follows native activation. Re-registering
121
+ // must not reactivate a tool the user deactivated, so `defaultActive: false` replaces the native
122
+ // default once the initial registration has made the tools available by default.
123
+ const register = (mode: EffectiveMode, ownActive: ReadonlySet<string>, initial = false): void => {
124
+ const own = new Set(ownNames());
125
+ applied = mode;
126
+ for (const definition of definitions) {
127
+ const scriptCallable = mode === "codemode" && ownActive.has(definition.name);
128
+ const exposure: ToolExposure = scriptCallable ? "deferred" : mode === "direct" ? "direct" : "model-only";
129
+ const promptGuidelines = [...(definition.promptGuidelines ?? [])];
130
+ if (scriptCallable && !promptGuidelines.includes(CODEMODE_DISCOVERY_GUIDELINE))
131
+ promptGuidelines.push(CODEMODE_DISCOVERY_GUIDELINE);
132
+ // Legacy direct tools stay ungrouped; the namespace reference only belongs to the opted-in profile.
133
+ const description =
134
+ mode === "direct" ? definition.description.replace(NAMESPACE_REFERENCE, "") : definition.description;
135
+ pi.registerTool({
136
+ ...definition,
137
+ description,
138
+ exposure,
139
+ namespace: mode === "codemode" ? SUBAGENT_NAMESPACE : undefined,
140
+ promptGuidelines,
141
+ prepareLoadout: hideDeclarations,
142
+ ...(initial ? {} : { defaultActive: false }),
143
+ } as AnyToolDefinition);
144
+ }
145
+ // With an explicit --tools/defaultTools allowlist the SDK force-activates every registered
146
+ // declarable tool it names, ignoring `defaultActive`. Restore exactly the own-name membership
147
+ // that was asked for; every unrelated name, including a newly registered one, is preserved.
148
+ if (!initial) {
149
+ const active = pi.getActiveTools();
150
+ if (active.some((name) => own.has(name) && !ownActive.has(name)))
151
+ pi.setActiveTools(active.filter((name) => !own.has(name) || ownActive.has(name)));
152
+ }
153
+ appliedOwn = new Set(ownActive);
154
+ };
155
+
156
+ return {
157
+ get preference() {
158
+ return preference;
159
+ },
160
+ get applied() {
161
+ return applied;
162
+ },
163
+ effective: (active = pi.getActiveTools()) => effectiveFrom(active),
164
+ describe() {
165
+ const active = pi.getActiveTools();
166
+ const target = effectiveFrom(active);
167
+ const shown = applied ?? target;
168
+ const available = codemodeActive(active);
169
+ const availability = available ? "codemode is active" : "codemode is not active";
170
+ const fallback = !available && preference === "codemode" ? "; using direct fallback" : "";
171
+ const pending = target !== shown ? `; pending ${target} at the next request boundary` : "";
172
+ return `Subagent exposure: mode ${preference} → effective ${shown} (${availability}${fallback}${pending}). Use /subagents mode auto|direct|codemode.`;
173
+ },
174
+ setPreference(mode) {
175
+ preference = mode;
176
+ pi.appendEntry(MODE_ENTRY_TYPE, { mode });
177
+ },
178
+ restore(ctx) {
179
+ let restored: SubagentMode | undefined;
180
+ for (const entry of ctx.sessionManager.getBranch()) {
181
+ const candidate = entry as { type?: string; customType?: string; data?: { mode?: unknown } };
182
+ if (
183
+ candidate.type === "custom" &&
184
+ candidate.customType === MODE_ENTRY_TYPE &&
185
+ isSubagentMode(candidate.data?.mode)
186
+ )
187
+ restored = candidate.data.mode;
188
+ }
189
+ // Branch-sensitive state: a branch without an entry uses the package default again.
190
+ preference = restored ?? "direct";
191
+ },
192
+ sync() {
193
+ const own = new Set(ownNames());
194
+ const active = pi.getActiveTools();
195
+ const next = effectiveFrom(active);
196
+ const ownActive = new Set(active.filter((name) => own.has(name)));
197
+ // Members inside a profile are part of the registration: a helper activated or deactivated
198
+ // natively must change its exposure (deferred vs model-only) at the next boundary.
199
+ if (next === applied && sameSet(ownActive, appliedOwn)) return false;
200
+ register(next, ownActive);
201
+ return true;
202
+ },
203
+ registerInitial() {
204
+ // Before the session runtime exists `pi.getActiveTools()` is not bound; the optimistic
205
+ // all-active set is corrected by `sync()` once the session starts.
206
+ register("direct", new Set(ownNames()), true);
207
+ },
208
+ };
209
+ }
package/src/schemas.ts CHANGED
@@ -19,7 +19,8 @@ const TaskItem = Type.Object({
19
19
  prompt: Type.Optional(Type.String({ description: "System prompt defining this agent's behavior" })),
20
20
  write: Type.Optional(
21
21
  Type.Boolean({
22
- description: "true = write toolset (adds bash, edit, write); default false = read-only (read, grep, find, ls)",
22
+ description:
23
+ "true = write toolset (adds bash, edit, write); default false = read-only (read, grep, find, ls, codemode)",
23
24
  }),
24
25
  ),
25
26
  model: Type.Optional(