@signalridge/pi-subagents 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/settings.ts CHANGED
@@ -9,7 +9,41 @@ import type { WorkflowTier } from "@signalridge/pi-subagents-protocol";
9
9
  import { NO_FALLBACK } from "./agent-types.js";
10
10
  import type { JoinMode, ThinkingLevel } from "./types.js";
11
11
 
12
- export type WorkflowThinking = ThinkingLevel | "inherit";
12
+ /** A tier's thinking value: a level, or `inherit` to keep the parent's. */
13
+ export type TierThinking = ThinkingLevel | "inherit";
14
+
15
+ /** Historical name for {@link TierThinking}, kept for the workflow tier types. */
16
+ export type WorkflowThinking = TierThinking;
17
+
18
+ /**
19
+ * One user-named (model, thinking) pair for ordinary subagent spawns.
20
+ *
21
+ * Separate from {@link WorkflowTierProfile} because the key space differs: these
22
+ * names are whatever the user chose, while workflow tiers are the protocol's
23
+ * fixed small/medium/large. `description` is what the host agent reads in the
24
+ * tool description to decide between tiers, so it is prose about the job the
25
+ * tier is for, not about the model.
26
+ */
27
+ export interface AgentTierProfile {
28
+ /** `inherit` keeps the parent model; other values are provider/model references. */
29
+ model: string;
30
+ /** `inherit` keeps the parent thinking level; other values are clamped natively. */
31
+ thinking: TierThinking;
32
+ /** Shown to the host agent. Defaults to the tier key when omitted. */
33
+ description?: string;
34
+ }
35
+
36
+ /** Tier policy for ordinary subagent spawns, resolved by `agent-tiers.ts`. */
37
+ export interface AgentTiersSettings {
38
+ /** Applied when neither the caller nor the agent names a tier. */
39
+ defaultTier?: string;
40
+ /** A configured entry replaces the complete entry inherited from global settings. */
41
+ profiles?: Record<string, AgentTierProfile>;
42
+ /** Tombstones for malformed explicit profiles; not a user policy field. */
43
+ blockedProfiles?: string[];
44
+ /** Tombstone for a malformed defaultTier; not a user policy field. */
45
+ blockedDefaultTier?: boolean;
46
+ }
13
47
 
