@nredd/pi-subagents 0.0.0-stage → 0.22.0-nredd.10

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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1444 -2
  3. package/dist/chunks/chunk-MSGGFB5Q.ts +6755 -0
  4. package/dist/chunks/chunk-MSGGFB5Q.ts.map +7 -0
  5. package/dist/chunks/conversation-viewer-A7AMRDVY.ts +15 -0
  6. package/dist/chunks/conversation-viewer-A7AMRDVY.ts.map +7 -0
  7. package/dist/index.ts +9874 -0
  8. package/dist/index.ts.map +7 -0
  9. package/package.json +82 -4
  10. package/src/abortable.ts +43 -0
  11. package/src/agent-color.ts +161 -0
  12. package/src/agent-file-toggle.ts +298 -0
  13. package/src/agent-manager.ts +2431 -0
  14. package/src/agent-runner.ts +1703 -0
  15. package/src/agent-source-badge.ts +75 -0
  16. package/src/agent-types.ts +346 -0
  17. package/src/child-context.ts +33 -0
  18. package/src/clock.ts +25 -0
  19. package/src/completion-outbox.ts +144 -0
  20. package/src/context.ts +58 -0
  21. package/src/cross-extension-rpc.ts +200 -0
  22. package/src/custom-agents.ts +441 -0
  23. package/src/default-agents.ts +126 -0
  24. package/src/detached-results.ts +150 -0
  25. package/src/enabled-models.ts +181 -0
  26. package/src/enterprise-local.ts +67 -0
  27. package/src/env.ts +33 -0
  28. package/src/error-message.ts +11 -0
  29. package/src/git-path.ts +66 -0
  30. package/src/group-join.ts +142 -0
  31. package/src/home-paths.ts +27 -0
  32. package/src/index.ts +5047 -0
  33. package/src/invocation-config.ts +158 -0
  34. package/src/memory.ts +179 -0
  35. package/src/mention-clone.ts +200 -0
  36. package/src/mention.ts +141 -0
  37. package/src/model-call.ts +83 -0
  38. package/src/model-resolver.ts +333 -0
  39. package/src/model-scope.ts +71 -0
  40. package/src/model-selection.ts +170 -0
  41. package/src/model-tiers.ts +176 -0
  42. package/src/nested-tools.ts +512 -0
  43. package/src/notification-result.ts +118 -0
  44. package/src/output-file.ts +186 -0
  45. package/src/package-resources.ts +639 -0
  46. package/src/plan-mode-children.ts +176 -0
  47. package/src/process-budget.ts +346 -0
  48. package/src/prompts.ts +161 -0
  49. package/src/quota-admission.ts +121 -0
  50. package/src/resource-loader.ts +65 -0
  51. package/src/schedule-store.ts +153 -0
  52. package/src/schedule.ts +514 -0
  53. package/src/settings.ts +851 -0
  54. package/src/skill-loader.ts +288 -0
  55. package/src/stale-ctx.ts +16 -0
  56. package/src/startup-error.ts +29 -0
  57. package/src/status-note.ts +113 -0
  58. package/src/structured-output.ts +130 -0
  59. package/src/types.ts +487 -0
  60. package/src/ui/agent-mention.ts +219 -0
  61. package/src/ui/agent-row.ts +245 -0
  62. package/src/ui/agent-widget.ts +722 -0
  63. package/src/ui/conversation-viewer.ts +837 -0
  64. package/src/ui/fallback-flag.ts +114 -0
  65. package/src/ui/fleet-list.ts +1020 -0
  66. package/src/ui/fleet-state.ts +154 -0
  67. package/src/ui/notification-view.ts +139 -0
  68. package/src/ui/schedule-menu.ts +105 -0
  69. package/src/ui/select-item.ts +45 -0
  70. package/src/ui/viewer-keys.ts +39 -0
  71. package/src/ui/workflow-card.ts +459 -0
  72. package/src/ui/workflow-dialog.ts +1138 -0
  73. package/src/ui/workflow-menu.ts +193 -0
  74. package/src/usage.ts +167 -0
  75. package/src/workflow/collisions.ts +123 -0
  76. package/src/workflow/entry.ts +47 -0
  77. package/src/workflow/host.ts +398 -0
  78. package/src/workflow/journal.ts +176 -0
  79. package/src/workflow/json-schema.ts +129 -0
  80. package/src/workflow/meta.ts +329 -0
  81. package/src/workflow/progress.ts +554 -0
  82. package/src/workflow/runtime.ts +1249 -0
  83. package/src/workflow/saved.ts +259 -0
  84. package/src/workflow/task.ts +317 -0
  85. package/src/workflow/tool-description.ts +200 -0
  86. package/src/workflow/worker-source.ts +903 -0
  87. package/src/worktree.ts +520 -0
  88. package/src/xml.ts +13 -0
