@bacnh85/pi-subagent 0.15.2 → 0.16.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.
@@ -13,12 +13,15 @@
13
13
 
14
14
  import type { Model } from "@earendil-works/pi-ai";
15
15
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
16
+ import { splitThinkingSuffix, type SubagentThinkingLevel } from "./roles.ts";
16
17
 
17
18
  export interface ResolvedModel {
18
19
  model: Model<any> | null;
19
20
  attempted: string[];
20
21
  /** The raw candidate name that matched, if a candidate resolved. Undefined for parent fallback. */
21
22
  matchedCandidate?: string;
23
+ /** Thinking level carried by the matched candidate's `:level` suffix, if any. */
24
+ matchedThinking?: SubagentThinkingLevel;
22
25
  }
23
26
 
24
27
  /** Known provider prefixes for unqualified model names. */
@@ -46,19 +49,20 @@ export async function resolveModel(
46
49
  };
47
50
 
48
51
  for (const modelName of [...new Set(modelNames.map((name) => name.trim()).filter(Boolean))]) {
49
- const idx = modelName.indexOf("/");
52
+ const { name: bareName, thinking } = splitThinkingSuffix(modelName);
53
+ const idx = bareName.indexOf("/");
50
54
  if (idx > 0) {
51
- const found = tryAvailable(modelName);
52
- if (found) return { model: found, attempted, matchedCandidate: modelName };
55
+ const found = tryAvailable(bareName);
56
+ if (found) return { model: found, attempted, matchedCandidate: modelName, matchedThinking: thinking };
53
57
  continue;
54
58
  }
55
59
  for (const [provider, pattern] of KNOWN_PROVIDERS) {
56
- if (!pattern.test(modelName)) continue;
57
- const found = tryAvailable(`${provider}/${modelName}`);
58
- if (found) return { model: found, attempted, matchedCandidate: modelName };
60
+ if (!pattern.test(bareName)) continue;
61
+ const found = tryAvailable(`${provider}/${bareName}`);
62
+ if (found) return { model: found, attempted, matchedCandidate: modelName, matchedThinking: thinking };
59
63
  }
60
- const found = tryAvailable(`anthropic/${modelName}`);
61
- if (found) return { model: found, attempted, matchedCandidate: modelName };
64
+ const found = tryAvailable(`anthropic/${bareName}`);
65
+ if (found) return { model: found, attempted, matchedCandidate: modelName, matchedThinking: thinking };
62
66
  }
63
67
 
64
68
  if (parentModel) {
@@ -67,3 +71,85 @@ export async function resolveModel(
67
71
  }
68
72
  return { model: null, attempted };
69
73
  }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Rate-limit model fallback (shared by tool path and service path)