14
48
  export interface WorkflowTierProfile {
15
49
  /** `inherit` keeps the parent model; other values are provider/model references. */
@@ -42,6 +76,12 @@ export const DEFAULT_WORKFLOW_TIER_PROFILES: Readonly<Record<WorkflowTier, Workf
42
76
  export interface SubagentsSettings {
43
77
  /** Semantic model-plus-thinking profiles used by workflow-owned spawns. */
44
78
  workflow?: WorkflowSettings;
79
+ /**
80
+ * User-named model tiers for ordinary subagent spawns. Independent of
81
+ * `workflow` above: the key space is arbitrary, and `pi-workflows` never reads
82
+ * these. See `agent-tiers.ts` for resolution and precedence.
83
+ */
84
+ agentTiers?: AgentTiersSettings;
45
85
  maxConcurrent?: number;
46
86
  /**
47
87
  * 0 = unlimited — the extension's single source of truth for that convention:
@@ -166,6 +206,8 @@ export interface SettingsAppliers {
166
206
  setFallbackSubagent: (v: string | undefined) => void;
167
207
  /** Optional because non-runtime settings tests and consumers need not apply workflow state. */
168
208
  setWorkflow?: (settings: WorkflowSettings) => void;
209
+ /** Optional for the same reason as `setWorkflow`. */
210
+ setAgentTiers?: (settings: AgentTiersSettings) => void;
169
211
  }
170
212
 
171
213
  /** Emit callback — a subset of `pi.events.emit` to keep helpers testable. */
@@ -184,6 +226,11 @@ const VALID_THINKING_LEVELS: ReadonlySet<string> = new Set([
184
226
  ]);
185
227
  const WORKFLOW_TIER_NAMES: readonly WorkflowTier[] = ["small", "medium", "large"];
186
228
  const MAX_MODEL_REFERENCE_LENGTH = 512;
229
+ /** Mirrors MAX_AGENT_TIER_KEY_LENGTH in agent-tiers.ts; duplicated to keep settings dependency-free. */
230
+ const MAX_AGENT_TIER_KEY_LENGTH = 64;
231
+ /** Bounds a hand-edited config; far above any realistic number of tiers. */
232
+ const MAX_AGENT_TIER_PROFILES = 64;
233
+ const MAX_AGENT_TIER_DESCRIPTION_LENGTH = 512;
187
234
 
188
235
  // Sanity ceilings — prevent hand-edited configs from asking for values that
189
236
  // make no operational sense (e.g. 1e6 concurrent subagents). Permissive enough
@@ -250,6 +297,70 @@ function sanitizeWorkflow(raw: unknown): WorkflowSettings | undefined {
250
297
  return out.defaultTier !== undefined || out.tiers !== undefined || out.blockedTiers !== undefined || out.blockedDefaultTier !== undefined ? out : undefined;
251
298
  }
252
299
 
300
+ function isAgentTierKey(value: unknown): value is string {
301
+ return (
302
+ typeof value === "string" &&
303
+ value.length > 0 &&
304
+ value.length <= MAX_AGENT_TIER_KEY_LENGTH &&
305
+ value.trim() === value &&
306
+ !/\s/u.test(value)
307
+ );
308
+ }
309
+
310
+ /**
311
+ * A profile is all-or-nothing, for the same reason a workflow profile is: half
312
+ * a profile with an implied `inherit` makes a typo look like a policy choice.
313
+ * `description` is the one optional field — it is prose for the tool
314
+ * description, and its absence has an obvious meaning (use the key).
315
+ */
316
+ function sanitizeAgentTierProfile(raw: unknown): AgentTierProfile | undefined {
317
+ if (!isRecord(raw)) return undefined;
318
+ const keys = Object.keys(raw);
319
+ if (keys.some((key) => key !== "model" && key !== "thinking" && key !== "description")) return undefined;
320
+ if (!Object.hasOwn(raw, "model") || !Object.hasOwn(raw, "thinking")) return undefined;
321
+ if (!validWorkflowModelReference(raw.model) || !validThinkingLevel(raw.thinking)) return undefined;
322
+ if (
323
+ Object.hasOwn(raw, "description") &&
324
+ (typeof raw.description !== "string" ||
325
+ raw.description.trim().length === 0 ||
326
+ raw.description.length > MAX_AGENT_TIER_DESCRIPTION_LENGTH)
327
+ ) {
328
+ return undefined;
329
+ }
330
+ return {
331
+ model: raw.model.trim(),
332
+ thinking: raw.thinking,
333
+ ...(typeof raw.description === "string" ? { description: raw.description.trim() } : {}),
334
+ };
335
+ }
336
+
337
+ function sanitizeAgentTiers(raw: unknown): AgentTiersSettings | undefined {
338
+ if (!isRecord(raw)) return undefined;
339
+ const out: AgentTiersSettings = {};
340
+ if (isAgentTierKey(raw.defaultTier)) out.defaultTier = raw.defaultTier;
341
+ if (Object.hasOwn(raw, "defaultTier") && !isAgentTierKey(raw.defaultTier)) out.blockedDefaultTier = true;
342
+ if (raw.blockedDefaultTier === true) out.blockedDefaultTier = true;
343
+ if (Array.isArray(raw.blockedProfiles)) {
344
+ const blocked = [...new Set(raw.blockedProfiles.filter(isAgentTierKey))];
345
+ if (blocked.length > 0) out.blockedProfiles = blocked;
346
+ }
347
+ if (isRecord(raw.profiles)) {
348
+ const profiles: Record<string, AgentTierProfile> = {};
349
+ for (const key of Object.keys(raw.profiles).slice(0, MAX_AGENT_TIER_PROFILES)) {
350
+ if (!isAgentTierKey(key)) continue;
351
+ const profile = sanitizeAgentTierProfile(raw.profiles[key]);
352
+ if (profile) profiles[key] = profile;
353
+ }
354
+ if (Object.keys(profiles).length > 0) out.profiles = profiles;
355
+ }
356
+ return out.defaultTier !== undefined ||
357
+ out.profiles !== undefined ||
358
+ out.blockedProfiles !== undefined ||
359
+ out.blockedDefaultTier !== undefined
360
+ ? out
361
+ : undefined;
362
+ }
363
+
253
364
  function isWorkflowTierValue(value: unknown): value is WorkflowTier {
254
365
  return value === "small" || value === "medium" || value === "large";
255
366
  }
@@ -323,6 +434,8 @@ function sanitize(raw: unknown): SubagentsSettings {
323
434
 
324
435
  const workflow = sanitizeWorkflow(r.workflow);
325
436
  if (workflow) out.workflow = workflow;
437
+ const agentTiers = sanitizeAgentTiers(r.agentTiers);
438
+ if (agentTiers) out.agentTiers = agentTiers;
326
439
  return out;
327
440
  }
328
441
 
@@ -351,9 +464,28 @@ interface WorkflowSource {
351
464
  blockedDefaultTier: boolean;
352
465
  }
353
466
 
467
+ /**
468
+ * Presence of each agent-tier entry in one file, kept separate from the
469
+ * sanitized value so the merge can tell "the project did not mention this
470
+ * profile" from "the project mentioned it and it was malformed". Only the
471
+ * second may block the global entry, and neither may silently revive it.
472
+ */
473
+ interface AgentTiersSource {
474
+ present: boolean;
475
+ object: boolean;
476
+ defaultTierPresent: boolean;
477
+ defaultTier?: string;
478
+ profilesPresent: boolean;
479
+ profilesObject: boolean;
480
+ profileEntries: Record<string, AgentTierProfile | undefined>;
481
+ blockedProfiles: string[];
482
+ blockedDefaultTier: boolean;
483
+ }
484
+
354
485
  interface ReadSettings {
355
486
  settings: SubagentsSettings;
356
487
  workflow: WorkflowSource;
488
+ agentTiers: AgentTiersSource;
357
489
  }
358
490
 
359
491
  function emptyWorkflowSource(): WorkflowSource {
@@ -369,6 +501,116 @@ function emptyWorkflowSource(): WorkflowSource {
369
501
  };
370
502
  }
371
503
 
504
+ function emptyAgentTiersSource(): AgentTiersSource {
505
+ return {
506
+ present: false,
507
+ object: false,
508
+ defaultTierPresent: false,
509
+ blockedDefaultTier: false,
510
+ profilesPresent: false,
511
+ profilesObject: false,
512
+ profileEntries: {},
513
+ blockedProfiles: [],
514
+ };
515
+ }
516
+
517
+ /** Preserve invalid agent-tier entry presence so project policy cannot resurrect a global entry. */
518
+ function agentTiersSource(raw: unknown): AgentTiersSource {
519
+ if (!isRecord(raw) || !Object.hasOwn(raw, "agentTiers")) return emptyAgentTiersSource();
520
+ const value = raw.agentTiers;
521
+ if (!isRecord(value)) {
522
+ return { ...emptyAgentTiersSource(), present: true, object: false, blockedDefaultTier: true };
523
+ }
524
+
525
+ const source: AgentTiersSource = {
526
+ present: true,
527
+ object: true,
528
+ defaultTierPresent: Object.hasOwn(value, "defaultTier"),
529
+ blockedDefaultTier:
530
+ (Object.hasOwn(value, "defaultTier") && !isAgentTierKey(value.defaultTier)) || value.blockedDefaultTier === true,
531
+ ...(isAgentTierKey(value.defaultTier) ? { defaultTier: value.defaultTier } : {}),
532
+ profilesPresent: Object.hasOwn(value, "profiles"),
533
+ profilesObject: isRecord(value.profiles),
534
+ profileEntries: {},
535
+ blockedProfiles: Array.isArray(value.blockedProfiles) ? value.blockedProfiles.filter(isAgentTierKey) : [],
536
+ };
537
+
538
+ if (isRecord(value.profiles)) {
539
+ for (const key of Object.keys(value.profiles).slice(0, MAX_AGENT_TIER_PROFILES)) {
540
+ if (!isAgentTierKey(key)) continue;
541
+ const profile = sanitizeAgentTierProfile(value.profiles[key]);
542
+ source.profileEntries[key] = profile;
543
+ if (!profile) source.blockedProfiles.push(key);
544
+ }
545
+ }
546
+ source.blockedProfiles = [...new Set(source.blockedProfiles)];
547
+ return source;
548
+ }
549
+
550
+ /**
551
+ * Global supplies the catalogue, project edits it. A project profile replaces
552
+ * its global namesake whole — never field by field, which would let a project
553
+ * change a model while inheriting a thinking level nobody chose for that pair.
554
+ */
555
+ function mergeAgentTierSources(
556
+ global: AgentTiersSource,
557
+ project: AgentTiersSource,
558
+ ): AgentTiersSettings | undefined {
559
+ if (!global.present && !project.present) return undefined;
560
+
561
+ const merged: AgentTiersSettings = {};
562
+ if (project.present && !project.object) {
563
+ // The project said "agentTiers" and gave something that is not an object.
564
+ // Inheriting the global catalogue here would run models the project was
565
+ // trying to change, so the whole policy is blocked instead.
566
+ merged.blockedDefaultTier = true;
567
+ const inherited = [
568
+ ...new Set([...Object.keys(global.profileEntries), ...global.blockedProfiles]),
569
+ ].sort((a, b) => a.localeCompare(b));
570
+ if (inherited.length > 0) merged.blockedProfiles = inherited;
571
+ return merged;
572
+ }
573
+
574
+ if (project.defaultTierPresent) {
575
+ if (project.defaultTier !== undefined) merged.defaultTier = project.defaultTier;
576
+ else merged.blockedDefaultTier = true;
577
+ } else {
578
+ if (global.defaultTier !== undefined) merged.defaultTier = global.defaultTier;
579
+ if (global.blockedDefaultTier || project.blockedDefaultTier) merged.blockedDefaultTier = true;
580
+ }
581
+
582
+ const blocked = new Set<string>([...global.blockedProfiles, ...project.blockedProfiles]);
583
+ const profiles: Record<string, AgentTierProfile> = {};
584
+ const keys = [...new Set([...Object.keys(global.profileEntries), ...Object.keys(project.profileEntries)])];
585
+ for (const key of keys) {
586
+ if (project.profilesPresent && !project.profilesObject) {
587
+ blocked.add(key);
588
+ continue;
589
+ }
590
+ if (Object.hasOwn(project.profileEntries, key)) {
591
+ const profile = project.profileEntries[key];
592
+ if (profile) {
593
+ profiles[key] = profile;
594
+ blocked.delete(key);
595
+ } else {
596
+ blocked.add(key);
597
+ }
598
+ continue;
599
+ }
600
+ const inherited = global.profileEntries[key];
601
+ if (inherited) profiles[key] = inherited;
602
+ }
603
+
604
+ if (Object.keys(profiles).length > 0) merged.profiles = profiles;
605
+ if (blocked.size > 0) merged.blockedProfiles = [...blocked].sort((a, b) => a.localeCompare(b));
606
+ return merged.defaultTier !== undefined ||
607
+ merged.profiles !== undefined ||
608
+ merged.blockedProfiles !== undefined ||
609
+ merged.blockedDefaultTier !== undefined
610
+ ? merged
611
+ : undefined;
612
+ }
613
+
372
614
  /** Preserve invalid workflow entry presence so project policy cannot resurrect a global entry. */
373
615
  function workflowSource(raw: unknown): WorkflowSource {
374
616
  if (!isRecord(raw) || !Object.hasOwn(raw, "workflow")) return emptyWorkflowSource();
@@ -415,10 +657,12 @@ function workflowSource(raw: unknown): WorkflowSource {
415
657
  }
416
658
 
417
659
  function readSettingsFile(path: string): ReadSettings {
418
- if (!existsSync(path)) return { settings: {}, workflow: emptyWorkflowSource() };
660
+ if (!existsSync(path)) {
661
+ return { settings: {}, workflow: emptyWorkflowSource(), agentTiers: emptyAgentTiersSource() };
662
+ }
419
663
  try {
420
664
  const raw: unknown = JSON.parse(readFileSync(path, "utf-8"));
421
- return { settings: sanitize(raw), workflow: workflowSource(raw) };
665
+ return { settings: sanitize(raw), workflow: workflowSource(raw), agentTiers: agentTiersSource(raw) };
422
666
  } catch (err) {
423
667
  const reason = err instanceof Error ? err.message : String(err);
424
668
  console.warn(`[pi-subagents] Ignoring malformed settings at ${path}: ${reason}`);
@@ -431,6 +675,9 @@ function readSettingsFile(path: string): ReadSettings {
431
675
  blockedTiers: [...WORKFLOW_TIER_NAMES],
432
676
  blockedDefaultTier: true,
433
677
  },
678
+ // An unreadable file may have held the tier catalogue; block rather than
679
+ // fall through to whatever global happens to define.
680
+ agentTiers: { ...emptyAgentTiersSource(), present: true, object: false, blockedDefaultTier: true },
434
681
  };
435
682
  }
436
683
  }
@@ -444,13 +691,15 @@ function readSettingsFile(path: string): ReadSettings {
444
691
  export function loadSettings(cwd: string = process.cwd()): SubagentsSettings {
445
692
  const global = readSettingsFile(globalPath());
446
693
  const project = readSettingsFile(projectPath(cwd));
447
- const { workflow: _globalWorkflow, ...globalSettings } = global.settings;
448
- const { workflow: _projectWorkflow, ...projectSettings } = project.settings;
694
+ const { workflow: _globalWorkflow, agentTiers: _globalAgentTiers, ...globalSettings } = global.settings;
695
+ const { workflow: _projectWorkflow, agentTiers: _projectAgentTiers, ...projectSettings } = project.settings;
449
696
  const workflow = mergeWorkflowSources(global.workflow, project.workflow);
697
+ const agentTiers = mergeAgentTierSources(global.agentTiers, project.agentTiers);
450
698
  return {
451
699
  ...globalSettings,
452
700
  ...projectSettings,
453
701
  ...(workflow ? { workflow } : {}),
702
+ ...(agentTiers ? { agentTiers } : {}),
454
703
  };
455
704
  }
456
705
 
@@ -533,6 +782,9 @@ export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers):
533
782
  if (typeof s.fleetView === "boolean") appliers.setFleetView(s.fleetView);
534
783
  if (typeof s.outputTranscript === "boolean") appliers.setOutputTranscript(s.outputTranscript);
535
784
  if (s.workflow) appliers.setWorkflow?.(s.workflow);
785
+ // Applied unconditionally so a session that had tiers and no longer does gets
786
+ // the empty catalogue rather than keeping the previous one.
787
+ appliers.setAgentTiers?.(s.agentTiers ?? {});
536
788
  }
