pi-workflow-engine 0.6.1 → 0.8.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,11 +1,12 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
  import { Type } from "typebox";
3
- import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
4
- import { Text } from "@earendil-works/pi-tui";
3
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
4
+ import { SelectList, Text, truncateToWidth, type Component, type SelectItem, type SelectListTheme, type TUI } from "@earendil-works/pi-tui";
5
5
  import type { WorkflowProgressSnapshot } from "./src/progress.ts";
6
- import type { WorkflowModule, WorkflowRunOptions } from "./src/types.ts";
6
+ import type { WorkflowModule, WorkflowProgressSource, WorkflowRef, WorkflowRunOptions } from "./src/types.ts";
7
7
  import { WorkflowInspector } from "./src/ui/workflow-inspector.ts";
8
8
  import type { PerfSink, PerfSnapshot } from "./src/perf.ts";
9
+ import { formatWorkflowUsageLine, type WorkflowUsageSnapshot } from "./src/usage.ts";
9
10
  import { registerDynamax } from "./src/dynamax.ts";
10
11
  import { handleReviewViewerAction } from "./src/review/review-actions.ts";
11
12
  import { decideReviewResultsPresentation, extensionContextMode, maybeShowReviewResultsViewer } from "./src/review/review-results-flow.ts";
@@ -21,13 +22,15 @@ function summarize(result: unknown): string {
21
22
  return typeof result === "string" ? result : "Workflow finished.";
22
23
  }
23
24
 
