pi-subagents 0.13.4 → 0.14.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.14.0] - 2026-04-14
6
+
7
+ ### Added
8
+ - Builtin agents can now be customized through settings-backed field overrides in `~/.pi/agent/settings.json` and `.pi/settings.json` under `subagents.agentOverrides`, with `/agents` exposing a create/edit override flow instead of forcing full-file copies for model/thinking/tool/prompt tweaks.
9
+
10
+ ### Fixed
11
+ - Shared temp paths are now scoped under a user-specific temp root across async result storage, async run state, chain directories, artifact fallback storage, and detached async config files, avoiding cross-user collisions on shared machines while still handling arbitrary-UID/container environments where `os.userInfo()` can throw.
12
+ - Async/background runs now launch child `pi` processes in JSON mode, stream child events into `events.jsonl` with step metadata while the run is active, keep `output-<n>.log` live with human-readable child output, and document that `subagent-log-<id>.md` is a completion artifact.
13
+ - Bare model IDs now prefer the active parent-session provider when that provider actually exposes the model, across sync, chain, parallel, async, and clarify flows. Ambiguous bare IDs still fall back to conservative resolution.
14
+ - Skill resolution now includes local package roots declared in project/user `settings.json -> packages`, checks the effective task `cwd` before the runtime cwd, and still falls back to the runtime cwd when a nested task inherits package-provided skills from the repo root.
15
+
5
16
  ## [0.13.4] - 2026-04-13
6
17
 
7
18
  ### Fixed
package/README.md CHANGED
@@ -44,7 +44,14 @@ Agents are markdown files with YAML frontmatter that define specialized subagent
44
44
 
45
45
  Use `agentScope` parameter to control discovery: `"user"`, `"project"`, or `"both"` (default; project takes priority).
46
46
 
47
- **Builtin agents:** The extension ships with ready-to-use agents — `scout`, `planner`, `worker`, `reviewer`, `context-builder`, `researcher`, and `delegate`. They load at lowest priority so any user or project agent with the same name overrides them. Builtin agents appear with a `[builtin]` badge in listings and cannot be modified through management actions (create a same-named user agent to override instead).
47
+ **Builtin agents:** The extension ships with ready-to-use agents — `scout`, `planner`, `worker`, `reviewer`, `context-builder`, `researcher`, and `delegate`. They load at lowest priority so any user or project agent with the same name overrides them.
48
+
49
+ You can also override selected builtin fields without copying the whole agent. Builtin overrides are stored in settings under `subagents.agentOverrides`:
50
+
51
+ - User scope: `~/.pi/agent/settings.json`
52
+ - Project scope: `.pi/settings.json`
53
+
54
+ Supported builtin override fields are `model`, `fallbackModels`, `thinking`, `skills`, `tools`, and `systemPrompt`. Project overrides beat user overrides. In `/agents`, press `e` on a builtin to create or edit its override. Overridden builtins show badges like `[builtin+user]` or `[builtin+project]`.
48
55
 
