pi-ultracode 0.1.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/mode.ts ADDED
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Ultracode mode controller.
3
+ *
4
+ * Ultracode is a session-scoped effort mode. While on, it:
5
+ * - raises the thinking level to xhigh (remembering the previous level),
6
+ * - keeps the `workflow` tool active,
7
+ * - injects a standing "author and run a workflow by default" system block on
8
+ * every turn, plus an optional token budget,
9
+ * - persists its on/off + budget state in session custom entries so it survives
10
+ * reload, resume, fork, and compaction.
11
+ */
12
+
13
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
+ import { ULTRACODE_ACTIVE_REMINDER, ULTRACODE_TAGLINE, ultracodeSystemBlock } from "./prompts.ts";
15
+
16
+ export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
17
+
18
+ export const MODE_ENTRY_TYPE = "ultracode-mode";
19
+
20
+ interface PersistedModeState {
21
+ enabled: boolean;
22
+ budgetTotal: number | null;
23
+ previousThinking?: ThinkingLevel;
24
+ }
25
+
26
+ export class UltracodeMode {
27
+ private enabled = false;
28
+ private budgetTotal: number | null = null;
29
+ private previousThinking: ThinkingLevel | undefined;
30
+ /** The level pi actually applied after clamping "xhigh" to the model's capability. */
31
+ private appliedThinking: ThinkingLevel | undefined;
32
+ private readonly workflowToolName: string;
33
+
34
+ constructor(workflowToolName: string) {
35
+ this.workflowToolName = workflowToolName;
36
+ }
37
+
38
+ /** Enable if off, disable if on. Returns the new enabled state. */
39
+ toggle(pi: ExtensionAPI, opts: { budget?: number | null } = {}): boolean {
40
+ if (this.enabled) {
41
+ this.disable(pi);
42
+ return false;
43
+ }
44
+ this.enable(pi, opts);
45
+ return true;
46
+ }
47
+
48
+ /** The thinking level pi actually applied (xhigh, or clamped down for models that lack it). */
49
+ getAppliedThinking(): ThinkingLevel | undefined {
50
+ return this.appliedThinking;
51
+ }
52
+
53
+ /** The effort level to forward to workflow subagents: xhigh when ultracode is
54
+ * on, so each subagent's own session clamps it to THAT subagent model's max
55
+ * (mirroring the parent's "request xhigh, clamp per model" contract). This
56
+ * keeps subagents at their respective model's max even when they run a
57
+ * different model than the parent. Undefined when off, so subagents fall back
58
+ * to the session default. */
59
+ getSubagentThinkingLevel(): ThinkingLevel | undefined {
60
+ return this.enabled ? "xhigh" : undefined;
61
+ }
62
+
63
+ isEnabled(): boolean {
64
+ return this.enabled;
65
+ }
66
+
67
+ getBudget(): number | null {
68
+ return this.budgetTotal;
69
+ }
70
+
71
+ tagline(): string {
72
+ return ULTRACODE_TAGLINE;
73
+ }
74
+
75
+ /** Turn ultracode on. Idempotent. */
76
+ enable(pi: ExtensionAPI, opts: { budget?: number | null } = {}): void {
77
+ if (opts.budget !== undefined) this.budgetTotal = opts.budget;
78
+ if (!this.enabled) {
79
+ this.previousThinking = safeGetThinking(pi);
80
+ this.enabled = true;
81
+ }
82
+ this.applyThinking(pi, "xhigh");
83
+ this.activateWorkflowTool(pi);
84
+ this.persist(pi);
85
+ }
86
+
87
+ /** Turn ultracode off, restoring the previous thinking level. */
88
+ disable(pi: ExtensionAPI): void {
89
+ if (this.enabled && this.previousThinking) this.applyThinking(pi, this.previousThinking);
90
+ this.enabled = false;
91
+ this.persist(pi);
92
+ }
93
+
94
+ setBudget(pi: ExtensionAPI, budget: number | null): void {
95
+ this.budgetTotal = budget;
96
+ this.persist(pi);
97
+ }
98
+
99
+ /** Restore mode state from session entries (called on session_start). */
100
+ restore(pi: ExtensionAPI, entries: Array<{ type?: string; customType?: string; data?: unknown }>): void {
101
+ let latest: PersistedModeState | undefined;
102
+ for (const entry of entries) {
103
+ if (entry.type === "custom" && entry.customType === MODE_ENTRY_TYPE && entry.data) {
104
+ latest = entry.data as PersistedModeState;
105
+ }
106
+ }
107
+ if (!latest) return;
108
+ this.budgetTotal = latest.budgetTotal ?? null;
109
+ this.previousThinking = latest.previousThinking;
110
+ if (latest.enabled) {
111
+ this.enabled = true;
112
+ this.applyThinking(pi, "xhigh");
113
+ this.activateWorkflowTool(pi);
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Build the before_agent_start result: appends the ultracode system block to the
119
+ * turn's system prompt when enabled.
120
+ */
121
+ beforeAgentStart(event: { systemPrompt: string }): { systemPrompt: string } | undefined {
122
+ if (!this.enabled) return undefined;
123
+ const block = ultracodeSystemBlock({ budgetTotal: this.budgetTotal });
124
+ return { systemPrompt: `${event.systemPrompt}\n\n${block}\n\n${ULTRACODE_ACTIVE_REMINDER}` };
125
+ }
126
+
127
+ statusLine(): string {
128
+ if (!this.enabled) return `ultracode: off`;
129
+ const parts = ["ultracode: on"];
130
+ // Show the level that actually applied (xhigh, or whatever the model clamped to).
131
+ if (this.appliedThinking) parts.push(`thinking ${this.appliedThinking}`);
132
+ if (this.budgetTotal) parts.push(`budget ~${formatTokens(this.budgetTotal)}`);
133
+ return parts.join(" · ");
134
+ }
135
+
136
+ private applyThinking(pi: ExtensionAPI, level: ThinkingLevel): void {
137
+ try {
138
+ // pi clamps the requested level down to the model's capability (e.g. "high",
139
+ // or "off" for non-reasoning models); it never throws. Read back what stuck.
140
+ pi.setThinkingLevel(level);
141
+ this.appliedThinking = safeGetThinking(pi) ?? level;
142
+ } catch {
143
+ this.appliedThinking = safeGetThinking(pi);
144
+ }
145
+ }
146
+
147
+ private activateWorkflowTool(pi: ExtensionAPI): void {
148
+ try {
149
+ const active = pi.getActiveTools();
150
+ if (!active.includes(this.workflowToolName)) {
151
+ pi.setActiveTools([...active, this.workflowToolName]);
152
+ }
153
+ } catch {
154
+ // ignore
155
+ }
156
+ }
157
+
158
+ private persist(pi: ExtensionAPI): void {
159
+ const state: PersistedModeState = {
160
+ enabled: this.enabled,
161
+ budgetTotal: this.budgetTotal,
162
+ previousThinking: this.previousThinking,
163
+ };
164
+ try {
165
+ pi.appendEntry(MODE_ENTRY_TYPE, state);
166
+ } catch {
167
+ // ignore persistence failures
168
+ }
169
+ }
170
+ }
171
+
172
+ function safeGetThinking(pi: ExtensionAPI): ThinkingLevel | undefined {
173
+ try {
174
+ return pi.getThinkingLevel() as ThinkingLevel;
175
+ } catch {
176
+ return undefined;
177
+ }
178
+ }
179
+
180
+ function formatTokens(n: number): string {
181
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
182
+ if (n >= 1_000) return `${Math.round(n / 1_000)}k`;
183
+ return String(n);
184
+ }
185
+
186
+ /** Parse a budget token like "500k", "1m", "250000", "+500k". */
187
+ export function parseBudget(input: string): number | null {
188
+ const match = input.trim().match(/^\+?\s*([0-9][0-9_.]*)\s*([kmKM])?$/);
189
+ if (!match) return null;
190
+ const value = Number(match[1].replace(/_/g, ""));
191
+ if (!Number.isFinite(value)) return null;
192
+ const unit = match[2]?.toLowerCase();
193
+ if (unit === "k") return Math.round(value * 1_000);
194
+ if (unit === "m") return Math.round(value * 1_000_000);
195
+ return Math.round(value);
196
+ }
package/src/prompts.ts ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Prompt text for ultracode mode.
3
+ *
4
+ * These strings reproduce the behavioural contract of Claude Code's "ultracode"
5
+ * effort level: a standing opt-in to deterministic multi-agent workflow
6
+ * orchestration, biased toward the most exhaustive, correct answer.
7
+ */
8
+
9
+ /** One-line description shown by `/ultracode status` and the footer. */
10
+ export const ULTRACODE_TAGLINE = "xhigh thinking + dynamic workflow orchestration";
11
+
12
+ /**
13
+ * The standing system-prompt block injected on every turn while ultracode is on.
14
+ * Mirrors the "Ultracode" section of the Workflow tool contract.
15
+ */
16
+ export function ultracodeSystemBlock(opts: { budgetTotal: number | null }): string {
17
+ const budgetLine =
18
+ opts.budgetTotal != null
19
+ ? `\n- Token budget for this session: ~${formatTokens(opts.budgetTotal)} output tokens. Treat it as a hard ceiling — scale workflow depth (fleet size, verification rounds) to fit it, and stop spawning agents as it runs out.`
20
+ : "";
21
+
22
+ return [
23
+ "<ultracode>",
24
+ "Ultracode is ON. This opt-in is standing: author and run a workflow for every substantive task by default.",
25
+ "",
26
+ "The goal is the most exhaustive, correct answer you can produce — token cost is not a constraint.",
27
+ "For multi-phase work (understand → design → implement → review), run several workflows in sequence — one per phase — so you stay in the loop between them, reading each result before deciding the next phase.",
28
+ "",
29
+ "Lean toward orchestrating with the workflow tool and adversarially verifying your findings, unless the work is trivial or already verified. Solo (no workflow) only on conversational turns or trivial mechanical edits.",
30
+ "",
31
+ "Quality patterns to compose as the task calls for it:",
32
+ "- Adversarial verify: spawn N independent skeptics per finding, each prompted to REFUTE it; kill the finding if a majority refute. Stops plausible-but-wrong findings from surviving.",
33
+ "- Perspective-diverse verify: when a finding can fail in more than one way, give each verifier a distinct lens (correctness, security, performance, does-it-reproduce) instead of N identical refuters.",
34
+ "- Multi-modal sweep: parallel agents each searching a different way (by-container, by-content, by-entity, by-time); each is blind to what the others surface.",
35
+ "- Loop-until-dry: for unknown-size discovery (bugs, edge cases), keep spawning finders until K consecutive rounds return nothing new.",
36
+ "- Completeness critic: a final agent that asks \"what's missing — a modality not run, a claim unverified, a source unread?\" What it finds becomes the next round of work.",
37
+ "- No silent caps: if a workflow bounds coverage (top-N, no-retry, sampling), log() what was dropped.",
38
+ "",
39
+ "Scale to the task: \"find any bugs\" → a few finders, single-vote verify; \"thoroughly audit\" / \"be comprehensive\" → larger finder pool, 3–5 vote adversarial pass, a synthesis stage." +
40
+ budgetLine,
41
+ "</ultracode>",
42
+ ].join("\n");
43
+ }
44
+
45
+ /** Short reminder appended to confirm the mode is active (system-reminder style). */
46
+ export const ULTRACODE_ACTIVE_REMINDER =
47
+ "Reminder: ultracode is on — default to authoring and running a workflow for substantive tasks, and adversarially verify your findings.";
48
+
49
+ /** Tool description for the `workflow` tool. */
50
+ export const WORKFLOW_TOOL_DESCRIPTION = [
51
+ "Execute a deterministic JavaScript workflow that orchestrates multiple subagents to be comprehensive (decompose and cover in parallel), confident (independent perspectives and adversarial checks before committing), or to take on scale one context can't hold (migrations, audits, broad sweeps).",
52
+ "`script` is required raw JavaScript. It must begin with `export const meta = { name, description }` (a pure literal) and must call agent() at least once.",
53
+ "Available globals: agent(prompt, opts), parallel(thunks), pipeline(items, ...stages), phase(title), log(message), workflow(nameOrRef, args), args, cwd, budget.",
54
+ ].join(" ");
55
+
56
+ /** One-line snippet for the Available tools section. */
57
+ export const WORKFLOW_PROMPT_SNIPPET =
58
+ "Run a deterministic JS workflow that fans out subagents. Header: export const meta = { name: 'snake_case', description: '...' }. Globals: agent/parallel/pipeline/phase/log/workflow/args/budget.";
59
+
60
+ /** Guideline bullets appended to the Guidelines section when the tool is active. */
61
+ export const WORKFLOW_GUIDELINES: string[] = [
62
+ "Use the workflow tool to decompose-and-cover in parallel, to gather independent perspectives that adversarially verify each other, or to take on scale a single context can't hold. Outside ultracode mode, reserve it for explicit fan-out / multi-agent requests.",
63
+ "For the workflow tool, pass one raw JavaScript string in the required `script` parameter; no Markdown fences, no prose around the script.",
64
+ "For the workflow tool, the script's first statement must be `export const meta = { name: 'short_snake_case', description: 'non-empty human description' }`. meta must be a pure literal: no variables, function calls, spreads, or template interpolation. meta.phases is optional and should mirror your phase() titles.",
65
+ "For the workflow tool, write plain JavaScript after the meta export. No TypeScript syntax, imports, require(), fs, network, Date.now(), Math.random(), or new Date() (they break determinism and resume). Stamp timestamps after the workflow returns; vary randomness by agent index.",
66
+ "For the workflow tool, every workflow must call agent() at least once. Each agent() call should pass a short unique label (2-5 words) so live status and error reporting stay readable.",
67
+ "For the workflow tool, DEFAULT TO pipeline(items, ...stages): items flow through stages independently with no barrier, so item A can be in stage 3 while item B is still in stage 1. Only use parallel() (a barrier that awaits all thunks) when a later stage genuinely needs ALL prior results together (dedup/merge across the full set, early-exit on zero, cross-item comparison).",
68
+ "For the workflow tool, parallel() takes functions, not promises: `await parallel(items.map(item => () => agent('...', { label: '...' })))`, never `await parallel(items.map(item => agent(...)))`. Results are returned in input order; a thunk that throws resolves to null, so .filter(Boolean) before using results.",
69
+ "For the workflow tool, pipeline(items, ...stages) passes each stage (previousValue, originalItem, index). A stage that throws drops that item to null and skips its remaining stages.",
70
+ "For the workflow tool, if agent() needs machine-readable output pass a plain JSON Schema via opts.schema; agent() then returns the validated object. Use JSON Schema, not TypeScript or TypeBox constructors.",
71
+ "For the workflow tool, when agent() is called WITHOUT a schema, its return value is the subagent's final assistant text (the last text the subagent produced). With a schema it returns the validated structured_output object. Prefer a schema for machine-readable results; use the text form only for prose summaries.",
72
+ "For the workflow tool, agent opts also accept: model (override the subagent model by pattern), agentType (use a custom subagent role/system-prompt), isolation:'worktree' (run the agent in an isolated git worktree — use ONLY when agents mutate files in parallel and would conflict), and phase (assign the agent to a progress group explicitly inside parallel()/pipeline()).",
73
+ "For the workflow tool, use budget for dynamic depth: `while (budget.total && budget.remaining() > 50000) { ... }`. Guard the loop on budget.total — with no budget set, remaining() is Infinity and the loop runs to the agent cap.",
74
+ "For the workflow tool, workflow(nameOrRef, args) runs a saved workflow (by name) or a scriptPath inline as a sub-step, sharing this run's concurrency cap, agent counter, and token budget. Nesting is one level only.",
75
+ "For the workflow tool, failed agent()/parallel()/pipeline() branches return null and log the failure (unless the whole run is aborted). Check for nulls before synthesizing conclusions, and prefer a final synthesis/assertion agent that returns a compact JSON-serializable verdict.",
76
+ "For the workflow tool, do not assume subagents share the parent's repository context; include enough task context and relevant file paths in each agent prompt.",
77
+ ];
78
+
79
+ function formatTokens(n: number): string {
80
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
81
+ if (n >= 1_000) return `${Math.round(n / 1_000)}k`;
82
+ return String(n);
83
+ }
@@ -0,0 +1,329 @@
1
+ /**
2
+ * In-memory subagent runner.
3
+ *
4
+ * Each agent() call in a workflow spins up a fresh in-memory Pi session with the
5
+ * standard coding tools (and a terminating structured_output tool when a schema
6
+ * is given), runs one prompt to completion, and returns the result plus token usage.
7
+ *
8
+ * Supports per-call model overrides, custom agent types (role system-prompt + tool
9
+ * allowlist), and an alternate cwd for git-worktree isolation.
10
+ */
11
+
12
+ import {
13
+ createAgentSession,
14
+ createCodingTools,
15
+ getAgentDir,
16
+ SessionManager,
17
+ SettingsManager,
18
+ type ToolDefinition,
19
+ } from "@earendil-works/pi-coding-agent";
20
+ import type { AssistantMessage, TextContent } from "@earendil-works/pi-ai";
21
+ import type { Static, TSchema } from "typebox";
22
+ import { createStructuredOutputTool, type StructuredOutputCapture } from "./structured-output.ts";
23
+ import { jsonSchemaToTypeBox } from "./json-schema.ts";
24
+ import type { AgentTypeDef } from "./agent-types.ts";
25
+
26
+ export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
27
+
28
+ /** A minimal structural view of a Pi model (avoids importing the heavy generic type). */
29
+ export interface ModelLike {
30
+ provider: string;
31
+ id: string;
32
+ name?: string;
33
+ }
34
+
35
+ export interface ModelRegistryLike {
36
+ getAvailable(): ModelLike[];
37
+ getAll?(): ModelLike[];
38
+ }
39
+
40
+ export interface AgentUsage {
41
+ outputTokens: number;
42
+ totalTokens: number;
43
+ cost: number;
44
+ }
45
+
46
+ export interface AgentRunResult {
47
+ value: unknown;
48
+ usage: AgentUsage;
49
+ /** cwd the agent actually ran in (differs from the shared cwd under worktree isolation). */
50
+ cwd: string;
51
+ }
52
+
53
+ export interface WorkflowAgentRunnerOptions {
54
+ cwd: string;
55
+ modelRegistry?: ModelRegistryLike;
56
+ /** Default model used when an agent() call does not override it. */
57
+ model?: ModelLike;
58
+ /** Default thinking level for subagents. */
59
+ thinkingLevel?: ThinkingLevel;
60
+ }
61
+
62
+ /** Normalized activity signal forwarded from inside a running subagent. */
63
+ export interface AgentActivityInput {
64
+ kind: "text" | "thinking" | "tool";
65
+ detail?: string;
66
+ }
67
+
68
+ export interface AgentRunCall {
69
+ prompt: string;
70
+ label: string;
71
+ schema?: unknown;
72
+ instructions?: string;
73
+ signal?: AbortSignal;
74
+ /** Resolved model pattern (e.g. "sonnet" or "anthropic/...:high"). */
75
+ modelPattern?: string;
76
+ agentTypeDef?: AgentTypeDef;
77
+ /** Override cwd (worktree). */
78
+ cwd?: string;
79
+ /** Live activity stream from the subagent (text deltas / tool calls). */
80
+ onActivity?: (event: AgentActivityInput) => void;
81
+ }
82
+
83
+ export class WorkflowAgentRunner {
84
+ private readonly baseCwd: string;
85
+ private readonly modelRegistry?: ModelRegistryLike;
86
+ private readonly defaultModel?: ModelLike;
87
+ private readonly defaultThinking?: ThinkingLevel;
88
+
89
+ constructor(options: WorkflowAgentRunnerOptions) {
90
+ this.baseCwd = options.cwd;
91
+ this.modelRegistry = options.modelRegistry;
92
+ this.defaultModel = options.model;
93
+ this.defaultThinking = options.thinkingLevel;
94
+ }
95
+
96
+ async run(call: AgentRunCall): Promise<AgentRunResult> {
97
+ if (call.signal?.aborted) throw new Error("Subagent was aborted");
98
+
99
+ const cwd = call.cwd ?? this.baseCwd;
100
+ const capture: StructuredOutputCapture<any> = { called: false, value: undefined };
101
+
102
+ const customTools: ToolDefinition[] = [...createCodingTools(cwd)];
103
+ let toolAllowlist: string[] | undefined = call.agentTypeDef?.tools
104
+ ? [...call.agentTypeDef.tools]
105
+ : undefined;
106
+
107
+ let schemaTSchema: TSchema | undefined;
108
+ if (call.schema) {
109
+ schemaTSchema = jsonSchemaToTypeBox(call.schema);
110
+ customTools.push(
111
+ createStructuredOutputTool({ schema: schemaTSchema, capture }) as unknown as ToolDefinition,
112
+ );
113
+ if (toolAllowlist) toolAllowlist.push("structured_output");
114
+ }
115
+
116
+ const { model, thinkingLevel } = this.resolveModel(call.modelPattern, call.agentTypeDef);
117
+
118
+ const agentDir = getAgentDir();
119
+ const { session } = await createAgentSession({
120
+ cwd,
121
+ agentDir,
122
+ sessionManager: SessionManager.inMemory(cwd),
123
+ settingsManager: SettingsManager.create(cwd, agentDir),
124
+ customTools,
125
+ ...(model ? { model: model as any } : {}),
126
+ ...(thinkingLevel ? { thinkingLevel } : {}),
127
+ ...(toolAllowlist ? { tools: toolAllowlist } : {}),
128
+ ...(this.modelRegistry ? { modelRegistry: this.modelRegistry as any } : {}),
129
+ });
130
+
131
+ let removeAbort: (() => void) | undefined;
132
+ let unsubscribe: (() => void) | undefined;
133
+ try {
134
+ if (call.signal) {
135
+ const onAbort = () => void session.abort();
136
+ call.signal.addEventListener("abort", onAbort, { once: true });
137
+ removeAbort = () => call.signal?.removeEventListener("abort", onAbort);
138
+ }
139
+
140
+ // Forward live activity (text deltas / tool calls) so the workflow
141
+ // snapshot can show per-agent progress and detect stuck subagents.
142
+ if (call.onActivity) {
143
+ const onActivity = call.onActivity;
144
+ unsubscribe = session.subscribe((event) => forwardActivity(event, onActivity));
145
+ }
146
+
147
+ await session.prompt(this.buildPrompt(call, Boolean(call.schema)));
148
+ if (call.signal?.aborted) throw new Error("Subagent was aborted");
149
+
150
+ let value: unknown;
151
+ if (call.schema) {
152
+ if (!capture.called) throw new Error("Subagent finished without calling structured_output");
153
+ value = capture.value;
154
+ } else {
155
+ value = lastAssistantText(session.messages as unknown[]);
156
+ }
157
+
158
+ return { value, usage: readUsage(session), cwd };
159
+ } finally {
160
+ removeAbort?.();
161
+ unsubscribe?.();
162
+ session.dispose();
163
+ }
164
+ }
165
+
166
+ private buildPrompt(call: AgentRunCall, structured: boolean): string {
167
+ const role = call.agentTypeDef;
168
+ const parts: Array<string | undefined> = [];
169
+ if (role?.systemPrompt) {
170
+ const header = role.systemPromptMode === "replace" ? `Role (operate strictly as):` : `Role:`;
171
+ parts.push(`${header}\n${role.systemPrompt}`);
172
+ }
173
+ parts.push(call.instructions);
174
+ parts.push(call.label ? `Task label: ${call.label}` : undefined);
175
+ parts.push(call.prompt);
176
+ if (structured) {
177
+ parts.push(
178
+ [
179
+ "Final output contract:",
180
+ "- Your final action MUST be a structured_output tool call.",
181
+ "- The structured_output arguments are the return value of this subagent.",
182
+ "- Do not emit a prose final answer instead of structured_output.",
183
+ ].join("\n"),
184
+ );
185
+ }
186
+ return parts.filter(Boolean).join("\n\n");
187
+ }
188
+
189
+ private resolveModel(
190
+ pattern: string | undefined,
191
+ role: AgentTypeDef | undefined,
192
+ ): { model?: ModelLike; thinkingLevel?: ThinkingLevel } {
193
+ return resolveModelSelection({
194
+ pattern,
195
+ roleModel: role?.model,
196
+ roleThinking: role?.thinking,
197
+ defaultModel: this.defaultModel,
198
+ defaultThinking: this.defaultThinking,
199
+ models: this.modelRegistry?.getAvailable(),
200
+ });
201
+ }
202
+ }
203
+
204
+ const THINKING_LEVELS = new Set<ThinkingLevel>(["off", "minimal", "low", "medium", "high", "xhigh"]);
205
+
206
+ export function splitThinkingSuffix(pattern: string): { base: string; thinking?: ThinkingLevel } {
207
+ const idx = pattern.lastIndexOf(":");
208
+ if (idx === -1) return { base: pattern };
209
+ const raw = pattern.slice(idx + 1).trim();
210
+ const suffix = raw as ThinkingLevel;
211
+ if (THINKING_LEVELS.has(suffix)) return { base: pattern.slice(0, idx).trim(), thinking: suffix };
212
+ // Trailing colon with no/invalid suffix (e.g. "sonnet:"): strip it so the base
213
+ // is still matchable instead of silently falling back to the default model.
214
+ if (raw === "") return { base: pattern.slice(0, idx).trim() };
215
+ return { base: pattern };
216
+ }
217
+
218
+ /** Match a model pattern against a registry list: exact provider/id, then exact id, then substring. */
219
+ export function matchModelIn(models: ModelLike[] | undefined, pattern: string): ModelLike | undefined {
220
+ if (!models) return undefined;
221
+ // An empty pattern must never match: "any-id".includes("") === true would
222
+ // otherwise silently return the FIRST registered model.
223
+ if (!pattern.trim()) return undefined;
224
+ const lower = pattern.trim().toLowerCase();
225
+ const slash = lower.includes("/");
226
+ // 1. exact provider/id, 2. exact id, 3. substring on id / provider/id / name.
227
+ return (
228
+ models.find((m) => `${m.provider}/${m.id}`.toLowerCase() === lower) ??
229
+ models.find((m) => m.id.toLowerCase() === lower) ??
230
+ models.find((m) =>
231
+ slash
232
+ ? `${m.provider}/${m.id}`.toLowerCase().includes(lower)
233
+ : m.id.toLowerCase().includes(lower) || (m.name?.toLowerCase().includes(lower) ?? false),
234
+ )
235
+ );
236
+ }
237
+
238
+ /**
239
+ * Resolve the model + thinking level for an agent() call.
240
+ *
241
+ * `pattern` may carry a thinking suffix like "anthropic/claude:high". A bare
242
+ * ":high" (empty base) means "keep the default model, only override thinking" —
243
+ * it must NOT fall through to matching an empty string, which would silently
244
+ * pick the first registered model (`id.includes("") === true`).
245
+ */
246
+ export function resolveModelSelection(args: {
247
+ pattern?: string;
248
+ roleModel?: string;
249
+ roleThinking?: ThinkingLevel;
250
+ defaultModel?: ModelLike;
251
+ defaultThinking?: ThinkingLevel;
252
+ models?: ModelLike[];
253
+ }): { model?: ModelLike; thinkingLevel?: ThinkingLevel } {
254
+ const { pattern, roleModel, roleThinking, defaultModel, defaultThinking, models } = args;
255
+ const effectivePattern = pattern ?? roleModel;
256
+ if (!effectivePattern) {
257
+ return { model: defaultModel, thinkingLevel: roleThinking ?? defaultThinking };
258
+ }
259
+ const { base, thinking } = splitThinkingSuffix(effectivePattern);
260
+ const model = base ? matchModelIn(models, base) ?? defaultModel : defaultModel;
261
+ return { model, thinkingLevel: thinking ?? roleThinking ?? defaultThinking };
262
+ }
263
+
264
+ function readUsage(session: any): AgentUsage {
265
+ try {
266
+ const stats = session.getSessionStats?.();
267
+ if (stats?.tokens) {
268
+ return {
269
+ outputTokens: stats.tokens.output ?? 0,
270
+ totalTokens: stats.tokens.total ?? 0,
271
+ cost: stats.cost ?? 0,
272
+ };
273
+ }
274
+ } catch {
275
+ // fall through to message-based estimate
276
+ }
277
+ // Fallback: sum assistant usage from messages.
278
+ let output = 0;
279
+ let total = 0;
280
+ let cost = 0;
281
+ for (const message of (session.messages ?? []) as Array<Partial<AssistantMessage>>) {
282
+ if (message?.role === "assistant" && message.usage) {
283
+ output += message.usage.output ?? 0;
284
+ total += message.usage.totalTokens ?? 0;
285
+ cost += message.usage.cost?.total ?? 0;
286
+ }
287
+ }
288
+ return { outputTokens: output, totalTokens: total, cost };
289
+ }
290
+
291
+ function lastAssistantText(messages: unknown[]): string {
292
+ for (let i = messages.length - 1; i >= 0; i--) {
293
+ const message = messages[i] as Partial<AssistantMessage> | undefined;
294
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
295
+ const text = message.content
296
+ .filter((part): part is TextContent => (part as TextContent).type === "text")
297
+ .map((part) => part.text)
298
+ .join("");
299
+ if (text.trim()) return text;
300
+ }
301
+ return "";
302
+ }
303
+
304
+ /**
305
+ * Map a raw AgentSessionEvent into a normalized activity signal and forward it.
306
+ * Defensive: activity forwarding must never break the subagent run.
307
+ */
308
+ export function forwardActivity(event: unknown, onActivity: (e: AgentActivityInput) => void): void {
309
+ try {
310
+ const e = event as any;
311
+ if (e?.type === "message_update") {
312
+ const ame = e.assistantMessageEvent;
313
+ if (!ame) return;
314
+ if (ame.type === "text_delta" && typeof ame.delta === "string") {
315
+ onActivity({ kind: "text", detail: ame.delta });
316
+ } else if (ame.type === "thinking_delta") {
317
+ onActivity({ kind: "thinking" });
318
+ } else if (ame.type === "toolcall_start" && ame.toolName) {
319
+ onActivity({ kind: "tool", detail: String(ame.toolName) });
320
+ }
321
+ return;
322
+ }
323
+ if (e?.type === "tool_execution_start" && e.toolName) {
324
+ onActivity({ kind: "tool", detail: String(e.toolName) });
325
+ }
326
+ } catch {
327
+ // best-effort: never let observability break the run
328
+ }
329
+ }