pi-subagents 0.32.0 → 0.33.0

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +30 -3
  2. package/README.md +147 -58
  3. package/install.mjs +2 -1
  4. package/package.json +3 -2
  5. package/skills/pi-subagents/SKILL.md +70 -14
  6. package/src/agents/agent-management.ts +177 -5
  7. package/src/agents/agent-memory.ts +254 -0
  8. package/src/agents/agent-serializer.ts +11 -0
  9. package/src/agents/agents.ts +142 -12
  10. package/src/agents/chain-serializer.ts +27 -2
  11. package/src/extension/doctor.ts +1 -9
  12. package/src/extension/fanout-child.ts +2 -2
  13. package/src/extension/index.ts +65 -90
  14. package/src/extension/rpc.ts +369 -0
  15. package/src/extension/schemas.ts +52 -8
  16. package/src/extension/tool-description.ts +200 -0
  17. package/src/intercom/intercom-bridge.ts +21 -253
  18. package/src/intercom/native-supervisor-channel.ts +510 -0
  19. package/src/runs/background/async-execution.ts +51 -7
  20. package/src/runs/background/async-job-tracker.ts +12 -2
  21. package/src/runs/background/async-status.ts +27 -2
  22. package/src/runs/background/completion-batcher.ts +166 -0
  23. package/src/runs/background/control-channel.ts +106 -1
  24. package/src/runs/background/fleet-view.ts +515 -0
  25. package/src/runs/background/notify.ts +161 -44
  26. package/src/runs/background/result-watcher.ts +1 -2
  27. package/src/runs/background/run-id-resolver.ts +3 -2
  28. package/src/runs/background/run-status.ts +166 -6
  29. package/src/runs/background/scheduled-runs.ts +514 -0
  30. package/src/runs/background/subagent-runner.ts +409 -35
  31. package/src/runs/background/wait.ts +353 -0
  32. package/src/runs/foreground/chain-execution.ts +95 -21
  33. package/src/runs/foreground/execution.ts +150 -21
  34. package/src/runs/foreground/subagent-executor.ts +378 -64
  35. package/src/runs/shared/dynamic-fanout.ts +1 -1
  36. package/src/runs/shared/model-fallback.ts +167 -20
  37. package/src/runs/shared/model-scope.ts +128 -0
  38. package/src/runs/shared/nested-events.ts +31 -0
  39. package/src/runs/shared/parallel-utils.ts +1 -0
  40. package/src/runs/shared/pi-args.ts +30 -1
  41. package/src/runs/shared/subagent-prompt-runtime.ts +108 -2
  42. package/src/runs/shared/tool-budget.ts +74 -0
  43. package/src/runs/shared/turn-budget.ts +52 -0
  44. package/src/shared/artifacts.ts +1 -0
  45. package/src/shared/atomic-json.ts +15 -2
  46. package/src/shared/child-transcript.ts +212 -0
  47. package/src/shared/settings.ts +3 -1
  48. package/src/shared/types.ts +134 -19
  49. package/src/slash/prompt-workflows.ts +330 -0
  50. package/src/slash/slash-commands.ts +16 -2
  51. package/src/tui/render.ts +16 -8
  52. package/src/extension/companion-suggestions.ts +0 -359
@@ -21,7 +21,7 @@ Use this skill when the parent orchestrator needs to launch a specialized subage
21
21
  - **Recon and planning**: use `scout` or `context-builder`, then `planner`
22
22
  - **Parallel exploration**: run multiple non-conflicting tasks concurrently
23
23
  - **Regular skill specialists**: when discovery shows proactive skill subagent suggestions and the current work is broad enough, launch a small fresh-context fanout that asks one subagent per relevant regularly used skill to apply that skill's perspective to the task
24
- - **Long-running work**: launch async/background runs and inspect them later; use `timeoutMs` or `maxRuntimeMs` when a foreground or async run needs a hard max runtime
24
+ - **Long-running work**: launch async/background runs and inspect them later; use `timeoutMs` or `maxRuntimeMs` when a foreground or async run needs a hard max runtime, `turnBudget: { maxTurns, graceTurns }` for a soft assistant-turn budget, or `toolBudget: { soft?, hard, block? }` to nudge after a tool-call threshold and then block read/search tools so the child can finalize
25
25
  - **Subagent control**: watch needs-attention signals and soft-interrupt only when a delegated run is genuinely blocked
26
26
  - **Agent authoring**: create, update, or override agents and chains for a project
27
27
 
@@ -194,6 +194,8 @@ For one run, use inline config:
194
194
 
195
195
  For persistent tweaks, edit `subagents.agentOverrides` in user or project settings. User overrides apply everywhere. Project overrides apply only in that repo and win over user overrides.
196
196
 
