projectinator 0.2.0 → 0.3.1

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: 187 passing](https://img.shields.io/badge/tests-187%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
@@ -30,6 +30,8 @@ npx projectinator # run without installing
30
30
  npm install -g projectinator # then just: projectinator
31
31
  ```
32
32
 
33
+ `projectinator --help` / `--version` work without opening the app; `projectinator doctor` checks your setup.
34
+
33
35
  > **It's a CLI, not a library.** `npm install projectinator` (without `-g`) only drops it into a
34
36
  > project's `node_modules` — it won't create a runnable command. Use `npx projectinator` or
35
37
  > `npm install -g projectinator`. (If `-g` installs but the command isn't found, npm's global
@@ -120,16 +122,32 @@ Real, unedited output from a full build, kept in [`examples/`](examples/):
120
122
 
121
123
  ## CLI (same engine, for scripting/CI)
122
124
 
125
+ Everything below uses the same workspace as the cockpit, so headless builds show up in the app.
126
+
123
127
  ```bash
124
- npm start # the cockpit (the normal way to use it)
125
- npm run build -- --live --mini # cheap end-to-end proof (~$0.10)
126
- npm run build -- --live --lock anthropic "idea" # full pipeline on one provider
127
- npm run build -- --live --mini --resume # resume a halted/finished build (skips done tasks)
128
- npm run build -- --live --mini --task-cap 0.5 --task-timeout 5 # per-task limits (USD / minutes)
128
+ projectinator doctor # Node, keys, Chromium, Pi catalog, git
129
+ projectinator build "a tip calculator" --dry-run # plan + estimate only (one PM call)
130
+ projectinator build "a tip calculator" --yes # build without the confirmation prompt
131
+ projectinator build "…" --json --budget 2 --provider anthropic # NDJSON events; cap; lock provider
132
+ projectinator build "…" --task-cap 0.5 --task-timeout 5 # per-task limits (USD / minutes)
133
+ projectinator projects # past builds, status, cost
134
+ projectinator models # the roster as it will run, with prices
135
+ ```
136
+
137
+ Exit codes: `0` ok · `1` environment problem · `2` bad usage · `3` build halted (resume it in the app).
138
+
139
+ <details>
140
+ <summary>Developer scripts (from a clone)</summary>
141
+
142
+ ```bash
143
+ npm start # the cockpit
144
+ npm run build -- --live --mini # fixed 4-task build, cheap end-to-end proof (~$0.10)
145
+ npm run build -- --live --mini --resume # resume a halted run
129
146
  npm run bakeoff -- --capability design "Design a pricing page" # model bake-off
130
- npm test # 157 tests
147
+ npm test # 187 tests
131
148
  npm run typecheck
132
149
  ```
150
+ </details>
133
151
 
134
152
  ## Configuration & data
135
153
 
@@ -2,14 +2,52 @@
2
2
  // Launcher for `projectinator` / `npx github:smanookian/projectinator`.
3
3
  // Runs the Ink TUI (TypeScript) through tsx — no build step, no compiled dist.
4
4
  import { spawnSync } from "node:child_process";
5
+ import { readFileSync } from "node:fs";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { dirname, join } from "node:path";
7
8
 
8
9
  const root = dirname(dirname(fileURLToPath(import.meta.url)));
9
10
  const entry = join(root, "src", "tui.tsx");
10
11
 
12
+ const args = process.argv.slice(2);
13
+ const { version } = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
14
+
15
+ if (args.includes("--version") || args.includes("-v")) {
16
+ console.log(version);
17
+ process.exit(0);
18
+ }
19
+ if (args.includes("--help") || args.includes("-h") || args[0] === "help") {
20
+ console.log(`projectinator ${version} — your AI build team in the terminal
21
+
22
+ Usage
23
+ projectinator open the cockpit (TUI)
24
+ projectinator doctor check Node, API keys, Chromium, Pi catalog, git
25
+ projectinator build "<idea>" [...] plan + build headless (--dry-run, --yes, --json,
26
+ --budget, --provider, --concurrency, --task-cap, --task-timeout)
27
+ projectinator projects list past builds with status and cost
28
+ projectinator models the roster as it will run, with prices
29
+ projectinator --version | --help
30
+
31
+ Setup
32
+ Inside the app: Settings → API keys (Anthropic, OpenAI, Gemini, OpenRouter).
33
+ Keys live in ~/.projectinator/config.json (chmod 0600).
34
+ Optional: npx playwright install chromium → lets the tester run the app headless.
35
+
36
+ Exit codes: 0 ok · 1 environment problem · 2 bad usage · 3 build halted
37
+ Docs: https://github.com/smanookian/projectinator#readme`);
38
+ process.exit(0);
39
+ }
40
+
41
+ const COMMANDS = new Set(["doctor", "build", "projects", "models"]);
42
+ if (args.length && !COMMANDS.has(args[0])) {
43
+ console.error(`projectinator: unknown ${args[0].startsWith("-") ? "option" : "command"} "${args[0]}". Try --help.`);
44
+ process.exit(2);
45
+ }
46
+
11
47
  // `node --import tsx <entry>` registers tsx's loader, then runs the TS entry.
