@jameslovespancakes/pi-plus 1.0.11 → 1.0.12

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.
Files changed (120) hide show
  1. package/README.md +44 -20
  2. package/config/skills/workflow-code-review-actions/SKILL.md +44 -0
  3. package/package.json +6 -3
  4. package/src/core/accounts/oauth-pool.ts +102 -0
  5. package/src/core/accounts/registry.ts +8 -25
  6. package/src/core/accounts/routing.ts +122 -0
  7. package/src/core/anthropic/client-identity.ts +9 -64
  8. package/src/core/anthropic/quota.ts +8 -53
  9. package/src/core/anthropic/routing.ts +40 -115
  10. package/src/core/anthropic/store.ts +7 -9
  11. package/src/core/catalog/quality.ts +33 -15
  12. package/src/core/codex/quota.ts +8 -2
  13. package/src/core/codex/store.ts +12 -9
  14. package/src/core/config.ts +4 -17
  15. package/src/core/store.ts +13 -12
  16. package/src/domains/models/provider-picker.ts +1 -1
  17. package/src/domains/setup/index.ts +23 -23
  18. package/src/domains/subscriptions/accounts-picker.ts +18 -37
  19. package/src/domains/subscriptions/accounts.ts +23 -29
  20. package/src/domains/subscriptions/footer.ts +48 -29
  21. package/src/domains/subscriptions/index.ts +11 -27
  22. package/src/domains/subscriptions/provider.ts +38 -103
  23. package/src/domains/subscriptions/providers/anthropic.ts +5 -17
  24. package/src/domains/subscriptions/providers/codex.ts +171 -4
  25. package/src/domains/subscriptions/providers/hosted.ts +18 -0
  26. package/src/domains/subscriptions/providers/oauth-pool.ts +273 -0
  27. package/src/domains/subscriptions/routing.ts +19 -11
  28. package/src/domains/workflows/LICENSE.md +21 -0
  29. package/src/domains/workflows/index.ts +836 -0
  30. package/src/domains/workflows/runtime/advisory-challenge.ts +75 -0
  31. package/src/domains/workflows/runtime/advisory-evidence.ts +90 -0
  32. package/src/domains/workflows/runtime/advisory-schema.ts +83 -0
  33. package/src/domains/workflows/runtime/agent-attempt.ts +147 -0
  34. package/src/domains/workflows/runtime/agent-limits.ts +43 -0
  35. package/src/domains/workflows/runtime/agent-replay.ts +399 -0
  36. package/src/domains/workflows/runtime/agent-retry.ts +116 -0
  37. package/src/domains/workflows/runtime/agent-runner-types.ts +96 -0
  38. package/src/domains/workflows/runtime/agent-runner.ts +203 -0
  39. package/src/domains/workflows/runtime/agent-session-identity.ts +256 -0
  40. package/src/domains/workflows/runtime/agent-session-providers.ts +50 -0
  41. package/src/domains/workflows/runtime/agent-session.ts +382 -0
  42. package/src/domains/workflows/runtime/agent-skills.ts +270 -0
  43. package/src/domains/workflows/runtime/agent-workspace.ts +103 -0
  44. package/src/domains/workflows/runtime/background-workflow-tool.ts +75 -0
  45. package/src/domains/workflows/runtime/background-workflows.ts +492 -0
  46. package/src/domains/workflows/runtime/budget.ts +53 -0
  47. package/src/domains/workflows/runtime/cancellation.ts +87 -0
  48. package/src/domains/workflows/runtime/command-completions.ts +36 -0
  49. package/src/domains/workflows/runtime/concurrency.ts +403 -0
  50. package/src/domains/workflows/runtime/debug.ts +3 -0
  51. package/src/domains/workflows/runtime/diff-capture.ts +81 -0
  52. package/src/domains/workflows/runtime/discovery.ts +137 -0
  53. package/src/domains/workflows/runtime/dynamax-shortcuts.ts +122 -0
  54. package/src/domains/workflows/runtime/dynamax.ts +330 -0
  55. package/src/domains/workflows/runtime/engine.ts +543 -0
  56. package/src/domains/workflows/runtime/filesystem-error.ts +4 -0
  57. package/src/domains/workflows/runtime/finalizers.ts +66 -0
  58. package/src/domains/workflows/runtime/identity-canonicalization.ts +138 -0
  59. package/src/domains/workflows/runtime/identity-fingerprint.ts +15 -0
  60. package/src/domains/workflows/runtime/inline-workflow.ts +403 -0
  61. package/src/domains/workflows/runtime/journal.ts +313 -0
  62. package/src/domains/workflows/runtime/model-profiles.ts +310 -0
  63. package/src/domains/workflows/runtime/options.ts +157 -0
  64. package/src/domains/workflows/runtime/perf.ts +146 -0
  65. package/src/domains/workflows/runtime/pi-compat.ts +32 -0
  66. package/src/domains/workflows/runtime/process-runner.ts +260 -0
  67. package/src/domains/workflows/runtime/progress-types.ts +59 -0
  68. package/src/domains/workflows/runtime/progress.ts +400 -0
  69. package/src/domains/workflows/runtime/provider-usage-limit.ts +191 -0
  70. package/src/domains/workflows/runtime/replay-path-identity.ts +48 -0
  71. package/src/domains/workflows/runtime/research-contract.ts +89 -0
  72. package/src/domains/workflows/runtime/research-evidence.ts +289 -0
  73. package/src/domains/workflows/runtime/resume-context.ts +751 -0
  74. package/src/domains/workflows/runtime/review/code-review-orchestration.ts +24 -0
  75. package/src/domains/workflows/runtime/review/github-pr-comments.ts +249 -0
  76. package/src/domains/workflows/runtime/review/patch-validation.ts +120 -0
  77. package/src/domains/workflows/runtime/review/review-actions.ts +180 -0
  78. package/src/domains/workflows/runtime/review/review-budget.ts +45 -0
  79. package/src/domains/workflows/runtime/review/review-fix-workflow.ts +153 -0
  80. package/src/domains/workflows/runtime/review/review-format.ts +133 -0
  81. package/src/domains/workflows/runtime/review/review-handoff.ts +38 -0
  82. package/src/domains/workflows/runtime/review/review-issues.ts +87 -0
  83. package/src/domains/workflows/runtime/review/review-report.ts +42 -0
  84. package/src/domains/workflows/runtime/review/review-results-flow.ts +52 -0
  85. package/src/domains/workflows/runtime/review/review-results-viewer.ts +312 -0
  86. package/src/domains/workflows/runtime/review/review-session-coordinator.ts +153 -0
  87. package/src/domains/workflows/runtime/review/review-snapshot.ts +294 -0
  88. package/src/domains/workflows/runtime/review-diff-target.ts +130 -0
  89. package/src/domains/workflows/runtime/session-identity.ts +6 -0
  90. package/src/domains/workflows/runtime/structured-output.ts +32 -0
  91. package/src/domains/workflows/runtime/tool-capabilities.ts +44 -0
  92. package/src/domains/workflows/runtime/tool-source-identity.ts +150 -0
  93. package/src/domains/workflows/runtime/tree-fingerprint.ts +404 -0
  94. package/src/domains/workflows/runtime/types.ts +255 -0
  95. package/src/domains/workflows/runtime/ui/display-text.ts +13 -0
  96. package/src/domains/workflows/runtime/ui/dynamax-editor-decoration.ts +220 -0
  97. package/src/domains/workflows/runtime/ui/workflow-format.ts +165 -0
  98. package/src/domains/workflows/runtime/ui/workflow-inspector.ts +398 -0
  99. package/src/domains/workflows/runtime/ui/workflow-result-renderer.ts +171 -0
  100. package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +51 -0
  101. package/src/domains/workflows/runtime/ui/workflow-widget.ts +78 -0
  102. package/src/domains/workflows/runtime/unknown-error.ts +8 -0
  103. package/src/domains/workflows/runtime/usage.ts +341 -0
  104. package/src/domains/workflows/runtime/workflow-advisory-utils.ts +245 -0
  105. package/src/domains/workflows/runtime/workflow-execution.ts +121 -0
  106. package/src/domains/workflows/runtime/workflow-module.ts +75 -0
  107. package/src/domains/workflows/runtime/workflow-run-background.ts +72 -0
  108. package/src/domains/workflows/runtime/workflow-run-controller.ts +342 -0
  109. package/src/domains/workflows/runtime/workflow-run-history.ts +162 -0
  110. package/src/domains/workflows/runtime/workflow-run-record.ts +635 -0
  111. package/src/domains/workflows/runtime/workflow-run-store.ts +176 -0
  112. package/src/domains/workflows/runtime/workflow-usage-limit-scheduler.ts +80 -0
  113. package/src/domains/workflows/runtime/workflows.ts +40 -0
  114. package/src/domains/workflows/runtime/worktree.ts +581 -0
  115. package/src/domains/workflows/workflows/code-review.ts +232 -0
  116. package/src/domains/workflows/workflows/diagnose.ts +154 -0
  117. package/src/domains/workflows/workflows/perf-review.ts +149 -0
  118. package/src/domains/workflows/workflows/refactor-scout.ts +143 -0
  119. package/src/domains/workflows/workflows/research.ts +169 -0
  120. package/src/services/usage-service.ts +6 -30