197
+ Model ids do not have to be exact. Separator variations (`claude-haiku-4.5` vs `claude-haiku-4-5`), case (`Claude-Sonnet-4`), and optional trailing date stamps (`claude-haiku-4-5-20251001`) all resolve to the same registry model. Exact `provider/id` wins; a qualified `provider/model` never switches providers. To constrain subagents to a budget or compliance profile, set `subagents.modelScope: { enforce: true, allow: ["anthropic/*", "openai/gpt-5-*"] }` in user or project settings. Out-of-scope models you pass explicitly error and abort; models inherited from frontmatter, `defaultModel`, or the parent session only warn.
198
+
197
199
  ## Prompting role subagents
198
200
 
199
201
  Builtin role agents inherit the current Pi default model unless you override them. When launching them, write the task prompt as a compact contract, not a long procedural script. Define the destination and let the role choose the efficient path.
@@ -241,6 +243,8 @@ If a provider rejects model IDs with thinking suffixes, use
241
243
  builtin thinking defaults globally. A higher-precedence per-agent `thinking`
242
244
  override can opt one builtin back in.
243
245
 
246
+ Tool description modes live in `~/.pi/agent/extensions/subagent/config.json`, not `subagents` settings. Set `toolDescriptionMode` to `compact` to reduce tool-description prompt cost while keeping the execution, async/wait, child-safety, one-writer, management/action, and artifact/status guardrails. Set it to `custom` to read `subagent-tool-description.md` from the project config dir or agent dir; invalid custom files fall back to full mode and the safety guidance is still appended.
247
+
244
248
  ## Discovery and Scope Rules
245
249
 
246
250
  Agent files can live in:
@@ -336,7 +340,11 @@ Prefer async mode for every subagent launch. Set `async: true` no matter the tas
336
340
 
337
341
  Async does not mean parallel writes. Do not edit the same active worktree while an async worker is changing it. Parent-side overlap should be reading, validation prep, synthesis, command planning, or review of unaffected context unless the writer is isolated in a separate worktree.
338
342
 
339
- Do not end your turn immediately after launching an async child if you promised to keep working. Continue the local inspection, synthesis, or validation prep, then check the async run when its result is needed. If there is no independent work left and you would only be running `sleep` or status polling commands to wait, end your turn instead. Pi will deliver the async completion when it arrives.
343
+ Do not end your turn immediately after launching an async child if you promised to keep working. Continue the local inspection, synthesis, or validation prep, then check the async run when its result is needed.
344
+
345
+ When there is no independent work left and you just need the next async result, **call `wait()`** rather than `sleep`/status-polling loops. `wait()` returns when the next active run finishes or needs attention and keeps the turn alive for normal notification delivery. Use `wait({ all: true })` to drain every active run, `wait({ id: "..." })` to block on one run, and `wait({ timeoutMs })` to cap how long you block.
346
+
347
+ Prefer `wait()` over ending the turn whenever you must keep going to finish the job — inside a skill that has to run to completion, or in any non-interactive run (`pi -p ...`) where the whole task is a single turn. In those cases ending the turn abandons the still-running children, because there is no next turn to receive their completion. Only end the turn to wait when you are in an interactive session and are certain the user will prompt you again; then Pi will wake you when the run finishes.
340
348
 
341
349
  ```typescript
342
350
  subagent({
@@ -360,7 +368,7 @@ const run = subagent({
360
368
  // Continue local inspection, then later call status with the returned id.
361
369
  ```
362
370
 
363
- Inspect async runs with `subagent({ action: "status", id: "..." })` or `subagent({ action: "status" })` for active runs. If a delegated fanout child launches nested runs, the parent status view shows them as a tree and you can target a nested run directly with its nested id.
371
+ Inspect async runs with `subagent({ action: "status", id: "..." })` or `subagent({ action: "status" })` for active runs. Use `subagent({ action: "status", view: "fleet" })` when supervising several active foreground/background runs and `subagent({ action: "status", id: "...", view: "transcript", index: 0 })` when you need the latest child output without digging through artifacts. If a delegated fanout child launches nested runs, the parent status view shows them as a tree and you can target a nested run directly with its nested id.
364
372
 
365
373
  Use `resume` for follow-up work after a delegated run:
366
374
 
@@ -385,6 +393,27 @@ Use diagnostics when setup or child startup looks wrong:
385
393
  subagent({ action: "doctor" })