24
- function formatMessageContent(name: string, result: unknown, perf?: WorkflowPerfDetails): string {
25
+ function formatMessageContent(name: string, result: unknown, usage?: WorkflowUsageSnapshot, perf?: WorkflowPerfDetails): string {
26
+ const usageLine = formatWorkflowUsageLine(usage);
25
27
  const perfLine = formatPerfLine(perf);
26
- return `## Workflow: ${name}\n\n${summarize(result)}${perfLine ? `\n\n${perfLine}` : ""}`;
28
+ const details = [usageLine, perfLine].filter((line): line is string => line !== undefined);
29
+ return `## Workflow: ${name}\n\n${summarize(result)}${details.length > 0 ? `\n\n${details.join("\n")}` : ""}`;
27
30
  }
28
31
 
29
- function workflowEnvelope(name: string, result: unknown, perf?: WorkflowPerfDetails): WorkflowResultEnvelope {
30
- return { name, result, completedAt: Date.now(), perf };
32
+ function workflowEnvelope(name: string, result: unknown, usage?: WorkflowUsageSnapshot, perf?: WorkflowPerfDetails): WorkflowResultEnvelope {
33
+ return { name, result, completedAt: Date.now(), usage, perf };
31
34
  }
32
35
 
33
36
  function compactPerfSnapshot(snapshot: PerfSnapshot | undefined): WorkflowPerfDetails | undefined {
@@ -64,7 +67,95 @@ async function createInvocationPerf(options: WorkflowRunOptions): Promise<PerfSi
64
67
  return createPerfRecorder(true);
65
68
  }
66
69
 
67
- const AUTHOR_TEMP_WORKFLOW_CHOICE = "✍ Author temporary one-shot workflow…";
70
+ /**
71
+ * Resolve an `api.workflow()` reference to a registered workflow module. Throws on an unknown name.
72
+ */
73
+ export async function resolveWorkflowRef(ref: WorkflowRef, perf?: PerfSink): Promise<WorkflowModule> {
74
+ const { discoverWorkflows } = await loadDiscovery();
75
+ const workflows = await discoverWorkflows(EXTENSION_DIR, { perf });
76
+ const mod = workflows.get(ref);
77
+ if (!mod) {
78
+ const available = [...workflows.keys()].join(", ") || "(none)";
79
+ throw new Error(`Unknown workflow "${ref}". Available: ${available}`);
80
+ }
81
+ return mod;
82
+ }
83
+
84
+ const AUTHOR_TEMP_WORKFLOW_VALUE = "__author-temporary-workflow__";
85
+ const AUTHOR_TEMP_WORKFLOW_LABEL = "Author temporary one-shot workflow";
86
+ const AUTHOR_TEMP_WORKFLOW_DESCRIPTION = "Ask the host agent to author and run an inline workflow.";
87
+ const WORKFLOW_PICKER_MAX_VISIBLE = 8;
88
+ const WORKFLOW_PICKER_LAYOUT = { minPrimaryColumnWidth: 18, maxPrimaryColumnWidth: 22 };
89
+
90
+ class WorkflowPickerComponent implements Component {
91
+ private readonly list: SelectList;
92
+
93
+ constructor(
94
+ items: SelectItem[],
95
+ private readonly tui: TUI,
96
+ private readonly theme: Theme,
97
+ done: (value: string | undefined) => void,
98
+ ) {
99
+ this.list = new SelectList(items, Math.min(WORKFLOW_PICKER_MAX_VISIBLE, items.length), selectListTheme(theme), WORKFLOW_PICKER_LAYOUT);
100
+ this.list.onSelect = (item) => done(item.value);
101
+ this.list.onCancel = () => done(undefined);
102
+ this.list.onSelectionChange = () => this.tui.requestRender();
103
+ }
104
+
105
+ render(width: number): string[] {
106
+ const safeWidth = Math.max(20, width);
107
+ const rule = this.theme.fg("borderMuted", "-".repeat(safeWidth));
108
+ const lines = [
109
+ rule,
110
+ this.theme.fg("accent", this.theme.bold("Run workflow")),
111
+ "",
112
+ ...this.list.render(safeWidth),
113
+ "",
114
+ this.theme.fg("muted", "up/down navigate enter select escape cancel"),
115
+ rule,
116
+ ];
117
+ return lines.map((line) => truncateToWidth(line, safeWidth));
118
+ }
119
+
120
+ handleInput(data: string): void {
121
+ this.list.handleInput(data);
122
+ this.tui.requestRender();
123
+ }
124
+
125
+ invalidate(): void {
126
+ this.list.invalidate();
127
+ }
128
+ }
129
+
130
+ function selectListTheme(theme: Theme): SelectListTheme {
131
+ return {
132
+ selectedPrefix: (text) => theme.fg("accent", text),
133
+ selectedText: (text) => theme.fg("accent", text),
134
+ description: (text) => theme.fg("muted", text),
135
+ scrollInfo: (text) => theme.fg("muted", text),
136
+ noMatch: (text) => theme.fg("muted", text),
137
+ };
138
+ }
139
+
140
+ function workflowPickerItems(workflows: ReadonlyMap<string, WorkflowModule>): SelectItem[] {
141
+ return [
142
+ { value: AUTHOR_TEMP_WORKFLOW_VALUE, label: AUTHOR_TEMP_WORKFLOW_LABEL, description: AUTHOR_TEMP_WORKFLOW_DESCRIPTION },
143
+ ...[...workflows.values()].map((mod) => ({ value: mod.meta.name, label: mod.meta.name, description: mod.meta.description })),
144
+ ];
145
+ }
146
+
147
+ async function selectWorkflowValue(workflows: ReadonlyMap<string, WorkflowModule>, ctx: ExtensionCommandContext): Promise<string | undefined> {
148
+ const items = workflowPickerItems(workflows);
149
+ if (typeof ctx.ui.custom === "function") {
150
+ return await ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => new WorkflowPickerComponent(items, tui, theme, done));
151
+ }
152
+
153
+ const choice = await ctx.ui.select(
154
+ "Run workflow",
155
+ items.map((item) => item.label),
156
+ );
157
+ return items.find((item) => item.label === choice)?.value ?? choice;
158
+ }
68
159
 
69
160
  export interface LastWorkflowInspection {
70
161
  readonly name: string;
@@ -73,19 +164,53 @@ export interface LastWorkflowInspection {
73
164
  readonly snapshot: WorkflowProgressSnapshot;
74
165
  }
75
166
 
167
+ export interface ActiveWorkflowInspection {
168
+ readonly name: string;
169
+ readonly args: string;
170
+ readonly startedAt: number;
171
+ readonly snapshot: () => WorkflowProgressSnapshot;
172
+ }
173
+
76
174
  let lastWorkflowInspection: LastWorkflowInspection | undefined;
175
+ let activeWorkflowInspection: ActiveWorkflowInspection | undefined;
77
176
 
78
177
  export function getLastWorkflowInspection(): LastWorkflowInspection | undefined {
79
178
  return lastWorkflowInspection;
80
179
  }
81
180
 
82
- export async function openWorkflowInspector(ctx: ExtensionCommandContext, inspection: LastWorkflowInspection): Promise<void> {
181
+ export function getActiveWorkflowInspection(): ActiveWorkflowInspection | undefined {
182
+ return activeWorkflowInspection;
183
+ }
184
+
185
+ export async function openWorkflowInspector(ctx: ExtensionContext, inspection: LastWorkflowInspection | ActiveWorkflowInspection): Promise<void> {
83
186
  await ctx.ui.custom<void>(
84
- (tui, theme, _keybindings, done) => new WorkflowInspector(() => inspection.snapshot, tui, theme, () => done(undefined)),
187
+ (tui, theme, _keybindings, done) => new WorkflowInspector(snapshotGetter(inspection), tui, theme, () => done(undefined)),
85
188
  { overlay: true, overlayOptions: { anchor: "right-center", width: "60%", maxHeight: "80%", margin: 1 } },
86
189
  );
87
190
  }
88
191
 
192
+ async function openAvailableWorkflowInspector(ctx: ExtensionContext): Promise<void> {
193
+ if (!ctx.hasUI) {
194
+ ctx.ui.notify("Workflow inspector requires the TUI", "warning");
195
+ return;
196
+ }
197
+ const inspection = activeWorkflowInspection ?? lastWorkflowInspection;
198
+ if (!inspection) {
199
+ ctx.ui.notify("No workflow inspector is available yet", "warning");
200
+ return;
201
+ }
202
+ await openWorkflowInspector(ctx, inspection);
203
+ }
204
+
205
+ function snapshotGetter(inspection: LastWorkflowInspection | ActiveWorkflowInspection): () => WorkflowProgressSnapshot {
206
+ const snapshot = inspection.snapshot;
207
+ return typeof snapshot === "function" ? snapshot : () => snapshot;
208
+ }
209
+
210
+ function bindActiveWorkflowInspection(name: string, args: string, source: WorkflowProgressSource): ActiveWorkflowInspection {
211
+ return { name, args, startedAt: Date.now(), snapshot: () => source.snapshot() };
212
+ }
213
+
89
214
  export interface WorkflowInvocation {
90
215
  name: string;
91
216
  args: string;
@@ -218,11 +343,10 @@ export async function pickWorkflow(
218
343
  workflows: ReadonlyMap<string, WorkflowModule>,
219
344
  ctx: ExtensionCommandContext,
220
345
  ): Promise<WorkflowInvocation | undefined> {
221
- const choices = [AUTHOR_TEMP_WORKFLOW_CHOICE, ...[...workflows.values()].map((mod) => `${mod.meta.name} — ${mod.meta.description}`)];
222
- const choice = await ctx.ui.select("Run workflow", choices);
346
+ const choice = await selectWorkflowValue(workflows, ctx);
223
347
  if (!choice) return undefined;
224
348
 
225
- if (choice === AUTHOR_TEMP_WORKFLOW_CHOICE) {
349
+ if (choice === AUTHOR_TEMP_WORKFLOW_VALUE || choice === AUTHOR_TEMP_WORKFLOW_LABEL) {
226
350
  const brief = await ctx.ui.editor(
227
351
  "Describe temporary workflow",
228
352
  "Goal:\n\nAgents to run:\n- \n\nFinal output should include:\n- summary\n- findings\n- next steps\n",
@@ -249,14 +373,31 @@ export async function sendWorkflowResult(
249
373
  ): Promise<void> {
250
374
  const { runWorkflow } = await loadEngine();
251
375
  let perfSnapshot: PerfSnapshot | undefined;
376
+ let usageSnapshot: WorkflowUsageSnapshot | undefined;
377
+ let liveInspection: ActiveWorkflowInspection | undefined;
252
378
  const result = await runWorkflow(ctx, mod, args, {
253
379
  ...options,
254
380
  perf: options.perf ?? perfRecorder !== undefined,
255
381
  perfRecorder,
382
+ resolveWorkflow: (ref) => resolveWorkflowRef(ref, perfRecorder),
383
+ onProgressSource(source) {
384
+ if (source) {
385
+ liveInspection = bindActiveWorkflowInspection(name, args, source);
386
+ activeWorkflowInspection = liveInspection;
387
+ } else if (activeWorkflowInspection === liveInspection) {
388
+ activeWorkflowInspection = undefined;
389
+ liveInspection = undefined;
390
+ }
391
+ options.onProgressSource?.(source);
392
+ },
256
393
  onPerfSnapshot(snapshot) {
257
394
  perfSnapshot = snapshot;
258
395
  options.onPerfSnapshot?.(snapshot);
259
396
  },
397
+ onUsageSnapshot(snapshot) {
398
+ usageSnapshot = snapshot;
399
+ options.onUsageSnapshot?.(snapshot);
400
+ },
260
401
  onProgressSnapshot(snapshot) {
261
402
  lastWorkflowInspection = { name, args, completedAt: snapshot.doneAt ?? Date.now(), snapshot };
262
403
  options.onProgressSnapshot?.(snapshot);
@@ -276,38 +417,29 @@ export async function sendWorkflowResult(
276
417
  await handleReviewViewerAction(pi, ctx, reviewAction, reviewDecision.issues, reviewDecision.report.reviewContext);
277
418
  }
278
419
  pi.sendMessage(
279
- { customType: "workflow-result", content: formatMessageContent(name, result, perf), display: true, details: workflowEnvelope(name, result, perf) },
420
+ { customType: "workflow-result", content: formatMessageContent(name, result, usageSnapshot, perf), display: true, details: workflowEnvelope(name, result, usageSnapshot, perf) },
280
421
  { triggerTurn: false },
281
422
  );
282
423
  }
283
424
 
284
425
  export default function workflowEngine(pi: ExtensionAPI): void {
285
- registerDynamax(pi);
426
+ registerDynamax(pi, undefined, { openInspector: openAvailableWorkflowInspector });
286
427
 
287
428
  pi.registerMessageRenderer("workflow-result", (message, { expanded }, theme) => {
288
429
  const details = message.details;
289
- if (isWorkflowResult(details)) return renderWorkflowResult(details.name, details.result, expanded, theme);
430
+ if (isWorkflowResult(details)) return renderWorkflowResult(details.name, details.result, expanded, theme, details.usage);
290
431
  return renderWorkflowResult("workflow", details ?? message.content, expanded, theme);
291
432
  });
292
433
 
293
434
  pi.registerCommand("workflow:inspector", {
294
- description: "Open the last completed workflow inspector",
435
+ description: "Open the current or last workflow inspector",
295
436
  handler: async (args: string, ctx: ExtensionCommandContext) => {
296
437
  const trimmed = args.trim();
297
438
  if (trimmed && trimmed !== "last") {
298
439
  ctx.ui.notify("Usage: /workflow:inspector [last]", "warning");
299
440
  return;
300
441
  }
301
- if (!ctx.hasUI) {
302
- ctx.ui.notify("Workflow inspector requires the TUI", "warning");
303
- return;
304
- }
305
- const inspection = lastWorkflowInspection;
306
- if (!inspection) {
307
- ctx.ui.notify("No completed workflow inspector is available yet", "warning");
308
- return;
309
- }
310
- await openWorkflowInspector(ctx, inspection);
442
+ await openAvailableWorkflowInspector(ctx);
311
443
  },
312
444
  });
313
445
 
@@ -354,6 +486,7 @@ export default function workflowEngine(pi: ExtensionAPI): void {
354
486
  "Use workflow with `name` for existing registered workflows such as code-review, diagnose, refactor-scout, or perf-review.",
355
487
  "Use workflow with `script` for a new one-off inline workflow; the script must start with `export const meta = { ... }` and default-export an async workflow function.",
356
488
  "Inline workflow scripts must use the injected `Type` object for schemas and must not contain imports or dynamic import().",
489
+ "Inline scripts may compose registered workflows in-process via `api.workflow(\"<name>\", args)` (e.g. `await api.workflow(\"code-review\", \"HEAD~3\")`); it returns the sub-workflow's result and nests one level only.",
357
490
  "Every workflow tool call must provide exactly one of `name` or `script`, never both.",
358
491
  ],
359
492
  parameters: Type.Object({
@@ -376,7 +509,7 @@ export default function workflowEngine(pi: ExtensionAPI): void {
376
509
  renderResult(result, { expanded, isPartial }, theme) {
377
510
  if (isPartial) return new Text(theme.fg("accent", "Running workflow…"), 0, 0);
378
511
  const details = result.details;
379
- if (isWorkflowResult(details)) return renderWorkflowResult(details.name, details.result, expanded, theme);
512
+ if (isWorkflowResult(details)) return renderWorkflowResult(details.name, details.result, expanded, theme, details.usage);
380
513
  const first = result.content[0];
381
514
  const text = first?.type === "text" ? first.text : "Workflow finished.";
382
515
  return new Text(theme.fg("muted", text), 0, 0);
@@ -386,6 +519,7 @@ export default function workflowEngine(pi: ExtensionAPI): void {
386
519
  if (request.kind === "error") return invalidWorkflowInvocationResult();
387
520
 
388
521
  const runOptions: WorkflowRunOptions = {
522
+ inspect: ctx.hasUI,
389
523
  concurrency: params.concurrency,
390
524
  parallelSubmissionLimit: params.parallelSubmissionLimit,
391
525
  perf: params.perf,
@@ -421,17 +555,40 @@ export default function workflowEngine(pi: ExtensionAPI): void {
421
555
 
422
556
  const { runWorkflow } = await loadEngine();
423
557
  let perfSnapshot: PerfSnapshot | undefined;
424
- const result = await runWorkflow(ctx, mod, params.args ?? "", {
558
+ let usageSnapshot: WorkflowUsageSnapshot | undefined;
559
+ let liveInspection: ActiveWorkflowInspection | undefined;
560
+ const resultArgs = params.args ?? "";
561
+ const result = await runWorkflow(ctx, mod, resultArgs, {
425
562
  ...runOptions,
426
563
  perf: runOptions.perf ?? perfRecorder !== undefined,
427
564
  perfRecorder,
565
+ resolveWorkflow: (ref) => resolveWorkflowRef(ref, perfRecorder),
566
+ onProgressSource(source) {
567
+ if (source) {
568
+ liveInspection = bindActiveWorkflowInspection(resultName, resultArgs, source);
569
+ activeWorkflowInspection = liveInspection;
570
+ } else if (activeWorkflowInspection === liveInspection) {
571
+ activeWorkflowInspection = undefined;
572
+ liveInspection = undefined;
573
+ }
574
+ },
428
575
  onPerfSnapshot: (snapshot) => {
429
576
  perfSnapshot = snapshot;
430
577
  },
578
+ onUsageSnapshot: (snapshot) => {
579
+ usageSnapshot = snapshot;
580
+ },
581
+ onProgressSnapshot: (snapshot) => {
582
+ // Record the run so /workflow:inspector can reopen it — tool-invoked (dynamax) runs were
583
+ // previously uninspectable, unlike the /workflow command path.
584
+ lastWorkflowInspection = { name: resultName, args: resultArgs, completedAt: snapshot.doneAt ?? Date.now(), snapshot };
585
+ },
431
586
  });
432
587
  const perf = compactPerfSnapshot(perfSnapshot);
588
+ const usageLine = formatWorkflowUsageLine(usageSnapshot);
433
589
  const perfLine = formatPerfLine(perf);
434
- return { content: [{ type: "text", text: `${summarize(result)}${perfLine ? `\n\n${perfLine}` : ""}` }], details: workflowEnvelope(resultName, result, perf) };
590
+ const detailLines = [usageLine, perfLine].filter((line): line is string => line !== undefined);
591
+ return { content: [{ type: "text", text: `${summarize(result)}${detailLines.length > 0 ? `\n\n${detailLines.join("\n")}` : ""}` }], details: workflowEnvelope(resultName, result, usageSnapshot, perf) };
435
592
  },
436
593
  });
437
594
  }
@@ -4,6 +4,7 @@ import type { Api, Model } from "@earendil-works/pi-ai";
4
4
  import type { AgentOptions } from "./types.ts";
5
5
  import type { Semaphore } from "./concurrency.ts";
6
6
  import type { PerfSink } from "./perf.ts";
7
+ import type { WorkflowUsageSink } from "./usage.ts";
7
8
  import { throwIfAborted } from "./cancellation.ts";
8
9
 
9
10
  /** Name of the synthetic terminating tool that carries structured output. */
@@ -42,9 +43,69 @@ export interface RunContext {
42
43
  progress: AgentProgress;
43
44
  signal: AbortSignal | undefined;
44
45
  perf: PerfSink;
46
+ usage: WorkflowUsageSink;
45
47
  createSession?: CreateAgentSession;
46
48
  }
47
49
 
50
+ export interface ResolvedAgentModelRequest {
51
+ readonly ref: string;
52
+ readonly provider: string;
53
+ readonly id: string;
54
+ }
55
+
56
+ export interface ResolvedAgentModel {
57
+ readonly model: Model<Api> | undefined;
58
+ readonly requested: ResolvedAgentModelRequest | undefined;
59
+ }
60
+
61
+ function parseAgentModelRef(modelRef: string): ResolvedAgentModelRequest {
62
+ const normalized = modelRef.trim();
63
+ if (normalized.length === 0) {
64
+ throw new Error('Invalid agent model ref: expected a bare model id or "provider/id".');
65
+ }
66
+ if (normalized !== modelRef) {
67
+ throw new Error(`Invalid agent model ref "${modelRef}": remove leading or trailing whitespace.`);
68
+ }
69
+
70
+ const slash = modelRef.indexOf("/");
71
+ if (slash === -1) {
72
+ return { ref: modelRef, provider: "anthropic", id: modelRef };
73
+ }
74
+
75
+ const provider = modelRef.slice(0, slash);
76
+ const id = modelRef.slice(slash + 1);
77
+ if (provider.length === 0 || id.length === 0 || id.startsWith("/")) {
78
+ throw new Error(`Invalid agent model ref "${modelRef}": expected "provider/id".`);
79
+ }
80
+ return { ref: modelRef, provider, id };
81
+ }
82
+
83
+ /**
84
+ * Resolve a workflow agent model reference.
85
+ *
86
+ * Omitted refs inherit the host/session default model. Explicit refs are strict:
87
+ * bare model ids keep the original Anthropic shorthand for compatibility, and
88
+ * provider-qualified refs split only on the first slash so provider routers such
89
+ * as OpenRouter can use ids that contain additional slashes.
90
+ */
91
+ export function resolveAgentModel(
92
+ modelRef: string | undefined,
93
+ modelRegistry: Pick<ModelRegistry, "find">,
94
+ hostModel: Model<Api> | undefined,
95
+ ): ResolvedAgentModel {
96
+ if (modelRef === undefined) {
97
+ return { model: hostModel, requested: undefined };
98
+ }
99
+
100
+ const parsed = parseAgentModelRef(modelRef);
101
+ const found = modelRegistry.find(parsed.provider, parsed.id);
102
+ if (!found) {
103
+ throw new Error(`Agent model "${modelRef}" not found (resolved as ${parsed.provider}/${parsed.id}).`);
104
+ }
105
+
106
+ return { model: found, requested: parsed };
107
+ }
108
+
48
109
  /** Pull the last assistant message's plain text out of a finished session. */
49
110
  function lastAssistantText(state: { readonly messages: readonly unknown[] }): string {
50
111
  const messages = state.messages;
@@ -124,7 +185,13 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
124
185
  let captured: unknown = null;
125
186
  let failed = false;
126
187
  let session: AgentRunnerSession | undefined;
188
+ let usageRecorded = false;
127
189
  let unsubscribe: (() => void) | undefined;
190
+ const recordUsage = (activeSession: AgentRunnerSession): void => {
191
+ if (usageRecorded) return;
192
+ usageRecorded = true;
193
+ rc.usage.recordAgentSession({ label, phase, messages: activeSession.state.messages });
194
+ };
128
195
  const customTools: ToolDefinition[] = opts.schema
129
196
  ? [
130
197
  defineTool({
@@ -149,8 +216,7 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
149
216
  : opts.tools
150
217
  : undefined;
151
218
 
152
- // Resolve the model by runtime id lookup (no compile-time model-id union); fall back to the host's model.
153
- const model = opts.model ? rc.modelRegistry.find("anthropic", opts.model) ?? rc.hostModel : rc.hostModel;
219
+ const { model } = resolveAgentModel(opts.model, rc.modelRegistry, rc.hostModel);
154
220
  const createSessionForRun = rc.createSession ?? defaultCreateSession;
155
221
 
156
222
  throwIfAborted(rc.signal);
@@ -188,6 +254,7 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
188
254
  } finally {
189
255
  unlinkPromptAbort();
190
256
  }
257
+ recordUsage(activeSession);
191
258
  throwIfAborted(rc.signal);
192
259
 
193
260
  return rc.perf.timeSync(
@@ -213,6 +280,7 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
213
280
  } finally {
214
281
  const disposable = session;
215
282
  if (disposable) {
283
+ recordUsage(disposable);
216
284
  rc.perf.timeSync(
217
285
  "agent.dispose_ms",
218
286
  () => {
@@ -0,0 +1,75 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ import type { KeyId } from "@earendil-works/pi-tui";
5
+
6
+ export const DEFAULT_DYNAMAX_INSPECTOR_SHORTCUT = "ctrl+shift+m" satisfies KeyId;
7
+
8
+ const CONFIG_FILE_NAME = "pi-workflow-engine.json";
9
+
10
+ export interface DynamaxShortcuts {
11
+ inspector: KeyId | null;
12
+ }
13
+
14
+ interface DynamaxShortcutConfig {
15
+ inspector?: unknown;
16
+ }
17
+
18
+ export function dynamaxShortcutsConfigPath(agentDir: string = getAgentDir()): string {
19
+ return join(agentDir, "extensions", CONFIG_FILE_NAME);
20
+ }
21
+
22
+ export function resolveDynamaxShortcuts(configPath: string = dynamaxShortcutsConfigPath()): DynamaxShortcuts {
23
+ if (!existsSync(configPath)) return defaultDynamaxShortcuts();
24
+
25
+ const config = readShortcutConfig(configPath);
26
+ if (!config) return defaultDynamaxShortcuts();
27
+
28
+ return {
29
+ inspector: shortcutFromConfig(config.inspector, DEFAULT_DYNAMAX_INSPECTOR_SHORTCUT),
30
+ };
31
+ }
32
+
33
+ function readShortcutConfig(configPath: string): DynamaxShortcutConfig | null {
34
+ let parsed: unknown;
35
+ try {
36
+ parsed = JSON.parse(readFileSync(configPath, "utf-8"));
37
+ } catch {
38
+ warnUsingDefaults("Could not read", configPath);
39
+ return null;
40
+ }
41
+
42
+ const shortcuts = isRecord(parsed) ? parsed.shortcuts : undefined;
43
+ if (shortcuts === undefined) return {};
44
+ if (!isRecord(shortcuts) || !hasValidShortcutValues(shortcuts)) {
45
+ warnUsingDefaults("Invalid", configPath);
46
+ return null;
47
+ }
48
+
49
+ return shortcuts;
50
+ }
51
+
52
+ function hasValidShortcutValues(shortcuts: Record<string, unknown>): boolean {
53
+ return isValidShortcutConfigValue(shortcuts.inspector);
54
+ }
55
+
56
+ function isValidShortcutConfigValue(value: unknown): value is string | null | undefined {
57
+ return value === undefined || value === null || (typeof value === "string" && value.trim().length > 0);
58
+ }
59
+
60
+ function shortcutFromConfig(configured: unknown, fallback: KeyId): KeyId | null {
61
+ if (configured === null) return null;
62
+ return typeof configured === "string" ? (configured.trim() as KeyId) : fallback;
63
+ }
64
+
65
+ function defaultDynamaxShortcuts(): DynamaxShortcuts {
66
+ return { inspector: DEFAULT_DYNAMAX_INSPECTOR_SHORTCUT };
67
+ }
68
+
69
+ function isRecord(value: unknown): value is Record<string, unknown> {
70
+ return typeof value === "object" && value !== null && !Array.isArray(value);
71
+ }
72
+
73
+ function warnUsingDefaults(reason: "Could not read" | "Invalid", configPath: string): void {
74
+ console.warn(`${reason} pi-workflow-engine config at ${configPath}; using default Dynamax shortcuts.`);
75
+ }