@@ -0,0 +1,313 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, readdir, readFile, rm, stat, appendFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, join } from "node:path";
5
+ import type { AgentOptions } from "./types.ts";
6
+ import type { WorktreeBaseline } from "./worktree.ts";
7
+ import { canonicalizeIdentity } from "./identity-canonicalization.ts";
8
+ import { unknownErrorMessage } from "./unknown-error.ts";
9
+ import {
10
+ isAgentResumeContext,
11
+ resumeContextMismatchReason,
12
+ type AgentResumeContext,
13
+ type ResumeContextComparisonOptions,
14
+ } from "./resume-context.ts";
15
+
16
+ export const WORKFLOW_RUNS_DIR = join(".pi", ".workflow-runs");
17
+
18
+ /**
19
+ * Run records and replay journals live outside the project by default so they
20
+ * never clutter a working directory. Override with PI_WORKFLOW_RUNS_DIR, or set
21
+ * it to "project" to restore the legacy `<cwd>/.pi/.workflow-runs` layout.
22
+ */
23
+ export function workflowRunsDir(cwd: string): string {
24
+ const configured = process.env.PI_WORKFLOW_RUNS_DIR?.trim();
25
+ if (configured === "project") return join(cwd, WORKFLOW_RUNS_DIR);
26
+ const root = configured && configured.length > 0
27
+ ? configured
28
+ : join(process.env.PI_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "workflow-runs");
29
+ const slug = basename(cwd).replace(/[^A-Za-z0-9._-]+/g, "-").slice(0, 40) || "project";
30
+ const hash = createHash("sha256").update(cwd).digest("hex").slice(0, 8);
31
+ return join(root, `${slug}-${hash}`);
32
+ }
33
+ export const WORKFLOW_JOURNAL_KEEP = 50;
34
+
35
+ export interface JournalEntryV2 {
36
+ readonly version: 2;
37
+ readonly key: string;
38
+ readonly result: unknown;
39
+ readonly identity: AgentResumeContext;
40
+ }
41
+
42
+ /** Parsed so journals written before effective-session identity can fail closed without aborting resume. */
43
+ export interface LegacyJournalEntryV2 {
44
+ readonly version: 2;
45
+ readonly key: string;
46
+ readonly result: unknown;
47
+ readonly identity: unknown;
48
+ }
49
+
50
+ /** Parsed only so resume can explain why an older entry is not replayed. */
51
+ export interface LegacyJournalEntryV1 {
52
+ readonly version?: 1;
53
+ readonly key: string;
54
+ readonly value: unknown;
55
+ readonly context?: unknown;
56
+ }
57
+
58
+ export type JournalEntry = LegacyJournalEntryV2 | LegacyJournalEntryV1;
59
+
60
+ export type JournalLookup = { readonly hit: true; readonly value: unknown } | { readonly hit: false; readonly reason?: string };
61
+ export type JournalRecordResult = { readonly ok: true } | { readonly ok: false; readonly error: string };
62
+
63
+ export type AgentJournalKeyCapture =
64
+ | { readonly kind: "verified"; readonly key: string }
65
+ | { readonly kind: "unverifiable"; readonly reason: string };
66
+
67
+ export interface WorkflowJournal {
68
+ lookup(key: string, identity: AgentResumeContext, options?: ResumeContextComparisonOptions): JournalLookup;
69
+ record(key: string, result: unknown, identity: AgentResumeContext): Promise<JournalRecordResult>;
70
+ }
71
+
72
+ export class WorkflowJournalLoadError extends Error {
73
+ override readonly name = "WorkflowJournalLoadError";
74
+ }
75
+
76
+ export function createWorkflowRunId(): string {
77
+ return randomUUID();
78
+ }
79
+
80
+ export function workflowJournalPath(cwd: string, runId: string): string {
81
+ return join(workflowRunsDir(cwd), `${validateWorkflowRunId(runId)}.jsonl`);
82
+ }
83
+
84
+ export function agentJournalKey(prompt: string, opts: AgentOptions = {}, worktreeBaseline?: WorktreeBaseline): string {
85
+ const capture = captureAgentJournalKey(prompt, opts, worktreeBaseline);
86
+ return capture.kind === "verified" ? capture.key : `agent:unverifiable:${randomUUID()}`;
87
+ }
88
+
89
+ export function hashAgentCall(prompt: string, opts: AgentOptions = {}, worktreeBaseline?: WorktreeBaseline): string {
90
+ const capture = captureAgentCallHash(prompt, opts, worktreeBaseline);
91
+ return capture.kind === "verified"
92
+ ? capture.hash
93
+ : createHash("sha256").update(`unverifiable:${randomUUID()}`).digest("hex");
94
+ }
95
+
96
+ /** Capture a replay key without allowing hostile or oversized schemas to escape as exceptions. */
97
+ export function captureAgentJournalKey(
98
+ prompt: string,
99
+ opts: AgentOptions = {},
100
+ worktreeBaseline?: WorktreeBaseline,
101
+ ): AgentJournalKeyCapture {
102
+ const behavior = captureAgentCallHash(prompt, opts, worktreeBaseline);
103
+ if (behavior.kind === "unverifiable") return behavior;
104
+ let cacheKey: string | undefined;
105
+ try {
106
+ cacheKey = opts.cacheKey?.trim();
107
+ } catch {
108
+ return { kind: "unverifiable", reason: "agent cache key could not be inspected" };
109
+ }
110
+ if (!cacheKey) return { kind: "verified", key: `agent:${behavior.hash}` };
111
+ const cacheKeyHash = createHash("sha256").update(cacheKey).digest("hex");
112
+ return { kind: "verified", key: `agent:${cacheKeyHash}:${behavior.hash}` };
113
+ }
114
+
115
+ type AgentCallHashCapture =
116
+ | { readonly kind: "verified"; readonly hash: string }
117
+ | { readonly kind: "unverifiable"; readonly reason: string };
118
+
119
+ function captureAgentCallHash(
120
+ prompt: string,
121
+ opts: AgentOptions,
122
+ worktreeBaseline: WorktreeBaseline | undefined,
123
+ ): AgentCallHashCapture {
124
+ try {
125
+ const behavior = {
126
+ prompt,
127
+ opts: {
128
+ thinkingLevel: opts.thinkingLevel,
129
+ tools: opts.tools,
130
+ toolHints: opts.toolHints,
131
+ requireToolHints: opts.requireToolHints === true ? true : undefined,
132
+ skills: opts.skills,
133
+ schema: opts.schema,
134
+ isolation: opts.isolation,
135
+ candidatePatch: opts.candidatePatch
136
+ ? {
137
+ baselineOid: opts.candidatePatch.baselineOid,
138
+ hash: createHash("sha256").update(opts.candidatePatch.patch).digest("hex"),
139
+ }
140
+ : undefined,
141
+ worktreeBaseline: worktreeBaseline
142
+ ? {
143
+ ref: worktreeBaseline.ref,
144
+ patchFingerprint: createHash("sha256").update(worktreeBaseline.patch ?? "").digest("hex"),
145
+ }
146
+ : undefined,
147
+ },
148
+ };
149
+ const canonical = canonicalizeIdentity(behavior);
150
+ return canonical.kind === "verified"
151
+ ? { kind: "verified", hash: createHash("sha256").update(canonical.value).digest("hex") }
152
+ : canonical;
153
+ } catch {
154
+ return { kind: "unverifiable", reason: "agent replay inputs could not be inspected" };
155
+ }
156
+ }
157
+
158
+ export async function loadJournalEntries(path: string, options: { readonly required?: boolean } = {}): Promise<JournalEntry[]> {
159
+ let content: string;
160
+ try {
161
+ content = await readFile(path, "utf8");
162
+ } catch (error) {
163
+ if (options.required) {
164
+ throw new WorkflowJournalLoadError(`Could not load workflow resume journal at ${path}: ${unknownErrorMessage(error)}`);
165
+ }
166
+ return [];
167
+ }
168
+
169
+ const entries: JournalEntry[] = [];
170
+ let lineNumber = 0;
171
+ for (const line of content.split(/\r?\n/)) {
172
+ lineNumber += 1;
173
+ if (line.trim() === "") continue;
174
+ try {
175
+ const parsed = JSON.parse(line) as unknown;
176
+ if (isJournalEntry(parsed)) {
177
+ entries.push(parsed);
178
+ continue;
179
+ }
180
+ if (options.required) {
181
+ throw new WorkflowJournalLoadError(`Workflow resume journal at ${path}:${lineNumber} has an incompatible entry format.`);
182
+ }
183
+ } catch (error) {
184
+ if (options.required) {
185
+ if (error instanceof WorkflowJournalLoadError) throw error;
186
+ throw new WorkflowJournalLoadError(`Workflow resume journal at ${path}:${lineNumber} is not valid JSON.`);
187
+ }
188
+ }
189
+ }
190
+ return entries;
191
+ }
192
+
193
+ export async function createWorkflowJournal(options: {
194
+ readonly resumePath?: string;
195
+ readonly writePath: string;
196
+ }): Promise<WorkflowJournal> {
197
+ const priorEntries = options.resumePath ? await loadJournalEntries(options.resumePath, { required: true }) : [];
198
+ await mkdir(dirname(options.writePath), { recursive: true });
199
+ await appendFile(options.writePath, "", "utf8");
200
+ return createMemoryBackedJournal(priorEntries, options.writePath);
201
+ }
202
+
203
+ export function createMemoryBackedJournal(priorEntries: readonly JournalEntry[] = [], writePath?: string): WorkflowJournal {
204
+ const priorByKey = new Map<string, JournalEntry[]>();
205
+ for (const entry of priorEntries) {
206
+ const entries = priorByKey.get(entry.key) ?? [];
207
+ entries.push(entry);
208
+ priorByKey.set(entry.key, entries);
209
+ }
210
+
211
+ return {
212
+ lookup(key, identity, options) {
213
+ const entries = priorByKey.get(key);
214
+ if (!entries || entries.length === 0) return { hit: false };
215
+
216
+ const current = entries.filter(
217
+ (entry): entry is JournalEntryV2 => entry.version === 2 && isAgentResumeContext(entry.identity),
218
+ );
219
+ if (current.length === 0) {
220
+ return {
221
+ hit: false,
222
+ reason: entries.some((entry) => entry.version === 2)
223
+ ? "journal entry predates effective replay identity"
224
+ : "journal entry predates replay contract v2",
225
+ };
226
+ }
227
+ const matches = current.filter(
228
+ (entry) => resumeContextMismatchReason(entry.identity, identity, options) === undefined,
229
+ );
230
+ if (matches.length === 1) return { hit: true, value: matches[0]!.result };
231
+ if (matches.length > 1) return { hit: false, reason: "multiple cached entries match this agent call" };
232
+ return { hit: false, reason: resumeContextMismatchReason(current[0]!.identity, identity, options) };
233
+ },
234
+ async record(key, result, identity) {
235
+ const entry: JournalEntryV2 = { version: 2, key, result, identity };
236
+ if (writePath) {
237
+ try {
238
+ await mkdir(dirname(writePath), { recursive: true });
239
+ await appendFile(writePath, `${JSON.stringify(entry)}\n`, "utf8");
240
+ } catch (error) {
241
+ return { ok: false, error: unknownErrorMessage(error) };
242
+ }
243
+ }
244
+ return { ok: true };
245
+ },
246
+ };
247
+ }
248
+
249
+ export async function pruneWorkflowJournals(cwd: string, keep = WORKFLOW_JOURNAL_KEEP): Promise<void> {
250
+ const dir = workflowRunsDir(cwd);
251
+ let files: string[];
252
+ try {
253
+ files = await readdir(dir);
254
+ } catch {
255
+ return;
256
+ }
257
+
258
+ const candidates = await Promise.all(
259
+ files
260
+ .filter((file) => file.endsWith(".jsonl"))
261
+ .map(async (file) => {
262
+ const path = join(dir, file);
263
+ try {
264
+ const info = await stat(path);
265
+ return { path, mtimeMs: info.mtimeMs };
266
+ } catch {
267
+ return undefined;
268
+ }
269
+ }),
270
+ );
271
+
272
+ const stale = candidates
273
+ .filter((entry): entry is { readonly path: string; readonly mtimeMs: number } => entry !== undefined)
274
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
275
+ .slice(Math.max(0, keep));
276
+
277
+ await Promise.all(stale.map((entry) => rm(entry.path, { force: true }).catch(() => undefined)));
278
+ }
279
+
280
+ export function validateWorkflowRunId(runId: string): string {
281
+ const trimmed = runId.trim();
282
+ if (!/^[A-Za-z0-9_.-]+$/.test(trimmed)) {
283
+ throw new Error("Workflow run id must contain only letters, numbers, dots, underscores, or hyphens.");
284
+ }
285
+ return trimmed;
286
+ }
287
+
288
+ function isJournalEntry(value: unknown): value is JournalEntry {
289
+ if (typeof value !== "object" || value === null) return false;
290
+ const entry = value as {
291
+ readonly version?: unknown;
292
+ readonly key?: unknown;
293
+ readonly result?: unknown;
294
+ readonly identity?: unknown;
295
+ readonly value?: unknown;
296
+ readonly context?: unknown;
297
+ };
298
+ if (entry.version === 2) {
299
+ return typeof entry.key === "string" && "result" in entry && "identity" in entry;
300
+ }
301
+ return (
302
+ (entry.version === undefined || entry.version === 1) &&
303
+ typeof entry.key === "string" &&
304
+ "value" in entry &&
305
+ (entry.context === undefined || isLegacyAgentResumeContext(entry.context))
306
+ );
307
+ }
308
+
309
+ function isLegacyAgentResumeContext(value: unknown): boolean {
310
+ if (typeof value !== "object" || value === null) return false;
311
+ const context = value as { readonly repository?: unknown; readonly workflow?: unknown; readonly model?: unknown };
312
+ return "repository" in context && "workflow" in context && "model" in context;
313
+ }
@@ -0,0 +1,310 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import type { Api, Model } from "@earendil-works/pi-ai";
5
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
6
+ import { getAgentDir, type ModelRegistry } from "@earendil-works/pi-coding-agent";
7
+ import { isMissingPathError } from "./filesystem-error.ts";
8
+ import { unknownErrorMessage } from "./unknown-error.ts";
9
+
10
+ export const WORKFLOW_MODEL_PROFILE_NAMES = ["small", "medium", "big"] as const;
11
+ export type WorkflowModelProfileName = (typeof WORKFLOW_MODEL_PROFILE_NAMES)[number];
12
+
13
+ export interface WorkflowModelProfileConfig {
14
+ readonly model: string;
15
+ readonly thinkingLevel?: ThinkingLevel;
16
+ }
17
+
18
+ export interface WorkflowModelProfileFile {
19
+ readonly profiles: Partial<Record<WorkflowModelProfileName, WorkflowModelProfileConfig>>;
20
+ }
21
+
22
+ export interface WorkflowModelProfilePaths {
23
+ readonly user: string;
24
+ readonly project: string;
25
+ }
26
+
27
+ interface ResolvedWorkflowModelProfileBase {
28
+ readonly name: WorkflowModelProfileName;
29
+ readonly model: Model<Api> | undefined;
30
+ readonly thinkingLevel: ThinkingLevel | undefined;
31
+ }
32
+
33
+ export type ResolvedWorkflowModelProfile = ResolvedWorkflowModelProfileBase & (
34
+ | { readonly source: "host" }
35
+ | { readonly source: "project" | "user"; readonly configPath: string }
36
+ );
37
+
38
+ export type ResolvedWorkflowModelProfiles = Readonly<Record<WorkflowModelProfileName, ResolvedWorkflowModelProfile>>;
39
+
40
+ export interface WorkflowModelRouteRequest {
41
+ readonly model?: string;
42
+ readonly thinkingLevel?: ThinkingLevel;
43
+ readonly profile?: WorkflowModelProfileName;
44
+ }
45
+
46
+ export class WorkflowModelProfileConfigError extends Error {
47
+ override readonly name = "WorkflowModelProfileConfigError";
48
+ readonly code = "WORKFLOW_MODEL_PROFILE_CONFIG";
49
+
50
+ constructor(
51
+ message: string,
52
+ readonly configPath: string,
53
+ readonly profile?: WorkflowModelProfileName,
54
+ ) {
55
+ super(message);
56
+ }
57
+ }
58
+
59
+ export const WORKFLOW_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const satisfies readonly ThinkingLevel[];
60
+ const THINKING_LEVELS = new Set<ThinkingLevel>(WORKFLOW_THINKING_LEVELS);
61
+ const HOST_FALLBACK_THINKING: Record<WorkflowModelProfileName, ThinkingLevel> = {
62
+ small: "low",
63
+ medium: "medium",
64
+ big: "high",
65
+ };
66
+ const CONFIG_FILE_NAME = "workflow-models.json";
67
+
68
+ export function workflowModelProfilePaths(
69
+ cwd: string,
70
+ agentDir: string = getAgentDir(),
71
+ ): WorkflowModelProfilePaths {
72
+ return {
73
+ user: join(agentDir, CONFIG_FILE_NAME),
74
+ project: join(cwd, ".pi", CONFIG_FILE_NAME),
75
+ };
76
+ }
77
+
78
+ export function resolveWorkflowModelProfiles(input: {
79
+ readonly cwd: string;
80
+ readonly modelRegistry: Pick<ModelRegistry, "find">;
81
+ readonly hostModel: Model<Api> | undefined;
82
+ readonly paths?: WorkflowModelProfilePaths;
83
+ }): ResolvedWorkflowModelProfiles {
84
+ const paths = input.paths ?? workflowModelProfilePaths(input.cwd);
85
+ const user = readWorkflowModelProfileFile(paths.user);
86
+ const project = readWorkflowModelProfileFile(paths.project);
87
+ const resolved = { ...hostWorkflowModelProfiles(input.hostModel) };
88
+
89
+ for (const name of WORKFLOW_MODEL_PROFILE_NAMES) {
90
+ const projectProfile = project.profiles[name];
91
+ const configured = projectProfile ?? user.profiles[name];
92
+ if (!configured) continue;
93
+
94
+ const source = projectProfile ? "project" : "user";
95
+ const configPath = paths[source];
96
+ resolved[name] = {
97
+ name,
98
+ model: resolveConfiguredModel(name, configured.model, input.modelRegistry, configPath),
99
+ thinkingLevel: configured.thinkingLevel,
100
+ source,
101
+ configPath,
102
+ };
103
+ }
104
+
105
+ return resolved;
106
+ }
107
+
108
+ export function hostWorkflowModelProfiles(
109
+ hostModel: Model<Api> | undefined,
110
+ ): ResolvedWorkflowModelProfiles {
111
+ return {
112
+ small: { name: "small", model: hostModel, thinkingLevel: HOST_FALLBACK_THINKING.small, source: "host" },
113
+ medium: { name: "medium", model: hostModel, thinkingLevel: HOST_FALLBACK_THINKING.medium, source: "host" },
114
+ big: { name: "big", model: hostModel, thinkingLevel: HOST_FALLBACK_THINKING.big, source: "host" },
115
+ };
116
+ }
117
+
118
+ export function resolveAgentModelProfile(
119
+ input: {
120
+ readonly request: WorkflowModelRouteRequest;
121
+ readonly profiles: ResolvedWorkflowModelProfiles;
122
+ readonly resolveExplicitModel: (modelRef: string) => Model<Api> | undefined;
123
+ readonly hostModel: Model<Api> | undefined;
124
+ },
125
+ ): { readonly model: Model<Api> | undefined; readonly thinkingLevel: ThinkingLevel | undefined } {
126
+ const profile = input.request.profile === undefined
127
+ ? undefined
128
+ : input.profiles[assertWorkflowModelProfileName(input.request.profile)];
129
+ return {
130
+ model: input.request.model === undefined
131
+ ? (profile?.model ?? input.hostModel)
132
+ : input.resolveExplicitModel(input.request.model),
133
+ thinkingLevel: input.request.thinkingLevel ?? profile?.thinkingLevel,
134
+ };
135
+ }
136
+
137
+ export function readWorkflowModelProfileFile(configPath: string): WorkflowModelProfileFile {
138
+ let contents: string;
139
+ try {
140
+ contents = readFileSync(configPath, "utf8");
141
+ } catch (error) {
142
+ if (isMissingPathError(error)) return { profiles: {} };
143
+ throw new WorkflowModelProfileConfigError(
144
+ `Could not read workflow model profiles at ${configPath}: ${unknownErrorMessage(error)}. Fix the file permissions or remove the file.`,
145
+ configPath,
146
+ );
147
+ }
148
+ let parsed: unknown;
149
+ try {
150
+ parsed = JSON.parse(contents);
151
+ } catch (error) {
152
+ throw new WorkflowModelProfileConfigError(
153
+ `Invalid JSON in workflow model profiles at ${configPath}: ${unknownErrorMessage(error)}. Fix or remove the file.`,
154
+ configPath,
155
+ );
156
+ }
157
+ return parseWorkflowModelProfileFile(parsed, configPath);
158
+ }
159
+
160
+ export function setWorkflowModelProfile(input: {
161
+ readonly configPath: string;
162
+ readonly name: WorkflowModelProfileName;
163
+ readonly profile: WorkflowModelProfileConfig;
164
+ readonly modelRegistry: Pick<ModelRegistry, "find">;
165
+ }): void {
166
+ const current = readWorkflowModelProfileFile(input.configPath);
167
+ const next = { ...current.profiles, [input.name]: input.profile };
168
+ for (const name of WORKFLOW_MODEL_PROFILE_NAMES) {
169
+ const profile = next[name];
170
+ if (profile) resolveConfiguredModel(name, profile.model, input.modelRegistry, input.configPath);
171
+ }
172
+ writeWorkflowModelProfileFile(input.configPath, { profiles: next });
173
+ }
174
+
175
+ export function clearWorkflowModelProfile(configPath: string, name: WorkflowModelProfileName): void {
176
+ const current = readWorkflowModelProfileFile(configPath);
177
+ if (!current.profiles[name]) return;
178
+ const profiles = { ...current.profiles };
179
+ delete profiles[name];
180
+ writeWorkflowModelProfileFile(configPath, { profiles });
181
+ }
182
+
183
+ export function isWorkflowModelProfileName(value: string): value is WorkflowModelProfileName {
184
+ return WORKFLOW_MODEL_PROFILE_NAMES.includes(value as WorkflowModelProfileName);
185
+ }
186
+
187
+ export function isWorkflowThinkingLevel(value: string): value is ThinkingLevel {
188
+ return THINKING_LEVELS.has(value as ThinkingLevel);
189
+ }
190
+
191
+ function parseWorkflowModelProfileFile(value: unknown, configPath: string): WorkflowModelProfileFile {
192
+ if (!isRecord(value)) throw invalidConfig(configPath, "the root must be an object");
193
+ assertOnlyKeys(value, ["profiles"], configPath);
194
+ if (!isRecord(value.profiles)) throw invalidConfig(configPath, '"profiles" must be an object');
195
+
196
+ const profiles: Partial<Record<WorkflowModelProfileName, WorkflowModelProfileConfig>> = {};
197
+ for (const [name, profile] of Object.entries(value.profiles)) {
198
+ if (!isWorkflowModelProfileName(name)) {
199
+ throw invalidConfig(configPath, `unknown profile "${name}"; expected small, medium, or big`);
200
+ }
201
+ if (!isRecord(profile)) throw invalidConfig(configPath, `profile "${name}" must be an object`, name);
202
+ assertOnlyKeys(profile, ["model", "thinkingLevel"], configPath, name);
203
+ if (typeof profile.model !== "string") {
204
+ throw invalidConfig(configPath, `profile "${name}" requires a provider-qualified "model" string`, name);
205
+ }
206
+ assertThinkingLevel(profile.thinkingLevel, configPath, name);
207
+ profiles[name] = {
208
+ model: profile.model,
209
+ ...(profile.thinkingLevel === undefined ? {} : { thinkingLevel: profile.thinkingLevel }),
210
+ };
211
+ }
212
+ return { profiles };
213
+ }
214
+
215
+ function resolveConfiguredModel(
216
+ name: WorkflowModelProfileName,
217
+ modelRef: string,
218
+ modelRegistry: Pick<ModelRegistry, "find">,
219
+ configPath: string,
220
+ ): Model<Api> {
221
+ const slash = modelRef.indexOf("/");
222
+ if (modelRef.trim() !== modelRef || slash <= 0 || slash === modelRef.length - 1) {
223
+ throw invalidConfig(
224
+ configPath,
225
+ `profile "${name}" model must be an exact provider/model identity; received "${modelRef}"`,
226
+ name,
227
+ );
228
+ }
229
+ const provider = modelRef.slice(0, slash);
230
+ const modelId = modelRef.slice(slash + 1);
231
+ if (/\s/.test(provider) || modelId.startsWith("/") || modelId.endsWith("/") || /\s/.test(modelId)) {
232
+ throw invalidConfig(
233
+ configPath,
234
+ `profile "${name}" model must be an exact provider/model identity; received "${modelRef}"`,
235
+ name,
236
+ );
237
+ }
238
+ const model = modelRegistry.find(provider, modelId);
239
+ if (!model) {
240
+ throw invalidConfig(
241
+ configPath,
242
+ `profile "${name}" references unavailable model "${modelRef}"; update ${configPath} with an exact provider/model identity`,
243
+ name,
244
+ );
245
+ }
246
+ return model;
247
+ }
248
+
249
+ function assertWorkflowModelProfileName(value: string): WorkflowModelProfileName {
250
+ if (isWorkflowModelProfileName(value)) return value;
251
+ throw new Error(`Unknown workflow model profile "${value}"; expected small, medium, or big.`);
252
+ }
253
+
254
+ function assertThinkingLevel(
255
+ value: unknown,
256
+ configPath: string,
257
+ profile: WorkflowModelProfileName,
258
+ ): asserts value is ThinkingLevel | undefined {
259
+ if (value === undefined || (typeof value === "string" && isWorkflowThinkingLevel(value))) return;
260
+ throw invalidConfig(
261
+ configPath,
262
+ `profile "${profile}" thinkingLevel must be off, minimal, low, medium, high, xhigh, or max`,
263
+ profile,
264
+ );
265
+ }
266
+
267
+ function assertOnlyKeys(
268
+ value: Record<string, unknown>,
269
+ allowed: readonly string[],
270
+ configPath: string,
271
+ profile?: WorkflowModelProfileName,
272
+ ): void {
273
+ const unexpected = Object.keys(value).find((key) => !allowed.includes(key));
274
+ if (unexpected) throw invalidConfig(configPath, `unexpected key "${unexpected}"`, profile);
275
+ }
276
+
277
+ function writeWorkflowModelProfileFile(configPath: string, config: WorkflowModelProfileFile): void {
278
+ const temporaryPath = `${configPath}.${process.pid}.${randomUUID()}.tmp`;
279
+ try {
280
+ mkdirSync(dirname(configPath), { recursive: true });
281
+ writeFileSync(temporaryPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
282
+ renameSync(temporaryPath, configPath);
283
+ } catch (error) {
284
+ try {
285
+ unlinkSync(temporaryPath);
286
+ } catch {
287
+ // Best effort: preserve the original write error.
288
+ }
289
+ throw new WorkflowModelProfileConfigError(
290
+ `Could not write workflow model profiles at ${configPath}: ${unknownErrorMessage(error)}.`,
291
+ configPath,
292
+ );
293
+ }
294
+ }
295
+
296
+ function invalidConfig(
297
+ configPath: string,
298
+ reason: string,
299
+ profile?: WorkflowModelProfileName,
300
+ ): WorkflowModelProfileConfigError {
301
+ return new WorkflowModelProfileConfigError(
302
+ `Invalid workflow model profile config at ${configPath}: ${reason}.`,
303
+ configPath,
304
+ profile,
305
+ );
306
+ }
307
+
308
+ function isRecord(value: unknown): value is Record<string, unknown> {
309
+ return typeof value === "object" && value !== null && !Array.isArray(value);
310
+ }