537
789
 
538
790
  /**
package/src/types.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  import type { ThinkingLevel } from "@earendil-works/pi-ai";
6
6
  import type { AgentSession } from "@earendil-works/pi-coding-agent";
7
7
  import type { WorkflowTier } from "@signalridge/pi-subagents-protocol";
8
+ import type { AgentTierResolutionSnapshot } from "./agent-tiers.js";
8
9
  import type { LifetimeUsage } from "./usage.js";
9
10
  import type { WorkflowTierResolutionSnapshot } from "./workflow-tiers.js";
10
11
  import type { WorktreeCleanupResult, WorktreeInfo } from "./worktree.js";
@@ -50,6 +51,14 @@ export interface AgentConfig {
50
51
  excludeExtensions?: string[];
51
52
  /** true = inherit all, string[] = only listed, false = none */
52
53
  skills: true | string[] | false;
54
+ /**
55
+ * This agent's default model tier, from `tier:` in its frontmatter. A tier
56
+ * names a (model, thinking) pair in `agentTiers.profiles`; a tier passed at
57
+ * the call site overrides it. When set, it takes precedence over the legacy
58
+ * `model`/`thinking` fields below, which stay only for agents written before
59
+ * tiers existed.
60
+ */
61
+ agentTier?: string;
53
62
  model?: string;
