projectinator 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,8 +6,8 @@
6
6
 
7
7
  [![npm](https://img.shields.io/npm/v/projectinator.svg?color=e0a72d&label=npm)](https://www.npmjs.com/package/projectinator)
8
8
  ![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)
9
- ![node](https://img.shields.io/badge/node-%E2%89%A520-brightgreen.svg)
10
- ![tests: 157 passing](https://img.shields.io/badge/tests-157%20passing-brightgreen.svg)
9
+ ![node](https://img.shields.io/badge/node-%E2%89%A522.19-brightgreen.svg)
10
+ ![tests: 181 passing](https://img.shields.io/badge/tests-181%20passing-brightgreen.svg)
11
11
  ![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6.svg)
12
12
  ![built on Pi](https://img.shields.io/badge/built%20on-Pi%20agent%20harness-e0a72d.svg)
13
13
 
@@ -22,7 +22,7 @@ watch it happen from a terminal cockpit: a live board, budget bar, and a standup
22
22
 
23
23
  Built on the [Pi](https://pi.dev) agent harness (Node/TypeScript). Bring your own API key.
24
24
 
25
- **Install & run** (Node ≥ 20):
25
+ **Install & run** (Node ≥ 22.19):
26
26
 
27
27
  ```bash
28
28
  npx projectinator # run without installing
@@ -127,7 +127,7 @@ npm run build -- --live --lock anthropic "idea" # full pipeline on one provide
127
127
  npm run build -- --live --mini --resume # resume a halted/finished build (skips done tasks)
128
128
  npm run build -- --live --mini --task-cap 0.5 --task-timeout 5 # per-task limits (USD / minutes)
129
129
  npm run bakeoff -- --capability design "Design a pricing page" # model bake-off
130
- npm test # 157 tests
130
+ npm test # 181 tests
131
131
  npm run typecheck
132
132
  ```
133
133
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectinator",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Your AI build team in the terminal — hand it an app idea, a PM model plans a Scrum backlog, and the best model per role designs, codes, and tests it into working files. Bring your own API key.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -36,7 +36,7 @@
36
36
  "src"
37
37
  ],
38
38
  "engines": {
39
- "node": ">=20"
39
+ "node": ">=22.19.0"
40
40
  },
41
41
  "scripts": {
42
42
  "start": "tsx src/tui.tsx",
@@ -61,13 +61,13 @@
61
61
  "vitest": "^2.1.8"
62
62
  },
63
63
  "dependencies": {
64
- "@earendil-works/pi-coding-agent": "0.80.7",
64
+ "@earendil-works/pi-coding-agent": "0.85.1",
65
65
  "@inkjs/ui": "2.0.0",
66
66
  "ink": "7.1.0",
67
67
  "ink-spinner": "5.0.0",
68
68
  "playwright": "1.61.1",
69
69
  "react": "19.2.7",
70
70
  "tsx": "4.23.1",
71
- "typebox": "1.1.38"
71
+ "typebox": "1.3.7"
72
72
  }
73
73
  }
package/src/bakeoff.ts CHANGED
@@ -7,15 +7,13 @@
7
7
  // scoring) is a later step.
8
8
 
9
9
  import {
10
- AuthStorage,
11
- ModelRegistry,
12
10
  createAgentSession,
13
11
  defineTool,
14
12
  type AgentSession,
15
13
  } from "@earendil-works/pi-coding-agent";
16
14
  import { Type, type Static } from "typebox";
17
15
  import type { Capability, Difficulty, Provider, Task } from "./types.js";
18
- import { resolvePiModel } from "./executor.js";
16
+ import { piRuntime, resolvePiModel } from "./executor.js"
19
17
  import { buildRolePrompt } from "./roles.js";
20
18
  import { estimateTokens } from "./estimate.js";
21
19
  import { addSessionCost } from "./session-cost.js";
@@ -72,13 +70,11 @@ const id = (c: Candidate) => `${c.provider}/${c.model}`;
72
70
  async function runCandidate(task: Task, cand: Candidate): Promise<BakeoffEntry> {
73
71
  const base: BakeoffEntry = { provider: cand.provider, model: cand.model, output: "", cost: 0, ms: 0, outputTokens: 0 };
74
72
  try {
75
- const authStorage = AuthStorage.create();
76
- const registry = ModelRegistry.create(authStorage);
77
- const model = resolvePiModel(registry, cand.provider, cand.model);
73
+ const runtime = await piRuntime();
74
+ const model = resolvePiModel(runtime, cand.provider, cand.model);
78
75
  const { session } = await createAgentSession({
79
76
  model,
80
- authStorage,
81
- modelRegistry: registry,
77
+ modelRuntime: runtime,
82
78
  thinkingLevel: "medium",
83
79
  noTools: "all",
84
80
  });
@@ -144,14 +140,12 @@ async function judge(task: Task, entries: BakeoffEntry[], judgeCand: Candidate):
144
140
 
145
141
  const letters = scored.map((_, i) => String.fromCharCode(65 + i)); // A, B, C…
146
142
  const blocks = scored.map((e, i) => `### Option ${letters[i]}\n${e.output}`).join("\n\n");
147
- const authStorage = AuthStorage.create();
148
- const registry = ModelRegistry.create(authStorage);
149
- const model = resolvePiModel(registry, judgeCand.provider, judgeCand.model);
143
+ const runtime = await piRuntime();
144
+ const model = resolvePiModel(runtime, judgeCand.provider, judgeCand.model);
150
145
  const { tool, get } = buildJudgeTool();
151
146
  const { session } = await createAgentSession({
152
147
  model,
153
- authStorage,
154
- modelRegistry: registry,
148
+ modelRuntime: runtime,
155
149
  thinkingLevel: "medium",
156
150
  noTools: "all",
157
151
  customTools: [tool],
package/src/council.ts CHANGED
@@ -4,8 +4,6 @@
4
4
  // via the normal decomposer seeded with these epics.
5
5
 
6
6
  import {
7
- AuthStorage,
8
- ModelRegistry,
9
7
  createAgentSession,
10
8
  defineTool,
11
9
  type AgentSession,
@@ -13,7 +11,7 @@ import {
13
11
  import { Type, type Static } from "typebox";
14
12
  import type { Backend, Provider } from "./types.js";
15
13
  import { findEntry } from "./registry.js";
16
- import { resolvePiModel } from "./executor.js";
14
+ import { piRuntime, resolvePiModel } from "./executor.js"
17
15
  import { addSessionCost } from "./session-cost.js";
18
16
 
19
17
  export interface Epic {
@@ -67,17 +65,15 @@ interface Ctx {
67
65
  }
68
66
 
69
67
  async function runEpicAgent(idea: string, system: string, toolName: string, ctx: Ctx): Promise<Epic[]> {
70
- const authStorage = AuthStorage.create();
71
- const registry = ModelRegistry.create(authStorage);
68
+ const runtime = await piRuntime();
72
69
  const { entry } = findEntry("plan", "mid");
73
70
  const pick = ctx.modelOverride ?? entry.byBackend[ctx.backend];
74
71
  try {
75
- const model = resolvePiModel(registry, pick.provider, pick.model);
72
+ const model = resolvePiModel(runtime, pick.provider, pick.model);
76
73
  const { tool, get } = buildEpicsTool(toolName);
77
74
  const { session } = await createAgentSession({
78
75
  model,
79
- authStorage,
80
- modelRegistry: registry,
76
+ modelRuntime: runtime,
81
77
  thinkingLevel: "low",
82
78
  noTools: "all",
83
79
  customTools: [tool],
@@ -145,15 +141,14 @@ export async function councilEpics(idea: string, ctx: Ctx): Promise<CouncilResul
145
141
  if (!proposals.length) return { epics: [], proposals: [] };
146
142
 
147
143
  // Synthesize.
148
- const authStorage = AuthStorage.create();
149
- const registry = ModelRegistry.create(authStorage);
144
+ const runtime = await piRuntime();
150
145
  const { entry } = findEntry("plan", "mid");
151
146
  const pick = ctx.modelOverride ?? entry.byBackend[ctx.backend];
152
147
  try {
153
- const model = resolvePiModel(registry, pick.provider, pick.model);
148
+ const model = resolvePiModel(runtime, pick.provider, pick.model);
154
149
  const { tool, get } = buildEpicsTool("submit_epics");
155
150
  const { session } = await createAgentSession({
156
- model, authStorage, modelRegistry: registry, thinkingLevel: "low",
151
+ model, modelRuntime: runtime, thinkingLevel: "low",
157
152
  noTools: "all", customTools: [tool], tools: ["submit_epics"],
158
153
  });
159
154
  try {
package/src/executor.ts CHANGED
@@ -6,8 +6,7 @@
6
6
  // hits the provider API and spends money — that path is guarded by the caller.
7
7
 
8
8
  import {
9
- AuthStorage,
10
- ModelRegistry,
9
+ ModelRuntime,
11
10
  createAgentSession,
12
11
  type AgentSession,
13
12
  type AgentSessionEvent,
@@ -20,17 +19,23 @@ import { getModel } from "./models.js";
20
19
  import { addSessionCost } from "./session-cost.js";
21
20
 
22
21
  /** Pi's own Model type, derived so we don't depend on a deep sub-path import. */
23
- type PiModel = NonNullable<ReturnType<ModelRegistry["find"]>>;
22
+ export type PiModel = NonNullable<ReturnType<ModelRuntime["getModel"]>>;
23
+
24
+ /** Pi's model/auth runtime: built-in catalog + env keys / ~/.pi/agent/auth.json.
25
+ * Created per call on purpose — a key added in Settings must apply to the next session. */
26
+ export function piRuntime(): Promise<ModelRuntime> {
27
+ return ModelRuntime.create();
28
+ }
24
29
 
25
30
  /** Resolve a Projectinator (provider, modelId) to Pi's executable Model.
26
31
  * Offline + free — reads Pi's built-in registry. Throws with a clear message
27
32
  * if the id isn't one Pi knows (our ids are kept identical to Pi's on purpose). */
28
33
  export function resolvePiModel(
29
- registry: ModelRegistry,
34
+ runtime: ModelRuntime,
30
35
  provider: Provider,
31
36
  modelId: string,
32
37
  ): PiModel {
33
- const m = registry.find(provider, modelId);
38
+ const m = runtime.getModel(provider, modelId);
34
39
  if (!m) {
35
40
  throw new Error(
36
41
  `Pi has no model "${provider}/${modelId}". ` +
@@ -47,8 +52,6 @@ export interface ExecuteOptions {
47
52
  thinkingLevel?: "off" | "low" | "medium" | "high";
48
53
  /** Optional progress hook — receives raw Pi session events. */
49
54
  onEvent?: (event: AgentSessionEvent) => void;
50
- /** Override auth (tests). Default resolves env keys / ~/.pi/agent/auth.json. */
51
- authStorage?: AuthStorage;
52
55
  /** Tools the agent may use. Default: the coding set. */
53
56
  tools?: string[];
54
57
  }
@@ -95,15 +98,13 @@ export async function executeTask(
95
98
  decision: RouteDecision,
96
99
  opts: ExecuteOptions,
97
100
  ): Promise<ExecuteResult> {
98
- const authStorage = opts.authStorage ?? AuthStorage.create();
99
- const registry = ModelRegistry.create(authStorage);
100
- const model = resolvePiModel(registry, decision.provider, decision.model.id);
101
+ const runtime = await piRuntime();
102
+ const model = resolvePiModel(runtime, decision.provider, decision.model.id);
101
103
 
102
104
  const { session } = await createAgentSession({
103
105
  model,
104
106
  cwd: opts.workspace,
105
- authStorage,
106
- modelRegistry: registry,
107
+ modelRuntime: runtime,
107
108
  thinkingLevel: opts.thinkingLevel ?? "medium",
108
109
  tools: opts.tools ?? ["read", "write", "edit", "bash", "ls", "grep", "find"],
109
110
  });
package/src/intake.ts CHANGED
@@ -6,15 +6,13 @@
6
6
  // schema, coerced/validated in code.
7
7
 
8
8
  import {
9
- AuthStorage,
10
- ModelRegistry,
11
9
  createAgentSession,
12
10
  defineTool,
13
11
  } from "@earendil-works/pi-coding-agent";
14
12
  import { Type, type Static } from "typebox";
15
13
  import type { Backend, Provider } from "./types.js";
16
14
  import { findEntry } from "./registry.js";
17
- import { resolvePiModel } from "./executor.js";
15
+ import { piRuntime, resolvePiModel } from "./executor.js"
18
16
  import { addSessionCost } from "./session-cost.js";
19
17
 
20
18
  const IntakeSchema = Type.Object(
@@ -78,18 +76,16 @@ export interface AssessOptions {
78
76
  /** Ask the PM whether the request needs clarification; returns up to 4 questions
79
77
  * (empty = clear enough to plan directly). Never throws — returns [] on trouble. */
80
78
  export async function assessIntake(idea: string, opts: AssessOptions): Promise<IntakeQuestion[]> {
81
- const authStorage = AuthStorage.create();
82
- const registry = ModelRegistry.create(authStorage);
79
+ const runtime = await piRuntime();
83
80
  const { entry } = findEntry("plan", "mid");
84
81
  const pick = opts.modelOverride ?? entry.byBackend[opts.backend];
85
82
 
86
83
  try {
87
- const model = resolvePiModel(registry, pick.provider, pick.model);
84
+ const model = resolvePiModel(runtime, pick.provider, pick.model);
88
85
  const { tool, get } = buildIntakeTool();
89
86
  const { session } = await createAgentSession({
90
87
  model,
91
- authStorage,
92
- modelRegistry: registry,
88
+ modelRuntime: runtime,
93
89
  thinkingLevel: "low",
94
90
  noTools: "all",
95
91
  customTools: [tool],
package/src/models.ts CHANGED
@@ -1,36 +1,42 @@
1
- // Model pricing table. Rates USD per 1,000,000 tokens (verified July 2026).
2
- // Shape mirrors Pi's models.json `cost` block so this ports to Pi later.
3
- // cacheRead ~= 0.1x input, cacheWrite ~= 1.25x input (Anthropic-style; approximations
4
- // for models whose exact cache rates we haven't pinned — refine against provider docs).
1
+ // Model pricing table. Rates USD per 1,000,000 tokens copied verbatim from Pi's
2
+ // bundled catalog (pi-coding-agent 0.85.1, Sept 2026); test/executor.test.ts pins every
3
+ // entry against it. Shape mirrors Pi's models.json `cost` block.
5
4
 
6
5
  import type { Model } from "./types.js";
7
6
  import { findOpenRouterModel } from "./openrouter.js";
8
7
 
9
8
  export const MODELS: Record<string, Model> = {
10
- // ---- OpenAI: GPT-5.6 family ----
9
+ // ---- OpenAI: GPT-5.6 family (prices cut Sept 2026; past 272k input costs 2x) ----
11
10
  "gpt-5.6-sol": {
12
11
  id: "gpt-5.6-sol",
13
12
  provider: "openai",
14
13
  name: "GPT-5.6 Sol",
15
14
  contextWindow: 272_000,
16
- cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 },
15
+ cost: { input: 4, output: 20, cacheRead: 0.4, cacheWrite: 5, tiers: [{ inputTokensAbove: 272_000, input: 8, output: 30, cacheRead: 0.8, cacheWrite: 10 }] },
17
16
  },
18
17
  "gpt-5.6-terra": {
19
18
  id: "gpt-5.6-terra",
20
19
  provider: "openai",
21
20
  name: "GPT-5.6 Terra",
22
21
  contextWindow: 272_000,
23
- cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 3.125 },
22
+ cost: { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5, tiers: [{ inputTokensAbove: 272_000, input: 4, output: 18, cacheRead: 0.4, cacheWrite: 5 }] },
24
23
  },
25
24
  "gpt-5.6-luna": {
26
25
  id: "gpt-5.6-luna",
27
26
  provider: "openai",
28
27
  name: "GPT-5.6 Luna",
29
28
  contextWindow: 272_000,
30
- cost: { input: 1, output: 6, cacheRead: 0.1, cacheWrite: 1.25 },
29
+ cost: { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25, tiers: [{ inputTokensAbove: 272_000, input: 0.4, output: 1.8, cacheRead: 0.04, cacheWrite: 0.5 }] },
31
30
  },
32
31
 
33
32
  // ---- Anthropic: Claude ----
33
+ "claude-fable-5-1": {
34
+ id: "claude-fable-5-1",
35
+ provider: "anthropic",
36
+ name: "Claude Fable 5.1",
37
+ contextWindow: 1_000_000,
38
+ cost: { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 },
39
+ },
34
40
  "claude-fable-5": {
35
41
  id: "claude-fable-5",
36
42
  provider: "anthropic",
@@ -38,6 +44,13 @@ export const MODELS: Record<string, Model> = {
38
44
  contextWindow: 1_000_000,
39
45
  cost: { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 },
40
46
  },
47
+ "claude-opus-5": {
48
+ id: "claude-opus-5",
49
+ provider: "anthropic",
50
+ name: "Claude Opus 5",
51
+ contextWindow: 1_000_000,
52
+ cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
53
+ },
41
54
  "claude-opus-4-8": {
42
55
  id: "claude-opus-4-8",
43
56
  provider: "anthropic",
@@ -45,6 +58,13 @@ export const MODELS: Record<string, Model> = {
45
58
  contextWindow: 1_000_000,
46
59
  cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
47
60
  },
61
+ "claude-sonnet-5": {
62
+ id: "claude-sonnet-5",
63
+ provider: "anthropic",
64
+ name: "Claude Sonnet 5",
65
+ contextWindow: 1_000_000,
66
+ cost: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
67
+ },
48
68
  "claude-sonnet-4-6": {
49
69
  id: "claude-sonnet-4-6",
50
70
  provider: "anthropic",
@@ -77,6 +97,13 @@ export const MODELS: Record<string, Model> = {
77
97
  tiers: [{ inputTokensAbove: 200_000, input: 4, output: 18, cacheRead: 0.4, cacheWrite: 5 }],
78
98
  },
79
99
  },
100
+ "gemini-3.8-flash": {
101
+ id: "gemini-3.8-flash",
102
+ provider: "google",
103
+ name: "Gemini 3.8 Flash",
104
+ contextWindow: 1_000_000,
105
+ cost: { input: 0.75, output: 3.75, cacheRead: 0.075 },
106
+ },
80
107
  "gemini-3-flash-preview": {
81
108
  id: "gemini-3-flash-preview",
82
109
  provider: "google",
@@ -88,6 +115,13 @@ export const MODELS: Record<string, Model> = {
88
115
  // ---- OpenRouter (one key → frontier models). ids are Pi's OpenRouter-catalog
89
116
  // slugs (vendor/model). Pricing mirrors the underlying model (OpenRouter passes
90
117
  // it through, ~small margin); ACTUAL cost still comes from Pi per run.
118
+ "anthropic/claude-opus-5": {
119
+ id: "anthropic/claude-opus-5",
120
+ provider: "openrouter",
121
+ name: "Claude Opus 5 (OpenRouter)",
122
+ contextWindow: 1_000_000,
123
+ cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
124
+ },
91
125
  "anthropic/claude-opus-4.8": {
92
126
  id: "anthropic/claude-opus-4.8",
93
127
  provider: "openrouter",
@@ -95,6 +129,13 @@ export const MODELS: Record<string, Model> = {
95
129
  contextWindow: 1_000_000,
96
130
  cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
97
131
  },
132
+ "anthropic/claude-sonnet-5": {
133
+ id: "anthropic/claude-sonnet-5",
134
+ provider: "openrouter",
135
+ name: "Claude Sonnet 5 (OpenRouter)",
136
+ contextWindow: 1_000_000,
137
+ cost: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
138
+ },
98
139
  "anthropic/claude-sonnet-4.6": {
99
140
  id: "anthropic/claude-sonnet-4.6",
100
141
  provider: "openrouter",
@@ -102,12 +143,19 @@ export const MODELS: Record<string, Model> = {
102
143
  contextWindow: 1_000_000,
103
144
  cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
104
145
  },
146
+ "google/gemini-3.8-flash": {
147
+ id: "google/gemini-3.8-flash",
148
+ provider: "openrouter",
149
+ name: "Gemini 3.8 Flash (OpenRouter)",
150
+ contextWindow: 1_000_000,
151
+ cost: { input: 0.75, output: 3.75, cacheRead: 0.075 },
152
+ },
105
153
  "openai/gpt-5.6-luna": {
106
154
  id: "openai/gpt-5.6-luna",
107
155
  provider: "openrouter",
108
156
  name: "GPT-5.6 Luna (OpenRouter)",
109
- contextWindow: 272_000,
110
- cost: { input: 1, output: 6, cacheRead: 0.1, cacheWrite: 1.25 },
157
+ contextWindow: 1_050_000,
158
+ cost: { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 },
111
159
  },
112
160
  };
113
161
 
package/src/narrate.ts CHANGED
@@ -3,15 +3,13 @@
3
3
  // (costs a small call) and cached on the build state by the caller.
4
4
 
5
5
  import {
6
- AuthStorage,
7
- ModelRegistry,
8
6
  createAgentSession,
9
7
  type AgentSession,
10
8
  } from "@earendil-works/pi-coding-agent";
11
9
  import type { Backend, Provider } from "./types.js";
12
10
  import type { RetroReport } from "./retro.js";
13
11
  import { findEntry } from "./registry.js";
14
- import { resolvePiModel } from "./executor.js";
12
+ import { piRuntime, resolvePiModel } from "./executor.js"
15
13
  import { addSessionCost } from "./session-cost.js";
16
14
 
17
15
  function lastAssistantText(session: AgentSession): string {
@@ -66,16 +64,14 @@ export interface NarrateOptions {
66
64
 
67
65
  /** Generate the narrative. Throws on failure (caller shows the error). */
68
66
  export async function narrateRetro(report: RetroReport, opts: NarrateOptions): Promise<string> {
69
- const authStorage = AuthStorage.create();
70
- const registry = ModelRegistry.create(authStorage);
67
+ const runtime = await piRuntime();
71
68
  const { entry } = findEntry("plan", "mid");
72
69
  const pick = opts.modelOverride ?? entry.byBackend[opts.backend];
73
- const model = resolvePiModel(registry, pick.provider, pick.model);
70
+ const model = resolvePiModel(runtime, pick.provider, pick.model);
74
71
 
75
72
  const { session } = await createAgentSession({
76
73
  model,
77
- authStorage,
78
- modelRegistry: registry,
74
+ modelRuntime: runtime,
79
75
  thinkingLevel: "low",
80
76
  noTools: "all",
81
77
  });
package/src/openrouter.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // - openRouterModels() / findOpenRouterModel(): SYNC reads (disk cache, else Pi's
7
7
  // built-in list) so cost estimation (getModel) can price any picked model.
8
8
 
9
- import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
9
+ import { piRuntime } from "./executor.js";
10
10
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
11
11
  import { homedir } from "node:os";
12
12
  import { join } from "node:path";
@@ -20,19 +20,25 @@ const CACHE = join(homedir(), ".projectinator", "openrouter-models.json");
20
20
  let builtinMemo: ORModel[] | null = null;
21
21
  let diskMemo: ORModel[] | null | undefined; // undefined = not read yet, null = no cache
22
22
 
23
- /** Pi's built-in OpenRouter catalog — offline, always available, names + pricing. */
23
+ /** Pi's built-in OpenRouter catalog — offline, names + pricing. Sync read of the memo;
24
+ * call warmBuiltinOpenRouterModels() once at startup to fill it (Pi's runtime is async). */
24
25
  export function builtinOpenRouterModels(): ORModel[] {
26
+ return builtinMemo ?? [];
27
+ }
28
+
29
+ /** Load Pi's built-in OpenRouter list into the memo. Safe to call repeatedly. */
30
+ export async function warmBuiltinOpenRouterModels(): Promise<ORModel[]> {
25
31
  if (builtinMemo) return builtinMemo;
26
32
  try {
27
- const reg = ModelRegistry.create(AuthStorage.create());
28
- const all = (reg.getAll() as unknown as Array<{ id: string; provider: string; name?: string; contextWindow?: number; cost?: ModelCost }>);
29
- builtinMemo = all
30
- .filter((m) => m.provider === "openrouter" && m.cost)
33
+ const runtime = await piRuntime();
34
+ builtinMemo = runtime
35
+ .getModels("openrouter")
36
+ .filter((m) => !!m.cost)
31
37
  .map((m) => ({
32
38
  id: m.id,
33
39
  name: m.name ?? m.id,
34
40
  contextWindow: m.contextWindow ?? 200_000,
35
- cost: m.cost as ModelCost,
41
+ cost: m.cost,
36
42
  }))
37
43
  .sort((a, b) => a.name.localeCompare(b.name));
38
44
  } catch {
package/src/pm.ts CHANGED
@@ -9,8 +9,6 @@
9
9
  // code buckets after decomposition. The PM only decomposes + tags.
10
10
 
11
11
  import {
12
- AuthStorage,
13
- ModelRegistry,
14
12
  createAgentSession,
15
13
  defineTool,
16
14
  type AgentSession,
@@ -19,7 +17,7 @@ import { Type, type Static } from "typebox";
19
17
  import type { Backend, Capability, Difficulty, Provider, Task } from "./types.js";
20
18
  import { estimateTokens } from "./estimate.js";
21
19
  import { findEntry } from "./registry.js";
22
- import { resolvePiModel } from "./executor.js";
20
+ import { piRuntime, resolvePiModel } from "./executor.js";
23
21
  import { addSessionCost } from "./session-cost.js";
24
22
 
25
23
  // ---- typebox schema = the backlog contract ----
@@ -215,7 +213,6 @@ export function extractBacklogFromText(text: string): Backlog | undefined {
215
213
 
216
214
  export interface DecomposeOptions {
217
215
  backend: Backend;
218
- authStorage?: AuthStorage;
219
216
  thinkingLevel?: "off" | "low" | "medium" | "high";
220
217
  onEvent?: Parameters<import("@earendil-works/pi-coding-agent").AgentSession["subscribe"]>[0];
221
218
  /** Override the PM model (else resolved from registry plan/mid). */
@@ -237,19 +234,17 @@ export interface DecomposeResult {
237
234
  }
238
235
 
239
236
  export async function decomposeIdea(idea: string, opts: DecomposeOptions): Promise<DecomposeResult> {
240
- const authStorage = opts.authStorage ?? AuthStorage.create();
241
- const registry = ModelRegistry.create(authStorage);
237
+ const runtime = await piRuntime();
242
238
 
243
239
  // PM = plan capability, mid tier — unless the caller overrides the model.
244
240
  const { entry } = findEntry("plan", "mid");
245
241
  const pick = opts.modelOverride ?? entry.byBackend[opts.backend];
246
- const model = resolvePiModel(registry, pick.provider, pick.model);
242
+ const model = resolvePiModel(runtime, pick.provider, pick.model);
247
243
 
248
244
  const { tool, get } = buildBacklogTool();
249
245
  const { session } = await createAgentSession({
250
246
  model,
251
- authStorage,
252
- modelRegistry: registry,
247
+ modelRuntime: runtime,
253
248
  thinkingLevel: opts.thinkingLevel ?? "medium",
254
249
  noTools: "all",
255
250
  customTools: [tool],
package/src/registry.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // The Model Registry — the swappable brain.
2
2
  // Maps capability + tier -> model, per backend. Change an entry, re-route everything.
3
- // Seeded from the July-2026 verified roster. This is the ONE file the scout edits.
3
+ // Seeded from the September-2026 verified roster. This is the ONE file the scout edits.
4
4
 
5
5
  import type { Capability, RegistryEntry, Tier } from "./types.js";
6
6
 
@@ -18,8 +18,8 @@ export const REGISTRY: RegistryEntry[] = [
18
18
  web: { provider: "openai", model: "gpt-5.6-sol" },
19
19
  api: { provider: "openai", model: "gpt-5.6-terra" },
20
20
  },
21
- evidence: "OpenAI leads DeepPlanning long-horizon planning",
22
- updated: "2026-07-15",
21
+ evidence: "OpenAI leads DeepPlanning long-horizon planning; Terra repriced to $2/$12 (Sept 2026)",
22
+ updated: "2026-09-15",
23
23
  },
24
24
 
25
25
  // --- DESIGN (UI/UX) ---
@@ -27,12 +27,12 @@ export const REGISTRY: RegistryEntry[] = [
27
27
  capability: "design",
28
28
  tier: "high",
29
29
  byBackend: {
30
- web: { provider: "anthropic", model: "claude-fable-5" },
30
+ web: { provider: "anthropic", model: "claude-fable-5-1" },
31
31
  api: { provider: "openai", model: "gpt-5.6-sol" },
32
32
  },
33
33
  ask: true,
34
- evidence: "Design Arena Elo — Fable 5 #2, GPT-5.6 Sol #3",
35
- updated: "2026-07-15",
34
+ evidence: "Design Arena Elo — Fable 5 #2, GPT-5.6 Sol #3; Sol repriced to $4/$20 (Sept 2026)",
35
+ updated: "2026-09-15",
36
36
  },
37
37
 
38
38
  // --- CODE (development) ---
@@ -40,30 +40,32 @@ export const REGISTRY: RegistryEntry[] = [
40
40
  capability: "code",
41
41
  tier: "high",
42
42
  byBackend: {
43
- web: { provider: "anthropic", model: "claude-fable-5" }, // free -> 95% SWE-bench ceiling
44
- api: { provider: "anthropic", model: "claude-opus-4-8" }, // paid -> 88.6% value pick
43
+ web: { provider: "anthropic", model: "claude-fable-5-1" },
44
+ api: { provider: "anthropic", model: "claude-opus-5" }, // same price as Opus 4.8, 96% SWE-bench V
45
45
  },
46
46
  ask: true,
47
- evidence: "SWE-bench Verified — Fable 5 95%, Opus 4.8 88.6%",
48
- updated: "2026-07-15",
47
+ evidence: "SWE-bench Verified — Opus 5 96% (Opus 4.8 was 88.6%), same $5/$25",
48
+ updated: "2026-09-15",
49
49
  },
50
50
  {
51
51
  capability: "code",
52
52
  tier: "mid",
53
53
  byBackend: {
54
- web: { provider: "anthropic", model: "claude-opus-4-8" },
55
- api: { provider: "anthropic", model: "claude-sonnet-4-6" },
54
+ web: { provider: "anthropic", model: "claude-opus-5" },
55
+ api: { provider: "anthropic", model: "claude-sonnet-5" },
56
56
  },
57
- updated: "2026-07-15",
57
+ evidence: "Sonnet 5 — 85.2% SWE-bench V, beats Opus 4.8 on Terminal-Bench 2.1, $2/$10 in Pi's table",
58
+ updated: "2026-09-15",
58
59
  },
59
60
  {
60
61
  capability: "code",
61
62
  tier: "fast",
62
63
  byBackend: {
63
- web: { provider: "anthropic", model: "claude-sonnet-4-6" },
64
- api: { provider: "anthropic", model: "claude-haiku-4-5" },
64
+ web: { provider: "anthropic", model: "claude-sonnet-5" },
65
+ api: { provider: "google", model: "gemini-3.8-flash" },
65
66
  },
66
- updated: "2026-07-15",
67
+ evidence: "Gemini 3.8 Flash — 90.8% Terminal-Bench 2.1 at $0.75/$3.75",
68
+ updated: "2026-09-15",
67
69
  },
68
70
 
69
71
  // --- REVIEW (read-only wiring check before the tester; one row -> every difficulty is cheap) ---
@@ -71,10 +73,10 @@ export const REGISTRY: RegistryEntry[] = [
71
73
  capability: "review",
72
74
  tier: "fast",
73
75
  byBackend: {
74
- web: { provider: "google", model: "gemini-3.1-pro-preview" },
75
- api: { provider: "google", model: "gemini-3-flash-preview" },
76
+ web: { provider: "google", model: "gemini-3.8-flash" },
77
+ api: { provider: "google", model: "gemini-3.8-flash" },
76
78
  },
77
- evidence: "Read-only static check; cheapest tier is adequate, same picks as test",
79
+ evidence: "Read-only wiring check; strongest cheap model, same pick as test",
78
80
  updated: "2026-09-15",
79
81
  },
80
82
 
@@ -84,10 +86,10 @@ export const REGISTRY: RegistryEntry[] = [
84
86
  tier: "fast",
85
87
  byBackend: {
86
88
  web: { provider: "google", model: "gemini-3.1-pro-preview" },
87
- api: { provider: "google", model: "gemini-3-flash-preview" },
89
+ api: { provider: "google", model: "gemini-3.8-flash" },
88
90
  },
89
- evidence: "Fast tier ~5x cheaper for high-volume review",
90
- updated: "2026-07-15",
91
+ evidence: "Gemini 3.8 Flash 90.8% Terminal-Bench 2.1; +50% over 3 Flash for a much stronger tester",
92
+ updated: "2026-09-15",
91
93
  },
92
94
 
93
95
  // --- OPS (Runner: terminal / CI / file-driving autonomy) ---
@@ -98,8 +100,8 @@ export const REGISTRY: RegistryEntry[] = [
98
100
  web: { provider: "openai", model: "gpt-5.6-sol" },
99
101
  api: { provider: "openai", model: "gpt-5.6-sol" },
100
102
  },
101
- evidence: "GPT-5.6 Sol leads Terminal-Bench 2.1",
102
- updated: "2026-07-15",
103
+ evidence: "GPT-5.6 Sol on Terminal-Bench; GPT-6 Astra scores higher (57.9 vs 37.3 on TB 4.0) but 2.5x the price — ops tasks are rare",
104
+ updated: "2026-09-15",
103
105
  },
104
106
  ];
105
107
 
package/src/research.ts CHANGED
@@ -8,8 +8,6 @@
8
8
  // Flow: research report (text) -> extractFindings() -> findings.json -> scout --from
9
9
 
10
10
  import {
11
- AuthStorage,
12
- ModelRegistry,
13
11
  createAgentSession,
14
12
  defineTool,
15
13
  type AgentSession,
@@ -17,7 +15,7 @@ import {
17
15
  import { Type, type Static } from "typebox";
18
16
  import type { Provider } from "./types.js";
19
17
  import type { Finding } from "./scout.js";
20
- import { resolvePiModel } from "./executor.js";
18
+ import { piRuntime, resolvePiModel } from "./executor.js"
21
19
  import { MODELS } from "./models.js";
22
20
 
23
21
  const FindingsSchema = Type.Object({
@@ -89,19 +87,17 @@ export function extractionPrompt(report: string): string {
89
87
 
90
88
  export interface ExtractOptions {
91
89
  model: { provider: Provider; model: string };
92
- authStorage?: AuthStorage;
93
90
  onEvent?: Parameters<AgentSession["subscribe"]>[0];
94
91
  }
95
92
 
96
93
  /** Extract findings from a report via a model. Spends money (one model call). */
97
94
  export async function extractFindings(report: string, opts: ExtractOptions): Promise<Finding[]> {
98
- const authStorage = opts.authStorage ?? AuthStorage.create();
99
- const registry = ModelRegistry.create(authStorage);
100
- const model = resolvePiModel(registry, opts.model.provider, opts.model.model);
95
+ const runtime = await piRuntime();
96
+ const model = resolvePiModel(runtime, opts.model.provider, opts.model.model);
101
97
 
102
98
  const { tool, get } = buildFindingsTool();
103
99
  const { session } = await createAgentSession({
104
- model, authStorage, modelRegistry: registry,
100
+ model, modelRuntime: runtime,
105
101
  thinkingLevel: "low",
106
102
  noTools: "all",
107
103
  customTools: [tool],
package/src/roles.ts CHANGED
@@ -5,8 +5,6 @@
5
5
  // the orchestrator's feedback loop depends on.
6
6
 
7
7
  import {
8
- AuthStorage,
9
- ModelRegistry,
10
8
  createAgentSession,
11
9
  defineTool,
12
10
  type AgentSession,
@@ -24,7 +22,7 @@ import {
24
22
  type TaskLimits,
25
23
  type Verdict,
26
24
  } from "./types.js";
27
- import { resolvePiModel } from "./executor.js";
25
+ import { piRuntime, resolvePiModel } from "./executor.js"
28
26
  import { renderCheck, chromiumAvailable, CHROMIUM_INSTALL_HINT } from "./preview.js";
29
27
  import { estimateCost } from "./cost.js";
30
28
  import { getModel } from "./models.js";
@@ -161,10 +159,10 @@ function buildVerdictTool(runtimeChecked: () => boolean) {
161
159
  // that provider's sensible model, so route() resolves everything to it.
162
160
 
163
161
  const PROVIDER_MODELS: Record<Provider, { strong: string; mid: string; cheap: string }> = {
164
- anthropic: { strong: "claude-opus-4-8", mid: "claude-sonnet-4-6", cheap: "claude-haiku-4-5" },
162
+ anthropic: { strong: "claude-opus-5", mid: "claude-sonnet-5", cheap: "claude-haiku-4-5" },
165
163
  openai: { strong: "gpt-5.6-sol", mid: "gpt-5.6-terra", cheap: "gpt-5.6-luna" },
166
- google: { strong: "gemini-3.1-pro-preview", mid: "gemini-3.1-pro-preview", cheap: "gemini-3-flash-preview" },
167
- openrouter: { strong: "anthropic/claude-opus-4.8", mid: "anthropic/claude-sonnet-4.6", cheap: "openai/gpt-5.6-luna" },
164
+ google: { strong: "gemini-3.1-pro-preview", mid: "gemini-3.1-pro-preview", cheap: "gemini-3.8-flash" },
165
+ openrouter: { strong: "anthropic/claude-opus-5", mid: "anthropic/claude-sonnet-5", cheap: "google/gemini-3.8-flash" },
168
166
  };
169
167
 
170
168
  const CAP_STRENGTH: Record<Capability, "strong" | "mid" | "cheap"> = {
@@ -201,7 +199,6 @@ export function lockRegistryToProvider(provider: Provider): RegistryEntry[] {
201
199
  export interface PiExecutorOptions {
202
200
  workspace: string;
203
201
  backend: Backend;
204
- authStorage?: AuthStorage;
205
202
  thinkingLevel?: "off" | "low" | "medium" | "high";
206
203
  onEvent?: Parameters<AgentSession["subscribe"]>[0];
207
204
  /** Called when a task falls back from its routed provider to another one. */
@@ -280,8 +277,6 @@ function listFiles(dir: string): string[] {
280
277
  /** Build a real RoleExecutor backed by Pi. Each call spends money. Falls back to
281
278
  * another key-holding provider when the routed one errors or returns 0 tokens. */
282
279
  export function makePiExecutor(opts: PiExecutorOptions): RoleExecutor {
283
- const authStorage = opts.authStorage ?? AuthStorage.create();
284
-
285
280
  // One attempt on a specific provider/model. Returns the result + total tokens
286
281
  // (0 tokens = the provider call didn't really happen → treat as a failure).
287
282
  const runOnce = async (
@@ -291,8 +286,8 @@ export function makePiExecutor(opts: PiExecutorOptions): RoleExecutor {
291
286
  modelId: string,
292
287
  limits: TaskLimits,
293
288
  ): Promise<{ result: RoleResult; tokensTotal: number }> => {
294
- const registry = ModelRegistry.create(authStorage);
295
- const model = resolvePiModel(registry, provider, modelId);
289
+ const runtime = await piRuntime();
290
+ const model = resolvePiModel(runtime, provider, modelId);
296
291
 
297
292
  const isTest = task.capability === "test";
298
293
  const isReview = task.capability === "review";
@@ -303,8 +298,7 @@ export function makePiExecutor(opts: PiExecutorOptions): RoleExecutor {
303
298
  const { session } = await createAgentSession({
304
299
  model,
305
300
  cwd: opts.workspace,
306
- authStorage,
307
- modelRegistry: registry,
301
+ modelRuntime: runtime,
308
302
  thinkingLevel: opts.thinkingLevel ?? "medium",
309
303
  ...(isTest
310
304
  ? { customTools: [verdictTool!.tool, checkTool!.tool], tools: ["read", "bash", "ls", "grep", "find", "check_app", "submit_verdict"] }
package/src/run-build.ts CHANGED
@@ -11,7 +11,6 @@
11
11
  import { mkdirSync } from "node:fs";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { dirname, join } from "node:path";
14
- import { AuthStorage } from "@earendil-works/pi-coding-agent";
15
14
  import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
16
15
  import type { Provider, Task } from "./types.js";
17
16
  import { DEFAULT_POLICY } from "./router.js";
package/src/run-dev.ts CHANGED
@@ -8,11 +8,10 @@
8
8
  import { mkdirSync } from "node:fs";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { dirname, join } from "node:path";
11
- import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
12
11
  import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
13
12
  import type { Task } from "./types.js";
14
13
  import { DEFAULT_POLICY, route } from "./router.js";
15
- import { buildDeveloperPrompt, executeTask, resolvePiModel } from "./executor.js";
14
+ import { buildDeveloperPrompt, executeTask, piRuntime, resolvePiModel } from "./executor.js";
16
15
 
17
16
  const TASK: Task = {
18
17
  id: "T-DEV1",
@@ -29,9 +28,7 @@ const live = process.argv.includes("--live");
29
28
  const policy = { ...DEFAULT_POLICY, backendMode: "api" as const };
30
29
  const decision = route(TASK, { policy });
31
30
 
32
- const auth = AuthStorage.create();
33
- const registry = ModelRegistry.create(auth);
34
- const piModel = resolvePiModel(registry, decision.provider, decision.model.id); // offline, free
31
+ const piModel = resolvePiModel(await piRuntime(), decision.provider, decision.model.id); // offline, free
35
32
 
36
33
  const money = (n: number) => `$${n.toFixed(2)}`;
37
34
 
package/src/run-pm.ts CHANGED
@@ -5,11 +5,10 @@
5
5
  //
6
6
  // Live decomposition needs an API key (PM routes to an OpenAI model by default).
7
7
 
8
- import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
9
8
  import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
10
9
  import { DEFAULT_POLICY, routeBacklog } from "./router.js";
11
10
  import { findEntry } from "./registry.js";
12
- import { resolvePiModel } from "./executor.js";
11
+ import { piRuntime, resolvePiModel } from "./executor.js"
13
12
  import { decomposeIdea, pmSystemPrompt } from "./pm.js";
14
13
 
15
14
  const args = process.argv.slice(2);
@@ -26,13 +25,12 @@ const idea = args.filter((a) => !consumed.has(a)).join(" ").trim() ||
26
25
  const backend = "api" as const; // web-login backend not built yet
27
26
  const money = (n: number) => `$${n.toFixed(2)}`;
28
27
 
29
- const auth = AuthStorage.create();
30
- const registry = ModelRegistry.create(auth);
28
+ const runtime = await piRuntime();
31
29
  const { entry } = findEntry("plan", "mid");
32
30
  const pick = pmOverride
33
31
  ? { provider: pmOverride.split("/")[0] as typeof entry.byBackend[typeof backend]["provider"], model: pmOverride.split("/").slice(1).join("/") }
34
32
  : entry.byBackend[backend];
35
- const pm = resolvePiModel(registry, pick.provider, pick.model); // offline
33
+ const pm = resolvePiModel(runtime, pick.provider, pick.model); // offline
36
34
 
37
35
  console.log(`\n Projectinator — Phase 3 PM decomposer [${live ? "LIVE" : "DRY"}]\n`);
38
36
  console.log(` Idea: ${idea}`);
package/src/tui.tsx CHANGED
@@ -8,9 +8,13 @@ import { applyKeysToEnv } from "./tui/config.js";
8
8
  import { listProjects } from "./tui/engine.js";
9
9
  import { sessionCost } from "./session-cost.js";
10
10
  import { closeWebSessions } from "./web/session.js";
11
+ import { warmBuiltinOpenRouterModels } from "./openrouter.js";
11
12
 
12
13
  // Load any keys saved via Settings into the environment so Pi picks them up.
13
14
  applyKeysToEnv();
15
+ // Pi's catalog is async; fill the OpenRouter pricing memo in the background so the
16
+ // sync cost estimator can price OpenRouter slugs by the time a plan is shown.
17
+ void warmBuiltinOpenRouterModels();
14
18
 
15
19
  const AMBER = "\x1b[38;2;224;167;45m";
16
20
  const DIM = "\x1b[2m";