@signalridge/pi-subagents 1.4.0 → 1.6.1

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.
@@ -25,7 +25,7 @@
25
25
 
26
26
  import { type Api, clampThinkingLevel, getSupportedThinkingLevels, type Model } from "@earendil-works/pi-ai";
27
27
  import { type ModelRegistry, resolveModel } from "./model-resolver.js";
28
- import type { AgentTierProfile, AgentTiersSettings, TierThinking } from "./settings.js";
28
+ import { type AgentTierProfile, type AgentTiersSettings, TIER_THINKING_LEVELS, type TierThinking } from "./settings.js";
29
29
  import type { AgentConfig, ThinkingLevel } from "./types.js";
30
30
 
31
31
  /** `provider/id`, or undefined when no model was selected. */
@@ -36,14 +36,82 @@ function effectiveModelId(model: Model<Api> | undefined): string | undefined {
36
36
  /** Longest accepted tier key. Long enough for any real name, short enough to render. */
37
37
  export const MAX_AGENT_TIER_KEY_LENGTH = 64;
38
38
 
39
- let agentTiersSettings: AgentTiersSettings = {};
39
+ /**
40
+ * The tier every fresh install gets: a cheap, low-thinking tier named `fast`.
41
+ *
42
+ * Explore — the highest-frequency built-in spawn — points its `tier:` at it, so
43
+ * read-only search does not silently inherit the parent session's most
44
+ * expensive model on a machine that never configured `agentTiers`. This is the
45
+ * tier strategy, not a per-agent vendor pin: the shipped profile is
46
+ * provider-neutral (`inherit` model, low thinking), and any user who defines
47
+ * `fast` in `subagents.json` replaces it wholesale.
48
+ *
49
+ * It is not shown as an available tier until settings are loaded; the merge in
50
+ * `setAgentTiersSettings` is where a worktree that explicitly disables/renames
51
+ * `fast` can win.
52
+ */
53
+ const SHIPPED_FAST_PROFILE: AgentTierProfile = {
54
+ model: "inherit",
55
+ thinking: "low",
56
+ description: "Fast, low-cost tier for cheap read-only work (shipped default)",
57
+ };
40
58
 
59
+ export const SHIPPED_AGENT_TIER_PROFILES: Readonly<Record<string, AgentTierProfile>> = {
60
+ fast: SHIPPED_FAST_PROFILE,
61
+ };
62
+
63
+ let agentTiersSettings: AgentTiersSettings = {}; // effective view (shipped tiers merged)
64
+ let agentTiersConfigured: AgentTiersSettings = {}; // exactly what the user configured
65
+
66
+ /** Effective catalogue: shipped tiers merged under any user configuration. */
41
67
  export function getAgentTiersSettings(): AgentTiersSettings {
42
68
  return structuredClone(agentTiersSettings);
43
69
  }
44
70
 
71
+ /** The raw user configuration, without shipped tiers — what snapshotSettings writes back. */
72
+ export function getAgentTiersConfiguredSettings(): AgentTiersSettings {
73
+ return structuredClone(agentTiersConfigured);
74
+ }
75
+
76
+ /** Exactly-equal profile? Used to strip untouched shipped tiers from the configured view. */
77
+ function sameProfile(a: AgentTierProfile, b: AgentTierProfile): boolean {
78
+ return a.model === b.model && a.thinking === b.thinking && (a.description ?? "") === (b.description ?? "");
79
+ }
80
+
81
+ /**
82
+ * Install the effective tier catalogue.
83
+ *
84
+ * The shipped `fast` tier is merged in unless the caller already defined it or
85
+ * explicitly blocked it — a user catalogue wins over the shipped default, and a
86
+ * tombstone means "do not substitute", which applies to shipped defaults too.
87
+ *
88
+ * The configured view is derived from the same input by stripping profiles that
89
+ * exactly equal a shipped default, so the UI can operate on the effective view
90
+ * and send it back without materializing untouched shipped tiers into
91
+ * `subagents.json`. Editing a shipped tier (changing its model, thinking, or
92
+ * description) makes it a user-owned profile and it is then persisted; deleting
93
+ * one leaves its tombstone, which persists.
94
+ */
45
95
  export function setAgentTiersSettings(settings: AgentTiersSettings): void {
46
- agentTiersSettings = structuredClone(settings);
96
+ const effective = structuredClone(settings);
97
+ const profiles = { ...(effective.profiles ?? {}) };
98
+ const blocked = new Set<string>(effective.blockedProfiles ?? []);
99
+
100
+ const configuredProfiles: Record<string, AgentTierProfile> = {};
101
+ for (const [key, profile] of Object.entries(profiles)) {
102
+ const shipped = SHIPPED_AGENT_TIER_PROFILES[key];
103
+ if (!blocked.has(key) && shipped !== undefined && sameProfile(profile, shipped)) continue;
104
+ configuredProfiles[key] = profile;
105
+ }
106
+ for (const [key, profile] of Object.entries(SHIPPED_AGENT_TIER_PROFILES)) {
107
+ if (!blocked.has(key) && profiles[key] === undefined) profiles[key] = profile;
108
+ }
109
+
110
+ agentTiersSettings = { ...effective, profiles };
111
+ const configured: AgentTiersSettings = { ...effective };
112
+ if (Object.keys(configuredProfiles).length > 0) configured.profiles = configuredProfiles;
113
+ else delete configured.profiles;
114
+ agentTiersConfigured = configured;
47
115
  }
48
116
 
49
117
  /**
@@ -111,15 +179,128 @@ export class AgentTierError extends Error {
111
179
  }
112
180
  }
113
181
 
114
- function knownTierKeys(settings: AgentTiersSettings): string[] {
182
+ /** Every defined tier key, sorted — the catalogue as the UI and the host see it. */
183
+ export function listAgentTierKeys(settings: AgentTiersSettings): string[] {
115
184
  return Object.keys(settings.profiles ?? {}).sort((a, b) => a.localeCompare(b));
116
185
  }
117
186
 
118
187
  function tierKeyList(settings: AgentTiersSettings): string {
119
- const keys = knownTierKeys(settings);
188
+ const keys = listAgentTierKeys(settings);
120
189
  return keys.length > 0 ? keys.join(", ") : "(none configured)";
121
190
  }
122
191
 
192
+ /**
193
+ * Tier edits, as pure settings-to-settings functions.
194
+ *
195
+ * The `/agents → Model tiers` menu is the only caller, but the rules it has to
196
+ * obey are policy, not presentation — retiring a tombstone, not leaving
197
+ * `defaultTier` pointing at a tier that no longer exists — so they live here
198
+ * with the resolver that enforces the other half of the same invariants.
199
+ *
200
+ * Each returns a fresh object and omits empty containers, so a catalogue edited
201
+ * back down to nothing serializes as nothing rather than as empty braces.
202
+ */
203
+ function withoutBlocked(blocked: string[] | undefined, key: string): string[] | undefined {
204
+ const rest = (blocked ?? []).filter((k) => k !== key);
205
+ return rest.length > 0 ? rest : undefined;
206
+ }
207
+
208
+ function compactTierSettings(settings: AgentTiersSettings): AgentTiersSettings {
209
+ const out: AgentTiersSettings = {};
210
+ if (settings.defaultTier !== undefined) out.defaultTier = settings.defaultTier;
211
+ if (settings.profiles && Object.keys(settings.profiles).length > 0) out.profiles = settings.profiles;
212
+ if (settings.blockedProfiles && settings.blockedProfiles.length > 0) {
213
+ out.blockedProfiles = settings.blockedProfiles;
214
+ }
215
+ if (settings.blockedDefaultTier) out.blockedDefaultTier = true;
216
+ return out;
217
+ }
218
+
219
+ /**
220
+ * Define or replace one tier.
221
+ *
222
+ * Writing a valid profile retires that key's tombstone: the tombstone exists to
223
+ * stop a malformed entry from silently resolving to some other model, and an
224
+ * explicit definition is the fix it was waiting for.
225
+ */
226
+ export function upsertAgentTierProfile(
227
+ settings: AgentTiersSettings,
228
+ key: string,
229
+ profile: AgentTierProfile,
230
+ ): AgentTiersSettings {
231
+ return compactTierSettings({
232
+ ...settings,
233
+ profiles: { ...settings.profiles, [key]: profile },
234
+ blockedProfiles: withoutBlocked(settings.blockedProfiles, key),
235
+ });
236
+ }
237
+
238
+ /**
239
+ * Delete one tier.
240
+ *
241
+ * A `defaultTier` pointing at it is cleared in the same step. Leaving it would
242
+ * turn every later spawn that names no tier into a hard refusal, which is a
243
+ * strange thing to get from deleting a tier you had stopped using.
244
+ *
245
+ * Deleting a shipped tier (the default `fast`) tombstones it instead of just
246
+ * dropping it: the shipped merge in `setAgentTiersSettings` would otherwise
247
+ * silently re-add it on the next load, and a user who deletes it means it. The
248
+ * tombstone says "do not substitute", which is exactly the semantics the load
249
+ * path already honors for malformed profiles. Explore still names `fast` in its
250
+ * frontmatter, so the spawn refusal then says so loudly until the agent file or
251
+ * the tier is fixed.
252
+ */
253
+ export function removeAgentTierProfile(settings: AgentTiersSettings, key: string): AgentTiersSettings {
254
+ const { [key]: _removed, ...profiles } = settings.profiles ?? {};
255
+ const shipped = Object.hasOwn(SHIPPED_AGENT_TIER_PROFILES, key);
256
+ let blocked = withoutBlocked(settings.blockedProfiles, key);
257
+ if (shipped) blocked = [...(blocked ?? []), key];
258
+ return compactTierSettings({
259
+ ...settings,
260
+ profiles,
261
+ blockedProfiles: blocked,
262
+ ...(settings.defaultTier === key ? { defaultTier: undefined } : {}),
263
+ });
264
+ }
265
+
266
+ /**
267
+ * The thinking values a tier may usefully store for one model reference.
268
+ *
269
+ * Asked of the model rather than read off a fixed list, because `resolveAgentTier`
270
+ * clamps an unsupported level at spawn time: a menu offering a level destined to
271
+ * be silently lowered would be a menu that lies. `inherit` is always offerable —
272
+ * it defers to the parent session, which this model has no say over.
273
+ *
274
+ * A reference of `inherit`, or one this machine cannot resolve, yields the full
275
+ * static list: the model is not knowable here, and refusing to let the user
276
+ * configure a tier for a provider they have not authed yet would make the menu
277
+ * weaker than hand-editing the file.
278
+ */
279
+ export function offerableTierThinking(
280
+ modelRef: string,
281
+ registry: ModelRegistry<Model<Api>>,
282
+ ): TierThinking[] {
283
+ if (modelRef === "inherit") return [...TIER_THINKING_LEVELS];
284
+ const resolved = resolveModel(modelRef, registry);
285
+ if (typeof resolved === "string") return [...TIER_THINKING_LEVELS];
286
+ const supported = new Set<string>(getSupportedThinkingLevels(resolved));
287
+ return TIER_THINKING_LEVELS.filter(level => level === "inherit" || supported.has(level));
288
+ }
289
+
290
+ /**
291
+ * Set or clear the default tier.
292
+ *
293
+ * Always clears `blockedDefaultTier`: that tombstone describes the malformed
294
+ * value this call is replacing, and keeping it would make the resolver refuse
295
+ * the choice the user just made explicitly.
296
+ */
297
+ export function setDefaultAgentTier(
298
+ settings: AgentTiersSettings,
299
+ key: string | undefined,
300
+ ): AgentTiersSettings {
301
+ return compactTierSettings({ ...settings, defaultTier: key, blockedDefaultTier: false });
302
+ }
303
+
123
304
  /**
124
305
  * Which tier applies, and where it came from.
125
306
  *
@@ -285,7 +466,7 @@ export function findUnknownAgentTierReferences(
285
466
  * models and thinking levels appear — nothing here reads credentials.
286
467
  */
287
468
  export function buildAgentTierListText(settings: AgentTiersSettings = agentTiersSettings): string {
288
- const keys = knownTierKeys(settings);
469
+ const keys = listAgentTierKeys(settings);
289
470
  if (keys.length === 0) return "";
290
471
 
291
472
  const entries = keys.map((key) => {
@@ -304,7 +485,7 @@ export function buildAgentTierListText(settings: AgentTiersSettings = agentTiers
304
485
 
305
486
  /** One line per tier, for the compact tool description. */
306
487
  export function buildCompactAgentTierListText(settings: AgentTiersSettings = agentTiersSettings): string {
307
- const keys = knownTierKeys(settings);
488
+ const keys = listAgentTierKeys(settings);
308
489
  if (keys.length === 0) return "";
309
490
 
310
491
  const entries = keys.map((key) => {
@@ -323,7 +504,7 @@ export function getDefaultAgentTierText(settings: AgentTiersSettings = agentTier
323
504
 
324
505
  /** Description for the `tier` parameter, naming the keys this workspace defines. */
325
506
  export function buildAgentTierParameterDescription(settings: AgentTiersSettings = agentTiersSettings): string {
326
- const keys = knownTierKeys(settings);
507
+ const keys = listAgentTierKeys(settings);
327
508
  const available = keys.length > 0 ? keys.join(", ") : "none configured";
328
509
  const fallback =
329
510
  settings.defaultTier !== undefined
@@ -295,6 +295,7 @@ export function getToolNamesForType(type: string): string[] {
295
295
  /** Get config for a type (case-insensitive, returns a SubagentTypeConfig-compatible object). Falls back to general-purpose. */
296
296
  export function getConfig(type: string): {
297
297
  displayName: string;
298
+ color?: string;
298
299
  description: string;
299
300
  builtinToolNames: string[];
300
301
  extensions: true | string[] | false;
@@ -307,6 +308,7 @@ export function getConfig(type: string): {
307
308
  if (config && config.enabled !== false) {
308
309
  return {
309
310
  displayName: config.displayName ?? config.name,
311
+ color: config.color,
310
312
  description: config.description,
311
313
  builtinToolNames: config.builtinToolNames ?? BUILTIN_TOOL_NAMES,
312
314
  extensions: config.extensions,
@@ -321,6 +323,7 @@ export function getConfig(type: string): {
321
323
  if (gp && gp.enabled !== false) {
322
324
  return {
323
325
  displayName: gp.displayName ?? gp.name,
326
+ color: gp.color,
324
327
  description: gp.description,
325
328
  builtinToolNames: gp.builtinToolNames ?? BUILTIN_TOOL_NAMES,
326
329
  extensions: gp.extensions,
@@ -0,0 +1,114 @@
1
+ /**
2
+ * ask-tools.ts — `ask_tools:`, the third answer between allow and deny.
3
+ *
4
+ * `tools:` and `disallowed_tools:` are static: a tool is available for the whole
5
+ * run or never. That forces a bad choice for the tools that are usually fine and
6
+ * occasionally not — grant `bash` and hope, or withhold it and cripple the
7
+ * agent. `ask_tools:` names the tools whose every call needs a person to agree.
8
+ *
9
+ * The approver is the HUMAN, deliberately. Upstream projects put an LLM in this
10
+ * seat because their subagents run headless in another process and cannot reach
11
+ * a person; ours share the parent's `ExtensionContext`, so a real approver is
12
+ * one dialog away. Asking a model whether a model should be allowed to do
13
+ * something is a security regression wherever a person is reachable, so this
14
+ * module contains no arbitrator — only the rule vocabulary and a prompt.
15
+ */
16
+
17
+ import { sanitizeDisplayText, truncateCodePoints } from "./ui/safe-text.js";
18
+
19
+ /** Longest tool-argument preview shown in the approval prompt. */
20
+ const MAX_PREVIEW = 300;
21
+
22
+ export interface AskGateDecision {
23
+ block: true;
24
+ reason: string;
25
+ }
26
+
27
+ export interface AskGateContext {
28
+ /** Tool names requiring approval. Matched case-insensitively. */
29
+ askTools: readonly string[];
30
+ /** Prompt the human. Omitted when no human can be reached. */
31
+ confirm?: (title: string, message: string) => Promise<boolean>;
32
+ /** Display name of the agent asking, for the prompt. */
33
+ agentLabel: string;
34
+ }
35
+
36
+ /**
37
+ * Build the per-call approval gate, or `undefined` when nothing needs asking.
38
+ *
39
+ * Returns a function that resolves to a block decision when the call must not
40
+ * proceed, and `undefined` when it may.
41
+ */
42
+ export function createAskGate(
43
+ context: AskGateContext,
44
+ ): ((toolName: string, input: unknown) => Promise<AskGateDecision | undefined>) | undefined {
45
+ const gated = new Set(context.askTools.map((name) => name.trim().toLowerCase()).filter(Boolean));
46
+ if (gated.size === 0) return undefined;
47
+
48
+ /** Tools the user has approved for the rest of this run. */
49
+ const approvedForRun = new Set<string>();
50
+
51
+ return async (toolName, input) => {
52
+ const key = toolName.toLowerCase();
53
+ if (!gated.has(key)) return undefined;
54
+ if (approvedForRun.has(key)) return undefined;
55
+
56
+ // No approver, no approval. Failing OPEN here would silently delete the
57
+ // rule the user wrote — the one case where it matters most is the one where
58
+ // nobody is watching. A headless run of an agent with `ask_tools:` is
59
+ // therefore refused, with a reason that says how to fix it.
60
+ if (!context.confirm) {
61
+ return {
62
+ block: true,
63
+ reason:
64
+ `Tool "${toolName}" requires approval (ask_tools), and there is no interactive session to approve it. ` +
65
+ "Run this agent interactively, or move the tool to `tools:`/`disallowed_tools:` to decide it statically.",
66
+ };
67
+ }
68
+
69
+ let approved: boolean;
70
+ try {
71
+ approved = await context.confirm(
72
+ `${context.agentLabel} wants to use ${toolName}`,
73
+ `${describeInput(input)}\n\nAllow this call?`,
74
+ );
75
+ } catch {
76
+ // A prompt that cannot be shown is not an approval.
77
+ return {
78
+ block: true,
79
+ reason: `Tool "${toolName}" requires approval (ask_tools) and the prompt could not be shown.`,
80
+ };
81
+ }
82
+
83
+ if (!approved) {
84
+ return {
85
+ block: true,
86
+ reason: `The user declined the "${toolName}" call. Do not retry it; continue without that tool or explain what you cannot do.`,
87
+ };
88
+ }
89
+ // Approved for the remainder of the run rather than for this call alone:
90
+ // re-asking on every call of a tool the user just allowed trains them to
91
+ // approve without reading, which is how an approval prompt stops working.
92
+ approvedForRun.add(key);
93
+ return undefined;
94
+ };
95
+ }
96
+
97
+ /**
98
+ * One-line, bounded, inert rendering of a tool call's arguments.
99
+ *
100
+ * The user is being asked to approve a specific call, so the arguments are the
101
+ * whole point — but they are model-authored and about to be drawn into a
102
+ * terminal, so they are sanitized before truncation, never after.
103
+ */
104
+ function describeInput(input: unknown): string {
105
+ if (input === undefined || input === null) return "(no arguments)";
106
+ let rendered: string;
107
+ try {
108
+ rendered = typeof input === "string" ? input : JSON.stringify(input);
109
+ } catch {
110
+ return "(arguments could not be displayed)";
111
+ }
112
+ if (!rendered) return "(no arguments)";
113
+ return truncateCodePoints(sanitizeDisplayText(rendered).replace(/\s+/g, " ").trim(), MAX_PREVIEW, "…");
114
+ }
@@ -41,6 +41,7 @@ export interface SpawnCapable {
41
41
  abort(id: string): boolean;
42
42
  abortOwned?(id: string, owner: AgentOwner): boolean;
43
43
  quiesceOwned?(runId: string, agentIds: string[], timeoutMs: number, owners?: AgentOwner[]): Promise<{ settled: boolean; pending: string[] }>;
44
+ reconcileManaged?(spawnKey: string, owner: AgentOwner): ManagedSpawnResult | undefined;
44
45
  }
45
46
 
46
47
  export interface RpcDeps {
@@ -56,6 +57,7 @@ export interface RpcHandle {
56
57
  unsubStop: () => void;
57
58
  unsubStopOwned: () => void;
58
59
  unsubSpawnManaged: () => void;
60
+ unsubReconcile: () => void;
59
61
  unsubQuiesce: () => void;
60
62
  }
61
63
 
@@ -157,7 +159,7 @@ function handleRpc(
157
159
  }
158
160
 
159
161
  /**
160
- * Register ping, legacy spawn/stop, and managed workflow spawn handlers.
162
+ * Register ping, legacy spawn/stop, and managed workflow spawn/reconciliation handlers.
161
163
  * Returns unsubscribe functions for cleanup.
162
164
  */
163
165
  export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
@@ -204,9 +206,8 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
204
206
  if (!ctx) throw new Error("No active session");
205
207
  if (!manager.spawnManaged) throw new Error("Managed spawn is unavailable");
206
208
  const request = validateManagedSpawnRequest(params);
207
- // The wire request deliberately carries no execution policy. The production
208
- // wrapper resolves it from the agent configuration; direct fixtures receive
209
- // an explicit empty policy so the manager seam remains total.
209
+ // Policy hints are validated at the protocol boundary, then resolved by the
210
+ // production wrapper and AgentManager so the peer never bypasses local policy.
210
211
  const result = manager.spawnManaged(pi, ctx, request, {}) as ManagedSpawnResult | string;
211
212
  // Keep the additive handler tolerant of an older in-process manager fixture
212
213
  // while protocol-v3 managers return the richer state snapshot.
@@ -214,6 +215,16 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
214
215
  return result;
215
216
  });
216
217
 
218
+ const unsubReconcile = handleRpc(events, "subagents:rpc:reconcile-managed", (params) => {
219
+ if (!manager.reconcileManaged) throw new Error("Managed reconciliation is unavailable");
220
+ rejectUnknownKeys(params, new Set(["requestId", "spawnKey", "owner"]), "managed reconciliation request");
221
+ const spawnKey = boundedString(params.spawnKey, "spawnKey", 256);
222
+ const owner = validateManagedOwner(params.owner, true);
223
+ const result = manager.reconcileManaged(spawnKey, owner);
224
+ if (!result) throw new Error("Managed spawn key not found or owner mismatch");
225
+ return result;
226
+ });
227
+
217
228
  const unsubStop = handleRpc(events, "subagents:rpc:stop", (params) => {
218
229
  const agentId = boundedString(params.agentId, "agentId", 128);
219
230
  if (!manager.abort(agentId)) throw new Error("Agent not found");
@@ -254,5 +265,5 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
254
265
  return manager.quiesceOwned(runId, agentIds, rawTimeout, owners);
255
266
  });
256
267
 
257
- return { unsubPing, unsubSpawn, unsubStop, unsubStopOwned, unsubSpawnManaged, unsubQuiesce };
268
+ return { unsubPing, unsubSpawn, unsubStop, unsubStopOwned, unsubSpawnManaged, unsubQuiesce, unsubReconcile };
258
269
  }
@@ -26,6 +26,12 @@ import { sanitizeDisplayText } from "./ui/safe-text.js";
26
26
  type WarningSink = (message: string, key?: string) => void;
27
27
  type SkippedAgent = { name: string; priority: number };
28
28
 
29
+ /**
30
+ * Colon is reserved for plugin-scoped identifiers (extension-owned agent
31
+ * types). A file may not claim one; see the `name:` guard in `loadFromDir`.
32
+ */
33
+ const RESERVED_IN_TYPE = ":";
34
+
29
35
  /** Normalize a discovery root so aliases and symlinked worktree paths share state. */
30
36
  function normalizeDiscoveryRoot(cwd: string): string {
31
37
  const absolute = resolve(cwd);
@@ -71,6 +77,27 @@ function warningReasonKey(reason: string): string {
71
77
  return reason.split(":", 1)[0] ?? reason;
72
78
  }
73
79
 
80
+ /** Best-effort extraction of `name:` from raw frontmatter when strict parsing failed. */
81
+ function peekDeclaredName(path: string): string | undefined {
82
+ try {
83
+ const raw = readFileSync(path, "utf-8");
84
+ const fmMatch = /^---\s*\n([\s\S]*?)\n---/m.exec(raw);
85
+ if (!fmMatch) return undefined;
86
+ const block = fmMatch[1];
87
+ // Match `name:` with optional quotes, trimming inline comments.
88
+ const nameMatch = /^\s*name\s*:\s*["']?([^"'\n#]+?)["']?\s*(?:#.*)?$/m.exec(block);
89
+ if (!nameMatch) return undefined;
90
+ const declared = nameMatch[1].trim();
91
+ // Guard `||` semantics: empty/whitespace falls back to filename.
92
+ if (!declared) return undefined;
93
+ // Colon is plugin-scope reserved; such files never enter skip override logic.
94
+ if (declared.includes(RESERVED_IN_TYPE)) return undefined;
95
+ return declared;
96
+ } catch {
97
+ return undefined;
98
+ }
99
+ }
100
+
74
101
  const MAX_WARNING_ROOTS = 64;
75
102
  const warningCache = new Map<string, Set<string>>();
76
103
 
@@ -154,30 +181,68 @@ function loadFromDir(
154
181
  }
155
182
 
156
183
  for (const file of files) {
157
- const name = basename(file, ".md");
184
+ const filenameType = basename(file, ".md");
185
+
158
186
  const path = join(dir, file);
187
+
159
188
  const parsed = readAgentFile(path, strict, warn);
160
189
  if (!parsed) {
161
- skipped.push({ name, priority });
190
+ // Even a malformed file may have declared `name:`; when it does, the
191
+ // skip must be filed under that name, not the filename, so a higher-
192
+ // priority higher file declaring the same name still suppresses the
193
+ // warning (B1#1). Best-effort regex — the authoritative parse already
194
+ // failed, so this is only for the override warning key.
195
+ const peeked = peekDeclaredName(path);
196
+ const skipName = peeked !== undefined ? peeked : filenameType;
197
+ skipped.push({ name: skipName, priority });
162
198
  continue;
163
199
  }
164
200
  const { frontmatter: fm, body } = parsed;
165
201
 
202
+ // Claude Code's rule: `name:` IS the agent type, and the filename need not
203
+ // match. Absent, the filename stands in — Claude Code requires the field,
204
+ // but most files here predate it and must keep loading.
205
+ const declared = str(fm.name)?.trim();
206
+ if (declared?.includes(RESERVED_IN_TYPE)) {
207
+ // Refusing beats silently substituting: the file would otherwise load
208
+ // under its filename, so `Agent({subagent_type})` would succeed against
209
+ // an agent whose declared identity nothing honoured.
210
+ warn(
211
+ `Agent file ${path} declares name "${declared}", which contains "${RESERVED_IN_TYPE}" — reserved for `
212
+ + "plugin-scoped identifiers. Rename it, or move the label to `display_name:`. Skipping.",
213
+ `reserved-name:${warningIdentity(path)}`,
214
+ );
215
+ // No skipped entry: this file would have registered under its *declared*
216
+ // name, which nothing else can hold (a colon keeps it out of the
217
+ // registry), so it shadowed nothing. Reporting the filename instead
218
+ // would claim a substitution of an unrelated agent that never happened.
219
+ continue;
220
+ }
221
+ // `||`, not `??`: a quoted empty or all-whitespace `name:` would otherwise
222
+ // register the agent under the empty type — unspawnable, and it takes the
223
+ // filename-derived one down with it.
224
+ const name = declared || filenameType;
225
+
166
226
  const { builtinToolNames, extSelectors } = parseToolsField(fm.tools);
167
227
  warnLegacyModelFields(fm, path, warn);
168
228
 
169
229
  agents.set(name, {
170
230
  name,
171
231
  displayName: label(fm.display_name),
232
+ color: str(fm.color)?.trim(),
172
233
  description: label(fm.description) ?? sanitizeDisplayText(name),
173
234
  builtinToolNames,
174
235
  extSelectors,
175
236
  disallowedTools: csvListOptional(fm.disallowed_tools),
237
+ askTools: csvListOptional(fm.ask_tools),
238
+ gate: str(fm.gate)?.trim(),
176
239
  extensions: inheritField(fm.extensions ?? fm.inherit_extensions),
177
240
  excludeExtensions: csvListOptional(fm.exclude_extensions),
178
241
  skills: inheritField(fm.skills ?? fm.inherit_skills),
179
242
  agentTier: parseTier(fm.tier, path, warn),
180
243
  maxTurns: nonNegativeInt(fm.max_turns),
244
+ maxTokens: nonNegativeInt(fm.max_tokens),
245
+ maxToolCalls: nonNegativeInt(fm.max_tool_calls),
181
246
  persistSession: fm.persist_session != null ? fm.persist_session === true : undefined,
182
247
  outputTranscript: fm.output_transcript != null ? fm.output_transcript !== false : undefined,
183
248
  sessionDir: str(fm.session_dir),
@@ -34,11 +34,12 @@ export const DEFAULT_AGENTS: Map<string, AgentConfig> = new Map([
34
34
  builtinToolNames: READ_ONLY_TOOLS,
35
35
  extensions: true,
36
36
  skills: true,
37
- // No model pin. Which model a subagent runs is the tier catalogue's
38
- // decision; a built-in that pinned one would be the same end-run around it
39
- // that agent frontmatter is no longer allowed to make, and it would name a
40
- // vendor on a machine that may not have it. With no tier configured this
41
- // inherits the parent's model, which is the documented fallback.
37
+ // Runs on the shipped `fast` agent tier (model inherit, low thinking) so
38
+ // read-only search does not inherit the parent session's most expensive
39
+ // model on machines that never configured agentTiers. The tier is the
40
+ // policy: point `fast` at a cheap model in subagents.json and Explore
41
+ // follows without touching agent files.
42
+ agentTier: "fast",
42
43
  systemPrompt: `# CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS
43
44
  You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
44
45
  Your role is EXCLUSIVELY to search and analyze existing code. You do NOT have access to file editing tools.
package/src/gate.ts ADDED
Binary file