386
394
  ```
387
395
 
396
+ ### Scheduled subagent runs
397
+
398
+ Scheduled runs defer a subagent launch until a future time. They are opt-in and require `{ "scheduledRuns": { "enabled": true } }` in `~/.pi/agent/extensions/subagent/config.json`. Only schedule explicit delayed runs the user asked for; do not schedule runs speculatively.
399
+
400
+ ```typescript
401
+ // Launch a reviewer in 30 minutes
402
+ subagent({ action: "schedule", agent: "reviewer", task: "Review the diff for correctness issues.", schedule: "+30m", scheduleName: "evening review" })
403
+
404
+ // Schedule a parallel fanout
405
+ subagent({ action: "schedule", tasks: [{ agent: "scout", task: "Map the auth module" }, { agent: "scout", task: "Map the billing module" }], schedule: "+1h" })
406
+
407
+ // Inspect, list, and cancel
408
+ subagent({ action: "schedule-list" })
409
+ subagent({ action: "schedule-status", id: "ab12" })
410
+ subagent({ action: "schedule-cancel", id: "ab12" })
411
+ ```
412
+
413
+ `schedule` accepts the same execution fields as a normal async run (`agent`/`tasks`/`chain`, `cwd`, `model`, `output`, `reads`, `progress`, `acceptance`, `timeoutMs`) plus `schedule` (a relative delay like `+10m`/`+2h`/`+1d` or a future ISO timestamp with a timezone such as `2030-01-01T09:00:00Z`) and an optional `scheduleName`. Scheduled runs always launch async with fresh context; `context: "fork"`, `async: false`, and `clarify: true` are rejected. Once the timer fires, the run becomes a normal tracked async run: it appears in the async widget, is inspectable with `subagent({ action: "status" })`, can be awaited with `wait()`, and delivers the normal completion notification.
414
+
415
+ Schedules are persisted per session and restored after a Pi restart. A job whose scheduled time passed by more than `scheduledRuns.maxLatenessMs` (default 5 minutes) while Pi was unavailable is marked `missed` instead of firing late. `scheduledRuns.maxPending` (default 20) caps pending or running scheduled jobs per session.
416
+
388
417
  Humans can use `/subagents-doctor` for the same read-only report. It checks runtime paths, discovery counts, async support, current session context, and intercom bridge state.
389
418
 
390
419
  ### Subagent control
@@ -436,8 +465,8 @@ subagent({
436
465
  })
437
466
  ```
438
467
 
439
- Chains default to clarify mode; set `clarify: false` to skip it. Clarify edits affect only the next run; use management actions, settings, or markdown files for persistent changes.
440
- For programmatic background launches, use `async: true`. Set `clarify: false` when you want to bypass chain clarification explicitly; `clarify: true` keeps the run foreground for the clarify UI.
468
+ Tool calls launch directly by default. Set `clarify: true` on single, parallel, or chain runs when you want the clarify UI. Clarify edits affect only the next run; use management actions, settings, or markdown files for persistent changes.
469
+ For programmatic background launches, use `async: true`. `clarify: true` keeps the run foreground for the clarify UI.
441
470
 
442
471
 
443
472
  ## Worktree Isolation
@@ -491,7 +520,7 @@ Use `oracle` as a smart-friend escalation when the parent needs help with trajec
491
520
 
492
521
  ## Subagent + Intercom Coordination
493
522
 
494
- `pi-subagents` works without `pi-intercom`. When `pi-intercom` is installed and enabled, the intercom bridge can automatically give child agents a private coordination channel back to the parent session.
523
+ `pi-subagents` includes native supervisor coordination. Child agents can use `contact_supervisor` to ask the exact parent session that spawned them; messages are scoped by parent session id and should not appear in other Pi sessions.
495
524
 
496
525
  Most agents should not call generic `intercom` directly unless bridge instructions provide a target and `contact_supervisor` is unavailable. Do not invent a target. Prefer the tool from the injected bridge instructions.
497
526
 
@@ -510,7 +539,7 @@ Use `contact_supervisor` with `reason: "progress_update"` when:
510
539
  Message conventions:
511
540
  - `reason: "need_decision"` waits for the parent reply and returns it to the child.
512
541
  - `reason: "progress_update"` is non-blocking and should stay concise.
513
- - Child-side routine completion handoffs are not expected. With the intercom bridge active, parent-side `pi-subagents` sends grouped completion results through `pi-intercom`: one grouped message per foreground parent run and one per completed async result file. Acknowledged foreground delivery returns a compact receipt with artifact/session paths; if unacknowledged, the normal full output is preserved. Grouped messages include child intercom targets, full child summaries, and compact nested summaries under the parent child that launched them.
542
+ - Child-side routine completion handoffs are not expected. Native supervisor messages are for decisions, structured input, and meaningful progress updates while a child is still running.
514
543
 
515
544
  If bridge instructions provide the child-facing tool, a child can ask:
516
545
 