54
63
  thinking?: ThinkingLevel;
55
64
  maxTurns?: number;
@@ -171,6 +180,15 @@ export interface AgentInvocation {
171
180
  tier?: WorkflowTier;
172
181
  /** Immutable model/thinking resolution captured by pi-subagents. */
173
182
  tierSnapshot?: WorkflowTierResolutionSnapshot;
183
+ /**
184
+ * User-named tier applied to an ordinary spawn. Deliberately a separate field
185
+ * from `tier` above: that one is the workflow protocol's small/medium/large
186
+ * union, and widening it to hold arbitrary names would take the protocol's
187
+ * exhaustiveness with it.
188
+ */
189
+ agentTier?: string;
190
+ /** Immutable model/thinking resolution for `agentTier`. */
191
+ agentTierSnapshot?: AgentTierResolutionSnapshot;
174
192
  maxTurns?: number;
175
193
  isolated?: boolean;
176
194
  inheritContext?: boolean;
@@ -188,6 +206,7 @@ export type AgentRecordSnapshot = Omit<Readonly<AgentRecord>, "session" | "abort
188
206
  readonly pendingSteers?: readonly string[];
189
207
  readonly invocation?: Readonly<AgentInvocation> & {
190
208
  readonly tierSnapshot?: Readonly<NonNullable<AgentInvocation["tierSnapshot"]>>;
209
+ readonly agentTierSnapshot?: Readonly<NonNullable<AgentInvocation["agentTierSnapshot"]>>;
191
210
  };
192
211
  };