12
- const res = spawnSync(process.execPath, ["--import", "tsx", entry], {
48
+ // No command the TUI; a command → the headless CLI with the remaining args.
49
+ const target = args.length ? join(root, "src", "cli.ts") : entry;
50
+ const res = spawnSync(process.execPath, ["--import", "tsx", target, ...args], {
13
51
  stdio: "inherit",
14
52
  cwd: root,
15
53
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectinator",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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/cli.ts ADDED
@@ -0,0 +1,263 @@
1
+ // Headless CLI — `projectinator <command>`. Same engine as the cockpit (engine.ts),
2
+ // same workspace root, so builds started here show up in the TUI's project list.
3
+ //
4
+ // doctor check Node, keys, Chromium, Pi catalog, git
5
+ // build "<idea>" [...] plan + build without the TUI
6
+ // projects list past builds
7
+ // models the roster as it will actually run, with prices
8
+ //
9
+ // The launcher (bin/projectinator.mjs) handles --version/--help itself and only
10
+ // spawns this file for a real command, so those stay instant.
11
+
12
+ import { createInterface } from "node:readline";
13
+ import { spawnSync } from "node:child_process";
14
+ import { accessSync, constants, mkdirSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { join } from "node:path";
17
+ import type { OrchestratorEvent } from "./orchestrator.js";
18
+ import type { Provider } from "./types.js";
19
+ import { MODELS, getModel } from "./models.js";
20
+ import { REGISTRY } from "./registry.js";
21
+ import { piRuntime, resolvePiModel } from "./executor.js";
22
+ import { chromiumAvailable, CHROMIUM_INSTALL_HINT } from "./preview.js";
23
+ import { applyKeysToEnv, getPrefs, loadConfig, ENV_VAR } from "./tui/config.js";
24
+ import {
25
+ availableProviders,
26
+ effectiveRoster,
27
+ listProjects,
28
+ planBuild,
29
+ startBuild,
30
+ PROVIDER_LABEL,
31
+ } from "./tui/engine.js";
32
+
33
+ const NODE_MIN = "22.19.0";
34
+ const money = (n: number) => `$${n.toFixed(2)}`;
35
+
36
+ // ---- tiny argv parser: `--flag value`, `--flag=value`, `--bool`, positionals ----
37
+
38
+ interface Argv {
39
+ positional: string[];
40
+ flags: Record<string, string | true>;
41
+ }
42
+
43
+ export function parseArgv(args: string[]): Argv {
44
+ const out: Argv = { positional: [], flags: {} };
45
+ for (let i = 0; i < args.length; i++) {
46
+ const a = args[i]!;
47
+ if (!a.startsWith("--")) { out.positional.push(a); continue; }
48
+ const eq = a.indexOf("=");
49
+ if (eq > 0) { out.flags[a.slice(2, eq)] = a.slice(eq + 1); continue; }
50
+ const next = args[i + 1];
51
+ if (next !== undefined && !next.startsWith("--")) { out.flags[a.slice(2)] = next; i++; }
52
+ else out.flags[a.slice(2)] = true;
53
+ }
54
+ return out;
55
+ }
56
+
57
+ function num(v: string | true | undefined, fallback: number): number {
58
+ if (v === undefined || v === true) return fallback;
59
+ const n = parseFloat(v);
60
+ return Number.isFinite(n) ? n : fallback;
61
+ }
62
+
63
+ export function semverGte(a: string, b: string): boolean {
64
+ const pa = a.split(".").map(Number);
65
+ const pb = b.split(".").map(Number);
66
+ for (let i = 0; i < 3; i++) {
67
+ if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
68
+ if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
69
+ }
70
+ return true;
71
+ }
72
+
73
+ // ---- doctor ----
74
+
75
+ type Check = { label: string; ok: boolean; detail: string; fatal?: boolean };
76
+
77
+ async function doctor(): Promise<number> {
78
+ const checks: Check[] = [];
79
+
80
+ const node = process.versions.node;
81
+ checks.push({ label: "Node", ok: semverGte(node, NODE_MIN), detail: `v${node} (need ≥ ${NODE_MIN})`, fatal: true });
82
+
83
+ const cfg = loadConfig();
84
+ const providers = availableProviders();
85
+ for (const p of Object.keys(ENV_VAR) as Provider[]) {
86
+ const has = providers.includes(p);
87
+ const src = cfg.keys[p] ? "~/.projectinator/config.json" : has ? "env" : "";
88
+ checks.push({ label: `Key: ${PROVIDER_LABEL[p]}`, ok: has, detail: has ? `set (${src})` : `not set — Settings → API keys, or export ${ENV_VAR[p]}` });
89
+ }
90
+ if (providers.length === 0) checks.push({ label: "Any provider key", ok: false, detail: "no keys at all — nothing can run", fatal: true });
91
+
92
+ try {
93
+ const runtime = await piRuntime();
94
+ const bad: string[] = [];
95
+ for (const e of REGISTRY) for (const b of ["api", "web"] as const) {
96
+ try { resolvePiModel(runtime, e.byBackend[b].provider, e.byBackend[b].model); } catch { bad.push(`${e.capability}/${e.tier}/${b}`); }
97
+ }
98
+ checks.push({ label: "Pi model catalog", ok: bad.length === 0, detail: bad.length ? `unresolved: ${bad.join(", ")}` : `${Object.keys(MODELS).length} models priced, every registry pick resolves`, fatal: true });
99
+ } catch (e) {
100
+ checks.push({ label: "Pi model catalog", ok: false, detail: e instanceof Error ? e.message : String(e), fatal: true });
101
+ }
102
+
103
+ const chromium = await chromiumAvailable();
104
+ checks.push({ label: "Headless Chromium", ok: chromium, detail: chromium ? "installed — tester runs the app" : `missing — tests will be code-reading only (PASS*); ${CHROMIUM_INSTALL_HINT}` });
105
+
106
+ const git = spawnSync("git", ["--version"], { encoding: "utf8" });
107
+ checks.push({ label: "git", ok: git.status === 0, detail: git.status === 0 ? git.stdout.trim() : "not found — builds won't be versioned (undo/history disabled)" });
108
+
109
+ const home = join(homedir(), ".projectinator");
110
+ try { mkdirSync(home, { recursive: true }); accessSync(home, constants.W_OK); checks.push({ label: "Data dir", ok: true, detail: home }); }
111
+ catch { checks.push({ label: "Data dir", ok: false, detail: `${home} not writable`, fatal: true }); }
112
+
113
+ const prefs = getPrefs();
114
+ checks.push({ label: "Prefs", ok: true, detail: `budget cap ${money(prefs.budgetCapUSD)} · ${prefs.concurrency} at once · task limits ${prefs.taskTimeoutMin || "∞"} min / ${prefs.taskCostCapUSD ? money(prefs.taskCostCapUSD) : "∞"}` });
115
+
116
+ console.log("\n projectinator doctor\n");
117
+ for (const c of checks) console.log(` ${c.ok ? "✓" : c.fatal ? "✗" : "!"} ${c.label.padEnd(22)} ${c.detail}`);
118
+ const fatal = checks.filter((c) => !c.ok && c.fatal);
119
+ const warn = checks.filter((c) => !c.ok && !c.fatal);
120
+ console.log(`\n ${fatal.length ? `${fatal.length} blocking problem${fatal.length === 1 ? "" : "s"}` : "Ready"}${warn.length ? ` · ${warn.length} warning${warn.length === 1 ? "" : "s"}` : ""}\n`);
121
+ return fatal.length ? 1 : 0;
122
+ }
123
+
124
+ // ---- projects ----
125
+
126
+ function projects(): number {
127
+ const list = listProjects();
128
+ if (!list.length) { console.log("\n No projects yet. Run `projectinator` or `projectinator build \"an idea\"`.\n"); return 0; }
129
+ console.log("");
130
+ for (const p of list) {
131
+ const done = p.state.outcomes.filter((o) => !o.error).length;
132
+ const mark = p.status === "complete" ? "✓" : p.status === "halted" ? "⚠" : "…";
133
+ console.log(` ${mark} ${p.slug.padEnd(40)} ${p.status.padEnd(9)} ${money(p.totalCost).padStart(8)} ${done}/${p.taskCount} tasks`);
134
+ console.log(` ${p.idea.slice(0, 90)}${p.idea.length > 90 ? "…" : ""}`);
135
+ }
136
+ console.log(`\n ${list.length} project${list.length === 1 ? "" : "s"} · ${money(list.reduce((a, p) => a + p.totalCost, 0))} all time · ${join(homedir(), "…")}\n`);
137
+ return 0;
138
+ }
139
+
140
+ // ---- models ----
141
+
142
+ function models(): number {
143
+ const providers = availableProviders();
144
+ console.log(`\n Roster as it will run now${providers.length ? ` (keys: ${providers.join(", ")})` : " (no keys — best-of-breed picks shown)"}\n`);
145
+ for (const r of effectiveRoster()) {
146
+ const m = r.model ? getModel(r.model) : undefined;
147
+ const price = m ? `$${m.cost.input}/$${m.cost.output} per 1M` : "";
148
+ console.log(` ${r.label.padEnd(18)} ${(r.model ?? "—").padEnd(28)} ${(r.provider ?? "").padEnd(11)} ${price}`);
149
+ }
150
+ console.log("\n Change in the app: Settings → Models. Prices are from Pi's catalog; actual cost is measured per run.\n");
151
+ return 0;
152
+ }
153
+
154
+ // ---- build ----
155
+
156
+ function ask(question: string): Promise<string> {
157
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
158
+ const { promise, resolve } = Promise.withResolvers<string>();
159
+ rl.question(question, (a) => { rl.close(); resolve(a.trim()); });
160
+ return promise;
161
+ }
162
+
163
+ async function build(argv: Argv): Promise<number> {
164
+ const idea = argv.positional.join(" ").trim();
165
+ const json = argv.flags.json === true;
166
+ const yes = json || argv.flags.yes === true || argv.flags.y === true;
167
+ const dry = argv.flags["dry-run"] === true;
168
+ const emit = (o: Record<string, unknown>) => { if (json) process.stdout.write(JSON.stringify(o) + "\n"); };
169
+ const say = (s: string) => { if (!json) console.log(s); };
170
+
171
+ if (!idea) { console.error(" build: give an idea, e.g. projectinator build \"a tip calculator\""); return 2; }
172
+
173
+ let providers = availableProviders();
174
+ const lock = argv.flags.provider;
175
+ if (typeof lock === "string") {
176
+ if (!(lock in PROVIDER_LABEL)) { console.error(` build: unknown provider "${lock}" (anthropic | openai | google | openrouter)`); return 2; }
177
+ if (!providers.includes(lock as Provider)) { console.error(` build: no key for ${lock} — export ${ENV_VAR[lock as Provider]} or set it in the app`); return 1; }
178
+ providers = [lock as Provider];
179
+ }
180
+ if (!providers.length) { console.error(" build: no API key found. Run `projectinator doctor`."); return 1; }
181
+
182
+ const prefs = getPrefs();
183
+ const budget = num(argv.flags.budget, prefs.budgetCapUSD);
184
+ const concurrency = Math.max(1, Math.floor(num(argv.flags.concurrency, prefs.concurrency)));
185
+ const taskLimits = {
186
+ timeoutMs: num(argv.flags["task-timeout"], prefs.taskTimeoutMin) * 60_000,
187
+ costCapUSD: num(argv.flags["task-cap"], prefs.taskCostCapUSD),
188
+ };
189
+
190
+ say(`\n Planning with the PM (${providers.join("/")})…`);
191
+ const plan = await planBuild(idea, providers);
192
+ emit({ event: "plan", provider: plan.provider, modelId: plan.modelId, estCost: plan.estCost, tasks: plan.tasks });
193
+ say(`\n ${plan.tasks.length} tasks · estimated ${money(plan.estCost)} · cap ${money(budget)}${plan.lock ? ` · locked to ${plan.lock}` : ""}\n`);
194
+ for (const t of plan.tasks) {
195
+ const dep = t.dependsOn?.length ? ` ← ${t.dependsOn.join(", ")}` : "";
196
+ say(` ${t.id.padEnd(6)} ${t.capability.padEnd(7)} ${t.difficulty.padEnd(8)} ${t.title}${dep}`);
197
+ }
198
+ if (plan.estCost > budget) say(`\n ⚠ Estimate exceeds the cap — the build may halt partway.`);
199
+
200
+ if (dry) { say("\n Dry run — nothing built (the PM call above was the only spend).\n"); return 0; }
201
+ if (!yes) {
202
+ const a = await ask(`\n Build it for ~${money(plan.estCost)}? [y/N] `);
203
+ if (!/^y(es)?$/i.test(a)) { say(" Cancelled.\n"); return 0; }
204
+ }
205
+
206
+ const onEvent = (e: OrchestratorEvent) => {
207
+ emit({ event: e.type, ...e });
208
+ if (json) return;
209
+ if (e.type === "task_start") console.log(` ▶ ${e.task.id} [${e.task.capability}] → ${e.provider}/${e.modelId}${e.round ? ` (round ${e.round})` : ""}`);
210
+ else if (e.type === "task_done") console.log(` ✓ ${e.outcome.taskId} ${money(e.outcome.cost)} running ${money(e.runningTotal)}${e.outcome.verdict ? ` ${e.outcome.verdict.passed ? (e.outcome.verdict.runtimeChecked || e.outcome.capability !== "test" ? "PASS" : "PASS* (app not executed)") : "FAIL"}` : ""}`);
211
+ else if (e.type === "task_failed") console.log(` ⛔ ${e.outcome.taskId} aborted: ${e.outcome.error} — billed ${money(e.outcome.cost)}`);
212
+ else if (e.type === "task_skipped") console.log(` · ${e.taskId} skipped`);
213
+ else if (e.type === "test_failed") console.log(` ✗ ${e.taskId} failed (${e.bugs} bugs) — round ${e.round}`);
214
+ else if (e.type === "retry_dev") console.log(` ↻ re-running ${e.taskId} to fix ${e.forTest}`);
215
+ else if (e.type === "budget_halt") console.log(` ⚠ budget halt at ${money(e.runningTotal)} (cap ${money(e.cap)})`);
216
+ else if (e.type === "cycle_or_error") console.log(` ✗ ${e.message}`);
217
+ };
218
+
219
+ say(`\n Building…\n`);
220
+ const handle = startBuild(idea, plan, { concurrency, budgetCapUSD: budget, taskLimits, onEvent, mode: "auto" });
221
+ const r = await handle.promise;
222
+ emit({ event: "done", halted: r.halted, haltReason: r.haltReason, totalCost: r.totalCost, files: r.files, workspace: handle.workspace });
223
+ say(`\n ${r.halted ? `⚠ Halted (${r.haltReason ?? "?"})` : "✓ Complete"} · ${money(r.totalCost)} · ${r.files.length} file${r.files.length === 1 ? "" : "s"}`);
224
+ say(` ${handle.workspace}\n`);
225
+ return r.halted ? 3 : 0;
226
+ }
227
+
228
+ // ---- main ----
229
+
230
+ const USAGE = `Usage: projectinator <command> [options]
231
+
232
+ doctor check Node, API keys, Chromium, Pi catalog, git
233
+ build "<idea>" [options] plan + build headless (same workspace as the app)
234
+ --dry-run plan and estimate only (spends one PM call)
235
+ --yes, -y don't ask before building
236
+ --json NDJSON events on stdout (implies --yes)
237
+ --budget <usd> cap for this build (default: your prefs)
238
+ --provider <name> anthropic|openai|google|openrouter (default: prefs / keys)
239
+ --concurrency <n> tasks at once (default: your prefs)
240
+ --task-cap <usd> per-task cost ceiling (default: your prefs; 0 = off)
241
+ --task-timeout <min> per-task timeout (default: your prefs; 0 = off)
242
+ projects list past builds with status and cost
243
+ models the roster as it will run, with prices
244
+
245
+ Exit codes: 0 ok · 1 environment problem · 2 bad usage · 3 build halted`;
246
+
247
+ export async function main(args: string[]): Promise<number> {
248
+ applyKeysToEnv();
249
+ const argv = parseArgv(args);
250
+ const cmd = argv.positional.shift();
251
+ switch (cmd) {
252
+ case "doctor": return doctor();
253
+ case "projects": return projects();
254
+ case "models": return models();
255
+ case "build": return build(argv);
256
+ case undefined: case "help": console.log(USAGE); return cmd ? 0 : 2;
257
+ default: console.error(`projectinator: unknown command "${cmd}".\n\n${USAGE}`); return 2;
258
+ }
259
+ }
260
+
261
+ if (process.argv[1] && import.meta.url === new URL(process.argv[1], "file://").href) {
262
+ process.exitCode = await main(process.argv.slice(2));
263
+ }
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";