@signalridge/pi-subagents 1.8.0 → 1.9.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.
@@ -1,4 +1,4 @@
1
- import type { Model } from "@earendil-works/pi-ai";
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
2
2
  import {
3
3
  type AgentSession,
4
4
  defineTool,
@@ -8,7 +8,7 @@ import {
8
8
  } from "@earendil-works/pi-coding-agent";
9
9
  import { Type } from "@sinclair/typebox";
10
10
  import { abortable } from "./abortable.js";
11
- import { buildAgentTierParameterDescription } from "./agent-tiers.js";
11
+ import { buildAgentTierParameterDescription, getAgentTiersSettings } from "./agent-tiers.js";
12
12
  import {
13
13
  buildAgentRegistry,
14
14
  getAgentConfigIn,
@@ -53,7 +53,7 @@ interface NestedSpawnOptions {
53
53
  description: string;
54
54
  /** User-named model tier; resolved by the runner like every other spawn path. */
55
55
  agentTier?: string;
56
- model?: Model<any>;
56
+ model?: Model<Api>;
57
57
  maxTurns?: number;
58
58
  isolated?: boolean;
59
59
  inheritContext?: boolean;
@@ -224,9 +224,15 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
224
224
  }
225
225
 
226
226
  const config = getAgentConfigIn(registry, resolvedType);
227
- const invocation = resolveAgentInvocationConfig(config, params);
228
- let model = ctx.model;
229
- if (invocation.modelInput) {
227
+ const invocation = resolveAgentInvocationConfig(config, {
228
+ ...params,
229
+ agentTiers: getAgentTiersSettings(),
230
+ });
231
+ // Leave model/thinking unset whenever an Agent tier may apply. runAgent
232
+ // is the sole final resolver; pre-resolving here would let a parent or
233
+ // legacy field bypass the selected profile.
234
+ let model: Model<Api> | undefined = invocation.agentTierSelected ? undefined : ctx.model;
235
+ if (!invocation.agentTierSelected && invocation.modelInput) {
230
236
  const resolvedModel = resolveModel(invocation.modelInput, ctx.modelRegistry);
231
237
  if (typeof resolvedModel === "string") {
232
238
  if (invocation.modelFromParams) return textResult(resolvedModel, true);
@@ -236,17 +242,19 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
236
242
  }
237
243
 
238
244
  // Same scopeModels policy as the top-level Agent tool — a nested spawn
239
- // must not escape the allowlist. A "warn" verdict proceeds silently:
240
- // child sessions have no UI surface to toast to.
241
- const scopeVerdict = checkModelScope({
242
- model,
243
- cwd: context.configCwd,
244
- modelRegistry: ctx.modelRegistry,
245
- callerSupplied: invocation.modelFromParams,
246
- agentLabel: config?.displayName ?? resolvedType,
247
- modelInput: invocation.modelInput,
248
- });
249
- if (scopeVerdict.kind === "error") return textResult(scopeVerdict.message, true);
245
+ // must not escape the allowlist. A tier's final model is scope-checked by
246
+ // runAgent after resolution; checking it here would duplicate resolution.
247
+ if (!invocation.agentTierSelected) {
248
+ const scopeVerdict = checkModelScope({
249
+ model,
250
+ cwd: context.configCwd,
251
+ modelRegistry: ctx.modelRegistry,
252
+ callerSupplied: invocation.modelFromParams,
253
+ agentLabel: config?.displayName ?? resolvedType,
254
+ modelInput: invocation.modelInput,
255
+ });
256
+ if (scopeVerdict.kind === "error") return textResult(scopeVerdict.message, true);
257
+ }
250
258
 
251
259
  // The whole branch shares the root session's transcript directory; read it
252
260
  // off the owning parent rather than this child session's own id.
@@ -254,18 +262,23 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
254
262
  const childDepth = context.depth + 1;
255
263
  const options: NestedSpawnOptions = {
256
264
  description: params.description,
265
+ // Only the caller's own tier. An agent's frontmatter tier is read by
266
+ // `resolveAgentTier` itself, and passing it here would relabel its
267
+ // source as "call" — which turns a scopeModels warning into a refusal
268
+ // and blames the caller for a choice the agent file made.
257
269
  agentTier: invocation.requestedAgentTier,
258
- model,
270
+ model: invocation.agentTierSelected ? undefined : model,
259
271
  maxTurns: invocation.maxTurns,
260
272
  isolated: invocation.isolated,
261
273
  inheritContext: invocation.inheritContext,
262
- thinkingLevel: invocation.thinking,
274
+ thinkingLevel: invocation.agentTierSelected ? undefined : invocation.thinking,
263
275
  isolation: invocation.isolation,
264
276
  invocation: {
265
- ...(invocation.requestedAgentTier === undefined
277
+ // Display only: the effective tier, whoever named it.
278
+ ...(invocation.requestedAgentTier === undefined && config?.agentTier === undefined
266
279
  ? {}
267
- : { agentTier: invocation.requestedAgentTier }),
268
- thinking: invocation.thinking,
280
+ : { agentTier: invocation.requestedAgentTier ?? config?.agentTier }),
281
+ thinking: invocation.agentTierSelected ? undefined : invocation.thinking,
269
282
  maxTurns: invocation.maxTurns,
270
283
  isolated: invocation.isolated,
271
284
  inheritContext: invocation.inheritContext,
package/src/schedule.ts CHANGED
@@ -40,6 +40,8 @@ export interface NewJobInput {
40
40
  schedule: string;
41
41
  subagent_type: SubagentType;
42
42
  prompt: string;
43
+ /** User-named Agent tier preserved until fire time. */
44
+ tier?: string;
43
45
  model?: string;
44
46
  thinking?: ThinkingLevel;
45
47
  max_turns?: number;
@@ -103,6 +105,7 @@ export class SubagentScheduler {
103
105
  intervalMs: detected.intervalMs,
104
106
  subagent_type: input.subagent_type,
105
107
  prompt: input.prompt,
108
+ tier: input.tier,
106
109
  model: input.model,
107
110
  thinking: input.thinking,
108
111
  max_turns: input.max_turns,
package/src/settings.ts CHANGED
@@ -6,7 +6,7 @@ import { randomUUID } from "node:crypto";
6
6
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
7
7
  import { basename, dirname, join } from "node:path";
8
8
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
9
- import type { WorkflowTier } from "@signalridge/pi-subagents-protocol";
9
+ import { isManagedAgentTier, MAX_AGENT_TIER_PROFILES } from "@signalridge/pi-subagents-protocol";
10
10
  // Imported only for the applySettings fallback so a persisted defaultToolTimeoutMs
11
11
  // takes effect even before the host wires the new applier — avoids needing to
12
12
  // edit index.ts in the same change.
@@ -22,17 +22,15 @@ const VALID_AGENT_MENTION_MODES: ReadonlySet<string> = new Set(AGENT_MENTION_MOD
22
22
  /** A tier's thinking value: a level, or `inherit` to keep the parent's. */
23
23
  export type TierThinking = ThinkingLevel | "inherit";
24
24
 
25
- /** Historical name for {@link TierThinking}, kept for the workflow tier types. */
26
- export type WorkflowThinking = TierThinking;
27
-
28
25
  /**
29
26
  * One user-named (model, thinking) pair for ordinary subagent spawns.
30
27
  *
31
- * Separate from {@link WorkflowTierProfile} because the key space differs: these
32
- * names are whatever the user chose, while workflow tiers are the protocol's
33
- * fixed small/medium/large. `description` is what the host agent reads in the
34
- * tool description to decide between tiers, so it is prose about the job the
35
- * tier is for, not about the model.
28
+ * The key space is whatever the user chose. `description` is what the host
29
+ * agent reads in the tool description to decide between tiers, so it is prose
30
+ * about the job the tier is for, not about the model.
31
+ *
32
+ * Managed workflow calls name a tier from this same catalogue; there is no
33
+ * second workflow-tier vocabulary.
36
34
  */
37
35
  export interface AgentTierProfile {
38
36
  /** `inherit` keeps the parent model; other values are provider/model references. */
@@ -47,6 +45,15 @@ export interface AgentTierProfile {
47
45
  export interface AgentTiersSettings {
48
46
  /** Applied when neither the caller nor the agent names a tier. */
49
47
  defaultTier?: string;
48
+ /**
49
+ * The user chose to have no default at all.
50
+ *
51
+ * Distinct from an absent `defaultTier`, which still lets a managed workflow
52
+ * call reach the shipped fallback in `agent-tiers.ts`. This is how a user
53
+ * withdraws that fallback as well, the same way `blockedProfiles` says "do not
54
+ * substitute a shipped profile".
55
+ */
56
+ noDefaultTier?: boolean;
50
57
  /** A configured entry replaces the complete entry inherited from global settings. */
51
58
  profiles?: Record<string, AgentTierProfile>;
52
59
  /** Tombstones for malformed explicit profiles; not a user policy field. */
@@ -55,41 +62,11 @@ export interface AgentTiersSettings {
55
62
  blockedDefaultTier?: boolean;
56
63
  }
57
64
 
58
- export interface WorkflowTierProfile {
59
- /** `inherit` keeps the parent model; other values are provider/model references. */
60
- model: string;
61
- /** `inherit` keeps the parent Thinking level; other values are clamped natively. */
62
- thinking: WorkflowThinking;
63
- }
64
-
65
- /** Workflow policy configured by the user and resolved only by pi-subagents. */
66
- export interface WorkflowSettings {
67
- /** Optional default used when a managed workflow task omits its tier. */
68
- defaultTier?: WorkflowTier;
69
- /** A configured entry replaces the complete entry inherited from global settings. */
70
- tiers?: Partial<Record<WorkflowTier, WorkflowTierProfile>>;
71
- /** Persistence tombstones for malformed explicit tier entries; not a user policy field. */
72
- blockedTiers?: WorkflowTier[];
73
- /** Persistence tombstone for a malformed defaultTier; not a user policy field. */
74
- blockedDefaultTier?: boolean;
75
- }
76
-
77
-
78
- export const DEFAULT_WORKFLOW_TIER_PROFILES: Readonly<Record<WorkflowTier, WorkflowTierProfile>> = {
79
- // Defaults are provider-neutral. Users may pin a tier to any concrete
80
- // provider/model in the same complete profile, but the extension must not
81
- // silently choose a vendor-specific model on a new machine.
82
- small: { model: "inherit", thinking: "low" },
83
- medium: { model: "inherit", thinking: "medium" },
84
- large: { model: "inherit", thinking: "high" },
85
- };
86
65
  export interface SubagentsSettings {
87
- /** Semantic model-plus-thinking profiles used by workflow-owned spawns. */
88
- workflow?: WorkflowSettings;
89
66
  /**
90
- * User-named model tiers for ordinary subagent spawns. Independent of
91
- * `workflow` above: the key space is arbitrary, and `pi-workflows` never reads
92
- * these. See `agent-tiers.ts` for resolution and precedence.
67
+ * User-named model tiers. The one tier catalogue: ordinary Agent spawns, the
68
+ * scheduler, nested delegation, and managed `pi-workflows` calls all name a
69
+ * key from here. See `agent-tiers.ts` for resolution and precedence.
93
70
  */
94
71
  agentTiers?: AgentTiersSettings;
95
72
  /**
@@ -304,9 +281,7 @@ export interface SettingsAppliers {
304
281
  setMaxSubagentDepth: (n: number) => void;
305
282
  setMaxSubagentSpawnsPerBranch: (n: number) => void;
306
283
  setFallbackSubagent: (v: string | undefined) => void;
307
- /** Optional because non-runtime settings tests and consumers need not apply workflow state. */
308
- setWorkflow?: (settings: WorkflowSettings) => void;
309
- /** Optional for the same reason as `setWorkflow`. */
284
+ /** Optional because non-runtime settings tests and consumers need not apply tier state. */
310
285
  setAgentTiers?: (settings: AgentTiersSettings) => void;
311
286
  }
312
287
 
@@ -332,12 +307,7 @@ export const TIER_THINKING_LEVELS: readonly TierThinking[] = [
332
307
  "max",
333
308
  ];
334
309
  const VALID_THINKING_LEVELS: ReadonlySet<string> = new Set(TIER_THINKING_LEVELS);
335
- const WORKFLOW_TIER_NAMES: readonly WorkflowTier[] = ["small", "medium", "large"];
336
310
  const MAX_MODEL_REFERENCE_LENGTH = 512;
337
- /** Mirrors MAX_AGENT_TIER_KEY_LENGTH in agent-tiers.ts; duplicated to keep settings dependency-free. */
338
- const MAX_AGENT_TIER_KEY_LENGTH = 64;
339
- /** Bounds a hand-edited config; far above any realistic number of tiers. */
340
- const MAX_AGENT_TIER_PROFILES = 64;
341
311
  const MAX_AGENT_TIER_DESCRIPTION_LENGTH = 512;
342
312
 
343
313
  // Sanity ceilings — prevent hand-edited configs from asking for values that
@@ -377,60 +347,10 @@ export function isModelReference(value: unknown): value is string {
377
347
  return slash > 0 && slash < model.length - 1;
378
348
  }
379
349
 
380
- function validThinkingLevel(value: unknown): value is WorkflowThinking {
350
+ function validThinkingLevel(value: unknown): value is TierThinking {
381
351
  return typeof value === "string" && VALID_THINKING_LEVELS.has(value);
382
352
  }
383
353
 
384
- /**
385
- * A profile is all-or-nothing. In particular, never turn `{ model: ... }` into
386
- * a profile whose thinking silently inherits from the parent: that would make a
387
- * typo in a policy file look like an intentional policy choice.
388
- */
389
- function sanitizeWorkflowProfile(raw: unknown): WorkflowTierProfile | undefined {
390
- if (!isRecord(raw)) return undefined;
391
- const keys = Object.keys(raw);
392
- if (keys.some((key) => key !== "model" && key !== "thinking")) return undefined;
393
- if (!Object.hasOwn(raw, "model") || !Object.hasOwn(raw, "thinking")) return undefined;
394
- if (!isModelReference(raw.model) || !validThinkingLevel(raw.thinking)) return undefined;
395
- return { model: raw.model.trim(), thinking: raw.thinking };
396
- }
397
-
398
- function sanitizeWorkflow(raw: unknown): WorkflowSettings | undefined {
399
- if (!isRecord(raw)) return undefined;
400
- const out: WorkflowSettings = {};
401
- if (isWorkflowTierValue(raw.defaultTier)) out.defaultTier = raw.defaultTier;
402
- if (Object.hasOwn(raw, "defaultTier") && !isWorkflowTierValue(raw.defaultTier)) out.blockedDefaultTier = true;
403
- if (raw.blockedDefaultTier === true) out.blockedDefaultTier = true;
404
- if (Object.hasOwn(raw, "blockedTiers")) {
405
- const rawBlocked = raw.blockedTiers;
406
- const blocked = Array.isArray(rawBlocked) && rawBlocked.every(isWorkflowTierValue)
407
- ? [...new Set(rawBlocked)]
408
- : [...WORKFLOW_TIER_NAMES];
409
- if (blocked.length > 0) out.blockedTiers = blocked;
410
- }
411
- const tiersValue = raw.tiers;
412
- if (isRecord(tiersValue)) {
413
- const tiers: Partial<Record<WorkflowTier, WorkflowTierProfile>> = {};
414
- for (const tier of WORKFLOW_TIER_NAMES) {
415
- if (!Object.hasOwn(tiersValue, tier)) continue;
416
- const profile = sanitizeWorkflowProfile(tiersValue[tier]);
417
- if (profile) tiers[tier] = profile;
418
- }
419
- if (Object.keys(tiers).length > 0) out.tiers = tiers;
420
- }
421
- return out.defaultTier !== undefined || out.tiers !== undefined || out.blockedTiers !== undefined || out.blockedDefaultTier !== undefined ? out : undefined;
422
- }
423
-
424
- function isAgentTierKey(value: unknown): value is string {
425
- return (
426
- typeof value === "string" &&
427
- value.length > 0 &&
428
- value.length <= MAX_AGENT_TIER_KEY_LENGTH &&
429
- value.trim() === value &&
430
- !/\s/u.test(value)
431
- );
432
- }
433
-
434
354
  /**
435
355
  * A profile is all-or-nothing, for the same reason a workflow profile is: half
436
356
  * a profile with an implied `inherit` makes a typo look like a policy choice.
@@ -461,23 +381,25 @@ function sanitizeAgentTierProfile(raw: unknown): AgentTierProfile | undefined {
461
381
  function sanitizeAgentTiers(raw: unknown): AgentTiersSettings | undefined {
462
382
  if (!isRecord(raw)) return undefined;
463
383
  const out: AgentTiersSettings = {};
464
- if (isAgentTierKey(raw.defaultTier)) out.defaultTier = raw.defaultTier;
465
- if (Object.hasOwn(raw, "defaultTier") && !isAgentTierKey(raw.defaultTier)) out.blockedDefaultTier = true;
384
+ if (isManagedAgentTier(raw.defaultTier)) out.defaultTier = raw.defaultTier;
385
+ if (Object.hasOwn(raw, "defaultTier") && !isManagedAgentTier(raw.defaultTier)) out.blockedDefaultTier = true;
466
386
  if (raw.blockedDefaultTier === true) out.blockedDefaultTier = true;
387
+ if (raw.noDefaultTier === true && out.defaultTier === undefined) out.noDefaultTier = true;
467
388
  if (Array.isArray(raw.blockedProfiles)) {
468
- const blocked = [...new Set(raw.blockedProfiles.filter(isAgentTierKey))];
389
+ const blocked = [...new Set(raw.blockedProfiles.filter(isManagedAgentTier))];
469
390
  if (blocked.length > 0) out.blockedProfiles = blocked;
470
391
  }
471
392
  if (isRecord(raw.profiles)) {
472
393
  const profiles: Record<string, AgentTierProfile> = {};
473
394
  for (const key of Object.keys(raw.profiles).slice(0, MAX_AGENT_TIER_PROFILES)) {
474
- if (!isAgentTierKey(key)) continue;
395
+ if (!isManagedAgentTier(key)) continue;
475
396
  const profile = sanitizeAgentTierProfile(raw.profiles[key]);
476
397
  if (profile) profiles[key] = profile;
477
398
  }
478
399
  if (Object.keys(profiles).length > 0) out.profiles = profiles;
479
400
  }
480
401
  return out.defaultTier !== undefined ||
402
+ out.noDefaultTier !== undefined ||
481
403
  out.profiles !== undefined ||
482
404
  out.blockedProfiles !== undefined ||
483
405
  out.blockedDefaultTier !== undefined
@@ -485,9 +407,25 @@ function sanitizeAgentTiers(raw: unknown): AgentTiersSettings | undefined {
485
407
  : undefined;
486
408
  }
487
409
 
488
- function isWorkflowTierValue(value: unknown): value is WorkflowTier {
489
- return value === "small" || value === "medium" || value === "large";
410
+ /**
411
+ * Announce the retired `workflow` key once per process.
412
+ *
413
+ * `sanitize` runs on both settings files on every load and again on every
414
+ * project write, so warning at the call site would repeat the same line several
415
+ * times per session and once more each time the user saves Settings. The key is
416
+ * one fact about the configuration, not an event; state it once.
417
+ */
418
+ let warnedRetiredWorkflowKey = false;
419
+ function warnRetiredWorkflowKey(): void {
420
+ if (warnedRetiredWorkflowKey) return;
421
+ warnedRetiredWorkflowKey = true;
422
+ console.warn(
423
+ '[pi-subagents] the "workflow" settings key is retired and ignored; ' +
424
+ 'managed workflow calls name an "agentTiers" key directly. ' +
425
+ 'Move any routing you had there to "agentTiers" (its "defaultTier" replaces "workflow.defaultTier").',
426
+ );
490
427
  }
428
+
491
429
  /** Drop fields that don't match the expected shape. Silent — garbage becomes absent. */
492
430
  function sanitize(raw: unknown): SubagentsSettings {
493
431
  if (!raw || typeof raw !== "object") return {};
@@ -597,8 +535,9 @@ function sanitize(raw: unknown): SubagentsSettings {
597
535
  out.worktreeIsolation = r.worktreeIsolation;
598
536
  }
599
537
 
600
- const workflow = sanitizeWorkflow(r.workflow);
601
- if (workflow) out.workflow = workflow;
538
+ // `workflow` is retired: workflow calls now name a key from `agentTiers`.
539
+ // Warn by name rather than silently dropping a policy the user still believes in.
540
+ if (Object.hasOwn(r, "workflow")) warnRetiredWorkflowKey();
602
541
  const agentTiers = sanitizeAgentTiers(r.agentTiers);
603
542
  if (agentTiers) out.agentTiers = agentTiers;
604
543
  return out;
@@ -614,21 +553,9 @@ function projectPath(cwd: string): string {
614
553
 
615
554
  /**
616
555
  * Read a settings file. Missing file is silent (returns `{}`). A file that
617
- * exists but can't be parsed emits a warning to stderr and blocks workflow-tier
556
+ * exists but can't be parsed emits a warning to stderr and blocks Agent-tier
618
557
  * policy so users aren't silently reverted to defaults.
619
558
  */
620
- interface WorkflowSource {
621
- present: boolean;
622
- object: boolean;
623
- defaultTierPresent: boolean;
624
- defaultTier?: WorkflowTier;
625
- tiersPresent: boolean;
626
- tiersObject: boolean;
627
- tierEntries: Partial<Record<WorkflowTier, WorkflowTierProfile | undefined>>;
628
- blockedTiers: WorkflowTier[];
629
- blockedDefaultTier: boolean;
630
- }
631
-
632
559
  /**
633
560
  * Presence of each agent-tier entry in one file, kept separate from the
634
561
  * sanitized value so the merge can tell "the project did not mention this
@@ -640,6 +567,7 @@ interface AgentTiersSource {
640
567
  object: boolean;
641
568
  defaultTierPresent: boolean;
642
569
  defaultTier?: string;
570
+ noDefaultTier: boolean;
643
571
  profilesPresent: boolean;
644
572
  profilesObject: boolean;
645
573
  profileEntries: Record<string, AgentTierProfile | undefined>;
@@ -649,29 +577,16 @@ interface AgentTiersSource {
649
577
 
650
578
  interface ReadSettings {
651
579
  settings: SubagentsSettings;
652
- workflow: WorkflowSource;
653
580
  agentTiers: AgentTiersSource;
654
581
  }
655
582
 
656
- function emptyWorkflowSource(): WorkflowSource {
657
- return {
658
- present: false,
659
- object: false,
660
- defaultTierPresent: false,
661
- blockedDefaultTier: false,
662
- tiersPresent: false,
663
- tiersObject: false,
664
- tierEntries: {},
665
- blockedTiers: [],
666
- };
667
- }
668
-
669
583
  function emptyAgentTiersSource(): AgentTiersSource {
670
584
  return {
671
585
  present: false,
672
586
  object: false,
673
587
  defaultTierPresent: false,
674
588
  blockedDefaultTier: false,
589
+ noDefaultTier: false,
675
590
  profilesPresent: false,
676
591
  profilesObject: false,
677
592
  profileEntries: {},
@@ -692,17 +607,18 @@ function agentTiersSource(raw: unknown): AgentTiersSource {
692
607
  object: true,
693
608
  defaultTierPresent: Object.hasOwn(value, "defaultTier"),
694
609
  blockedDefaultTier:
695
- (Object.hasOwn(value, "defaultTier") && !isAgentTierKey(value.defaultTier)) || value.blockedDefaultTier === true,
696
- ...(isAgentTierKey(value.defaultTier) ? { defaultTier: value.defaultTier } : {}),
610
+ (Object.hasOwn(value, "defaultTier") && !isManagedAgentTier(value.defaultTier)) || value.blockedDefaultTier === true,
611
+ ...(isManagedAgentTier(value.defaultTier) ? { defaultTier: value.defaultTier } : {}),
697
612
  profilesPresent: Object.hasOwn(value, "profiles"),
613
+ noDefaultTier: value.noDefaultTier === true,
698
614
  profilesObject: isRecord(value.profiles),
699
615
  profileEntries: {},
700
- blockedProfiles: Array.isArray(value.blockedProfiles) ? value.blockedProfiles.filter(isAgentTierKey) : [],
616
+ blockedProfiles: Array.isArray(value.blockedProfiles) ? value.blockedProfiles.filter(isManagedAgentTier) : [],
701
617
  };
702
618
 
703
619
  if (isRecord(value.profiles)) {
704
620
  for (const key of Object.keys(value.profiles).slice(0, MAX_AGENT_TIER_PROFILES)) {
705
- if (!isAgentTierKey(key)) continue;
621
+ if (!isManagedAgentTier(key)) continue;
706
622
  const profile = sanitizeAgentTierProfile(value.profiles[key]);
707
623
  source.profileEntries[key] = profile;
708
624
  if (!profile) source.blockedProfiles.push(key);
@@ -739,8 +655,13 @@ function mergeAgentTierSources(
739
655
  if (project.defaultTierPresent) {
740
656
  if (project.defaultTier !== undefined) merged.defaultTier = project.defaultTier;
741
657
  else merged.blockedDefaultTier = true;
658
+ } else if (project.noDefaultTier) {
659
+ // The project said "none" explicitly; that outranks a global default and
660
+ // stops the shipped one from filling the gap.
661
+ merged.noDefaultTier = true;
742
662
  } else {
743
663
  if (global.defaultTier !== undefined) merged.defaultTier = global.defaultTier;
664
+ else if (global.noDefaultTier) merged.noDefaultTier = true;
744
665
  if (global.blockedDefaultTier || project.blockedDefaultTier) merged.blockedDefaultTier = true;
745
666
  }
746
667
 
@@ -762,6 +683,7 @@ function mergeAgentTierSources(
762
683
  }
763
684
  continue;
764
685
  }
686
+ if (blocked.has(key)) continue;
765
687
  const inherited = global.profileEntries[key];
766
688
  if (inherited) profiles[key] = inherited;
767
689
  }
@@ -769,6 +691,7 @@ function mergeAgentTierSources(
769
691
  if (Object.keys(profiles).length > 0) merged.profiles = profiles;
770
692
  if (blocked.size > 0) merged.blockedProfiles = [...blocked].sort((a, b) => a.localeCompare(b));
771
693
  return merged.defaultTier !== undefined ||
694
+ merged.noDefaultTier !== undefined ||
772
695
  merged.profiles !== undefined ||
773
696
  merged.blockedProfiles !== undefined ||
774
697
  merged.blockedDefaultTier !== undefined
@@ -776,70 +699,18 @@ function mergeAgentTierSources(
776
699
  : undefined;
777
700
  }
778
701
 
779
- /** Preserve invalid workflow entry presence so project policy cannot resurrect a global entry. */
780
- function workflowSource(raw: unknown): WorkflowSource {
781
- if (!isRecord(raw) || !Object.hasOwn(raw, "workflow")) return emptyWorkflowSource();
782
- const value = raw.workflow;
783
- if (!isRecord(value)) {
784
- return {
785
- ...emptyWorkflowSource(),
786
- present: true,
787
- object: false,
788
- blockedTiers: [...WORKFLOW_TIER_NAMES],
789
- blockedDefaultTier: true,
790
- };
791
- }
792
- const tiersValue = value.tiers;
793
- const blockedTiers: WorkflowTier[] = [];
794
- if (Object.hasOwn(value, "blockedTiers")) {
795
- const rawBlocked = value.blockedTiers;
796
- if (Array.isArray(rawBlocked) && rawBlocked.every(isWorkflowTierValue)) blockedTiers.push(...new Set(rawBlocked));
797
- else blockedTiers.push(...WORKFLOW_TIER_NAMES);
798
- }
799
- const source: WorkflowSource = {
800
- present: true,
801
- object: true,
802
- defaultTierPresent: Object.hasOwn(value, "defaultTier"),
803
- blockedDefaultTier:
804
- (Object.hasOwn(value, "defaultTier") && !isWorkflowTierValue(value.defaultTier)) || value.blockedDefaultTier === true,
805
- ...(isWorkflowTierValue(value.defaultTier) ? { defaultTier: value.defaultTier } : {}),
806
- tiersPresent: Object.hasOwn(value, "tiers"),
807
- tiersObject: isRecord(tiersValue),
808
- tierEntries: {},
809
- blockedTiers,
810
- };
811
- if (Object.hasOwn(value, "tiers") && !source.tiersObject) blockedTiers.push(...WORKFLOW_TIER_NAMES);
812
- if (isRecord(tiersValue)) {
813
- for (const tier of WORKFLOW_TIER_NAMES) {
814
- if (!Object.hasOwn(tiersValue, tier)) continue;
815
- const profile = sanitizeWorkflowProfile(tiersValue[tier]);
816
- source.tierEntries[tier] = profile;
817
- if (!profile) blockedTiers.push(tier);
818
- }
819
- }
820
- source.blockedTiers = [...new Set(blockedTiers)];
821
- return source;
822
- }
823
-
824
702
  function readSettingsFile(path: string): ReadSettings {
825
703
  if (!existsSync(path)) {
826
- return { settings: {}, workflow: emptyWorkflowSource(), agentTiers: emptyAgentTiersSource() };
704
+ return { settings: {}, agentTiers: emptyAgentTiersSource() };
827
705
  }
828
706
  try {
829
707
  const raw: unknown = JSON.parse(readFileSync(path, "utf-8"));
830
- return { settings: sanitize(raw), workflow: workflowSource(raw), agentTiers: agentTiersSource(raw) };
708
+ return { settings: sanitize(raw), agentTiers: agentTiersSource(raw) };
831
709
  } catch (err) {
832
710
  const reason = err instanceof Error ? err.message : String(err);
833
711
  console.warn(`[pi-subagents] Ignoring malformed settings at ${path}: ${reason}`);
834
712
  return {
835
713
  settings: {},
836
- workflow: {
837
- ...emptyWorkflowSource(),
838
- present: true,
839
- object: false,
840
- blockedTiers: [...WORKFLOW_TIER_NAMES],
841
- blockedDefaultTier: true,
842
- },
843
714
  // An unreadable file may have held the tier catalogue; block rather than
844
715
  // fall through to whatever global happens to define.
845
716
  agentTiers: { ...emptyAgentTiersSource(), present: true, object: false, blockedDefaultTier: true },
@@ -848,7 +719,7 @@ function readSettingsFile(path: string): ReadSettings {
848
719
  }
849
720
 
850
721
  /**
851
- * Load merged settings: global provides defaults, project overrides. Workflow
722
+ * Load merged settings: global provides defaults, project overrides. Agent-tier
852
723
  * profiles are complete replacement units: a project entry never inherits one
853
724
  * field from the global entry, and an invalid project entry blocks that global
854
725
  * entry instead of silently reviving it.
@@ -856,65 +727,16 @@ function readSettingsFile(path: string): ReadSettings {
856
727
  export function loadSettings(cwd: string = process.cwd()): SubagentsSettings {
857
728
  const global = readSettingsFile(globalPath());
858
729
  const project = readSettingsFile(projectPath(cwd));
859
- const { workflow: _globalWorkflow, agentTiers: _globalAgentTiers, ...globalSettings } = global.settings;
860
- const { workflow: _projectWorkflow, agentTiers: _projectAgentTiers, ...projectSettings } = project.settings;
861
- const workflow = mergeWorkflowSources(global.workflow, project.workflow);
730
+ const { agentTiers: _globalAgentTiers, ...globalSettings } = global.settings;
731
+ const { agentTiers: _projectAgentTiers, ...projectSettings } = project.settings;
862
732
  const agentTiers = mergeAgentTierSources(global.agentTiers, project.agentTiers);
863
733
  return {
864
734
  ...globalSettings,
865
735
  ...projectSettings,
866
- ...(workflow ? { workflow } : {}),
867
736
  ...(agentTiers ? { agentTiers } : {}),
868
737
  };
869
738
  }
870
739
 
871
- function mergeWorkflowSources(global: WorkflowSource, project: WorkflowSource): WorkflowSettings | undefined {
872
- if (!global.present && !project.present) return undefined;
873
-
874
- const merged: WorkflowSettings = {};
875
- if (project.present && !project.object) {
876
- merged.blockedTiers = [...WORKFLOW_TIER_NAMES];
877
- merged.blockedDefaultTier = true;
878
- return merged;
879
- }
880
- if (project.defaultTierPresent) {
881
- if (project.defaultTier !== undefined) {
882
- merged.defaultTier = project.defaultTier;
883
- } else {
884
- merged.blockedDefaultTier = true;
885
- }
886
- } else {
887
- if (global.defaultTier !== undefined) merged.defaultTier = global.defaultTier;
888
- if (global.blockedDefaultTier || project.blockedDefaultTier) merged.blockedDefaultTier = true;
889
- }
890
-
891
- const blocked = new Set<WorkflowTier>([...global.blockedTiers, ...project.blockedTiers]);
892
- const tiers: Partial<Record<WorkflowTier, WorkflowTierProfile>> = {};
893
- for (const tier of WORKFLOW_TIER_NAMES) {
894
- if (project.tiersPresent && !project.tiersObject) {
895
- blocked.add(tier);
896
- continue;
897
- }
898
- if (project.tiersObject && Object.hasOwn(project.tierEntries, tier)) {
899
- const profile = project.tierEntries[tier];
900
- if (profile) {
901
- tiers[tier] = profile;
902
- blocked.delete(tier);
903
- } else {
904
- blocked.add(tier);
905
- }
906
- continue;
907
- }
908
- if (Object.hasOwn(global.tierEntries, tier)) {
909
- const profile = global.tierEntries[tier];
910
- if (profile) tiers[tier] = profile;
911
- }
912
- }
913
- if (Object.keys(tiers).length > 0) merged.tiers = tiers;
914
- if (blocked.size > 0) merged.blockedTiers = WORKFLOW_TIER_NAMES.filter((tier) => blocked.has(tier));
915
- return merged.defaultTier !== undefined || merged.tiers !== undefined || merged.blockedTiers !== undefined || merged.blockedDefaultTier !== undefined ? merged : undefined;
916
- }
917
-
918
740
  /**
919
741
  * Write project-local settings. Global is never touched from code.
920
742
  * Returns `true` on success, `false` if the write (or mkdir) failed so the
@@ -980,7 +802,6 @@ export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers):
980
802
  if (typeof s.rememberAgents === "boolean") appliers.setRememberAgents(s.rememberAgents);
981
803
  if (s.agentMentions) appliers.setAgentMentions(s.agentMentions);
982
804
  if (typeof s.supervisorQuestions === "boolean") appliers.setSupervisorQuestions(s.supervisorQuestions);
983
- if (s.workflow) appliers.setWorkflow?.(s.workflow);
984
805
  // Applied unconditionally so a session that had tiers and no longer does gets
985
806
  // the empty catalogue rather than keeping the previous one.
986
807
  appliers.setAgentTiers?.(s.agentTiers ?? {});