77
+ // ---------------------------------------------------------------------------
78
+
79
+ export type ModelFallbackExhaustReason = "no-model" | "already-tried" | "parent-rate-limited";
80
+
81
+ export interface ModelFallbackOptions<T> {
82
+ candidates: readonly string[];
83
+ parentModel: Model<any> | undefined;
84
+ modelRegistry?: ModelRegistry;
85
+ /** thinking suffix per stripped candidate name (from resolveAgentModelChain). */
86
+ thinkingByCandidate: ReadonlyMap<string, SubagentThinkingLevel>;
87
+ /** Fallback thinking when no candidate carries a `:level` suffix. */
88
+ defaultThinking?: SubagentThinkingLevel;
89
+ runAttempt: (model: Model<any>, thinkingLevel: SubagentThinkingLevel | undefined) => Promise<T>;
90
+ isRateLimited: (result: T) => boolean;
91
+ /** Build the terminal value when all models are exhausted (path-specific error mapping). */
92
+ onExhausted: (reason: ModelFallbackExhaustReason, triedModels: string[], remaining: string[]) => T;
93
+ }
94
+
95
+ /**
96
+ * Retry loop shared by the tool handler (index.ts) and the event-driven
97
+ * service path (service.ts): try candidates in priority order, falling back to
98
+ * the parent model, advancing on rate-limit errors. Single source of truth for
99
+ * `triedModels` bookkeeping and per-candidate `:thinking` resolution.
100
+ */
101
+ export async function runWithModelFallback<T>(options: ModelFallbackOptions<T>): Promise<T> {
102
+ const {
103
+ candidates,
104
+ parentModel,
105
+ modelRegistry,
106
+ thinkingByCandidate,
107
+ defaultThinking,
108
+ runAttempt,
109
+ isRateLimited,
110
+ onExhausted,
111
+ } = options;
112
+ const triedModels: string[] = [];
113
+
114
+ const attempt = async (): Promise<T> => {
115
+ const remaining = candidates.filter((m) => !triedModels.includes(m));
116
+ const isParentFallback = remaining.length === 0;
117
+ const fallbackResolved = await resolveModel(remaining, parentModel, modelRegistry);
118
+ if (!fallbackResolved.model) {
119
+ return onExhausted("no-model", triedModels, remaining);
120
+ }
121
+ const triedName = `${fallbackResolved.model!.provider}/${fallbackResolved.model!.id}`;
122
+ if (triedModels.includes(triedName)) {
123
+ // Already tried this model (e.g., all candidates unavailable
124
+ // and parent fallback) — no further options.
125
+ return onExhausted("already-tried", triedModels, remaining);
126
+ }
127
+ triedModels.push(triedName);
128
+ // Also track the raw candidate name so candidates.filter() can
129
+ // exclude it even when the agent uses unqualified names.
130
+ // Avoid duplicating when candidate name is already qualified (matchedCandidate === triedName).
131
+ if (fallbackResolved.matchedCandidate && fallbackResolved.matchedCandidate !== triedName) {
132
+ triedModels.push(fallbackResolved.matchedCandidate);
133
+ }
134
+
135
+ // The `:thinking` suffix lives on the candidate as written; resolve it from
136
+ // the stripped-name map (matchedCandidate is the raw candidate string).
137
+ const thinkingLevel =
138
+ thinkingByCandidate.get(fallbackResolved.matchedCandidate ?? triedName) ??
139
+ fallbackResolved.matchedThinking ??
140
+ defaultThinking;
141
+
142
+ const result = await runAttempt(fallbackResolved.model, thinkingLevel);
143
+ if (result && isRateLimited(result)) {
144
+ // If the model that just rate-limited was the parent fallback
145
+ // (no remaining candidates), stop — no further options.
146
+ if (isParentFallback) {
147
+ return onExhausted("parent-rate-limited", triedModels, remaining);
148
+ }
149
+ return attempt();
150
+ }
151
+ return result;
152
+ };
153
+
154
+ return attempt();
155
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * `/subagent roles` panel (kernel lives in @bacnh85/pi-config-panel).
3
+ *
4
+ * Row model for role-based model routing: one string row per role (comma
5
+ * chain, blank = reset to default) and one string row per discovered agent
6
+ * (`subagent.agentModels` override, blank = inherit agent file settings).
7
+ * Saving writes both maps to the GLOBAL `~/.pi/agent/settings.json` under
8
+ * `subagent.roles` / `subagent.agentModels` (merge + atomic rename), then
9
+ * invalidates the agent cache.
10
+ */
11
+
12
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { dirname, join } from "node:path";
15
+ import { row } from "@bacnh85/pi-config-panel";
16
+ import type { PanelGroup } from "@bacnh85/pi-config-panel";
17
+ import type { AgentConfig } from "./agents.ts";
18
+ import { DEFAULT_ROLES, readSubagentRoles, type RolesConfig } from "./roles.ts";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Settings persistence (global only — repo .pi/settings.json is read-only)
22
+ // ---------------------------------------------------------------------------
23
+
24
+ function settingsPath(): string {
25
+ const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
26
+ return join(agentDir, "settings.json");
27
+ }
28
+
29
+ /** Read the GLOBAL settings.json. Returns null only when the file is missing;
30
+ * throws when the file exists but is not valid JSON (so a corrupt file can
31
+ * never be silently overwritten by a panel save). */
32
+ function readSettingsJson(): Record<string, unknown> | null {
33
+ const path = settingsPath();
34
+ if (!existsSync(path)) return null;
35
+ const raw = readFileSync(path, "utf8");
36
+ try {
37
+ const parsed = JSON.parse(raw) as unknown;
38
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
39
+ throw new Error(`settings.json root is not a JSON object (${path})`);
40
+ }
41
+ return parsed as Record<string, unknown>;
42
+ } catch (err) {
43
+ if (err instanceof Error && err.message.includes("not a JSON object")) throw err;
44
+ throw new Error(`settings.json is not valid JSON (${path})`);
45
+ }
46
+ }
47
+
48
+ /** Read-modify-write the `subagent` section of the GLOBAL settings.json.
49
+ * Writes a temp file then renames (atomic); never touches other keys.
50
+ * Throws if the existing settings.json is corrupt (the file is left intact).
51
+ * Returns false when nothing changes. */
52
+ export function writeSubagentSection(patch: { roles?: RolesConfig["roles"]; agentModels?: RolesConfig["agentModels"] }): boolean {
53
+ const settings = readSettingsJson() ?? {};
54
+ const existing = (settings.subagent ?? {}) as Record<string, unknown>;
55
+ const subagent = { ...existing };
56
+ if (patch.roles !== undefined) subagent.roles = patch.roles;
57
+ if (patch.agentModels !== undefined) subagent.agentModels = patch.agentModels;
58
+ if (JSON.stringify(subagent) === JSON.stringify(existing)) return false;
59
+ const rolesEmpty = subagent.roles === undefined || (typeof subagent.roles === "object" && Object.keys(subagent.roles as object).length === 0);
60
+ const modelsEmpty = subagent.agentModels === undefined || (typeof subagent.agentModels === "object" && Object.keys(subagent.agentModels as object).length === 0);
61
+ if (rolesEmpty) delete subagent.roles;
62
+ if (modelsEmpty) delete subagent.agentModels;
63
+ // Preserve any unrelated subagent.* keys (forward compat); drop the section
64
+ // only when roles/agentModels were the only content.
65
+ if (Object.keys(subagent).length === 0) delete settings.subagent;
66
+ else settings.subagent = subagent;
67
+ const target = settingsPath();
68
+ mkdirSync(dirname(target), { recursive: true });
69
+ const tmp = `${target}.tmp-${Date.now()}`;
70
+ writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n", { mode: 0o600 });
71
+ renameSync(tmp, target);
72
+ return true;
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Row model — unit-testable without a TUI
77
+ // ---------------------------------------------------------------------------
78
+
79
+ export interface RolesPanelCfg {
80
+ /** Working copy: role name → comma chain ("" = use default). */
81
+ roles: Record<string, string>;
82
+ /** Working copy: agent name → override selector ("" = inherit). */
83
+ agentModels: Record<string, string>;
84
+ }
85
+
86
+ /** Seed a working config from current effective settings + bundled agents. */
87
+ export function buildRolesPanelCfg(agents: AgentConfig[], current: RolesConfig): RolesPanelCfg {
88
+ const roleNames = new Set([...Object.keys(DEFAULT_ROLES), ...Object.keys(current.roles)]);
89
+ const cfg: RolesPanelCfg = { roles: {}, agentModels: {} };
90
+ for (const name of roleNames) {
91
+ // Only show explicitly-configured values; defaults render blank (= default).
92
+ const explicit = (current.roles[name] !== undefined && JSON.stringify(current.roles[name]) !== JSON.stringify(DEFAULT_ROLES[name]))
93
+ ? (Array.isArray(current.roles[name]) ? current.roles[name].join(", ") : String(current.roles[name]))
94
+ : "";
95
+ cfg.roles[name] = explicit;
96
+ }
97
+ for (const agent of agents) cfg.agentModels[agent.name] = current.agentModels[agent.name] ?? "";
98
+ return cfg;
99
+ }
100
+
101
+ /** Build panel groups. Role rows first, then one override row per agent. */
102
+ export function buildRows(cfg: RolesPanelCfg, agents: AgentConfig[]): PanelGroup[] {
103
+ const defaultChain = (name: string) => Array.isArray(DEFAULT_ROLES[name]) ? (DEFAULT_ROLES[name] as string[]).join(", ") : String(DEFAULT_ROLES[name] ?? "");
104
+ const roleRows = Object.keys(cfg.roles).sort().map((name) => {
105
+ // Label shows the default chain so blank is meaningful.
106
+ return row(`role.${name}`, `@${name} (default: ${defaultChain(name) || "none"})`, "string", cfg.roles[name], (v) => {
107
+ cfg.roles[name] = String(v ?? "").trim();
108
+ });
109
+ });
110
+ const agentRows = agents.map((agent) =>
111
+ row(`agent.${agent.name}`, agent.name, "string", cfg.agentModels[agent.name] ?? "", (v) => {
112
+ const value = String(v ?? "").trim();
113
+ if (value) cfg.agentModels[agent.name] = value;
114
+ else delete cfg.agentModels[agent.name];
115
+ }),
116
+ );
117
+ return [
118
+ { key: "roles", label: "Model roles (chain, blank = default)", rows: roleRows },
119
+ { key: "agents", label: "Per-agent overrides (blank = inherit)", rows: agentRows },
120
+ ];
121
+ }
122
+
123
+ /** Convert a working config back to a settings patch. Blank values drop keys
124
+ * (role falls back to default; agent override is removed). */
125
+ export function cfgToPatch(cfg: RolesPanelCfg): { roles: RolesConfig["roles"]; agentModels: RolesConfig["agentModels"] } {
126
+ const roles: RolesConfig["roles"] = {};
127
+ for (const [name, chain] of Object.entries(cfg.roles)) {
128
+ const trimmed = chain.trim();
129
+ if (!trimmed) continue;
130
+ roles[name] = trimmed.includes(",") ? trimmed.split(",").map((s) => s.trim()).filter(Boolean) : trimmed;
131
+ }
132
+ const agentModels: RolesConfig["agentModels"] = {};
133
+ for (const [name, value] of Object.entries(cfg.agentModels)) {
134
+ const trimmed = value.trim();
135
+ if (trimmed) agentModels[name] = trimmed;
136
+ }
137
+ return { roles, agentModels };
138
+ }
139
+
140
+ /** Keep overrides for agents NOT shown in the panel (e.g. overrides for
141
+ * project-local agents saved globally from another project) so a panel save
142
+ * doesn't wipe them. Discovered-agent entries always follow the panel. */
143
+ export function preserveUnknownAgentModels(
144
+ patch: RolesConfig["agentModels"],
145
+ discoveredNames: readonly string[],
146
+ existing: RolesConfig["agentModels"] | undefined,
147
+ ): RolesConfig["agentModels"] {
148
+ if (!existing) return patch;
149
+ const out = { ...patch };
150
+ for (const [name, value] of Object.entries(existing)) {
151
+ if (!discoveredNames.includes(name) && out[name] === undefined) out[name] = value;
152
+ }
153
+ return out;
154
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Role-based model routing for pi-subagent.
3
+ *
4
+ * A role maps a *function* (fast / coder / smart / custom) to an ordered model
5
+ * chain in settings.json under `subagent.roles`. Agent frontmatter references
6
+ * roles via `@role` aliases (`model: @fast`), so remapping a function is one
7
+ * settings edit instead of N agent-file edits.
8
+ *
9
+ * Settings shape (`~/.pi/agent/settings.json`; repo `.pi/settings.json` is a
10
+ * read-only overlay honored when the project is trusted):
11
+ *
12
+ * {
13
+ * "subagent": {
14
+ * "roles": {
15
+ * "fast": "nvidia/openai/gpt-oss-20b, opencode-go/deepseek-v4-flash",
16
+ * "coder": ["zai-coding-cn/glm-5.1", "opencode-go/deepseek-v4-flash"],
17
+ * "smart": "*"
18
+ * },
19
+ * "agentModels": { "reviewer": "@smart:high" }
20
+ * }
21
+ * }
22
+ *
23
+ * - Role value: string (comma chain) or array. `*` / `@default` = parent model.
24
+ * - `@role` entries may reference other roles (visited-set cycle guard).
25
+ * - Unknown roles are skipped and recorded in `unresolved` for diagnostics.
26
+ * - A trailing `:thinking` suffix (off|minimal|low|medium|high|xhigh|max) on
27
+ * any entry overrides the agent's frontmatter thinking for that match.
28
+ * Strict trailing match only — openrouter ids like `model:free` keep intact.
29
+ */
30
+
31
+ import { existsSync, readFileSync } from "node:fs";
32
+ import { homedir } from "node:os";
33
+ import { join } from "node:path";
34
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
35
+ import type { AgentConfig } from "./agents.ts";
36
+
37
+ export type SubagentThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
38
+
39
+ const THINKING_LEVELS: readonly string[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
40
+
41
+ /** Role → raw chain value (string/array, may contain `@role` and `*` refs). */
42
+ export type RoleMap = Record<string, string | string[]>;
43
+
44
+ export interface RolesConfig {
45
+ roles: RoleMap;
46
+ /** Per-agent model override (agent name → selector / role alias / `*`). */
47
+ agentModels: Record<string, string>;
48
+ }
49
+
50
+ export interface ExpandedCandidates {
51
+ /** Concrete candidate names in priority order (may be empty = parent fallback). */
52
+ candidates: string[];
53
+ /** thinking suffix per matched candidate name (name as written, without suffix). */
54
+ thinkingByCandidate: Map<string, SubagentThinkingLevel>;
55
+ /** Unknown role aliases encountered (for diagnostics). */
56
+ unresolved: string[];
57
+ }
58
+
59
+ /** Today's bundled chains, as role defaults — behavior-preserving baseline. */
60
+ export const DEFAULT_ROLES: RoleMap = {
61
+ fast: ["zai-coding-cn/glm-5-turbo", "nvidia/openai/gpt-oss-20b", "opencode-go/deepseek-v4-flash"],
62
+ coder: [
63
+ "zai-coding-cn/glm-5.1",
64
+ "nvidia/mistralai/mistral-small-4-119b-2603",
65
+ "openrouter/nvidia/nemotron-3-super-120b-a12b:free",
66
+ "opencode-go/deepseek-v4-flash",
67
+ ],
68
+ smart: ["zai-coding-cn/glm-5.3", "openrouter/nvidia/nemotron-3-ultra-550b-a55b:free", "opencode-go/deepseek-v4-pro"],
69
+ };
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Settings reading
73
+ // ---------------------------------------------------------------------------
74
+
75
+ function agentDir(): string {
76
+ return process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
77
+ }
78
+
79
+ function readJson(path: string): Record<string, unknown> | null {
80
+ try {
81
+ if (!existsSync(path)) return null;
82
+ return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ function subagentSection(json: Record<string, unknown> | null): Record<string, unknown> {
89
+ const section = json?.subagent;
90
+ return section && typeof section === "object" && !Array.isArray(section)
91
+ ? (section as Record<string, unknown>)
92
+ : {};
93
+ }
94
+
95
+ /** Normalize a role value: comma string → array of trimmed non-empty entries. */
96
+ function normalizeChain(value: string | string[]): string[] {
97
+ const entries = typeof value === "string" ? value.split(",") : value;
98
+ return entries
99
+ .map((entry) => String(entry).trim())
100
+ .filter(Boolean);
101
+ }
102
+
103
+ /** Merge `subagent.roles` / `subagent.agentModels` from a settings JSON into cfg. */
104
+ function mergeSection(cfg: RolesConfig, json: Record<string, unknown> | null): void {
105
+ const section = subagentSection(json);
106
+ const roles = section.roles;
107
+ if (roles && typeof roles === "object" && !Array.isArray(roles)) {
108
+ for (const [name, value] of Object.entries(roles as RoleMap)) {
109
+ if (value === null || value === undefined) continue;
110
+ const chain = normalizeChain(value as string | string[]);
111
+ if (chain.length > 0) cfg.roles[name] = chain;
112
+ }
113
+ }
114
+ const agentModels = section.agentModels;
115
+ if (agentModels && typeof agentModels === "object" && !Array.isArray(agentModels)) {
116
+ for (const [name, value] of Object.entries(agentModels as Record<string, unknown>)) {
117
+ if (typeof value === "string" && value.trim()) cfg.agentModels[name] = value.trim();
118
+ }
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Read effective roles config. Precedence: global settings.json → repo
124
+ * `.pi/settings.json` (project-trusted only) → `(ctx as any).settings` (the
125
+ * SDK-layered view, when present). Later layers win per role/agent key.
126
+ */
127
+ export function readSubagentRoles(ctx?: ExtensionContext): RolesConfig {
128
+ const cfg: RolesConfig = {
129
+ roles: structuredClone(DEFAULT_ROLES),
130
+ agentModels: {},
131
+ };
132
+ mergeSection(cfg, readJson(join(agentDir(), "settings.json")));
133
+ try {
134
+ if (ctx?.isProjectTrusted?.()) {
135
+ mergeSection(cfg, readJson(join(ctx.cwd, ".pi", "settings.json")));
136
+ }
137
+ } catch { /* untrusted or ctx without cwd — global only */ }
138
+ const layered = (ctx as unknown as { settings?: Record<string, unknown> } | undefined)?.settings;
139
+ if (layered) mergeSection(cfg, layered);
140
+ return cfg;
141
+ }
142
+
143
+ /** Global-only variant used by the panel so a save never persists repo
144
+ * `.pi/settings.json` overlay values into the user's global settings. */
145
+ export function readSubagentRolesGlobal(): RolesConfig {
146
+ const cfg: RolesConfig = {
147
+ roles: structuredClone(DEFAULT_ROLES),
148
+ agentModels: {},
149
+ };
150
+ mergeSection(cfg, readJson(join(agentDir(), "settings.json")));
151
+ return cfg;
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Expansion
156
+ // ---------------------------------------------------------------------------
157
+
158
+ /** Split a trailing `:thinking` suffix. Returns null when the trailing segment
159
+ * is not a known level (e.g. openrouter `:free`), leaving the name intact. */
160
+ export function splitThinkingSuffix(name: string): { name: string; thinking?: SubagentThinkingLevel } {
161
+ const idx = name.lastIndexOf(":");
162
+ if (idx <= 0 || idx >= name.length - 1) return { name };
163
+ const suffix = name.slice(idx + 1);
164
+ if (!THINKING_LEVELS.includes(suffix)) return { name };
165
+ return { name: name.slice(0, idx), thinking: suffix as SubagentThinkingLevel };
166
+ }
167
+
168
+ function isParentRef(entry: string): boolean {
169
+ return entry === "*" || entry === "@default";
170
+ }
171
+
172
+ /**
173
+ * Expand a raw candidate list into concrete model names.
174
+ * `@role` entries expand in place (recursively, cycle-safe); `*` / `@default`
175
+ * resolve to an empty list (= parent fallback in resolveModel).
176
+ */
177
+ export function expandModelCandidates(candidates: readonly string[], roles: RoleMap): ExpandedCandidates {
178
+ const out: string[] = [];
179
+ const thinkingByCandidate = new Map<string, SubagentThinkingLevel>();
180
+ const unresolved: string[] = [];
181
+ const seen = new Set<string>();
182
+
183
+ const pushEntry = (entry: string, thinking?: SubagentThinkingLevel): void => {
184
+ const { name, thinking: own } = splitThinkingSuffix(entry);
185
+ if (!name) return;
186
+ if (isParentRef(name)) return; // parent fallback: resolveModel handles the tail
187
+ if (seen.has(name)) {
188
+ // Earlier occurrence wins for thinking too (priority order).
189
+ return;
190
+ }
191
+ seen.add(name);
192
+ out.push(name);
193
+ const level = own ?? thinking;
194
+ if (level) thinkingByCandidate.set(name, level);
195
+ };
196
+
197
+ const walk = (entry: string, visited: Set<string>, inheritedThinking?: SubagentThinkingLevel): void => {
198
+ const raw = entry.trim();
199
+ if (!raw) return;
200
+ const { name: bare, thinking: own } = splitThinkingSuffix(raw);
201
+ if (isParentRef(bare)) return;
202
+ if (bare.startsWith("@")) {
203
+ const roleName = bare.slice(1);
204
+ const value = roles[roleName];
205
+ if (value === undefined || normalizeChain(value).length === 0) {
206
+ if (!unresolved.includes(bare)) unresolved.push(bare);
207
+ return;
208
+ }
209
+ if (visited.has(roleName)) return; // cycle guard
210
+ const next = new Set(visited).add(roleName);
211
+ // A suffix on the role reference ("@smart:high") applies to every model
212
+ // the role expands to; per-entry suffixes win over the inherited one.
213
+ const effective = own ?? inheritedThinking;
214
+ for (const child of normalizeChain(value)) walk(child, next, effective);
215
+ return;
216
+ }
217
+ pushEntry(bare, own ?? inheritedThinking);
218
+ };
219
+
220
+ for (const entry of candidates) walk(entry, new Set());
221
+ return { candidates: out, thinkingByCandidate, unresolved };
222
+ }
223
+
224
+ export interface AgentModelChain {
225
+ candidates: string[];
226
+ thinkingByCandidate: Map<string, SubagentThinkingLevel>;
227
+ unresolved: string[];
228
+ /** True when `subagent.agentModels` replaced the agent's own list. */
229
+ overridden: boolean;
230
+ }
231
+
232
+ /**
233
+ * Effective model chain for an agent: `subagent.agentModels[name]` (if set)
234
+ * replaces the agent's own candidates entirely, then `@role` / `:thinking`
235
+ * entries expand. No settings → bundled defaults → today's exact chains.
236
+ */
237
+ export function resolveAgentModelChain(
238
+ agent: Pick<AgentConfig, "name" | "model" | "models">,
239
+ roles: RolesConfig,
240
+ ): AgentModelChain {
241
+ const override = roles.agentModels[agent.name];
242
+ const raw =
243
+ override !== undefined
244
+ ? [override]
245
+ : [...new Set([agent.model, ...(agent.models ?? [])].filter((model): model is string => Boolean(model)))];
246
+ const expanded = expandModelCandidates(raw, roles.roles);
247
+ return { ...expanded, overridden: override !== undefined };
248
+ }
249
+
250
+ /** Human-readable effective chain for prompt catalog + `/subagent` details. */
251
+ export function describeAgentModels(
252
+ agent: Pick<AgentConfig, "name" | "model" | "models">,
253
+ roles: RolesConfig,
254
+ ): string {
255
+ const raw =
256
+ roles.agentModels[agent.name] ??
257
+ [...new Set([agent.model, ...(agent.models ?? [])].filter((model): model is string => Boolean(model)))].join(", ");
258
+ const { candidates, unresolved } = resolveAgentModelChain(agent, roles);
259
+ const chain = candidates.length > 0 ? `${candidates.join(" → ")} → parent fallback` : "parent fallback";
260
+ const source = roles.agentModels[agent.name] ? "override" : "frontmatter";
261
+ const warn = unresolved.length > 0 ? ` (unresolved: ${unresolved.join(", ")})` : "";
262
+ return `[${source}: ${raw}] ${chain}${warn}`;
263
+ }
@@ -30,6 +30,8 @@ import {
30
30
  classifyStopReason,
31
31
  createCombinedAbortSignal,
32
32
  type SubagentStatus,
33
+ DEFAULT_TIMEOUT_MS,
34
+ HARD_TIMEOUT_MS,
33
35
  } from "./security.ts";
34
36
 
35
37
  // ---------------------------------------------------------------------------
@@ -46,8 +48,10 @@ export interface UsageStats {
46
48
  turns: number;
47
49
  }
48
50
 
49
- export const DEFAULT_INACTIVITY_TIMEOUT_MS = 3 * 60 * 1000;
50
- export const HARD_TIMEOUT_MS = 20 * 60 * 1000;
51
+ /** Re-export from security.ts single source of truth for both timeouts. */
52
+ export const DEFAULT_INACTIVITY_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
53
+ export { HARD_TIMEOUT_MS };
54
+
51
55
 
52
56
  // ---------------------------------------------------------------------------
53
57
  // Extension resource loader
@@ -409,7 +413,9 @@ export function getFinalOutput(messages: Message[]): string {
409
413
  if (part.type === "text" && part.text.trim()) texts.push(part.text);
410
414
  }
411
415
  if (texts.length === 0) continue;
412
- return texts.join("");
416
+ // Join with a newline so interleaved text segments (text around toolCall
417
+ // parts in one message) stay separated instead of gluing "…done.Next step…".
418
+ return texts.join("\n");
413
419
  }
414
420
  return "";
415
421
  }