49
56
  > **Note:** The `researcher` agent uses `web_search`, `fetch_content`, and `get_search_content` tools which require the [pi-web-access](https://github.com/nicobailon/pi-web-access) extension. Install it with `pi install npm:pi-web-access`.
50
57
 
@@ -74,7 +81,7 @@ The `thinking` field sets a default extended thinking level for the agent. At ru
74
81
 
75
82
  `fallbackModels` is an optional ordered list of backup models to try when the primary model fails with a provider/model-style error such as quota, auth, timeout, or provider/model unavailable. In markdown frontmatter, declare it as a comma-separated string. In management `config` objects, you can pass either a comma-separated string or a string array.
76
83
 
77
- Fallback resolution follows the same conservative model lookup as normal execution. Explicit `provider/model` values are used as-is. Bare model IDs are only upgraded to a full `provider/model` when they map cleanly to a single registry entry. If a bare ID is ambiguous, it stays bare.
84
+ Fallback resolution follows the same conservative model lookup as normal execution. Explicit `provider/model` values are used as-is. Bare model IDs first prefer the current session provider when that provider actually exposes the model, then fall back to a unique registry match. If a bare ID is still ambiguous, it stays bare.
78
85
 
79
86
  Fallback is only used for provider/model availability failures. Ordinary task failures such as bad `bash` commands, missing files, or other tool/runtime errors do not trigger a model hop.
80
87
 
@@ -221,7 +228,7 @@ Press **Ctrl+Shift+A** or type `/agents` to open the Agents Manager overlay —
221
228
  | Screen | Description |
222
229
  |--------|-------------|
223
230
  | List | Browse all agents and chains with search/filter, scope badges, chain badges |
224
- | Detail | View resolved prompt, frontmatter fields, recent run history |
231
+ | Detail | View resolved prompt, frontmatter fields, recent run history, and active builtin override path |
225
232
  | Edit | Edit fields with specialized pickers (model, thinking, skills, prompt editor) |
226
233
  | Chain Detail | View chain steps with flow visualization and dependency map |
227
234
  | Parallel Builder | Build parallel execution slots, add same agent multiple times, per-slot task overrides |
@@ -233,13 +240,15 @@ Press **Ctrl+Shift+A** or type `/agents` to open the Agents Manager overlay —
233
240
  - `Enter` — view detail
234
241
  - Type any character — search/filter
235
242
  - `Tab` — toggle selection (agents only)
236
- - `Ctrl+N` — new agent from template
243
+ - `Alt+N` — new agent from template
237
244
  - `Ctrl+K` — clone current item
238
245
  - `Ctrl+D` or `Del` — delete current item
239
246
  - `Ctrl+R` — run selected (1 agent: launch, 2+: sequential chain)
240
247
  - `Ctrl+P` — open parallel builder (from selection or cursor agent)
241
248
  - `Esc` — clear query, then selection, then close overlay
242
249
 
250
+ On a builtin detail screen, `e` opens the builtin override flow instead of cloning the whole agent. If no override exists yet, the manager asks whether to store it in user or project settings first.
251
+
243
252
  **Parallel builder keybindings:**
244
253
  - `↑↓` — navigate slots
245
254
  - `Ctrl+A` — add agent (opens search picker)
@@ -253,6 +262,12 @@ Press **Ctrl+Shift+A** or type `/agents` to open the Agents Manager overlay —
253
262
  - `Tab` — toggle skip-clarify (defaults to on for all manager launches)
254
263
  - `Esc` — back
255
264
 
265
+ **Builtin override edit keybindings:**
266
+ - `Ctrl+S` — save override
267
+ - `r` — reset the focused field back to the builtin value
268
+ - `D` — remove the entire override
269
+ - `Esc` — back
270
+
256
271
  **Multi-select workflow:** Select agents with `Tab`, then press `Ctrl+R` for a sequential chain or `Ctrl+P` to open the parallel builder. The parallel builder lets you add the same agent multiple times, set per-slot task overrides, and launch N agents in parallel. Slots without a custom task use the shared task entered on the next screen.
257
272
 
258
273
  ## Chain Files
@@ -306,7 +321,7 @@ Chains can be created from the Agents Manager template picker ("Blank Chain"), o
306
321
  - **Worktree Isolation**: `worktree: true` gives each parallel agent its own git worktree, preventing filesystem conflicts during concurrent execution
307
322
  - **Chain Clarification TUI**: Interactive preview/edit of chain templates and behaviors before execution
308
323
  - **Agent Frontmatter Extensions**: Agents declare default chain behavior (`output`, `defaultReads`, `defaultProgress`, `skill`) plus optional recursion limits via `maxSubagentDepth`
309
- - **Chain Artifacts**: Shared directory at `<tmpdir>/pi-chain-runs/{runId}/` for inter-step files
324
+ - **Chain Artifacts**: Shared directory at a user-scoped temp path like `<tmpdir>/pi-subagents-<scope>/chain-runs/{runId}/` for inter-step files
310
325
  - **Solo Agent Output**: Agents with `output` write to temp dir and return path to caller
311
326
  - **Live Progress Display**: Real-time visibility during sync execution showing current tool, recent output, tokens, and duration
312
327
  - **Output Truncation**: Configurable byte/line limits via `maxOutput`
@@ -397,9 +412,12 @@ Skills are specialized instructions loaded from SKILL.md files and injected into
397
412
  **Skill locations (project-first precedence):**
398
413
  - Project: `.pi/skills/{name}/SKILL.md`
399
414
  - Project packages: `.pi/npm/node_modules/*` via `package.json -> pi.skills`
415
+ - Project settings packages: local package roots from `.pi/settings.json -> packages`, then `package.json -> pi.skills`
416
+ - Current task cwd package: `<cwd>/package.json -> pi.skills`
400
417
  - Project settings: `.pi/settings.json -> skills`
401
418
  - User: `~/.pi/agent/skills/{name}/SKILL.md`
402
419
  - User packages: `~/.pi/agent/npm/node_modules/*` via `package.json -> pi.skills`
420
+ - User settings packages: local package roots from `~/.pi/agent/settings.json -> packages`, then `package.json -> pi.skills`
403
421
  - User settings: `~/.pi/agent/settings.json -> skills`
404
422
 
405
423
  **Usage:**
@@ -529,7 +547,7 @@ These are the parameters the **LLM agent** passes when it calls the `subagent` t
529
547
  ```typescript
530
548
  { action: "list" } // active async runs only
531
549
  { id: "a53ebe46" } // inspect one run
532
- { dir: "<tmpdir>/pi-async-subagent-runs/a53ebe46-..." }
550
+ { dir: "<tmpdir>/pi-subagents-<scope>/async-subagent-runs/a53ebe46-..." }
533
551
  ```
534
552
 
535
553
  **/subagents-status slash command:**
@@ -615,7 +633,7 @@ Notes:
615
633
  | `worktree` | boolean | false | Create isolated git worktrees for each parallel task. Requires clean git state. Per-worktree diffs included in output. |
616
634
  | `chain` | ChainItem[] | - | Sequential steps with behavior overrides (see below) |
617
635
  | `context` | `"fresh" \| "fork"` | `fresh` | Execution context mode. `fork` uses a real branched session from the parent's current leaf for each child run |
618
- | `chainDir` | string | `<tmpdir>/pi-chain-runs/` | Persistent directory for chain artifacts (default auto-cleaned after 24h) |
636
+ | `chainDir` | string | user-scoped temp dir like `<tmpdir>/pi-subagents-<scope>/chain-runs/` | Persistent directory for chain artifacts (default auto-cleaned after 24h) |
619
637
  | `clarify` | boolean | true (chains) | Show TUI to preview/edit chain; implies sync mode |
620
638
  | `agentScope` | `"user" \| "project" \| "both"` | `both` | Agent discovery scope (project wins on name collisions) |
621
639
  | `async` | boolean | false | Background execution (requires `clarify: false` for chains) |
@@ -850,7 +868,7 @@ Optional timeout (milliseconds) for each worktree hook invocation.
850
868
  Default: `30000` ms.
851
869
 
852
870
  ## Chain Directory
853
- Each chain run creates `<tmpdir>/pi-chain-runs/{runId}/` containing:
871
+ Each chain run creates a user-scoped temp directory like `<tmpdir>/pi-subagents-<scope>/chain-runs/{runId}/` containing:
854
872
  - `context.md` - Scout/context-builder output
855
873
  - `plan.md` - Planner output
856
874
  - `progress.md` - Worker/reviewer shared progress
@@ -863,7 +881,7 @@ Directories older than 24 hours are cleaned up on extension startup.
863
881
 
864
882
  ## Artifacts
865
883
 
866
- Location: `{sessionDir}/subagent-artifacts/` or `<tmpdir>/pi-subagent-artifacts/`
884
+ Location: `{sessionDir}/subagent-artifacts/` or a user-scoped temp directory like `<tmpdir>/pi-subagents-<scope>/artifacts/`
867
885
 
868
886
  Files per task:
869
887
  - `{runId}_{agent}_input.md` - Task prompt
@@ -946,14 +964,17 @@ export PI_SUBAGENT_MAX_DEPTH=0 # disable the subagent tool entirely
946
964
  Async runs write a dedicated observability folder:
947
965
 
948
966
  ```
949
- <tmpdir>/pi-async-subagent-runs/<id>/
967
+ <tmpdir>/pi-subagents-<scope>/async-subagent-runs/<id>/
950
968
  status.json
951
969
  events.jsonl
970
+ output-<n>.log
952
971
  subagent-log-<id>.md
953
972
  ```
954
973
 
955
974
  `status.json` is the source of truth for async progress and powers both the TUI widget and `/subagents-status`. Async status and result files are written atomically, so readers do not observe partial JSON during background updates.
956
975
 
976
+ `events.jsonl` is the live event stream. It includes high-level wrapper events plus child `pi` JSON events annotated with subagent metadata for the run and step they belong to. `output-<n>.log` is a live human-readable tail for the current step. `subagent-log-<id>.md` is written when the run completes.
977
+
957
978
  When fallback is used in async/background mode, `status.json` and the final result JSON include the final selected model, ordered attempted models, and per-attempt outcomes so background runs are as debuggable as sync runs.
958
979
 
959
980
  For programmatic access:
@@ -961,7 +982,7 @@ For programmatic access:
961
982
  ```typescript
962
983
  subagent_status({ action: "list" })
963
984
  subagent_status({ id: "<id>" })
964
- subagent_status({ dir: "<tmpdir>/pi-async-subagent-runs/<id>" })
985
+ subagent_status({ dir: "<tmpdir>/pi-subagents-<scope>/async-subagent-runs/<id>" })
965
986
  ```
966
987
 
967
988
  For an interactive overview, run the `/subagents-status` slash command to open the overlay listing active runs and recent completed/failed runs. The overlay auto-refreshes every 2 seconds while it is open.
@@ -35,13 +35,18 @@ function parseCsv(value: string): string[] {
35
35
  return [...new Set(value.split(",").map((v) => v.trim()).filter(Boolean))];
36
36
  }
37
37
 
38
- function configObject(config: unknown): Record<string, unknown> | undefined {
38
+ function configObject(config: unknown): { value?: Record<string, unknown>; error?: string } {
39
39
  let val = config;
40
40
  if (typeof val === "string") {
41
- try { val = JSON.parse(val); } catch { return undefined; }
41
+ try {
42
+ val = JSON.parse(val);
43
+ } catch (error) {
44
+ const message = error instanceof Error ? error.message : String(error);
45
+ return { error: `config must be valid JSON: ${message}` };
46
+ }
42
47
  }
43
- if (!val || typeof val !== "object" || Array.isArray(val)) return undefined;
44
- return val as Record<string, unknown>;
48
+ if (!val || typeof val !== "object" || Array.isArray(val)) return {};
49
+ return { value: val as Record<string, unknown> };
45
50
  }
46
51
 
47
52
  function hasKey(obj: Record<string, unknown>, key: string): boolean {
@@ -383,7 +388,9 @@ export function handleGet(params: ManagementParams, ctx: ManagementContext): Age
383
388
  }
384
389
 
385
390
  export function handleCreate(params: ManagementParams, ctx: ManagementContext): AgentToolResult<Details> {
386
- const cfg = configObject(params.config);
391
+ const parsedConfig = configObject(params.config);
392
+ if (parsedConfig.error) return result(parsedConfig.error, true);
393
+ const cfg = parsedConfig.value;
387
394
  if (!cfg) return result("config required for create.", true);
388
395
  if (typeof cfg.name !== "string" || !cfg.name.trim()) return result("config.name is required and must be a non-empty string.", true);
389
396
  if (typeof cfg.description !== "string" || !cfg.description.trim()) return result("config.description is required and must be a non-empty string.", true);
@@ -427,7 +434,9 @@ export function handleCreate(params: ManagementParams, ctx: ManagementContext):
427
434
  export function handleUpdate(params: ManagementParams, ctx: ManagementContext): AgentToolResult<Details> {
428
435
  if (!params.agent && !params.chainName) return result("Specify 'agent' or 'chainName' for update.", true);
429
436
  if (params.agent && params.chainName) return result("Specify either 'agent' or 'chainName', not both.", true);
430
- const cfg = configObject(params.config);
437
+ const parsedConfig = configObject(params.config);
438
+ if (parsedConfig.error) return result(parsedConfig.error, true);
439
+ const cfg = parsedConfig.value;
431
440
  if (!cfg) return result("config required for update.", true);
432
441
  const warnings: string[] = [];
433
442
  if (params.agent) {
@@ -61,6 +61,10 @@ function buildDetailLines(
61
61
  const maxSubagentDepth = agent.maxSubagentDepth !== undefined ? String(agent.maxSubagentDepth) : "(default)";
62
62
 
63
63
  lines.push(renderFieldLine("Model:", agent.model ?? "default", contentWidth, theme));
64
+ if (agent.override) {
65
+ const overrideLabel = `${agent.override.scope} · ${formatPath(agent.override.path)}`;
66
+ lines.push(renderFieldLine("Override:", overrideLabel, contentWidth, theme));
67
+ }
64
68
  lines.push(renderFieldLine("Thinking:", agent.thinking ?? "off", contentWidth, theme));
65
69
  lines.push(renderFieldLine("Tools:", tools, contentWidth, theme));
66
70
  lines.push(renderFieldLine("MCP:", mcp, contentWidth, theme));
@@ -131,7 +135,11 @@ export function renderDetail(
131
135
  theme: Theme,
132
136
  ): string[] {
133
137
  const lines: string[] = [];
134
- const scopeBadge = agent.source === "builtin" ? "[builtin]" : agent.source === "project" ? "[proj]" : "[user]";
138
+ const scopeBadge = agent.source === "builtin"
139
+ ? (agent.override ? `[builtin+${agent.override.scope}]` : "[builtin]")
140
+ : agent.source === "project"
141
+ ? "[proj]"
142
+ : "[user]";
135
143
  const headerText = ` ${agent.name} ${scopeBadge} ${formatPath(agent.filePath)} `;
136
144
  lines.push(renderHeader(headerText, width, theme));
137
145
  lines.push(row("", width, theme));
@@ -152,7 +160,9 @@ export function renderDetail(
152
160
  lines.push(row(scrollInfo ? ` ${theme.fg("dim", scrollInfo)}` : "", width, theme));
153
161
 
154
162
  const footer = agent.source === "builtin"
155
- ? " [l]aunch [v] raw/resolved [↑↓] scroll [esc] back "
163
+ ? agent.override
164
+ ? " [l]aunch [e]dit override [v] raw/resolved [↑↓] scroll [esc] back "
165
+ : " [l]aunch [e]create override [v] raw/resolved [↑↓] scroll [esc] back "
156
166
  : " [l]aunch [e]dit [v] raw/resolved [↑↓] scroll [esc] back ";
157
167
  lines.push(renderFooter(footer, width, theme));
158
168
  return lines;
@@ -1,6 +1,6 @@
1
1
  import type { Theme } from "@mariozechner/pi-coding-agent";
2
2
  import { matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
3
- import type { AgentConfig } from "./agents.ts";
3
+ import type { AgentConfig, BuiltinAgentOverrideBase } from "./agents.ts";
4
4
  import { createEditorState, ensureCursorVisible, getCursorDisplayPos, handleEditorInput, renderEditor, wrapText } from "./text-editor.ts";
5
5
  import type { TextEditorState } from "./text-editor.ts";
6
6
  import { pad, row, renderHeader, renderFooter, formatScrollInfo } from "./render-helpers.ts";
@@ -8,30 +8,70 @@ import { pad, row, renderHeader, renderFooter, formatScrollInfo } from "./render
8
8
  export interface ModelInfo { provider: string; id: string; fullId: string; }
9
9
  export interface SkillInfo { name: string; source: string; description?: string; }
10
10
  export type EditScreen = "edit" | "edit-field" | "edit-prompt";
11
+ export type EditField = typeof FIELD_ORDER[number];
12
+
11
13
  export interface EditState {
12
14
  draft: AgentConfig; isNew: boolean; fieldIndex: number; fieldMode: "text" | "model" | "thinking" | "skills" | null;
13
15
  fieldEditor: TextEditorState; promptEditor: TextEditorState; modelSearchQuery: string; modelCursor: number; filteredModels: ModelInfo[];
14
16
  thinkingCursor: number; skillSearchQuery: string; skillCursor: number; filteredSkills: SkillInfo[]; skillSelected: Set<string>; error?: string;
17
+ fields: EditField[];
18
+ title?: string;
19
+ overrideBase?: BuiltinAgentOverrideBase;
20
+ }
21
+ export interface EditInputResult { action?: "save" | "discard" | "delete"; nextScreen?: EditScreen; }
22
+ export interface CreateEditStateOptions {
23
+ fields?: EditField[];
24
+ title?: string;
25
+ overrideBase?: BuiltinAgentOverrideBase;
15
26
  }
16
- export interface EditInputResult { action?: "save" | "discard"; nextScreen?: EditScreen; }
17
27
 
18
28
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
19
29
  const FIELD_ORDER = ["name", "description", "model", "fallbackModels", "thinking", "tools", "extensions", "skills", "output", "reads", "progress", "interactive", "prompt"] as const;
20
- type EditField = typeof FIELD_ORDER[number];
21
30
  type ThinkingLevel = typeof THINKING_LEVELS[number];
22
31
  const PROMPT_VIEWPORT_HEIGHT = 16;
23
32
  const MODEL_SELECTOR_HEIGHT = 10;
24
33
  const SKILL_SELECTOR_HEIGHT = 10;
25
34
 
26
- function formatTools(draft: AgentConfig): string { const tools = [...(draft.tools ?? []), ...(draft.mcpDirectTools ?? []).map((tool) => `mcp:${tool}`)]; return tools.length > 0 ? tools.join(", ") : ""; }
35
+ function formatTools(draft: Pick<AgentConfig, "tools" | "mcpDirectTools">): string { const tools = [...(draft.tools ?? []), ...(draft.mcpDirectTools ?? []).map((tool) => `mcp:${tool}`)]; return tools.length > 0 ? tools.join(", ") : ""; }
36
+ function toolList(draft: Pick<AgentConfig, "tools" | "mcpDirectTools">): string[] | undefined { const tools = [...(draft.tools ?? []), ...(draft.mcpDirectTools ?? []).map((tool) => `mcp:${tool}`)]; return tools.length > 0 ? tools : undefined; }
27
37
  function parseTools(value: string): { tools?: string[]; mcp?: string[] } { const items = value.split(",").map((item) => item.trim()).filter((item) => item.length > 0); const tools: string[] = []; const mcp: string[] = []; for (const item of items) { if (item.startsWith("mcp:")) { const name = item.slice(4).trim(); if (name) mcp.push(name); } else { tools.push(item); } } return { tools: tools.length > 0 ? tools : undefined, mcp: mcp.length > 0 ? mcp : undefined }; }
28
38
  function parseCommaList(value: string): string[] | undefined { const items = value.split(",").map((item) => item.trim()).filter((item) => item.length > 0); return items.length > 0 ? items : undefined; }
39
+ function arraysEqual(a: string[] | undefined, b: string[] | undefined): boolean { if (!a && !b) return true; if (!a || !b || a.length !== b.length) return false; for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; return true; }
40
+
41
+ function fieldValueMatchesBase(field: EditField, state: EditState): boolean {
42
+ const base = state.overrideBase;
43
+ if (!base) return false;
44
+ switch (field) {
45
+ case "model": return state.draft.model === base.model;
46
+ case "fallbackModels": return arraysEqual(state.draft.fallbackModels, base.fallbackModels);
47
+ case "thinking": return state.draft.thinking === base.thinking;
48
+ case "tools": return arraysEqual(toolList(state.draft), toolList(base));
49
+ case "skills": return arraysEqual(state.draft.skills, base.skills);
50
+ case "prompt": return state.draft.systemPrompt === base.systemPrompt;
51
+ default: return false;
52
+ }
53
+ }
29
54
 
30
- export function createEditState(draft: AgentConfig, isNew: boolean, models: ModelInfo[], skills: SkillInfo[]): EditState {
55
+ function resetFieldToBase(field: EditField, state: EditState): void {
56
+ const base = state.overrideBase;
57
+ if (!base) return;
58
+ switch (field) {
59
+ case "model": state.draft.model = base.model; break;
60
+ case "fallbackModels": state.draft.fallbackModels = base.fallbackModels ? [...base.fallbackModels] : undefined; break;
61
+ case "thinking": state.draft.thinking = base.thinking; break;
62
+ case "tools": state.draft.tools = base.tools ? [...base.tools] : undefined; state.draft.mcpDirectTools = base.mcpDirectTools ? [...base.mcpDirectTools] : undefined; break;
63
+ case "skills": state.draft.skills = base.skills ? [...base.skills] : undefined; break;
64
+ case "prompt": state.draft.systemPrompt = base.systemPrompt; state.promptEditor = createEditorState(base.systemPrompt); break;
65
+ default: break;
66
+ }
67
+ }
68
+
69
+ export function createEditState(draft: AgentConfig, isNew: boolean, models: ModelInfo[], skills: SkillInfo[], options: CreateEditStateOptions = {}): EditState {
31
70
  return {
32
71
  draft: { ...draft, tools: draft.tools ? [...draft.tools] : undefined, mcpDirectTools: draft.mcpDirectTools ? [...draft.mcpDirectTools] : undefined, skills: draft.skills ? [...draft.skills] : undefined, fallbackModels: draft.fallbackModels ? [...draft.fallbackModels] : undefined, extensions: draft.extensions ? [...draft.extensions] : draft.extensions, defaultReads: draft.defaultReads ? [...draft.defaultReads] : undefined, extraFields: draft.extraFields ? { ...draft.extraFields } : undefined },
33
72
  isNew, fieldIndex: 0, fieldMode: null, fieldEditor: createEditorState(), promptEditor: createEditorState(draft.systemPrompt ?? ""),
34
73
  modelSearchQuery: "", modelCursor: 0, filteredModels: [...models], thinkingCursor: 0, skillSearchQuery: "", skillCursor: 0, filteredSkills: [...skills], skillSelected: new Set(draft.skills ?? []),
74
+ fields: options.fields ?? [...FIELD_ORDER], title: options.title, overrideBase: options.overrideBase,
35
75
  };
36
76
  }
37
77
 
@@ -71,15 +111,15 @@ function applyFieldValue(field: EditField, state: EditState, value: string): voi
71
111
  }
72
112
 
73
113
  function openModelPicker(state: EditState, models: ModelInfo[]): void {
74
- state.fieldIndex = FIELD_ORDER.indexOf("model"); state.fieldMode = "model"; state.modelSearchQuery = ""; state.filteredModels = [...models];
114
+ state.fieldIndex = state.fields.indexOf("model"); state.fieldMode = "model"; state.modelSearchQuery = ""; state.filteredModels = [...models];
75
115
  const idx = state.filteredModels.findIndex((m) => m.fullId === state.draft.model || m.id === state.draft.model); state.modelCursor = idx >= 0 ? idx : 0;
76
116
  }
77
117
  function openThinkingPicker(state: EditState): void {
78
- state.fieldIndex = FIELD_ORDER.indexOf("thinking"); state.fieldMode = "thinking";
118
+ state.fieldIndex = state.fields.indexOf("thinking"); state.fieldMode = "thinking";
79
119
  const idx = THINKING_LEVELS.indexOf((state.draft.thinking ?? "off") as ThinkingLevel); state.thinkingCursor = idx >= 0 ? idx : 0;
80
120
  }
81
121
  function openSkillPicker(state: EditState, skills: SkillInfo[]): void {
82
- state.fieldIndex = FIELD_ORDER.indexOf("skills"); state.fieldMode = "skills"; state.skillSearchQuery = ""; state.filteredSkills = [...skills]; state.skillSelected = new Set(state.draft.skills ?? []); state.skillCursor = 0;
122
+ state.fieldIndex = state.fields.indexOf("skills"); state.fieldMode = "skills"; state.skillSearchQuery = ""; state.filteredSkills = [...skills]; state.skillSelected = new Set(state.draft.skills ?? []); state.skillCursor = 0;
83
123
  }
84
124
 
85
125
  function renderModelPicker(state: EditState, width: number, theme: Theme): string[] {
@@ -183,9 +223,11 @@ export function handleEditInput(screen: EditScreen, state: EditState, data: stri
183
223
  if (screen === "edit") {
184
224
  if (matchesKey(data, "ctrl+s")) return { action: "save" };
185
225
  if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) return { action: "discard" };
226
+ if (data === "D" && state.overrideBase) return { action: "delete" };
186
227
  if (matchesKey(data, "up")) { state.fieldIndex = Math.max(0, state.fieldIndex - 1); return; }
187
- if (matchesKey(data, "down")) { state.fieldIndex = Math.min(FIELD_ORDER.length - 1, state.fieldIndex + 1); return; }
188
- const field = FIELD_ORDER[state.fieldIndex]!;
228
+ if (matchesKey(data, "down")) { state.fieldIndex = Math.min(state.fields.length - 1, state.fieldIndex + 1); return; }
229
+ const field = state.fields[state.fieldIndex]!;
230
+ if (data === "r" && state.overrideBase) { resetFieldToBase(field, state); return; }
189
231
  if (data === "m") { openModelPicker(state, models); return { nextScreen: "edit-field" }; }
190
232
  if (data === "t") { openThinkingPicker(state); return { nextScreen: "edit-field" }; }
191
233
  if (data === "s") { openSkillPicker(state, skills); return { nextScreen: "edit-field" }; }
@@ -234,7 +276,7 @@ export function handleEditInput(screen: EditScreen, state: EditState, data: stri
234
276
  return;
235
277
  }
236
278
  if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { state.fieldMode = null; return { nextScreen: "edit" }; }
237
- if (matchesKey(data, "return")) { const field = FIELD_ORDER[state.fieldIndex]!; applyFieldValue(field, state, state.fieldEditor.buffer); state.fieldMode = null; return { nextScreen: "edit" }; }
279
+ if (matchesKey(data, "return")) { const field = state.fields[state.fieldIndex]!; applyFieldValue(field, state, state.fieldEditor.buffer); state.fieldMode = null; return { nextScreen: "edit" }; }
238
280
  if (matchesKey(data, "tab")) return;
239
281
  const innerW = width - 2; const labelWidth = 12; const textWidth = Math.max(10, innerW - labelWidth - 6);
240
282
  const nextState = handleEditorInput(state.fieldEditor, data, textWidth); if (nextState) state.fieldEditor = nextState; return;
@@ -256,13 +298,14 @@ export function renderEdit(screen: EditScreen, state: EditState, width: number,
256
298
  if (screen === "edit-prompt") return renderPromptEditor(state, width, theme);
257
299
  const lines: string[] = [];
258
300
  const scopeBadge = state.draft.source === "user" ? "[user]" : "[proj]"; const label = state.isNew ? " [new]" : "";
259
- lines.push(renderHeader(` Editing: ${state.draft.name} ${scopeBadge}${label} `, width, theme));
301
+ lines.push(renderHeader(` ${state.title ?? `Editing: ${state.draft.name} ${scopeBadge}${label}`} `, width, theme));
260
302
  lines.push(row("", width, theme));
261
303
  const innerW = width - 2; const labelWidth = 12; const valueWidth = Math.max(10, innerW - labelWidth - 6);
262
- for (let i = 0; i < FIELD_ORDER.length; i++) {
263
- const field = FIELD_ORDER[i]!; if (field === "prompt") break;
304
+ for (let i = 0; i < state.fields.length; i++) {
305
+ const field = state.fields[i]!; if (field === "prompt") break;
264
306
  const isFocused = i === state.fieldIndex; const prefix = isFocused ? theme.fg("accent", "▸ ") : " ";
265
- const labelText = pad(`${field[0]!.toUpperCase()}${field.slice(1)}:`, labelWidth); let valueText = renderFieldValue(field, state);
307
+ const rawLabel = pad(`${field[0]!.toUpperCase()}${field.slice(1)}:`, labelWidth);
308
+ const labelText = state.overrideBase && !fieldValueMatchesBase(field, state) ? theme.fg("accent", rawLabel) : rawLabel; let valueText = renderFieldValue(field, state);
266
309
  if (field === "progress") { const toggle = state.draft.defaultProgress ? theme.fg("success", "[x]") : "[ ]"; valueText = `${toggle} ${state.draft.defaultProgress ? "on" : "off"}`; lines.push(row(` ${prefix}${labelText} ${pad(truncateToWidth(valueText, valueWidth), valueWidth)}`, width, theme)); continue; }
267
310
  if (field === "interactive") { const toggle = state.draft.interactive ? theme.fg("success", "[x]") : "[ ]"; valueText = `${toggle} ${state.draft.interactive ? "on" : "off"}`; lines.push(row(` ${prefix}${labelText} ${pad(truncateToWidth(valueText, valueWidth), valueWidth)}`, width, theme)); continue; }
268
311
  let displayValue = truncateToWidth(valueText, valueWidth);
@@ -275,14 +318,23 @@ export function renderEdit(screen: EditScreen, state: EditState, width: number,
275
318
  }
276
319
  lines.push(row(` ${prefix}${labelText} [${displayValue}]`, width, theme));
277
320
  }
278
- lines.push(row("", width, theme));
279
- const promptFocused = state.fieldIndex === FIELD_ORDER.indexOf("prompt");
280
- const promptPrefix = promptFocused ? theme.fg("accent", "▸ ") : " ";
281
- lines.push(row(` ${promptPrefix}${theme.fg("dim", "── System Prompt ──")}`, width, theme));
282
- const previewWidth = innerW - 2; const wrapped = wrapText(state.draft.systemPrompt ?? "", previewWidth); const previewLines = wrapped.lines.slice(0, 4);
283
- for (const line of previewLines) lines.push(row(` ${line}`, width, theme));
284
- for (let i = previewLines.length; i < 4; i++) lines.push(row("", width, theme));
321
+ if (state.fields.includes("prompt")) {
322
+ lines.push(row("", width, theme));
323
+ const promptIndex = state.fields.indexOf("prompt");
324
+ const promptFocused = state.fieldIndex === promptIndex;
325
+ const promptPrefix = promptFocused ? theme.fg("accent", "▸ ") : " ";
326
+ const promptTitle = state.overrideBase && !fieldValueMatchesBase("prompt", state)
327
+ ? theme.fg("accent", "── System Prompt ──")
328
+ : theme.fg("dim", "── System Prompt ──");
329
+ lines.push(row(` ${promptPrefix}${promptTitle}`, width, theme));
330
+ const previewWidth = innerW - 2; const wrapped = wrapText(state.draft.systemPrompt ?? "", previewWidth); const previewLines = wrapped.lines.slice(0, 4);
331
+ for (const line of previewLines) lines.push(row(` ${line}`, width, theme));
332
+ for (let i = previewLines.length; i < 4; i++) lines.push(row("", width, theme));
333
+ }
285
334
  if (state.error) lines.push(row(` ${theme.fg("error", state.error)}`, width, theme)); else lines.push(row("", width, theme));
286
- lines.push(renderFooter(" [ctrl+s] save [esc] back ", width, theme));
335
+ const footer = state.overrideBase
336
+ ? " [ctrl+s] save [r] reset field [D] remove override [esc] back "
337
+ : " [ctrl+s] save [esc] back ";
338
+ lines.push(renderFooter(footer, width, theme));
287
339
  return lines;
288
340
  }
@@ -9,6 +9,7 @@ export interface ListAgent {
9
9
  description: string;
10
10
  model?: string;
11
11
  source: AgentSource;
12
+ overrideScope?: "user" | "project";
12
13
  kind: "agent" | "chain";
13
14
  stepCount?: number;
14
15
  }
@@ -190,7 +191,7 @@ export function renderList(
190
191
  const innerW = width - 2;
191
192
  const nameWidth = 16;
192
193
  const modelWidth = 12;
193
- const scopeWidth = 9;
194
+ const scopeWidth = 17;
194
195
 
195
196
  for (let i = 0; i < visible.length; i++) {
196
197
  const agent = visible[i]!;
@@ -208,7 +209,13 @@ export function renderList(
208
209
  const modelDisplay = modelRaw.includes("/") ? modelRaw.split("/").pop() ?? modelRaw : modelRaw;
209
210
  const nameText = isCursor ? theme.fg("accent", agent.name) : agent.name;
210
211
  const modelText = theme.fg("dim", modelDisplay);
211
- const scopeLabel = agent.kind === "chain" ? "[chain]" : agent.source === "builtin" ? "[builtin]" : agent.source === "project" ? "[proj]" : "[user]";
212
+ const scopeLabel = agent.kind === "chain"
213
+ ? "[chain]"
214
+ : agent.source === "builtin"
215
+ ? (agent.overrideScope ? `[builtin+${agent.overrideScope}]` : "[builtin]")
216
+ : agent.source === "project"
217
+ ? "[proj]"
218
+ : "[user]";
212
219
  const scopeBadge = theme.fg("dim", scopeLabel);
213
220
  const descText = theme.fg("dim", agent.description);
214
221