@@ -0,0 +1,851 @@
1
+ // Persistence for pi-subagents operational settings.
2
+ // - Global: ~/.pi/agent/subagents.json (via getAgentDir()) — manual defaults, never written here
3
+ // - Project: <cwd>/.pi/subagents.json — written by /agents → Settings; overrides global on load
4
+
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { NO_FALLBACK } from "./agent-types.js";
8
+ import { readEnterpriseLocalManifest } from "./enterprise-local.js";
9
+ import { errorMessage } from "./error-message.js";
10
+ import { resolveAgentDir } from "./home-paths.js";
11
+ import { type ModelResolution, parseTierBinding, setModelTiers } from "./model-tiers.js";
12
+ import { NOTIFICATION_RESULT_CHARS_CEILING } from "./notification-result.js";
13
+ import { type BudgetPolicy, DEFAULT_BUDGET_POLICY } from "./process-budget.js";
14
+ import type { AgentMentionMode, JoinMode, ViewerMarkdownMode, WidgetMode } from "./types.js";
15
+
16
+ export interface SubagentsSettings {
17
+ maxConcurrent?: number;
18
+ /**
19
+ * Max concurrent FOREGROUND (blocking) agents — `0` = unlimited, the default,
20
+ * which preserves the behaviour that has always applied: nothing bounded
21
+ * foreground work, and pi dispatches a message's tool calls through
22
+ * `Promise.all`, so an unqualified fan-out of blocking `Agent` calls runs all
23
+ * at once. Set it to bound that (#253 — on local models, parallel agents
24
+ * thrash the prompt cache).
25
+ *
26
+ * Deliberately independent of `maxConcurrent` rather than folded into it: a
27
+ * foreground agent blocks the parent anyway, so charging it to the background
28
+ * pool would let a saturated pool starve the main session of work it could
29
+ * have done itself.
30
+ *
31
+ * Bounds only spawns a caller is blocking on inline. Nested children are
32
+ * exempt — their parent is blocked awaiting them, so queueing a child behind
33
+ * its own parent would deadlock — and so are detached spawns from
34
+ * cross-extension RPC or `@handle` mentions, which block nobody and are
35
+ * documented to start immediately. Foreground `resume` is also outside the
36
+ * pool: it reuses an existing session and never reaches the spawn path, so
37
+ * several blocking resumes in one message can still exceed the limit.
38
+ */
39
+ maxConcurrentForeground?: number;
40
+ /**
41
+ * Process-wide ceiling on running subagents, across every manager in this pi
42
+ * process: top-level, nested, workflow, scheduled, RPC and mention spawns,
43
+ * and resumes, in the root session and in sessions a `/new`, `/resume`,
44
+ * `/fork` or `/reload` left running. `0` = off, the default. The process,
45
+ * not the machine: two pi processes each get their own. See
46
+ * `process-budget.ts`.
47
+ *
48
+ * Global configuration only, like `modelAliases`: a project file's value is
49
+ * ignored with a warning. Shared by every activation in the process, so the
50
+ * last one to apply its settings wins. A change that tightens it waits until
51
+ * no subagent in the process holds a slot (`setBudgetPolicy`).
52
+ */
53
+ maxActiveSubagents?: number;
54
+ /**
55
+ * Slots of `maxActiveSubagents` held back per nesting level below a run, so
56
+ * parents that keep their slot while they wait can never starve their own
57
+ * children. Defaults to `2`; with the global `maxSubagentDepth: 2` that
58
+ * leaves `maxActiveSubagents - 2` for top-level runs. With nesting on it
59
+ * must be at least `1`, and `maxActiveSubagents` at least
60
+ * `nestedSubagentReserve * (maxSubagentDepth - 1) + 1`; a budget that
61
+ * breaks either is ignored with a warning, since it could deadlock.
62
+ */
63
+ nestedSubagentReserve?: number;
64
+ /**
65
+ * 0 = unlimited — the extension's single source of truth for that convention:
66
+ * `normalizeMaxTurns()` in agent-runner.ts treats 0 → `undefined`, and the
67
+ * `/agents` → Settings input prompt explicitly says "0 = unlimited".
68
+ */
69
+ defaultMaxTurns?: number;
70
+ graceTurns?: number;
71
+ /**
72
+ * Result characters a background completion notice carries per agent,
73
+ * defaulting to `16000`. Past it the notice keeps the head and tail and
74
+ * names the transcript and `get_subagent_result`; agents that finish
75
+ * together share four times this. A result delivered whole counts as read.
76
+ * `0` restores the 500-character preview (300 per `group` member). See
77
+ * `notification-result.ts`.
78
+ */
79
+ notificationResultChars?: number;
80
+ defaultJoinMode?: JoinMode;
81
+ /**
82
+ * Whether a top-level `Agent` spawn that doesn't say runs detached.
83
+ * Defaults to `true`, following Claude Code, where the agent backgrounds
84
+ * unless the caller passes `run_in_background: false`. Set `false` to restore
85
+ * the previous behaviour, where an unqualified spawn blocked the turn and
86
+ * returned its result inline.
87
+ *
88
+ * Top-level only. Nested spawns (a subagent spawning its own) always default
89
+ * to foreground regardless of this setting — see `nested-tools.ts`, where a
90
+ * detached child would be killed by `abortOwnedChildren` when its parent
91
+ * settles, with no notification path to deliver its result.
92
+ *
93
+ * An explicit `run_in_background` on the call, or in the agent file's
94
+ * frontmatter, overrides this in both directions; the setting only decides
95
+ * what "unspecified" means.
96
+ */
97
+ backgroundByDefault?: boolean;
98
+ /**
99
+ * Master switch for the schedule subagent feature. Defaults to `true`.
100
+ * When `false`: the `Agent` tool's `schedule` param + its guideline are
101
+ * stripped from the tool spec at registration (zero LLM-context cost), the
102
+ * scheduler doesn't bind to the session, and the `/agents → Scheduled jobs`
103
+ * menu entry is hidden. Schema-level removal applies at extension load
104
+ * (next pi session); runtime menu/runtime-fire short-circuit is immediate.
105
+ */
106
+ schedulingEnabled?: boolean;
107
+ /**
108
+ * When true, the effective model of each subagent spawn is validated
109
+ * against `enabledModels` from pi's settings — both global
110
+ * (`<agentDir>/settings.json`) and project-local (`<cwd>/.pi/settings.json`),
111
+ * with project overriding global (mirrors pi's SettingsManager deep-merge).
112
+ *
113
+ * scopeModels guards against runtime LLM choices, not user-level config.
114
+ * Out-of-scope handling reflects this:
115
+ * - Caller-supplied via `Agent({ model: "..." })` (only when frontmatter
116
+ * has no `model:`, since frontmatter is authoritative): hard error
117
+ * returned to the orchestrator, listing the allowed models. The LLM
118
+ * made an explicit out-of-scope choice and gets explicit feedback.
119
+ * - Frontmatter-pinned: warning toast + the pinned model runs. The
120
+ * agent's author/installer chose this; trust it.
121
+ * - Parent-inherited (neither caller nor frontmatter sets a model):
122
+ * warning toast + parent's model runs. The user chose the parent's
123
+ * model when starting the session; trust it.
124
+ *
125
+ * No-op when pi's `enabledModels` is empty or absent — nothing to validate
126
+ * against. Defaults to false: subagents may use any model.
127
+ */
128
+ scopeModels?: boolean;
129
+ /**
130
+ * When true, an unreadable or unparseable agent `.md` aborts extension load
131
+ * instead of being skipped with a warning — pi exits, naming the file.
132
+ *
133
+ * Startup only, by design. Mid-session reloads (one per `Agent` call) keep
134
+ * warning: a bad edit at 3pm should not kill the session on the next
135
+ * unrelated spawn, where the failure would look disconnected from its cause.
136
+ * For a checked-in `.pi/agents/`, failing at startup is the point — the
137
+ * alternative is running a *different* agent than the file names.
138
+ * Defaults to false.
139
+ */
140
+ strictAgentFiles?: boolean;
141
+ /**
142
+ * When true, the three built-in default agents (general-purpose, Explore, Plan)
143
+ * are not registered at startup. User-defined agents from project/global custom
144
+ * agent dirs are completely unaffected — only the hardcoded DEFAULT_AGENTS are suppressed.
145
+ * Defaults to false.
146
+ */
147
+ disableDefaultAgents?: boolean;
148
+ /**
149
+ * Which Agent tool description the LLM sees. "full" (default) is the rich
150
+ * Claude Code-style prompt; "compact" is a ~75% smaller version (one-line
151
+ * agent type list, terse usage notes) for small/local models where tool-spec
152
+ * tokens are expensive; "custom" reads `.pi/agent-tool-description.md`
153
+ * (project, falling back to `<agentDir>/agent-tool-description.md`) with
154
+ * `{{placeholder}}` substitution — a missing/empty file falls back to "full".
155
+ * The mode is read once at tool registration — changing it applies on the
156
+ * next pi session.
157
+ */
158
+ toolDescriptionMode?: ToolDescriptionMode;
159
+ /**
160
+ * Whether the Claude Code-style FleetView (the navigable main+subagents list
161
+ * rendered below the editor) is shown. Defaults to `true`. Pure-UI: when off,
162
+ * the list never registers and the global key handler never captures input.
163
+ */
164
+ fleetView?: boolean;
165
+ /**
166
+ * Whether `@handle message` typed at the prompt is routed to that subagent
167
+ * instead of the main model, and whether `@` offers running agents alongside
168
+ * pi's file completion. Defaults to `model`. Applied live.
169
+ *
170
+ * - `model`: mentioning an agent that is not running asks the main model to
171
+ * spawn it with the `Agent` tool, Claude Code's behaviour. Costs a turn,
172
+ * and the model writes the agent's prompt rather than your text being it.
173
+ * - `direct`: that agent is started here instead, with the typed message as
174
+ * its prompt and no main-model turn spent.
175
+ * - `off`: the input hook falls straight through and the stacked
176
+ * autocomplete provider delegates everything back to pi's built-in one.
177
+ *
178
+ * Messaging a running agent and resuming a finished one are direct in both
179
+ * `model` and `direct`. The legacy booleans are still accepted: `true` reads
180
+ * as `model`, `false` as `off`.
181
+ */
182
+ agentMentions?: AgentMentionMode;
183
+ /**
184
+ * Whether subagents persist their pi session by default, so `@handle` can
185
+ * reopen an agent's conversation long after its in-memory record is gone.
186
+ * Defaults to `true`. Per-agent `persist_session:` frontmatter overrides it
187
+ * in both directions. Turning it off restores the previous behaviour, where
188
+ * a handle stops resolving roughly ten minutes after the agent finishes and
189
+ * mentioning it starts a fresh run instead. Persisted sessions also appear
190
+ * nested under the spawning session in pi's `/resume`.
191
+ */
192
+ rememberAgents?: boolean;
193
+ /**
194
+ * Display mode for the persistent above-editor agent widget:
195
+ * - `all`: show every agent (foreground + background).
196
+ * - `background`: hide foreground agents — they already render inline as the
197
+ * Agent tool result, so the widget would otherwise double-render them
198
+ * (#118); everything else (background, queued, scheduled, RPC) stays.
199
+ * - `off`: hide the widget entirely.
200
+ * Defaults to `background`. Pure-UI and applied live (toggling refreshes the
201
+ * widget).
202
+ */
203
+ widgetMode?: WidgetMode;
204
+ /**
205
+ * Project/global default for writing each subagent's `.output` transcript
206
+ * (a JSON-lines copy of the run, stored under the OS temp dir).
207
+ * Defaults to `true`. Set `false` to make transcripts opt-in for the whole
208
+ * project (e.g. a repo that shouldn't leave run transcripts on disk for backup
209
+ * or DLP tooling to ingest). A custom agent's `output_transcript` frontmatter
210
+ * overrides this per agent. This governs only the transcript — it does NOT
211
+ * affect the persisted pi session (`persist_session`), worktree commits
212
+ * (`isolation: worktree`), or memory files.
213
+ */
214
+ outputTranscript?: boolean;
215
+ /**
216
+ * Whether `isolation: "worktree"` may create a worktree at all. Defaults to
217
+ * `true`. Set `false` on a repo where worktrees are too slow or too large to
218
+ * be worth it (#184): a requested worktree is then dropped and the agent runs
219
+ * in the main checkout.
220
+ *
221
+ * The drop is deliberately silent — there is no per-result note, because the
222
+ * setting exists for projects whose model asks for a worktree on every call,
223
+ * where a note would be noise on every result. What keeps the orchestrator
224
+ * from claiming a `pi-agent-*` branch anyway is that it is never told the
225
+ * capability exists: `isolationParam` (invocation-config.ts) drops the field
226
+ * from both tool schemas, and `isolationGuideline` (index.ts) drops the
227
+ * matching prose from the full and compact descriptions — a custom one opts
228
+ * in via the `{{isolationGuideline}}` placeholder. Anything that
229
+ * reintroduces the prose has to reintroduce a note with it.
230
+ *
231
+ * Deliberately a downgrade rather than an error. The fail-loud rule covers
232
+ * worktrees that *cannot* be created; this is the user declining one, and
233
+ * throwing would reject exactly the calls that the `isolation: "off"` value
234
+ * exists to tolerate. Enforced below the tool boundary, so it also covers the
235
+ * scheduler and the unvalidated cross-extension RPC path.
236
+ */
237
+ worktreeIsolation?: boolean;
238
+ /**
239
+ * Master switch for scripted workflows. Defaults to `true`.
240
+ *
241
+ * Off is not a soft hide: the `SubagentWorkflow` tool is never registered, so
242
+ * the model is not told it exists and cannot call it, the `/agents`
243
+ * Workflows entry is hidden, and `--subagents-workflow-file` is refused.
244
+ *
245
+ * Absent is not the same as `true`. Unset means *auto*: on, but yielding to
246
+ * another extension that already offers a workflow tool, because two
247
+ * orchestrators in one tool spec is a worse default than none — the model
248
+ * has to guess which to call, and pays for both descriptions to find out.
249
+ * Setting it explicitly pins the answer in both directions: `true` keeps
250
+ * ours whatever else is loaded, `false` is off regardless. See
251
+ * `resolveWorkflowCollisions` in index.ts.
252
+ *
253
+ * Read once at extension init, before registration, so flipping it in
254
+ * `/agents → Settings` takes effect on the next pi session — the same
255
+ * contract `schedulingEnabled` has, and for the same reason: a tool spec is
256
+ * fixed once pi has it.
257
+ */
258
+ workflowsEnabled?: boolean;
259
+ /**
260
+ * Hard ceiling on nested subagent delegation, counted from the main session:
261
+ * main = 0, its subagents = 1, their children = 2. Defaults to `2`; `0` or `1`
262
+ * disables nesting project-wide. Read when a subagent session is built, so a
263
+ * change applies to agents started after it.
264
+ */
265
+ maxSubagentDepth?: number;
266
+ /**
267
+ * Agent type substituted when a caller-supplied `subagent_type` doesn't
268
+ * resolve to exactly one enabled agent (unknown, disabled, or ambiguous by
269
+ * case). Omitted keeps the historical `general-purpose` fallback; a type name
270
+ * routes those calls to that agent instead; `"none"` disables the fallback so
271
+ * dispatch fails closed with an error naming the available types.
272
+ *
273
+ * The boolean `false` is accepted as a spelling of `"none"`, because a boolean
274
+ * would otherwise be dropped as the wrong type and silently leave the
275
+ * PERMISSIVE default in place while the author believes strict dispatch is on
276
+ * — the wrong direction to fail for this setting. Every other value is an
277
+ * agent name, so a mistaken `"off"` fails loudly at dispatch rather than
278
+ * meaning one thing here and another in the resolver.
279
+ */
280
+ fallbackSubagent?: string;
281
+ /** Trusted-global logical name -> ordered canonical provider/model candidates. */
282
+ modelAliases?: Record<string, string[]>;
283
+ /** Trusted global/enterprise exact model bindings; never read from a project. */
284
+ modelTiers?: Record<string, string[]>;
285
+ /** Missing/unavailable tier policy. Both default to inherit with disclosure. */
286
+ modelResolution?: ModelResolution;
287
+ /**
288
+ * Trusted-global map of agent `type` (or `"*"` for every type) -> model, in
289
+ * any form `Agent({ model })` accepts. Precedence, highest first: the Agent
290
+ * tool's `model` parameter, `agentModels[type]`, `agentModels["*"]`, the agent
291
+ * definition's `model:`, then the parent's model. Like `modelAliases` it
292
+ * decides which provider a subagent runs on, so a project `.pi/subagents.json`
293
+ * value is ignored with a warning.
294
+ */
295
+ agentModels?: Record<string, string>;
296
+ /**
297
+ * Whether this extension's tool results carry a `usage` field, so subagent
298
+ * spend reaches the parent session's own accounting. Defaults to `false`.
299
+ *
300
+ * Subagents run in their own pi sessions, so by default the parent's footer,
301
+ * statusline and `/cost` show only what the main model spent — a session that
302
+ * delegated most of its work reads as nearly free. Pi folds
303
+ * `toolResult.usage` into `getSessionStats()`, so attaching it makes those
304
+ * surfaces count subagents too, under `/cost`'s "Tools/summaries" bucket.
305
+ *
306
+ * Off by default because it changes numbers the user may already be tracking
307
+ * (a statusline reading session cost will step up), not because the numbers
308
+ * are wrong.
309
+ *
310
+ * Three properties of what gets reported:
311
+ * - Tokens exclude `cacheRead`, for the reason in `usage.ts` — the parent's
312
+ * token total therefore rises by billed tokens only.
313
+ * - Cost is pi's own per-message `usage.cost.total`; we price nothing, and
314
+ * a model pi has no rates for contributes 0.
315
+ * - The context-window percentage is untouched. Pi derives it from assistant
316
+ * messages alone (`getContextUsage`), so a delegating session's context
317
+ * does not appear to fill up faster.
318
+ */
319
+ reportUsage?: boolean;
320
+ /**
321
+ * Whether the subagent surfaces show an estimated dollar cost next to their
322
+ * token counts (widget, FleetView, conversation viewer, foreground results,
323
+ * completion notifications). Defaults to `false`. Applied live.
324
+ *
325
+ * Rendered as `~$0.0042` — the tilde marks it as pi's reported estimate
326
+ * rather than a billed figure, and it is omitted entirely when the model has
327
+ * no pricing data, so a local model shows tokens and no dollars.
328
+ *
329
+ * Independent of `reportUsage`: this one is what a human reads, that one is
330
+ * what the parent session counts.
331
+ */
332
+ showCost?: boolean;
333
+
334
+ /**
335
+ * Whether the widget's running rows name the model driving each agent and the
336
+ * thinking level it is running at.
337
+ *
338
+ * Off by default, unlike the tool result and the conversation viewer, which
339
+ * show the pair unconditionally: those have a line to themselves, while the
340
+ * widget row already carries the description, turns, tool uses, tokens and
341
+ * elapsed time, and every character it gains is one the description loses on a
342
+ * narrow terminal.
343
+ */
344
+ showModel?: boolean;
345
+ /**
346
+ * Whether agents declared by installed pi packages load, and from which
347
+ * packages. Defaults to `true`.
348
+ *
349
+ * - `true` — every package whose `package.json` declares `pi.subagents`
350
+ * (or the top-level `pi-subagents` key) contributes its agents.
351
+ * - `false` — none do.
352
+ * - list — only packages matching an entry, compared case-insensitively
353
+ * against the unscoped short name (`@scope/foo` matches `foo`),
354
+ * the full package name, or the settings source string.
355
+ *
356
+ * On by default because installing is already the trust decision: pi executes
357
+ * an installed package's `extensions/` and injects its `skills/` into the
358
+ * system prompt with no further prompt, so a declared `.md` agent is strictly
359
+ * less privileged than what the same package already runs. The gate exists for
360
+ * users who want to narrow that anyway, not as a second install ritual.
361
+ *
362
+ * Package agents always load at the *lowest* precedence, below global and
363
+ * project files, so a same-named local agent silently takes the name back.
364
+ */
365
+ packageAgents?: boolean | string[];
366
+ /**
367
+ * The same gate for workflow scripts declared by installed pi packages
368
+ * (`pi.subagents.workflows`). Defaults to `true`.
369
+ *
370
+ * Deliberately separate from `packageAgents` rather than folded into it: an
371
+ * agent is markdown we hand to a model, a workflow is `.js` we execute. Both
372
+ * still sit below the package's own `extensions/`, which pi runs unsandboxed,
373
+ * so the default matches — but a user who wants package agents without package
374
+ * workflow scripts can have that without giving up either wholesale.
375
+ *
376
+ * No-op when `workflowsEnabled` is off: the tool is never registered, so there
377
+ * is nothing to resolve a workflow name against.
378
+ */
379
+ packageWorkflows?: boolean | string[];
380
+ /**
381
+ * How much of the conversation viewer's transcript renders as Markdown.
382
+ * Defaults to `assistant`. Applied live — the viewer's `m` key cycles this
383
+ * same setting, so a choice made in the overlay persists like one made in
384
+ * `/agents → Settings`.
385
+ *
386
+ * Scoped rather than all-or-nothing because the two kinds of content have
387
+ * different contracts: assistant text is authored as Markdown, while a tool
388
+ * result is whatever bytes the tool produced. Rendering the latter as
389
+ * Markdown is lossy in ways that look like the tool misbehaved — see
390
+ * `ViewerMarkdownMode` for the specific rewrites — so `all` is opt-in.
391
+ */
392
+ viewerMarkdown?: ViewerMarkdownMode;
393
+ }
394
+
395
+ export type ToolDescriptionMode = "full" | "compact" | "custom";
396
+
397
+ /** Setter hooks used by applySettings to wire persisted values into in-memory state. */
398
+ export interface SettingsAppliers {
399
+ setMaxConcurrent: (n: number) => void;
400
+ setMaxConcurrentForeground: (n: number) => void;
401
+ /** The process budget, from `loadBudgetPolicy`: global configuration only. */
402
+ setBudgetPolicy: (policy: BudgetPolicy) => void;
403
+ setDefaultMaxTurns: (n: number) => void;
404
+ setGraceTurns: (n: number) => void;
405
+ setNotificationResultChars: (n: number) => void;
406
+ setDefaultJoinMode: (mode: JoinMode) => void;
407
+ setBackgroundByDefault: (b: boolean) => void;
408
+ setSchedulingEnabled: (b: boolean) => void;
409
+ setScopeModels: (enabled: boolean) => void;
410
+ setStrictAgentFiles: (b: boolean) => void;
411
+ setDisableDefaultAgents: (b: boolean) => void;
412
+ setToolDescriptionMode: (mode: ToolDescriptionMode) => void;
413
+ setFleetView: (b: boolean) => void;
414
+ setAgentMentions: (mode: AgentMentionMode) => void;
415
+ setRememberAgents: (b: boolean) => void;
416
+ setWidgetMode: (mode: WidgetMode) => void;
417
+ setOutputTranscript: (b: boolean) => void;
418
+ setWorktreeIsolation: (b: boolean) => void;
419
+ setWorkflowsEnabled: (b: boolean) => void;
420
+ setMaxSubagentDepth: (n: number) => void;
421
+ setFallbackSubagent: (v: string | undefined) => void;
422
+ setModelAliases: (v: Record<string, string[]> | undefined) => void;
423
+ setAgentModels: (v: Record<string, string> | undefined) => void;
424
+ setReportUsage: (b: boolean) => void;
425
+ setShowCost: (b: boolean) => void;
426
+ setShowModel: (b: boolean) => void;
427
+ setViewerMarkdown: (mode: ViewerMarkdownMode) => void;
428
+ setPackageAgents: (gate: boolean | string[]) => void;
429
+ setPackageWorkflows: (gate: boolean | string[]) => void;
430
+ }
431
+
432
+ /** Emit callback — a subset of `pi.events.emit` to keep helpers testable. */
433
+ export type SettingsEmit = (event: string, payload: unknown) => void;
434
+
435
+ const VALID_JOIN_MODES: ReadonlySet<string> = new Set<JoinMode>(["async", "group", "smart"]);
436
+ const VALID_TOOL_DESCRIPTION_MODES: ReadonlySet<string> = new Set<ToolDescriptionMode>(["full", "compact", "custom"]);
437
+ const VALID_WIDGET_MODES: ReadonlySet<string> = new Set<WidgetMode>(["all", "background", "off"]);
438
+ const VALID_VIEWER_MARKDOWN_MODES: ReadonlySet<string> = new Set<ViewerMarkdownMode>(["off", "assistant", "all"]);
439
+ const VALID_AGENT_MENTION_MODES: ReadonlySet<string> = new Set<AgentMentionMode>(["model", "direct", "off"]);
440
+
441
+ // Sanity ceilings — prevent hand-edited configs from asking for values that
442
+ // make no operational sense (e.g. 1e6 concurrent subagents). Permissive enough
443
+ // that any realistic power-user setting passes through.
444
+ const MAX_CONCURRENT_CEILING = 1024;
445
+ const MAX_TURNS_CEILING = 10_000;
446
+ const GRACE_TURNS_CEILING = 1_000;
447
+ const SUBAGENT_DEPTH_CEILING = 16;
448
+
449
+ /**
450
+ * Normalize a package gate: a boolean passes through, an array is trimmed to its
451
+ * non-empty string entries, a bare string becomes a one-entry allowlist, and
452
+ * anything else is dropped.
453
+ *
454
+ * The two coercions exist for the same reason: this setting's default is `true`,
455
+ * so *dropping* a malformed value silently admits every package — the opposite
456
+ * of what someone narrowing the gate meant. So an array that trims to empty is
457
+ * kept as `[]` (matching nothing, like `false`) rather than dropped, and
458
+ * `"my-package"` — the obvious typo for a one-entry list — is read as
459
+ * `["my-package"]` rather than widened to everything.
460
+ */
461
+ function sanitizePackageGate(val: unknown): boolean | string[] | undefined {
462
+ if (typeof val === "boolean") return val;
463
+ if (typeof val === "string") return val.trim() ? [val.trim()] : [];
464
+ if (!Array.isArray(val)) return undefined;
465
+ return val.filter((e): e is string => typeof e === "string").map(e => e.trim()).filter(Boolean);
466
+ }
467
+
468
+ /** Drop fields that don't match the expected shape. Silent — garbage becomes absent. */
469
+ function sanitize(raw: unknown): SubagentsSettings {
470
+ if (!raw || typeof raw !== "object") return {};
471
+ const r = raw as Record<string, unknown>;
472
+ const out: SubagentsSettings = {};
473
+ if (
474
+ Number.isInteger(r.maxConcurrent) &&
475
+ (r.maxConcurrent as number) >= 1 &&
476
+ (r.maxConcurrent as number) <= MAX_CONCURRENT_CEILING
477
+ ) {
478
+ out.maxConcurrent = r.maxConcurrent as number;
479
+ }
480
+ // Floor 0, not 1 like maxConcurrent above: 0 is the documented "unlimited"
481
+ // value and the default, so dropping it would silently be unrepresentable.
482
+ if (
483
+ Number.isInteger(r.maxConcurrentForeground) &&
484
+ (r.maxConcurrentForeground as number) >= 0 &&
485
+ (r.maxConcurrentForeground as number) <= MAX_CONCURRENT_CEILING
486
+ ) {
487
+ out.maxConcurrentForeground = r.maxConcurrentForeground as number;
488
+ }
489
+ if (
490
+ Number.isInteger(r.maxActiveSubagents) &&
491
+ (r.maxActiveSubagents as number) >= 0 &&
492
+ (r.maxActiveSubagents as number) <= MAX_CONCURRENT_CEILING
493
+ ) {
494
+ out.maxActiveSubagents = r.maxActiveSubagents as number;
495
+ }
496
+ if (
497
+ Number.isInteger(r.nestedSubagentReserve) &&
498
+ (r.nestedSubagentReserve as number) >= 0 &&
499
+ (r.nestedSubagentReserve as number) <= MAX_CONCURRENT_CEILING
500
+ ) {
501
+ out.nestedSubagentReserve = r.nestedSubagentReserve as number;
502
+ }
503
+ if (
504
+ Number.isInteger(r.defaultMaxTurns) &&
505
+ (r.defaultMaxTurns as number) >= 0 &&
506
+ (r.defaultMaxTurns as number) <= MAX_TURNS_CEILING
507
+ ) {
508
+ out.defaultMaxTurns = r.defaultMaxTurns as number;
509
+ }
510
+ if (
511
+ Number.isInteger(r.graceTurns) &&
512
+ (r.graceTurns as number) >= 1 &&
513
+ (r.graceTurns as number) <= GRACE_TURNS_CEILING
514
+ ) {
515
+ out.graceTurns = r.graceTurns as number;
516
+ }
517
+ if (
518
+ Number.isInteger(r.notificationResultChars) &&
519
+ (r.notificationResultChars as number) >= 0 &&
520
+ (r.notificationResultChars as number) <= NOTIFICATION_RESULT_CHARS_CEILING
521
+ ) {
522
+ out.notificationResultChars = r.notificationResultChars as number;
523
+ }
524
+ if (
525
+ Number.isInteger(r.maxSubagentDepth) &&
526
+ (r.maxSubagentDepth as number) >= 0 &&
527
+ (r.maxSubagentDepth as number) <= SUBAGENT_DEPTH_CEILING
528
+ ) {
529
+ out.maxSubagentDepth = r.maxSubagentDepth as number;
530
+ }
531
+ if (typeof r.defaultJoinMode === "string" && VALID_JOIN_MODES.has(r.defaultJoinMode)) {
532
+ out.defaultJoinMode = r.defaultJoinMode as JoinMode;
533
+ }
534
+ if (typeof r.backgroundByDefault === "boolean") {
535
+ out.backgroundByDefault = r.backgroundByDefault;
536
+ }
537
+ if (typeof r.schedulingEnabled === "boolean") {
538
+ out.schedulingEnabled = r.schedulingEnabled;
539
+ }
540
+ if (typeof r.scopeModels === "boolean") {
541
+ out.scopeModels = r.scopeModels;
542
+ }
543
+ if (typeof r.strictAgentFiles === "boolean") {
544
+ out.strictAgentFiles = r.strictAgentFiles;
545
+ }
546
+ if (typeof r.disableDefaultAgents === "boolean") {
547
+ out.disableDefaultAgents = r.disableDefaultAgents;
548
+ }
549
+ if (typeof r.toolDescriptionMode === "string" && VALID_TOOL_DESCRIPTION_MODES.has(r.toolDescriptionMode)) {
550
+ out.toolDescriptionMode = r.toolDescriptionMode as ToolDescriptionMode;
551
+ }
552
+ if (typeof r.fleetView === "boolean") {
553
+ out.fleetView = r.fleetView;
554
+ }
555
+ // Was a boolean before the `model` mode existed. A hand-written or
556
+ // previously-written `true` means "on", which is now the default `model`.
557
+ if (typeof r.agentMentions === "boolean") {
558
+ out.agentMentions = r.agentMentions ? "model" : "off";
559
+ } else if (typeof r.agentMentions === "string" && VALID_AGENT_MENTION_MODES.has(r.agentMentions)) {
560
+ out.agentMentions = r.agentMentions as AgentMentionMode;
561
+ }
562
+ if (typeof r.rememberAgents === "boolean") {
563
+ out.rememberAgents = r.rememberAgents;
564
+ }
565
+ if (typeof r.widgetMode === "string" && VALID_WIDGET_MODES.has(r.widgetMode)) {
566
+ out.widgetMode = r.widgetMode as WidgetMode;
567
+ }
568
+ if (typeof r.outputTranscript === "boolean") {
569
+ out.outputTranscript = r.outputTranscript;
570
+ }
571
+ if (typeof r.worktreeIsolation === "boolean") {
572
+ out.worktreeIsolation = r.worktreeIsolation;
573
+ }
574
+ if (typeof r.reportUsage === "boolean") {
575
+ out.reportUsage = r.reportUsage;
576
+ }
577
+ if (typeof r.showCost === "boolean") {
578
+ out.showCost = r.showCost;
579
+ }
580
+ if (typeof r.showModel === "boolean") {
581
+ out.showModel = r.showModel;
582
+ }
583
+ if (typeof r.viewerMarkdown === "string" && VALID_VIEWER_MARKDOWN_MODES.has(r.viewerMarkdown)) {
584
+ out.viewerMarkdown = r.viewerMarkdown as ViewerMarkdownMode;
585
+ }
586
+ if (typeof r.workflowsEnabled === "boolean") {
587
+ out.workflowsEnabled = r.workflowsEnabled;
588
+ }
589
+ const packageAgents = sanitizePackageGate(r.packageAgents);
590
+ if (packageAgents !== undefined) out.packageAgents = packageAgents;
591
+ const packageWorkflows = sanitizePackageGate(r.packageWorkflows);
592
+ if (packageWorkflows !== undefined) out.packageWorkflows = packageWorkflows;
593
+ if (r.fallbackSubagent === false) {
594
+ // The only non-string spelling worth accepting: a boolean would otherwise be
595
+ // dropped, silently leaving the PERMISSIVE default in place. Every string is
596
+ // an agent name except the `none` sentinel, which the resolver recognizes —
597
+ // so a mistaken "off" fails loudly at dispatch instead of meaning something
598
+ // different here than it does there.
599
+ out.fallbackSubagent = NO_FALLBACK;
600
+ } else if (typeof r.fallbackSubagent === "string" && r.fallbackSubagent.trim()) {
601
+ out.fallbackSubagent = r.fallbackSubagent.trim();
602
+ }
603
+ if (r.modelTiers !== undefined) {
604
+ out.modelTiers = {};
605
+ if (r.modelTiers && typeof r.modelTiers === "object" && !Array.isArray(r.modelTiers)) {
606
+ for (const [tier, members] of Object.entries(r.modelTiers)) {
607
+ const binding = parseTierBinding(tier, members);
608
+ if (binding) out.modelTiers[tier] = binding.kind === "routed" ? [binding.profile] : binding.members;
609
+ else console.warn(`[pi-subagents] Ignoring invalid modelTiers entry "${tier}".`);
610
+ }
611
+ } else console.warn("[pi-subagents] Ignoring modelTiers: expected an object of tier -> model list.");
612
+ }
613
+ if (r.modelResolution && typeof r.modelResolution === "object" && !Array.isArray(r.modelResolution)) {
614
+ out.modelResolution = {};
615
+ for (const key of ["onUnmapped", "onUnavailable"] as const) {
616
+ const mode = (r.modelResolution as Record<string, unknown>)[key];
617
+ if (mode === "error" || mode === "inherit") out.modelResolution[key] = mode;
618
+ else if (mode !== undefined) console.warn(`[pi-subagents] Ignoring invalid modelResolution.${key}.`);
619
+ }
620
+ }
621
+ if (r.modelAliases !== undefined) {
622
+ const aliases = sanitizeModelAliases(r.modelAliases);
623
+ if (aliases) out.modelAliases = aliases;
624
+ }
625
+ if (r.agentModels !== undefined) {
626
+ const models = sanitizeAgentModels(r.agentModels);
627
+ if (models) out.agentModels = models;
628
+ }
629
+ return out;
630
+ }
631
+
632
+ /** Validate the `agentModels` map: non-empty string keys and non-empty string values. */
633
+ function sanitizeAgentModels(raw: unknown): Record<string, string> | undefined {
634
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
635
+ console.warn("[pi-subagents] Ignoring agentModels: expected an object of agent type -> model.");
636
+ return undefined;
637
+ }
638
+ const models: Record<string, string> = {};
639
+ for (const [type, model] of Object.entries(raw)) {
640
+ const key = type.trim();
641
+ if (!key || typeof model !== "string" || !model.trim()) {
642
+ console.warn(`[pi-subagents] Ignoring invalid agentModels entry "${type}".`);
643
+ continue;
644
+ }
645
+ models[key] = model.trim();
646
+ }
647
+ return models;
648
+ }
649
+
650
+ /** Validate one-way logical aliases without interpreting provider details. */
651
+ function sanitizeModelAliases(raw: unknown): Record<string, string[]> | undefined {
652
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
653
+ console.warn("[pi-subagents] Ignoring modelAliases: expected an object of alias -> provider/model candidates.");
654
+ return undefined;
655
+ }
656
+ const aliases: Record<string, string[]> = {};
657
+ const seen = new Set<string>();
658
+ for (const [alias, rawCandidates] of Object.entries(raw)) {
659
+ const normalized = alias.trim().toLowerCase();
660
+ const candidates = Array.isArray(rawCandidates)
661
+ ? rawCandidates.filter((candidate): candidate is string => typeof candidate === "string")
662
+ .map(candidate => candidate.trim()).filter(Boolean)
663
+ : [];
664
+ if (!normalized || normalized.includes("/") || seen.has(normalized) || candidates.length === 0
665
+ || candidates.some(candidate => !candidate.includes("/"))) {
666
+ console.warn(`[pi-subagents] Ignoring invalid modelAliases entry "${alias}".`);
667
+ continue;
668
+ }
669
+ seen.add(normalized);
670
+ aliases[normalized] = [...new Set(candidates)];
671
+ }
672
+ return aliases;
673
+ }
674
+
675
+ function globalPath(homeDir?: string): string {
676
+ return join(resolveAgentDir(homeDir), "subagents.json");
677
+ }
678
+
679
+ function projectPath(cwd: string): string {
680
+ return join(cwd, ".pi", "subagents.json");
681
+ }
682
+
683
+ /**
684
+ * Read a settings file. Missing file is silent (returns `{}`). A file that
685
+ * exists but can't be parsed emits a warning to stderr so users aren't
686
+ * silently reverted to defaults — and still returns `{}` so startup proceeds.
687
+ */
688
+ function readSettingsFile(path: string): SubagentsSettings {
689
+ if (!existsSync(path)) return {};
690
+ try {
691
+ return sanitize(JSON.parse(readFileSync(path, "utf-8")));
692
+ } catch (err) {
693
+ const reason = errorMessage(err);
694
+ console.warn(`[pi-subagents] Ignoring malformed settings at ${path}: ${reason}`);
695
+ return {};
696
+ }
697
+ }
698
+
699
+ /** Load merged settings: global provides defaults, project overrides. */
700
+ export function loadSettings(cwd: string = process.cwd(), homeDir?: string): SubagentsSettings {
701
+ // Aliases pick the provider a subagent runs on, so they are machine
702
+ // configuration: a cloned repo's project file must not redirect them.
703
+ const {
704
+ modelAliases: projectModelAliases,
705
+ modelTiers: projectModelTiers,
706
+ modelResolution: projectModelResolution,
707
+ agentModels: projectAgentModels,
708
+ maxActiveSubagents: projectMaxActive,
709
+ nestedSubagentReserve: projectReserve,
710
+ ...project
711
+ } = readSettingsFile(projectPath(cwd));
712
+ if (projectModelTiers !== undefined || projectModelResolution !== undefined) {
713
+ console.warn("[pi-subagents] Ignoring modelTiers/modelResolution in project .pi/subagents.json: model policy is read from trusted global configuration only.");
714
+ }
715
+ if (projectModelAliases !== undefined) {
716
+ console.warn("[pi-subagents] Ignoring modelAliases in project .pi/subagents.json: aliases are read from trusted global configuration only.");
717
+ }
718
+ if (projectAgentModels !== undefined) {
719
+ console.warn("[pi-subagents] Ignoring agentModels in project .pi/subagents.json: per-agent models are read from trusted global configuration only.");
720
+ }
721
+ // The process budget spans every session in the process, so no one
722
+ // project's file may set it, least of all a cloned one lifting the cap.
723
+ if (projectMaxActive !== undefined || projectReserve !== undefined) {
724
+ console.warn("[pi-subagents] Ignoring maxActiveSubagents/nestedSubagentReserve in project .pi/subagents.json: the process-wide budget is read from global configuration only.");
725
+ }
726
+ return { ...trustedSettings(homeDir), ...project };
727
+ }
728
+
729
+ /** Global settings with the enterprise manifest over them: the layer a project file cannot touch. */
730
+ function trustedSettings(homeDir?: string): SubagentsSettings {
731
+ const enterprise = readEnterpriseLocalManifest(homeDir)?.subagents;
732
+ const enterpriseSettings = enterprise === undefined ? {} : sanitize(enterprise);
733
+ return { ...readSettingsFile(globalPath(homeDir)), ...enterpriseSettings };
734
+ }
735
+
736
+ /**
737
+ * The process budget's policy, from trusted configuration only -- including
738
+ * its depth, the GLOBAL `maxSubagentDepth`: a project file may still change
739
+ * how deep its own sessions nest, but not what the process-wide reserve
740
+ * covers. A missing key means its default, so removing the cap turns it off.
741
+ */
742
+ export function loadBudgetPolicy(homeDir?: string): BudgetPolicy {
743
+ const trusted = trustedSettings(homeDir);
744
+ return {
745
+ maxActive: trusted.maxActiveSubagents ?? DEFAULT_BUDGET_POLICY.maxActive,
746
+ nestedReserve: trusted.nestedSubagentReserve ?? DEFAULT_BUDGET_POLICY.nestedReserve,
747
+ maxDepth: trusted.maxSubagentDepth ?? DEFAULT_BUDGET_POLICY.maxDepth,
748
+ };
749
+ }
750
+
751
+ /**
752
+ * Write project-local settings. Global is never touched from code.
753
+ * Returns `true` on success, `false` if the write (or mkdir) failed so the
754
+ * caller can surface a warning — persistence isn't fatal but isn't silent.
755
+ */
756
+ export function saveSettings(s: SubagentsSettings, cwd: string = process.cwd()): boolean {
757
+ const path = projectPath(cwd);
758
+ try {
759
+ mkdirSync(dirname(path), { recursive: true });
760
+ writeFileSync(path, JSON.stringify(s, null, 2), "utf-8");
761
+ return true;
762
+ } catch {
763
+ return false;
764
+ }
765
+ }
766
+
767
+ /** Apply persisted settings to the in-memory state via caller-supplied setters. */
768
+ export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers): void {
769
+ if (typeof s.maxConcurrent === "number") appliers.setMaxConcurrent(s.maxConcurrent);
770
+ if (typeof s.maxConcurrentForeground === "number") {
771
+ appliers.setMaxConcurrentForeground(s.maxConcurrentForeground);
772
+ }
773
+ if (typeof s.defaultMaxTurns === "number") appliers.setDefaultMaxTurns(s.defaultMaxTurns);
774
+ if (typeof s.graceTurns === "number") appliers.setGraceTurns(s.graceTurns);
775
+ if (typeof s.notificationResultChars === "number") appliers.setNotificationResultChars(s.notificationResultChars);
776
+ if (typeof s.maxSubagentDepth === "number") appliers.setMaxSubagentDepth(s.maxSubagentDepth);
777
+ if (typeof s.fallbackSubagent === "string") appliers.setFallbackSubagent(s.fallbackSubagent);
778
+ // Module state: reset when a session omits it, or the previous activation's aliases leak.
779
+ setModelTiers(s.modelTiers, s.modelResolution);
780
+ appliers.setModelAliases(s.modelAliases);
781
+ appliers.setAgentModels(s.agentModels);
782
+ if (s.defaultJoinMode) appliers.setDefaultJoinMode(s.defaultJoinMode);
783
+ if (typeof s.backgroundByDefault === "boolean") appliers.setBackgroundByDefault(s.backgroundByDefault);
784
+ if (typeof s.schedulingEnabled === "boolean") appliers.setSchedulingEnabled(s.schedulingEnabled);
785
+ if (typeof s.scopeModels === "boolean") appliers.setScopeModels(s.scopeModels);
786
+ if (typeof s.strictAgentFiles === "boolean") appliers.setStrictAgentFiles(s.strictAgentFiles);
787
+ if (typeof s.disableDefaultAgents === "boolean") appliers.setDisableDefaultAgents(s.disableDefaultAgents);
788
+ if (s.toolDescriptionMode) appliers.setToolDescriptionMode(s.toolDescriptionMode);
789
+ if (typeof s.fleetView === "boolean") appliers.setFleetView(s.fleetView);
790
+ if (s.agentMentions) appliers.setAgentMentions(s.agentMentions);
791
+ if (typeof s.rememberAgents === "boolean") appliers.setRememberAgents(s.rememberAgents);
792
+ if (s.widgetMode) appliers.setWidgetMode(s.widgetMode);
793
+ if (typeof s.outputTranscript === "boolean") appliers.setOutputTranscript(s.outputTranscript);
794
+ if (typeof s.worktreeIsolation === "boolean") appliers.setWorktreeIsolation(s.worktreeIsolation);
795
+ if (typeof s.reportUsage === "boolean") appliers.setReportUsage(s.reportUsage);
796
+ if (typeof s.showCost === "boolean") appliers.setShowCost(s.showCost);
797
+ if (typeof s.showModel === "boolean") appliers.setShowModel(s.showModel);
798
+ if (s.viewerMarkdown) appliers.setViewerMarkdown(s.viewerMarkdown);
799
+ if (typeof s.workflowsEnabled === "boolean") appliers.setWorkflowsEnabled(s.workflowsEnabled);
800
+ if (s.packageAgents !== undefined) appliers.setPackageAgents(s.packageAgents);
801
+ if (s.packageWorkflows !== undefined) appliers.setPackageWorkflows(s.packageWorkflows);
802
+ }
803
+
804
+ /**
805
+ * Format the user-facing toast for a settings mutation. Pure function —
806
+ * routes the success/failure of `saveSettings` into the right message + level
807
+ * so the UI layer (index.ts) stays a thin wire between input and notification.
808
+ */
809
+ export function persistToastFor(
810
+ successMsg: string,
811
+ persisted: boolean,
812
+ ): { message: string; level: "info" | "warning" } {
813
+ return persisted
814
+ ? { message: successMsg, level: "info" }
815
+ : { message: `${successMsg} (session only; failed to persist)`, level: "warning" };
816
+ }
817
+
818
+ /**
819
+ * Load merged settings, apply them to in-memory state, and emit the
820
+ * `subagents:settings_loaded` lifecycle event. Returns the loaded settings so
821
+ * callers can log/inspect. Extension init wires this once.
822
+ */
823
+ export function applyAndEmitLoaded(
824
+ appliers: SettingsAppliers,
825
+ emit: SettingsEmit,
826
+ cwd: string = process.cwd(),
827
+ homeDir?: string,
828
+ ): SubagentsSettings {
829
+ const settings = loadSettings(cwd, homeDir);
830
+ applySettings(settings, appliers);
831
+ appliers.setBudgetPolicy(loadBudgetPolicy(homeDir));
832
+ emit("subagents:settings_loaded", { settings });
833
+ return settings;
834
+ }
835
+
836
+ /**
837
+ * Persist a settings snapshot, emit the `subagents:settings_changed` event
838
+ * (regardless of persist outcome so listeners see the in-memory change), and
839
+ * return the toast the UI should display. Event payload carries the `persisted`
840
+ * flag so listeners can react to write failures.
841
+ */
842
+ export function saveAndEmitChanged(
843
+ snapshot: SubagentsSettings,
844
+ successMsg: string,
845
+ emit: SettingsEmit,
846
+ cwd: string = process.cwd(),
847
+ ): { message: string; level: "info" | "warning" } {
848
+ const persisted = saveSettings(snapshot, cwd);
849
+ emit("subagents:settings_changed", { settings: snapshot, persisted });
850
+ return persistToastFor(successMsg, persisted);
851
+ }