@@ -580,6 +609,23 @@ subagent({
580
609
  subagent({ action: "delete", agent: "code-analysis.my-agent" })
581
610
  ```
582
611
 
612
+ ### Eject, disable, enable, and reset
613
+
614
+ ```typescript
615
+ // Copy a bundled builtin/package agent to user scope as an editable custom file.
616
+ subagent({ action: "eject", agent: "reviewer" })
617
+ subagent({ action: "eject", agent: "reviewer", agentScope: "project" })
618
+
619
+ // Hide an agent from runtime discovery without deleting it (reversible).
620
+ subagent({ action: "disable", agent: "reviewer" })
621
+ subagent({ action: "enable", agent: "reviewer", agentScope: "project" })
622
+
623
+ // Delete the scope's custom agent file and/or settings override, restoring the bundled default.
624
+ subagent({ action: "reset", agent: "reviewer" })
625
+ ```
626
+
627
+ `eject` copies a builtin or package agent verbatim into the user (default) or project agent dir so it can be customized without hunting package files; the copy shadows the original by runtime name. `disable` writes a reversible `agentOverrides.<name>.disabled: true` entry to the user or project settings file. `enable` removes that `disabled` field while keeping any other override fields. `reset` removes the scope's custom file and settings override to restore the bundled default, and refuses if no bundled default exists (use `delete` for purely custom agents). All four take optional `agentScope: "user" | "project"`; project overrides win over user ones, so target the project scope to undo a project-scope disable.
628
+
583
629
  Use management actions when the system needs to create or edit subagents on
584
630
  demand without dropping into raw file editing.
585
631
 
@@ -628,14 +674,13 @@ fixes worth doing now. Parent agents can also apply the same recipes directly
628
674
  with `subagent(...)` when the user describes the workflow in natural language
629
675
  instead of invoking a slash command.
630
676
 
631
- If `pi-prompt-template-model` is installed, additional user prompt templates can delegate into
632
- `pi-subagents`. This is useful when a slash command should always run through a
633
- particular agent or with forked context.
677
+ Additional user prompt templates can delegate into `pi-subagents` through the native `/prompt-workflow` and `/chain-prompts` commands. This is useful when a slash command should always run through a particular agent or with forked context. Prompt frontmatter can set `subagent`, `model`, `skill`, `cwd`, `worktree`, `fresh`, `fork`, or `inheritContext` for the native adapter.
634
678
 
635
- If `subagent({ action: "list" })`, `/subagents-doctor`, or a startup message recommends
636
- `pi-intercom` or `pi-prompt-template-model`, offer to run the shown `pi install npm:<package>`
637
- command only after user approval. To hide future recommendations, use
638
- `/subagents-companions hide <package> workspace` or `... user`.
679
+ ## Extension RPC
680
+
681
+ Other Pi extensions can call `pi-subagents` through the in-process event bus. The stable v1 channels are `subagents:rpc:v1:ready`, `subagents:rpc:v1:request`, and per-request replies at `subagents:rpc:v1:reply:<requestId>`. Envelopes use `{ version: 1, requestId, method, params }`, and replies use `{ version: 1, requestId, success, data | error }`.
682
+
683
+ Methods: `ping`, `status`, `spawn`, `interrupt`, and `stop`. `spawn` is async-only and rejects management actions, `async: false`, or `clarify: true`; it reuses the normal executor, so discovery, validation, session attribution, spawn limits, child-safety depth, artifacts, and async status are shared with the `subagent` tool. `status` and `interrupt` map to the normal control actions. `stop` targets running async runs through the existing timeout control channel. `pi.events` is process-local, so separate Pi processes and child subagents need lifecycle artifact files or `pi-intercom` instead.
639
684
 
640
685
  ## Important Constraints
641
686
 
@@ -659,6 +704,17 @@ command only after user approval. To hide future recommendations, use
659
704
 
660
705
  Launch every subagent asynchronously by default. Use `async: true` for scouts, researchers, workers, reviewers, validators, oracle checks, one-off delegates, chains, and parallel groups unless you intentionally need a foreground/blocking run. The parent should keep moving: inspect code while scouts run, prepare validation while a worker implements, do a local diff pass while reviewers review, and synthesize or verify while a fix worker applies accepted feedback. Async is the default orchestration posture; foreground runs are the explicit opt-out.
661
706
 
707
+ ### Use wait() to block until async runs finish
708
+
709
+ When you have launched async runs and have no independent work left but must keep going to finish the task, call `wait()`. It blocks the current turn until the next run completes or needs attention, keeps the turn alive for normal notification delivery, then returns.
710
+
711
+ - `wait()` — return when the next active async run in this session finishes or needs attention.
712
+ - `wait({ all: true })` — block until every active async run in this session finishes or one needs attention.
713
+ - `wait({ id: "..." })` — block on one run (id or prefix).
714
+ - `wait({ timeoutMs })` — cap the block; the runs keep going if it elapses.
715
+
716
+ `wait()` is the correct way to keep N workers in flight: launch N, call `wait()`, react to the result, launch a replacement if needed, then call `wait()` again. Use `wait({ all: true })` only when you intentionally want to drain the fleet to zero. Reserve ending-the-turn-to-wait for interactive sessions where the user will prompt you again; in a skill that must complete or a non-interactive `pi -p` run there is no next turn, so `wait()` is required to avoid abandoning live children.
717
+
662
718
  ### Keep writes single-threaded by default
663
719
 
664
720
  A strong pattern is one main decision-maker plus advisory/research/review/validation subagents around it. Use `oracle` for advice and `worker` for the actual write path. Parallelize reading, review, validation, and synthesis support, not normal writes, unless you deliberately isolate writers with worktrees. A child that writes should report what changed, what was left undone, commands run with exit codes, validation evidence, surprises, and any decisions that need parent approval.
@@ -16,6 +16,9 @@ import {
16
16
  buildRuntimeName,
17
17
  frontmatterNameForConfig,
18
18
  parsePackageName,
19
+ mergeBuiltinAgentOverride,
20
+ removeBuiltinAgentOverride,
21
+ removeBuiltinAgentOverrideFields,
19
22
  } from "./agents.ts";
20
23
  import { serializeAgent } from "./agent-serializer.ts";
21
24
  import { serializeChain, serializeJsonChain } from "./chain-serializer.ts";
@@ -26,12 +29,13 @@ import {
26
29
  import { parseFrontmatter } from "./frontmatter.ts";
27
30
  import { toModelInfo } from "../shared/model-info.ts";
28
31
  import { resolveSubagentModelOverride, type ParentModel } from "../runs/shared/model-fallback.ts";
29
- import type { Details, ExtensionConfig } from "../shared/types.ts";
32
+ import { validateToolBudgetConfig } from "../runs/shared/tool-budget.ts";
33
+ import type { Details, ExtensionConfig, ToolBudgetConfig } from "../shared/types.ts";
30
34
  import { getProjectConfigDir } from "../shared/utils.ts";
31
35
 
32
- type ManagementAction = "list" | "get" | "models" | "create" | "update" | "delete";
36
+ type ManagementAction = "list" | "get" | "models" | "create" | "update" | "delete" | "eject" | "disable" | "enable" | "reset";
33
37
  type ManagementScope = "user" | "project";
34
- type ManagementContext = Pick<ExtensionContext, "cwd" | "modelRegistry"> & { model?: ExtensionContext["model"]; config?: ExtensionConfig; companionSuggestionLines?: () => string[] };
38
+ type ManagementContext = Pick<ExtensionContext, "cwd" | "modelRegistry"> & { model?: ExtensionContext["model"]; config?: ExtensionConfig };
35
39
 
36
40
  interface ManagementParams {
37
41
  action?: string;
@@ -72,6 +76,12 @@ function asDisambiguationScope(scope: unknown): ManagementScope | undefined {
72
76
  return undefined;
73
77
  }
74
78
 
79
+ function actionScope(scope: unknown, action: ManagementAction): { scope?: ManagementScope; error?: AgentToolResult<Details> } {
80
+ if (scope === undefined) return { scope: "user" };
81
+ const parsed = asDisambiguationScope(scope);
82
+ return parsed ? { scope: parsed } : { error: result(`agentScope must be 'user' or 'project' for ${action}.`, true) };
83
+ }
84
+
75
85
  function normalizeListScope(scope: unknown): AgentScope | undefined {
76
86
  if (scope === undefined) return "both";
77
87
  if (scope === "user" || scope === "project" || scope === "both") return scope;
@@ -113,6 +123,19 @@ function findChains(name: string, cwd: string, scope: AgentScope = "both"): Chai
113
123
  .sort((a, b) => a.source.localeCompare(b.source));
114
124
  }
115
125
 
126
+ const AGENT_SOURCE_PRECEDENCE: Record<AgentSource, number> = { builtin: 0, package: 1, user: 2, project: 3 };
127
+
128
+ // Returns the highest-precedence agent for a name (project > user > package > builtin,
129
+ // matching mergeAgentsForScope for "both"), including disabled agents so disable/enable/reset
130
+ // can locate agents that runtime discovery filters out.
131
+ function pickEffectiveAgent(d: ReturnType<typeof discoverAgentsAll>, name: string): AgentConfig | undefined {
132
+ const raw = name.trim();
133
+ const sanitized = sanitizeName(raw);
134
+ const matches = allAgents(d).filter((a) => a.name === raw || a.name === sanitized);
135
+ if (matches.length === 0) return undefined;
136
+ return matches.reduce((best, agent) => (AGENT_SOURCE_PRECEDENCE[agent.source] > AGENT_SOURCE_PRECEDENCE[best.source] ? agent : best));
137
+ }
138
+
116
139
  function nameExistsInScope(cwd: string, scope: ManagementScope, name: string, excludePath?: string): boolean {
117
140
  const d = discoverAgentsAll(cwd);
118
141
  for (const a of scope === "user" ? d.user : d.project) {
@@ -245,6 +268,7 @@ function preservedAgentFrontmatterFields(agent: AgentConfig, cfg: Record<string,
245
268
  changed("completionGuard");
246
269
  if (cfg.completionGuard === true) fields.add("completionGuard");
247
270
  }
271
+ if (hasKey(cfg, "toolBudget")) changed("toolBudget");
248
272
 
249
273
  return fields;
250
274
  }
@@ -302,6 +326,11 @@ function parseStepList(raw: unknown): { steps?: ChainStepConfig[]; error?: strin
302
326
  if (typeof s.progress === "boolean") step.progress = s.progress;
303
327
  else return { error: `config.steps[${i}].progress must be a boolean.` };
304
328
  }
329
+ if (hasKey(s, "toolBudget")) {
330
+ const validation = validateToolBudgetConfig(s.toolBudget, `config.steps[${i}].toolBudget`);
331
+ if (validation.error) return { error: validation.error };
332
+ step.toolBudget = s.toolBudget as ChainStepConfig["toolBudget"];
333
+ }
305
334
  steps.push(step);
306
335
  }
307
336
  return { steps };
@@ -413,6 +442,14 @@ function applyAgentConfig(target: AgentConfig, cfg: Record<string, unknown>): st
413
442
  if (typeof cfg.completionGuard !== "boolean") return "config.completionGuard must be a boolean when provided.";
414
443
  target.completionGuard = cfg.completionGuard;
415
444
  }
445
+ if (hasKey(cfg, "toolBudget")) {
446
+ if (cfg.toolBudget === false || cfg.toolBudget === "") target.toolBudget = undefined;
447
+ else {
448
+ const validation = validateToolBudgetConfig(cfg.toolBudget, "config.toolBudget");
449
+ if (validation.error) return validation.error;
450
+ target.toolBudget = cfg.toolBudget as ToolBudgetConfig;
451
+ }
452
+ }
416
453
  return undefined;
417
454
  }
418
455
 
@@ -484,6 +521,8 @@ function formatAgentDetail(agent: AgentConfig): string {
484
521
  if (agent.defaultProgress) lines.push("Progress: true");
485
522
  if (agent.maxSubagentDepth !== undefined) lines.push(`Max subagent depth: ${agent.maxSubagentDepth}`);
486
523
  if (agent.completionGuard === false) lines.push("Completion guard: false");
524
+ if (agent.toolBudget) lines.push(`Tool budget: ${JSON.stringify(agent.toolBudget)}`);
525
+ if (agent.memory) lines.push(`Memory: ${agent.memory.scope} scope, path: ${agent.memory.path}`);
487
526
  if (agent.systemPrompt.trim()) lines.push("", "System Prompt:", agent.systemPrompt);
488
527
  return lines.join("\n");
489
528
  }
@@ -504,6 +543,7 @@ function formatChainStepDetail(step: ChainStepConfig, index: number): string[] {
504
543
  if (typeof parallel?.label === "string") lines.push(` Label: ${parallel.label}`);
505
544
  if (typeof parallel?.task === "string" && parallel.task.trim()) lines.push(` Task: ${parallel.task}`);
506
545
  if (parallel?.outputSchema) lines.push(" Structured output: true");
546
+ if (parallel && "toolBudget" in parallel) lines.push(` Tool budget: ${JSON.stringify((parallel as { toolBudget?: unknown }).toolBudget)}`);
507
547
  if (collect?.outputSchema) lines.push(" Collect schema: true");
508
548
  if (step.concurrency !== undefined) lines.push(` Concurrency: ${step.concurrency}`);
509
549
  if (step.failFast !== undefined) lines.push(` Fail fast: ${step.failFast ? "true" : "false"}`);
@@ -514,6 +554,7 @@ function formatChainStepDetail(step: ChainStepConfig, index: number): string[] {
514
554
  if (step.output === false) lines.push(" Output: false");
515
555
  else if (step.output) lines.push(` Output: ${step.output}`);
516
556
  if (step.outputMode) lines.push(` Output mode: ${step.outputMode}`);
557
+ if (step.toolBudget) lines.push(` Tool budget: ${JSON.stringify(step.toolBudget)}`);
517
558
  if (step.reads === false) lines.push(" Reads: false");
518
559
  else if (Array.isArray(step.reads) && step.reads.length > 0) lines.push(` Reads: ${step.reads.join(", ")}`);
519
560
  if (step.model) lines.push(` Model: ${step.model}`);
@@ -549,7 +590,6 @@ export function handleList(params: ManagementParams, ctx: ManagementContext): Ag
549
590
  config: ctx.config?.proactiveSkillSubagents,
550
591
  discoverAvailableSkills: () => discoverAvailableSkills(ctx.cwd),
551
592
  });
552
- const companionSuggestions = ctx.companionSuggestionLines?.() ?? [];
553
593
  const lines = [
554
594
  "Executable agents:",
555
595
  ...(agents.length
@@ -559,7 +599,6 @@ export function handleList(params: ManagementParams, ctx: ManagementContext): Ag
559
599
  "Chains:",
560
600
  ...(chains.length ? chains.map((c) => `- ${c.name} (${c.source}): ${c.description}`) : ["- (none)"]),
561
601
  ...(proactiveSuggestions.length ? ["", ...proactiveSuggestions] : []),
562
- ...(companionSuggestions.length ? ["", ...companionSuggestions] : []),
563
602
  ...(diagnostics.length ? ["", "Chain diagnostics:", ...diagnostics.map((entry) => `- ${entry.filePath}: ${entry.error}`)] : []),
564
603
  ];
565
604
  return result(lines.join("\n"));
@@ -867,6 +906,135 @@ function handleDelete(params: ManagementParams, ctx: ManagementContext): AgentTo
867
906
  return result(`Deleted chain '${target.name}' at ${target.filePath}.`);
868
907
  }
869
908
 
909
+ function handleEject(params: ManagementParams, ctx: ManagementContext): AgentToolResult<Details> {
910
+ if (!params.agent) return result("Specify 'agent' for eject.", true);
911
+ const raw = params.agent.trim();
912
+ const sanitized = sanitizeName(raw);
913
+ const parsedScope = actionScope(params.agentScope, "eject");
914
+ if (parsedScope.error) return parsedScope.error;
915
+ const scope = parsedScope.scope!;
916
+ const d = discoverAgentsAll(ctx.cwd);
917
+ const source = [...d.package, ...d.builtin].find((a) => a.name === raw || a.name === sanitized);
918
+ if (!source) {
919
+ return result(`Agent '${raw}' not found or is not a bundled/package agent. eject copies a builtin or package agent to ${scope} scope so it can be customized. Available: ${availableNames(ctx.cwd, "agent").join(", ") || "none"}.`, true);
920
+ }
921
+ const runtimeName = source.name;
922
+ const existingCustom = (scope === "user" ? d.user : d.project).find((a) => a.name === runtimeName);
923
+ if (existingCustom) {
924
+ return result(`Agent '${runtimeName}' is already a custom ${scope} agent at ${existingCustom.filePath}. Edit it with { action: "update", agent: "${runtimeName}" } or delete it first.`, true);
925
+ }
926
+ if (nameExistsInScope(ctx.cwd, scope, runtimeName)) {
927
+ return result(`An agent or chain named '${runtimeName}' already exists in ${scope} scope. Remove or rename it first.`, true);
928
+ }
929
+ const projectConfigDir = getProjectConfigDir(ctx.cwd);
930
+ const targetDir = scope === "user" ? d.userDir : d.projectDir ?? path.join(projectConfigDir, "agents");
931
+ fs.mkdirSync(targetDir, { recursive: true });
932
+ const targetPath = path.join(targetDir, `${runtimeName}.md`);
933
+ if (fs.existsSync(targetPath)) {
934
+ return result(`File already exists at ${targetPath} but is not a valid agent definition. Remove or rename it first.`, true);
935
+ }
936
+ let content: string;
937
+ try {
938
+ content = fs.readFileSync(source.filePath, "utf-8");
939
+ } catch (error) {
940
+ const message = error instanceof Error ? error.message : String(error);
941
+ return result(`Failed to read source agent at ${source.filePath}: ${message}`, true);
942
+ }
943
+ fs.writeFileSync(targetPath, content, "utf-8");
944
+ return result(`Ejected agent '${runtimeName}' from ${source.source} to ${scope} scope at ${targetPath}. Edit it there to customize; it shadows the bundled ${source.source} agent of the same name.`);
945
+ }
946
+
947
+ function handleDisable(params: ManagementParams, ctx: ManagementContext): AgentToolResult<Details> {
948
+ if (!params.agent) return result("Specify 'agent' for disable.", true);
949
+ const raw = params.agent.trim();
950
+ const parsedScope = actionScope(params.agentScope, "disable");
951
+ if (parsedScope.error) return parsedScope.error;
952
+ const scope = parsedScope.scope!;
953
+ const d = discoverAgentsAll(ctx.cwd);
954
+ if (scope === "project" && d.projectSettingsPath === null) {
955
+ return result("Project override is not available here: no project config root (.pi or .agents) was found above the cwd. Use agentScope: 'user' or run from inside a project.", true);
956
+ }
957
+ const effective = pickEffectiveAgent(d, raw);
958
+ if (!effective) {
959
+ return result(`Agent '${raw}' not found. Available: ${availableNames(ctx.cwd, "agent").join(", ") || "none"}.`, true);
960
+ }
961
+ const runtimeName = effective.name;
962
+ const settingsPath = mergeBuiltinAgentOverride(ctx.cwd, runtimeName, scope, { disabled: true });
963
+ const after = pickEffectiveAgent(discoverAgentsAll(ctx.cwd), raw);
964
+ if (after?.disabled === true) {
965
+ return result(`Disabled agent '${runtimeName}' via ${scope} settings override at ${settingsPath}. It is now hidden from runtime discovery and { action: "list" }.`);
966
+ }
967
+ return result(`Wrote a disabled override for '${runtimeName}' at ${settingsPath}, but the agent is still enabled. A higher-precedence ${after?.override?.scope ?? "project"} override is likely winning. Try agentScope: '${after?.override?.scope ?? "project"}'.`, true);
968
+ }
969
+
970
+ function handleEnable(params: ManagementParams, ctx: ManagementContext): AgentToolResult<Details> {
971
+ if (!params.agent) return result("Specify 'agent' for enable.", true);
972
+ const raw = params.agent.trim();
973
+ const parsedScope = actionScope(params.agentScope, "enable");
974
+ if (parsedScope.error) return parsedScope.error;
975
+ const scope = parsedScope.scope!;
976
+ const d = discoverAgentsAll(ctx.cwd);
977
+ if (scope === "project" && d.projectSettingsPath === null) {
978
+ return result("Project override is not available here: no project config root (.pi or .agents) was found above the cwd. Use agentScope: 'user' or run from inside a project.", true);
979
+ }
980
+ const effective = pickEffectiveAgent(d, raw);
981
+ if (!effective) {
982
+ return result(`Agent '${raw}' not found. Available: ${availableNames(ctx.cwd, "agent").join(", ") || "none"}.`, true);
983
+ }
984
+ const runtimeName = effective.name;
985
+ const { path: settingsPath, removed } = removeBuiltinAgentOverrideFields(ctx.cwd, runtimeName, scope, ["disabled"]);
986
+ const after = pickEffectiveAgent(discoverAgentsAll(ctx.cwd), raw);
987
+ if (after && after.disabled !== true) {
988
+ if (removed) return result(`Enabled agent '${runtimeName}' (removed disabled override at ${settingsPath}).`);
989
+ return result(`Agent '${runtimeName}' is already enabled.`);
990
+ }
991
+ if (after?.override?.scope && after.override.scope !== scope) {
992
+ return result(`Agent '${runtimeName}' is still disabled via a ${after.override.scope} scope override at ${after.override.path}. Specify agentScope: '${after.override.scope}' to enable it.`, true);
993
+ }
994
+ return result(`Agent '${runtimeName}' is still disabled after removing the ${scope} disabled override. It may be hidden via subagents.disableBuiltins in ${after?.override?.scope ?? scope} settings at ${after?.override?.path ?? settingsPath}.`, true);
995
+ }
996
+
997
+ function handleReset(params: ManagementParams, ctx: ManagementContext): AgentToolResult<Details> {
998
+ if (!params.agent) return result("Specify 'agent' for reset.", true);
999
+ const raw = params.agent.trim();
1000
+ const sanitized = sanitizeName(raw);
1001
+ const parsedScope = actionScope(params.agentScope, "reset");
1002
+ if (parsedScope.error) return parsedScope.error;
1003
+ const scope = parsedScope.scope!;
1004
+ const d = discoverAgentsAll(ctx.cwd);
1005
+ if (scope === "project" && d.projectSettingsPath === null) {
1006
+ return result("Project override is not available here: no project config root (.pi or .agents) was found above the cwd. Use agentScope: 'user' or run from inside a project.", true);
1007
+ }
1008
+ const bundled = [...d.package, ...d.builtin].find((a) => a.name === raw || a.name === sanitized);
1009
+ if (!bundled) {
1010
+ const custom = [...d.user, ...d.project].find((a) => a.name === raw || a.name === sanitized);
1011
+ if (custom) {
1012
+ return result(`Agent '${raw}' has no bundled default to reset to. Use { action: "delete", agent: "${custom.name}" } to remove the custom ${custom.source} agent.`, true);
1013
+ }
1014
+ return result(`Agent '${raw}' not found. Available: ${availableNames(ctx.cwd, "agent").join(", ") || "none"}.`, true);
1015
+ }
1016
+ const runtimeName = bundled.name;
1017
+ const custom = (scope === "user" ? d.user : d.project).find((a) => a.name === raw || a.name === sanitized);
1018
+ const lines: string[] = [];
1019
+ if (custom) {
1020
+ fs.unlinkSync(custom.filePath);
1021
+ lines.push(`Deleted custom ${scope} agent file at ${custom.filePath}.`);
1022
+ }
1023
+ const overrideRemoval = removeBuiltinAgentOverride(ctx.cwd, runtimeName, scope);
1024
+ if (overrideRemoval.removed) lines.push(`Removed ${scope} settings override at ${overrideRemoval.path}.`);
1025
+ if (lines.length === 0) {
1026
+ const otherScope = scope === "user" ? "project" : "user";
1027
+ const otherCustom = (otherScope === "user" ? d.user : d.project).find((a) => a.name === raw || a.name === sanitized);
1028
+ const hasOtherOverride = bundled.override?.scope === otherScope;
1029
+ const note = (otherCustom || hasOtherOverride)
1030
+ ? ` Customization exists in ${otherScope} scope; specify agentScope: '${otherScope}' to reset it.`
1031
+ : "";
1032
+ return result(`Agent '${runtimeName}' has no ${scope} customization to reset.${note} It is at its bundled ${bundled.source} default.`);
1033
+ }
1034
+ lines.push(`Reset agent '${runtimeName}' to its bundled ${bundled.source} default.`);
1035
+ return result(lines.join("\n"));
1036
+ }
1037
+
870
1038
  export function handleManagementAction(action: string, params: ManagementParams, ctx: ManagementContext): AgentToolResult<Details> {
871
1039
  switch (action as ManagementAction) {
872
1040
  case "list": return handleList(params, ctx);
@@ -875,6 +1043,10 @@ export function handleManagementAction(action: string, params: ManagementParams,
875
1043
  case "create": return handleCreate(params, ctx);
876
1044
  case "update": return handleUpdate(params, ctx);
877
1045
  case "delete": return handleDelete(params, ctx);
1046
+ case "eject": return handleEject(params, ctx);
1047
+ case "disable": return handleDisable(params, ctx);
1048
+ case "enable": return handleEnable(params, ctx);
1049
+ case "reset": return handleReset(params, ctx);
878
1050
  default: return result(`Unknown action: ${action}`, true);
879
1051
  }
880
1052
  }