193
212
 
@@ -234,6 +253,8 @@ export interface ScheduledSubagent {
234
253
  // spawn params (subset of Agent tool params; no inherit_context, no resume)
235
254
  subagent_type: SubagentType;
236
255
  prompt: string;
256
+ /** Model tier key, resolved at fire time against the then-current catalogue. */
257
+ tier?: string;
237
258
  model?: string;
238
259
  thinking?: ThinkingLevel;
239
260
  max_turns?: number;
@@ -177,11 +177,16 @@ export class ConversationViewer implements Component {
177
177
  const fitted = truncateToWidth(pad(content, innerW), innerW, "...", true);
178
178
  return ` ${fitted} `;
179
179
  };
180
- const hrTop = row(th.bold("Agent conversation"));
181
- const hrBot = row("");
180
+ // Two full-width rules, top and bottom, and none in between. The overlay
181
+ // floats over the transcript, so without them there is no telling where the
182
+ // agent's conversation ends and the parent's resumes. An inner rule would be
183
+ // a third horizontal line competing with the two that mark the boundary, and
184
+ // a four-sided box would cost two columns on every row for the same job.
185
+ const rule = () => th.fg("border", "─".repeat(Math.max(0, width)));
182
186
  const hrMid = row("");
183
187
 
184
- lines.push(hrTop);
188
+ lines.push(rule());
189
+ lines.push(row(th.bold("Agent conversation")));
185
190
  const name = getDisplayName(this.record.type);
186
191
  const modeLabel = getPromptModeLabel(this.record.type);
187
192
  const modeTag = modeLabel ? th.fg("dim", `mode ${modeLabel}`) : undefined;
@@ -256,7 +261,7 @@ export class ConversationViewer implements Component {
256
261
  const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
257
262
  lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
258
263
  }
259
- lines.push(hrBot);
264
+ lines.push(rule());
260
265
 
261
266
  return lines;
262
267
  }