From e066b3a772366251ffa2eb88f156a59669afbbf9 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sun, 6 Sep 2026 00:39:45 +0000 Subject: [PATCH 1/7] feat: add Pi coding agent providers and review runner (#6350) --- client/src/components/cos/constants.js | 1 + client/src/lib/reviewerPins.js | 5 ++- client/src/utils/providers.js | 1 + client/src/utils/providers.test.js | 2 +- data.reference/providers.json | 31 +++++++++++++++ scripts/migrations/354-pi-provider.js | 39 +++++++++++++++++++ scripts/migrations/354-pi-provider.test.js | 22 +++++++++++ server/lib/README.md | 2 + .../aiToolkit/defaults/providers.sample.json | 31 +++++++++++++++ .../lib/aiToolkit/internal/modelFetchers.js | 5 +++ .../aiToolkit/internal/modelFetchers.test.js | 1 + server/lib/aiToolkit/internal/pi.js | 17 ++++++++ server/lib/aiToolkit/providers.js | 18 +++++++-- server/lib/aiToolkit/providers.test.js | 19 +++++++++ server/lib/cliProviderArgs.js | 1 + server/lib/harnessOutput.js | 2 + server/lib/index.js | 1 + server/lib/pi.js | 25 ++++++++++++ server/lib/pi.test.js | 38 ++++++++++++++++++ server/lib/providerModels.js | 4 +- server/lib/providerVendors.js | 30 +++++++++++++- server/lib/reviewerConfig.js | 5 ++- server/lib/reviewerConfig.test.js | 2 +- server/lib/slashdoInvocation.js | 3 +- server/services/providerRuntimeInstaller.js | 5 +++ 25 files changed, 297 insertions(+), 13 deletions(-) create mode 100644 scripts/migrations/354-pi-provider.js create mode 100644 scripts/migrations/354-pi-provider.test.js create mode 100644 server/lib/aiToolkit/internal/pi.js create mode 100644 server/lib/pi.js create mode 100644 server/lib/pi.test.js diff --git a/client/src/components/cos/constants.js b/client/src/components/cos/constants.js index c178e95cdd..cdd57a59da 100644 --- a/client/src/components/cos/constants.js +++ b/client/src/components/cos/constants.js @@ -288,6 +288,7 @@ export function pinnedPrCompletion(metadata) { // Copy only — the ROSTER is `REVIEWER_VALUES` in `client/src/lib/reviewerPins.js`, // which the server suite pins against the server's own enum. const REVIEWER_COPY = { + pi: { label: 'Pi', description: 'Pi Coding Agent CLI reviews the supplied diff without tools' }, copilot: { label: 'Copilot', description: 'GitHub Copilot (GitHub-only)' }, claude: { label: 'Claude', description: 'Claude CLI reviews the PR diff (optional model on Models → Code Reviewers; supports an Ollama-backed Claude for local-only setups)' }, antigravity: { label: 'Antigravity', description: 'Antigravity CLI (agy) reviews the PR diff' }, diff --git a/client/src/lib/reviewerPins.js b/client/src/lib/reviewerPins.js index f7c233fe71..ec153e9418 100644 --- a/client/src/lib/reviewerPins.js +++ b/client/src/lib/reviewerPins.js @@ -37,7 +37,7 @@ import { // EFFORT_SELECTABLE_REVIEWERS below: `grok`/`opencode`/`kimi` take a model but no // pickable effort, and Cursor takes both while carrying its effort INSIDE the // model id rather than as a separate flag. -export const MODEL_CAPABLE_CLI_REVIEWERS = ['codex', 'claude', 'antigravity', 'grok', 'cursor', 'opencode', 'kimi']; +export const MODEL_CAPABLE_CLI_REVIEWERS = ['codex', 'claude', 'antigravity', 'grok', 'cursor', 'pi', 'opencode', 'kimi']; // The local-LLM backends, which take both a model and an effort. export const LOCAL_LLM_REVIEWERS = ['lmstudio', 'ollama', 'mtplx']; @@ -71,6 +71,7 @@ export const REVIEWER_EFFORT_LEVELS = Object.freeze({ codex: CODEX_EFFORT_LEVELS, antigravity: ANTIGRAVITY_EFFORT_LEVELS, cursor: CURSOR_EFFORT_LEVELS, + pi: ['low', 'medium', 'high', 'xhigh', 'max'], grok: GROK_EFFORT_LEVELS, lmstudio: LOCAL_LLM_EFFORT_LEVELS, ollama: LOCAL_LLM_EFFORT_LEVELS, @@ -132,7 +133,7 @@ export const sanitizeReviewerModelInput = (raw) => // never offer a slug the server's enum would reject. Mirror of REVIEWER_VALUES — // a reviewer listed here but unknown to the server leaves the user configuring a // review-loop reviewer that never runs; the reverse hides one their install has. -export const REVIEWER_VALUES = ['copilot', 'claude', 'antigravity', 'codex', 'grok', 'cursor', 'opencode', 'kimi', 'lmstudio', 'ollama', 'mtplx']; +export const REVIEWER_VALUES = ['copilot', 'claude', 'antigravity', 'codex', 'grok', 'cursor', 'pi', 'opencode', 'kimi', 'lmstudio', 'ollama', 'mtplx']; // The reviewer a task falls back to when none is configured. Mirror of // DEFAULT_REVIEWER / DEFAULT_REVIEWERS. diff --git a/client/src/utils/providers.js b/client/src/utils/providers.js index 532b1c4cbf..9725a743d3 100644 --- a/client/src/utils/providers.js +++ b/client/src/utils/providers.js @@ -733,6 +733,7 @@ export const effortLevelsForProvider = (provider, model = null) => { return perModel.length ? perModel : null; } if (isCursorProvider(provider)) return CURSOR_EFFORT_LEVELS; + if (commandBasename(provider.command) === 'pi') return ['low', 'medium', 'high', 'xhigh', 'max']; if (isGrokProvider(provider)) return GROK_EFFORT_LEVELS; const id = String(provider.id || '').toLowerCase(); if (id.startsWith('claude-code') || commandBasename(provider.command) === 'claude') return CLAUDE_EFFORT_LEVELS; diff --git a/client/src/utils/providers.test.js b/client/src/utils/providers.test.js index b36d2671f5..389ddacdbd 100644 --- a/client/src/utils/providers.test.js +++ b/client/src/utils/providers.test.js @@ -1226,7 +1226,7 @@ describe('supportsModelRefresh', () => { // provider — nothing here can enumerate that, and Models → Harnesses // ("Refresh models") is where their catalog comes from instead. 'opencode-zen', - 'openrouter', 'orcarouter', 'slotstream', + 'openrouter', 'orcarouter', 'pi-cli', 'pi-tui', 'slotstream', ]); }); }); diff --git a/data.reference/providers.json b/data.reference/providers.json index 8b886b8d23..2658ee884a 100644 --- a/data.reference/providers.json +++ b/data.reference/providers.json @@ -1,6 +1,37 @@ { "activeProvider": "claude-code-tui", "providers": { + "pi-cli": { + "id": "pi-cli", + "name": "Pi Coding Agent CLI", + "type": "cli", + "command": "pi", + "args": [ + "--print", + "--approve" + ], + "models": [], + "defaultModel": null, + "timeout": 600000, + "enabled": false, + "envVars": {}, + "secretEnvVars": [] + }, + "pi-tui": { + "id": "pi-tui", + "name": "Pi Coding Agent TUI", + "type": "tui", + "command": "pi", + "args": [ + "--approve" + ], + "models": [], + "defaultModel": null, + "timeout": 600000, + "enabled": false, + "envVars": {}, + "secretEnvVars": [] + }, "claude-code": { "id": "claude-code", "name": "Claude Code CLI", diff --git a/scripts/migrations/354-pi-provider.js b/scripts/migrations/354-pi-provider.js new file mode 100644 index 0000000000..e31cfaf6f3 --- /dev/null +++ b/scripts/migrations/354-pi-provider.js @@ -0,0 +1,39 @@ +/** Add disabled Pi presets without changing configured providers or starting work. */ +import { makeProviderSeedMigration } from './_lib.js'; + +export default makeProviderSeedMigration({ + label: 'Pi Coding Agent', + defs: [ + { + "id": "pi-cli", + "name": "Pi Coding Agent CLI", + "type": "cli", + "command": "pi", + "args": [ + "--print", + "--approve" + ], + "models": [], + "defaultModel": null, + "timeout": 600000, + "enabled": false, + "envVars": {}, + "secretEnvVars": [] + }, + { + "id": "pi-tui", + "name": "Pi Coding Agent TUI", + "type": "tui", + "command": "pi", + "args": [ + "--approve" + ], + "models": [], + "defaultModel": null, + "timeout": 600000, + "enabled": false, + "envVars": {}, + "secretEnvVars": [] + } +], +}); diff --git a/scripts/migrations/354-pi-provider.test.js b/scripts/migrations/354-pi-provider.test.js new file mode 100644 index 0000000000..c9570bd266 --- /dev/null +++ b/scripts/migrations/354-pi-provider.test.js @@ -0,0 +1,22 @@ +import { it, expect } from 'vitest'; +import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import migration from './354-pi-provider.js'; + +it('adds disabled Pi presets idempotently without replacing local configuration', async () => { + const rootDir = await mkdtemp(join(tmpdir(), 'pi-seed-')); + await mkdir(join(rootDir, 'data')); + const path = join(rootDir, 'data/providers.json'); + const custom = { id: 'pi-cli', command: '/opt/bin/pi', enabled: true }; + await writeFile(path, JSON.stringify({ activeProvider: 'pi-cli', providers: { 'pi-cli': custom } })); + await migration.up({ rootDir }); + const once = await readFile(path, 'utf8'); + await migration.up({ rootDir }); + expect(await readFile(path, 'utf8')).toBe(once); + const state = JSON.parse(once); + expect(state.activeProvider).toBe('pi-cli'); + expect(state.providers['pi-cli']).toEqual(custom); + expect(state.providers['pi-tui']).toMatchObject({ enabled: false, models: [], defaultModel: null, command: 'pi' }); + await rm(rootDir, { recursive: true }); +}); diff --git a/server/lib/README.md b/server/lib/README.md index b379e80784..fdad37329c 100644 --- a/server/lib/README.md +++ b/server/lib/README.md @@ -552,3 +552,5 @@ pm` default, `NPM_CONFIG_PREFIX`, nvm/Volta) installed `codex` successfully and | `eidoverseCityLayout.js` | Native halls, rooftop landmark clearance and plinths, curated furniture, visitor chambers, and ordered signal bays for PortOS Commons. | | `eidoverseCitySurface.js` | Deterministic GLB island scenery, pedestrian paths, gathering terraces, and physical district signs, stored through Eidoverse's content-addressed upload API. | | `eidoverseIslandLandscape.js` | `appendEidoverseIslandLandscape` appends deterministic coastline, ocean, and distant mountain-island geometry to the Commons asset. | + +| `pi.js` | Pi command identity, headless/TUI arguments, and positional prompt delivery. | diff --git a/server/lib/aiToolkit/defaults/providers.sample.json b/server/lib/aiToolkit/defaults/providers.sample.json index f49a300653..ec0658411e 100644 --- a/server/lib/aiToolkit/defaults/providers.sample.json +++ b/server/lib/aiToolkit/defaults/providers.sample.json @@ -1,6 +1,37 @@ { "activeProvider": "claude-code-tui", "providers": { + "pi-cli": { + "id": "pi-cli", + "name": "Pi Coding Agent CLI", + "type": "cli", + "command": "pi", + "args": [ + "--print", + "--approve" + ], + "models": [], + "defaultModel": null, + "timeout": 600000, + "enabled": false, + "envVars": {}, + "secretEnvVars": [] + }, + "pi-tui": { + "id": "pi-tui", + "name": "Pi Coding Agent TUI", + "type": "tui", + "command": "pi", + "args": [ + "--approve" + ], + "models": [], + "defaultModel": null, + "timeout": 600000, + "enabled": false, + "envVars": {}, + "secretEnvVars": [] + }, "claude-code": { "id": "claude-code", "name": "Claude Code CLI", diff --git a/server/lib/aiToolkit/internal/modelFetchers.js b/server/lib/aiToolkit/internal/modelFetchers.js index 2264f62734..eb09a93f3c 100644 --- a/server/lib/aiToolkit/internal/modelFetchers.js +++ b/server/lib/aiToolkit/internal/modelFetchers.js @@ -1,3 +1,4 @@ +import { isPiCommand } from './pi.js'; /** * The single per-vendor table behind model refresh. * @@ -54,6 +55,10 @@ const displayName = (provider) => String(provider?.name || '').toLowerCase(); * gemini) exactly as the old chain did. */ export const MODEL_FETCHERS = [ + { + key: 'pi', cliMatch: (p) => isPiCommand(p?.command), + tuiMatch: (p) => isPiCommand(p?.command), fetch: '_fetchPiModels', + }, { key: 'ollama', // Not a command test: the marker can be `ollamaBacked`, an id, or an diff --git a/server/lib/aiToolkit/internal/modelFetchers.test.js b/server/lib/aiToolkit/internal/modelFetchers.test.js index 47e414be27..a6189af874 100644 --- a/server/lib/aiToolkit/internal/modelFetchers.test.js +++ b/server/lib/aiToolkit/internal/modelFetchers.test.js @@ -15,6 +15,7 @@ const SHIPPED = JSON.parse(readFileSync(resolve(__dirname, '../../../../data.ref // failure mode is silent in both directions (a button that 404s, or a feature // that vanishes with no error at all). const SHIPPED_REFRESHABLE = [ + 'pi-cli', 'pi-tui', 'antigravity-cli', 'antigravity-tui', 'cerebras', 'claude-code', 'claude-code-bedrock', 'claude-ollama', 'claude-ollama-tui', 'cursor-cli', 'cursor-tui', 'grok', 'lmstudio', 'mtplx', 'nvidia-kimi', 'ollama', diff --git a/server/lib/aiToolkit/internal/pi.js b/server/lib/aiToolkit/internal/pi.js new file mode 100644 index 0000000000..c97a7b253f --- /dev/null +++ b/server/lib/aiToolkit/internal/pi.js @@ -0,0 +1,17 @@ +/** Toolkit-local Pi identity and model table parser. */ +import { commandBasename } from './commandBasename.js'; +export const PI_COMMAND = 'pi'; +export const isPiCommand = (command) => commandBasename(command) === PI_COMMAND; + +/** Pi lists provider, model, context, max output, thinking, and image columns. */ +export function parsePiModelList(stdout) { + const ids = []; + for (const line of String(stdout || '').split(/\r?\n/)) { + const columns = line.trim().split(/\s+/); + if (columns.length >= 4 && /^[\d.,]+[kKmM]?$/.test(columns[2]) + && /^[a-zA-Z0-9._-]+$/.test(columns[0]) && /^[a-zA-Z0-9._:/-]+$/.test(columns[1])) { + ids.push(`${columns[0]}/${columns[1]}`); + } + } + return [...new Set(ids)]; +} diff --git a/server/lib/aiToolkit/providers.js b/server/lib/aiToolkit/providers.js index 2e39364009..861e01acbe 100644 --- a/server/lib/aiToolkit/providers.js +++ b/server/lib/aiToolkit/providers.js @@ -1,3 +1,4 @@ +import { PI_COMMAND, parsePiModelList } from './internal/pi.js'; import { readFile, rename } from 'fs/promises'; import { existsSync } from 'fs'; import { join, dirname, delimiter, isAbsolute } from 'path'; @@ -1321,11 +1322,13 @@ export function createProviderService(config = {}) { * @param {object} provider * @param {string} defaultBin - binary to use when the provider pins no command * @param {(stdout: string) => string[]} parse - vendor's stdout → ids parser - * @returns {Promise} a non-empty id list + * @param {string[]} [listArgs] - catalog command arguments + * @param {(stdout: string) => boolean} [isEmptyCatalog] - explicit empty-catalog response + * @returns {Promise} parsed ids; empty only when explicitly recognized */ - async _execCliModelList(provider, defaultBin, parse) { + async _execCliModelList(provider, defaultBin, parse, listArgs = ['models'], isEmptyCatalog = () => false) { const bin = provider?.command || defaultBin; - const { command, args } = prepareWindowsSafeSpawn(bin, ['models']); + const { command, args } = prepareWindowsSafeSpawn(bin, listArgs); const pending = execFileAsync(command, args, { timeout: 15000, env: { ...process.env, ...provider?.envVars }, @@ -1340,16 +1343,23 @@ export function createProviderService(config = {}) { // or not a given binary has the behavior. pending.child?.stdin?.end(); const { stdout } = await pending.catch((err) => { + const output = `${err.stdout || ''}\n${err.stderr || ''}`; + if (!err.killed && isEmptyCatalog(output)) return { stdout: output }; throw new Error(`'${bin} models' failed: ${err?.message || 'could not run the binary'}`); }); const listed = parse(stdout); - if (listed.length === 0) { + if (listed.length === 0 && !isEmptyCatalog(stdout)) { throw new Error(`'${bin} models' returned no model ids`); } return listed; }, + async _fetchPiModels(provider) { + return this._execCliModelList(provider, PI_COMMAND, parsePiModelList, ['--list-models'], + (stdout) => /No models available/i.test(stdout) && /\/login/.test(stdout)); + }, + /** * cursor-agent ships a `models` subcommand that prints the authoritative * catalog for THIS account and binary version — 177 ids at time of writing, diff --git a/server/lib/aiToolkit/providers.test.js b/server/lib/aiToolkit/providers.test.js index 8efdbe022e..9f28001c7e 100644 --- a/server/lib/aiToolkit/providers.test.js +++ b/server/lib/aiToolkit/providers.test.js @@ -23,6 +23,25 @@ describe('Provider Service', () => { if (TEST_DATA_DIR) await rm(TEST_DATA_DIR, { recursive: true, force: true }); }); + it.skipIf(process.platform === 'win32')('refreshes Pi models and distinguishes authentication from probe failure', async () => { + const command = join(TEST_DATA_DIR, 'pi'); + const emit = async (text, code = 0) => { + await writeFile(command, `#!/usr/bin/env node\nif (process.argv[2] !== '--list-models') process.exit(9);\nconsole.log(${JSON.stringify(text)}); process.exit(${code});\n`); + await chmod(command, 0o755); + }; + await emit('provider model context max-out thinking images\nexample model-a 200K 32K yes yes'); + const provider = await providerService.createProvider({ name: 'Pi test', type: 'cli', command, models: [] }); + const refreshed = await providerService.refreshProviderModels(provider.id); + expect(refreshed.models).toEqual(['example/model-a']); + await emit('Temporary transport failure', 1); + await expect(providerService.refreshProviderModels(provider.id)).rejects.toThrow('failed'); + expect((await providerService.getProviderById(provider.id)).models).toEqual(['example/model-a']); + await emit('No models available. Use /login to authenticate.'); + expect(await providerService._fetchPiModels({ command })).toEqual([]); + await emit('No models available. Use /login to authenticate.', 1); + expect(await providerService._fetchPiModels({ command })).toEqual([]); + }); + it('should create a provider', async () => { const provider = await providerService.createProvider({ name: 'Test Provider', diff --git a/server/lib/cliProviderArgs.js b/server/lib/cliProviderArgs.js index e0b0560dc9..0ccddc329b 100644 --- a/server/lib/cliProviderArgs.js +++ b/server/lib/cliProviderArgs.js @@ -1,3 +1,4 @@ +/** Pi uses --print --approve, --thinking, and a trailing argv prompt (not stdin). */ /** * Per-CLI argv conventions for stdin-based prompt delivery. * diff --git a/server/lib/harnessOutput.js b/server/lib/harnessOutput.js index 2e0cea6e0a..ce3679a7ea 100644 --- a/server/lib/harnessOutput.js +++ b/server/lib/harnessOutput.js @@ -1,3 +1,4 @@ +import { parsePiModelList } from './aiToolkit/internal/pi.js'; /** * Parsers for what a coding-agent HARNESS prints about itself — its version * banner and its model catalog. @@ -112,6 +113,7 @@ const parseGrokModels = (lines) => lines * `providerRuntimeInstaller.test.js`. */ const MODEL_PARSERS = { + pi: (lines) => parsePiModelList(lines.join('\n')), opencode: parseOpencodeModels, grok: parseGrokModels, // Delegated — these two vendors' stdout shapes are already owned elsewhere. diff --git a/server/lib/index.js b/server/lib/index.js index 213b24d2db..df73fa8ba4 100644 --- a/server/lib/index.js +++ b/server/lib/index.js @@ -527,3 +527,4 @@ export * from './eidoverseCityLayout.js'; export * from './eidoverseCitySurface.js'; export * from './fableLoomShots.js'; export * from './eidoverseIslandLandscape.js'; +export * from './pi.js'; diff --git a/server/lib/pi.js b/server/lib/pi.js new file mode 100644 index 0000000000..d9eec5a0a7 --- /dev/null +++ b/server/lib/pi.js @@ -0,0 +1,25 @@ +/** Pi CLI argv conventions. See https://pi.dev/docs/latest/usage. */ +import { argvHasFlag, commandBasename, hasModelFlag, buildEffortArgs } from './providerModels.js'; + +export const PI_COMMAND = 'pi'; +export const isPiCommand = (command) => commandBasename(command) === PI_COMMAND; + +export function ensurePiTuiArgs(baseArgs = []) { + const args = [...baseArgs]; + if (!argvHasFlag(args, ['--approve', '-a', '--no-approve', '-na'])) args.push('--approve'); + return args; +} + +export function ensurePiHeadlessArgs(baseArgs = [], model, effort) { + const args = ensurePiTuiArgs(baseArgs); + if (!argvHasFlag(args, ['--print', '-p'])) args.push('--print'); + if (model && !hasModelFlag(args)) args.push('--model', model); + args.push(...buildEffortArgs(effort, { command: PI_COMMAND }, args)); + return args; +} + +export function preparePiPrompt(args = [], prompt = '') { + // Prefix prevents pi interpreting a leading @ as a file attachment; -- ends + // option parsing so a prompt cannot inject CLI flags. + return { args: [...args, '--', `Task:\n${prompt}`], useStdin: false, cleanup: () => {} }; +} diff --git a/server/lib/pi.test.js b/server/lib/pi.test.js new file mode 100644 index 0000000000..02b2ccd821 --- /dev/null +++ b/server/lib/pi.test.js @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { buildCliArgs, prepareCliPrompt } from './cliProviderArgs.js'; +import { isPiCommand, ensurePiHeadlessArgs } from './pi.js'; +import { PROVIDER_VENDORS, publicReviewRecipe, PUBLIC_REVIEW_NO_TOOL_POSTURE, PUBLIC_REVIEW_ACTIONS_POSTURE } from './providerVendors.js'; +import { parseHarnessModels } from './harnessOutput.js'; +import { reviewerEffortArgs } from './reviewerConfig.js'; + +describe('Pi provider boundaries', () => { + it('constructs headless arguments and delivers option-like prompts as text', () => { + const provider = { id: 'pi-cli', type: 'cli', command: 'pi', args: [], defaultModel: 'example/model', effort: 'high' }; + const args = buildCliArgs(provider); + expect(args).toEqual(expect.arrayContaining(['--print', '--approve', '--model', 'example/model', '--thinking', 'high'])); + expect(ensurePiHeadlessArgs(args, null, 'high')).toEqual(expect.arrayContaining(['--thinking', 'high'])); + const prepared = prepareCliPrompt('pi', args, '@private --approve'); + expect(prepared.useStdin).toBe(false); + expect(prepared.args.slice(-2)).toEqual(['--', 'Task:\n@private --approve']); + expect(reviewerEffortArgs('pi', 'high')).toEqual(['--thinking', 'high']); + }); + it('preserves explicitly configured flags and identifies exact binary names', () => { + const args = ['-p', '-na', '--model=example/custom', '--thinking=low']; + expect(ensurePiHeadlessArgs(args, 'example/other', 'high')).toEqual(args); + expect(isPiCommand('/opt/bin/pi.exe')).toBe(true); + expect(isPiCommand('pipeline')).toBe(false); + expect(PROVIDER_VENDORS.slice(-2).map(v => v.id)).toEqual(['pi', 'claude']); + }); + it('discards unsafe saved arguments in the no-tool posture and refuses action review', () => { + const provider = { type: 'cli', command: 'pi', args: ['--approve', '-e', 'untrusted.js', '--tools', 'bash'] }; + const recipe = publicReviewRecipe(provider, PUBLIC_REVIEW_NO_TOOL_POSTURE); + const { args } = recipe.spawnArgs(provider, {}); + expect(args).toEqual(expect.arrayContaining(['--no-approve', '--no-tools', '--no-extensions', '--no-context-files'])); + expect(args).not.toContain('--approve'); + expect(args).not.toContain('untrusted.js'); + expect(publicReviewRecipe(provider, PUBLIC_REVIEW_ACTIONS_POSTURE)).toBeNull(); + }); + it('parses qualified model IDs without treating login instructions as a model', () => { + expect(parseHarnessModels('pi', 'provider model context max-out thinking images\nexample model-a 200K 32K yes yes\nexample model-a 200K 32K yes yes\nUse /login to authenticate')).toEqual(['example/model-a']); + }); +}); diff --git a/server/lib/providerModels.js b/server/lib/providerModels.js index 44654fa147..218b4e639c 100644 --- a/server/lib/providerModels.js +++ b/server/lib/providerModels.js @@ -386,6 +386,7 @@ export function effortLevelsForProvider(provider, model = null) { if (perModel === null) return ANTIGRAVITY_EFFORT_LEVELS; return perModel.length ? perModel : null; } + if (commandBasename(provider.command) === 'pi') return ['low', 'medium', 'high', 'xhigh', 'max']; if (isCursorProvider(provider)) return CURSOR_EFFORT_LEVELS; if (isGrokProvider(provider)) return GROK_EFFORT_LEVELS; if (isClaudeProvider(provider)) return CLAUDE_EFFORT_LEVELS; @@ -438,7 +439,7 @@ export const CODEX_EFFORT_KEY = 'model_reasoning_effort'; // provider args gets a SECOND, injected `--effort ` appended. Grok's // parser accepts the duplicate and takes the last one, so their explicit pin // would be silently overridden — the exact opposite of the contract below. -const EFFORT_FLAG_NAMES = Object.freeze(['--effort', '--reasoning-effort']); +const EFFORT_FLAG_NAMES = Object.freeze(['--effort', '--reasoning-effort', '--thinking']); /** * True when the user has already baked an effort override into the provider's @@ -485,6 +486,7 @@ export function hasEffortFlag(args) { export function buildEffortArgs(effort, provider, existingArgs = [], model = null) { const effectiveEffort = resolveCliEffort(effort, provider, model); if (!effectiveEffort || hasEffortFlag(existingArgs)) return []; + if (commandBasename(provider?.command) === 'pi') return ['--thinking', effectiveEffort]; if (isCursorProvider(provider)) return []; // rides `--model`, not a flag — see above return isCodexProvider(provider) ? ['-c', `${CODEX_EFFORT_KEY}=${effectiveEffort}`] diff --git a/server/lib/providerVendors.js b/server/lib/providerVendors.js index fe50d61b81..b704936d9c 100644 --- a/server/lib/providerVendors.js +++ b/server/lib/providerVendors.js @@ -106,6 +106,7 @@ import { ensureCursorTuiArgs, ensureCursorHeadlessArgs, } from './cursor.js'; +import { PI_COMMAND, isPiCommand, ensurePiTuiArgs, ensurePiHeadlessArgs, preparePiPrompt } from './pi.js'; import { PROVIDER_TYPES } from './aiToolkit/constants.js'; import { publicReviewPostureForProfile, @@ -547,6 +548,33 @@ const GEMINI_LEGACY = { // is deliberately incomplete. }; +// Pi public review discards configured args and disables every tool/resource +// discovery surface. --no-builtin-tools alone leaves extension tools enabled. +const piCliArgs = (args, { model, effort }) => ensurePiHeadlessArgs(args, model, effort); +const PI = { + id: 'pi', + idFragment: 'pi-', + inferredCommand: PI_COMMAND, + matchCommand: isPiCommand, + tuiArgs: ensurePiTuiArgs, + cliArgs: piCliArgs, + preparePrompt: preparePiPrompt, + spawnArgs: defaultSpawnArgs(piCliArgs, PI_COMMAND), + publicReview: { + [PUBLIC_REVIEW_NO_TOOL_POSTURE]: { + matchProvider: (provider) => isDirectBinaryProvider(provider) && isPiCommand(provider?.command), + spawnArgs: (provider, { effectiveModel, effort } = {}) => ({ + command: provider.command, + args: ensurePiHeadlessArgs([ + '--no-approve', '--no-tools', '--no-builtin-tools', '--no-extensions', + '--no-skills', '--no-prompt-templates', '--no-themes', '--no-context-files', '--no-session', + ], effectiveModel, effort), + stdinMode: 'prompt', + }), + }, + }, +}; + // ─── claude (default fallback — MUST stay last) ──────────────────────────── function claudeCliArgs(baseArgs, { model, effort, provider }) { @@ -753,7 +781,7 @@ const CLAUDE = { * exclusive by construction (distinct binary basenames, or a provider-id * check that doesn't overlap with a command-basename check). */ -export const PROVIDER_VENDORS = [CODEX, ANTIGRAVITY, CURSOR, GEMINI_LEGACY, KIMI, GROK, OPENCODE, CLAUDE]; +export const PROVIDER_VENDORS = [CODEX, ANTIGRAVITY, CURSOR, GEMINI_LEGACY, KIMI, GROK, OPENCODE, PI, CLAUDE]; /** * A row's `matchCliProvider` may be absent when it's identical to diff --git a/server/lib/reviewerConfig.js b/server/lib/reviewerConfig.js index 24069b97e4..1b29f77d7a 100644 --- a/server/lib/reviewerConfig.js +++ b/server/lib/reviewerConfig.js @@ -30,7 +30,7 @@ import { CURSOR_COMMAND } from './cursor.js'; // `opencode`/`kimi`/`mtplx` — like `lmstudio` — have no slashdo counterpart, so // they are PORTOS_ONLY_REVIEWERS. // Mirrored in client/src/components/cos/constants.js → REVIEWER_OPTIONS. -export const REVIEWER_VALUES = ['copilot', 'claude', 'antigravity', 'codex', 'grok', 'cursor', 'opencode', 'kimi', 'lmstudio', 'ollama', 'mtplx']; +export const REVIEWER_VALUES = ['copilot', 'claude', 'antigravity', 'codex', 'grok', 'cursor', 'pi', 'opencode', 'kimi', 'lmstudio', 'ollama', 'mtplx']; export const REVIEWER_ALIASES = { gemini: 'antigravity', 'cursor-agent': 'cursor' }; export const DEFAULT_REVIEWER = 'copilot'; export const DEFAULT_REVIEWERS = ['copilot']; @@ -71,7 +71,7 @@ export const PORTOS_ONLY_REVIEWERS = ['lmstudio', 'mtplx', 'opencode', 'kimi']; // get their model injected server-side by `POST /api/code-review/local`. Add a // reviewer here when its CLI gains model selection; the `Model` // settings scalar is generated from this roster (codeReviewSettingsSchema). -export const MODEL_CAPABLE_CLI_REVIEWERS = ['codex', 'claude', 'antigravity', 'grok', 'cursor', 'opencode', 'kimi']; +export const MODEL_CAPABLE_CLI_REVIEWERS = ['codex', 'claude', 'antigravity', 'grok', 'cursor', 'pi', 'opencode', 'kimi']; // Every reviewer whose model the user can PICK in the UI: the model-capable CLIs // above (threaded into the follow-up prompt as ` --model `) plus the // local-LLM backends (whose id is injected server-side by @@ -96,6 +96,7 @@ export const REVIEWER_CLI_BINARIES = { codex: 'codex', grok: 'grok', cursor: CURSOR_COMMAND, + pi: 'pi', opencode: 'opencode', kimi: 'kimi', }; diff --git a/server/lib/reviewerConfig.test.js b/server/lib/reviewerConfig.test.js index 8be39203cc..be999627e8 100644 --- a/server/lib/reviewerConfig.test.js +++ b/server/lib/reviewerConfig.test.js @@ -184,7 +184,7 @@ describe('per-reviewer reasoning effort (reviewerEfforts)', () => { it('EFFORT_SELECTABLE_REVIEWERS is exactly the reviewers with a non-empty ladder', () => { expect([...EFFORT_SELECTABLE_REVIEWERS].sort()) - .toEqual(['antigravity', 'claude', 'codex', 'cursor', 'grok', 'lmstudio', 'mtplx', 'ollama']); + .toEqual(['antigravity', 'claude', 'codex', 'cursor', 'grok', 'lmstudio', 'mtplx', 'ollama', 'pi']); for (const reviewer of REVIEWER_VALUES) { expect(EFFORT_SELECTABLE_REVIEWERS.includes(reviewer)) .toBe((reviewerEffortLevels(reviewer) || []).length > 0); diff --git a/server/lib/slashdoInvocation.js b/server/lib/slashdoInvocation.js index 65fd82b555..3d4daf577b 100644 --- a/server/lib/slashdoInvocation.js +++ b/server/lib/slashdoInvocation.js @@ -73,7 +73,7 @@ export const SLASHDO_REVIEWER_INCLUDE_NAMES = Object.freeze(Object.values(SLASHD * (whose keys are the same roster); a reviewer added to one and not the other * is a drift the test catches. */ -export const LOCAL_AGENT_REVIEWERS = new Set(['claude', 'codex', 'antigravity', 'grok', 'cursor', 'opencode', 'kimi']); +export const LOCAL_AGENT_REVIEWERS = new Set(['claude', 'codex', 'antigravity', 'grok', 'cursor', 'pi', 'opencode', 'kimi']); /** Reviewer slugs that drive slashdo's local-model (Ollama-style) loop. */ const LOCAL_MODEL_REVIEWERS = new Set(['ollama', 'lmstudio', 'mtplx']); /** @@ -98,6 +98,7 @@ const SLASHDO_REVIEWER_SLUGS = Object.freeze({ claude: 'claude', grok: 'grok', cursor: 'cursor', + pi: 'pi', 'cursor-agent': 'cursor', agy: 'antigravity', gemini: 'antigravity', diff --git a/server/services/providerRuntimeInstaller.js b/server/services/providerRuntimeInstaller.js index 08f18c39c5..9295f23be0 100644 --- a/server/services/providerRuntimeInstaller.js +++ b/server/services/providerRuntimeInstaller.js @@ -94,6 +94,11 @@ const PROBE_TIMEOUT_MS = 15_000; /** One row per installable provider runtime, keyed by its vendor row. */ const RUNTIME_ROWS = [ + { + vendor: 'pi', label: 'Pi Coding Agent CLI', + install: { kind: 'npm', package: '@earendil-works/pi-coding-agent@latest' }, + selfUpdate: ['update'], modelsArgs: ['--list-models'], docsUrl: 'https://pi.dev/docs', + }, { vendor: 'claude', label: 'Claude Code CLI', From ce5c6b306d0f268d15fc771fc6599e104e6c557d Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sun, 6 Sep 2026 00:44:50 +0000 Subject: [PATCH 2/7] fix: preserve Pi provider identity and stored model catalogs (#6350) --- server/lib/README.md | 3 +-- server/lib/aiToolkit/internal/modelFetchers.js | 8 ++++---- server/lib/aiToolkit/providers.js | 12 ++++++++++-- server/lib/aiToolkit/providers.test.js | 2 ++ server/lib/pi.test.js | 4 +++- server/lib/providerModels.js | 2 ++ server/lib/providerVendors.js | 3 ++- 7 files changed, 24 insertions(+), 10 deletions(-) diff --git a/server/lib/README.md b/server/lib/README.md index fdad37329c..bf7143300c 100644 --- a/server/lib/README.md +++ b/server/lib/README.md @@ -171,6 +171,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub | `videoDurationProfiles.js` | Pure pinned duration/frame contracts shared by model-registry upgrades and migrations. LTX-2.5 A2V follows the full uploaded audio, rounds up to its 8n+1 temporal grid, and tops out at 1017 frames under the API's single-pass boundary. | | `videoReferenceModes.js` | The i2v reference-mode contract (#4874) — what a supplied conditioning image PROMISES. `I2V_REFERENCE_MODES` (`anchor` \| `inspire`) + `I2V_REFERENCE_MODE_OPTIONS` (the label + the promise sentence the UI prints), `I2V_REFERENCE_MODE_RUNTIMES` (only `ltx25` can honor `inspire` — it needs per-image conditioning strength), `INSPIRE_DEFAULT_IMAGE_STRENGTH`, plus `normalizeI2vReferenceMode` / `isDefaultI2vReferenceMode` / `isKnownI2vReferenceMode` / `runtimeSupportsI2vReferenceMode` / `i2vReferenceModeLabel` / `resolveI2vReferenceStrength` and the one rule `i2vReferenceModeViolation({ model, mode, referenceMode, hasFirstImage })` → `{ code, message }` or null. Pure (no `ServerError`) because it is MIRRORED to `client/src/lib/videoReferenceModes.js`; `videoGen/modeContract.js#videoReferenceModeError` wraps it for the route + render boundaries. | | `videoTextEncoders.js` | Swappable prompt conditioners for local video runtimes. MiniMax H3 reads the *unnormalized* hidden state after Qwen3-VL language layer 49 (layers 50-63, the final norm and `lm_head` are never evaluated), so any checkpoint carrying the same embedding + layers 0-49 + vision tower is a drop-in conditioner — swapping it changes how the model reads a prompt without touching the diffusion weights. `TEXT_ENCODERS_BY_RUNTIME` declares the shipped options per runtime (pinned repo/revision plus an explicit `files` LIST — one repackaged safetensors, or just the shards of an upstream checkpoint that carry parameters the loader actually builds; in code rather than the media-models registry so a stale `data/media-models.json` can't name a file the runner can't map); `videoTextEncoderOptions(model)` returns the TRUE list stock-first (it deliberately does NOT collapse a one-entry runtime to `[]` — that is a presentation rule, and folding it in here would change what the server believes a model supports and empty the "offers …" list in the error; `TextEncoderPicker` owns the hide-when-there-is-no-real-choice check), `isStockTextEncoder(id)` makes absence and the `stock` sentinel the same request, `resolveVideoTextEncoder(model, id)` returns `null` for the stock choice or throws `VIDEO_TEXT_ENCODER_UNSUPPORTED` (with the non-throwing `supportsVideoTextEncoder` + `videoTextEncoderUnsupportedError` split out so the request path can reject before staging uploads), and `downloadableVideoTextEncoders()` (deduped by id — the table is keyed by RUNTIME, so one conditioner can be offered by two) / `downloadableVideoTextEncoder(id)` feed the `/api/video-gen/text-encoders/:id/(download\|repair)` lane. Two loader-mechanics fields exist because a ComfyUI-packaged conditioner is namespaced differently from the HF checkpoint the MLX port matches: `keyPrefixMap` (`model.` → `model.language_model.`, `visual.` → `model.visual.`) is applied to every checkpoint key by `scripts/generate_minimax_h3.py` BEFORE the pinned loader sees it — no fork of the pinned runtime — and `finalNormKey` names where the runner synthesizes a ones-filled `norm.weight` for a checkpoint published without one (correct upstream, since H3 reads the state *before* the norm, but the pinned loader refuses to load with any parameter missing). Both are absent for an UPSTREAM Qwen3-VL-32B checkpoint, which already uses the loader namespace and ships its own norm. A candidate must BE Qwen3-VL-32B (the shim reuses upstream's config/tokenizer/processor) — a different Qwen generation is not a substitute however close its conditioning width looks; see docs/features/video-text-encoders.md. `publicTextEncoderOption(entry)` is the client projection and deliberately drops both, so the UI can't reimplement the remap. The `ltx25` table (#4320) uses a third mechanic, `configOverrides`, because an LTX-2.5 pack's OWN Gemma 4 tower wins over `--gemma` inside the pinned fork: the substitution is a standalone shim directory whose generated `config.json` is the substitute's own with these keys merged over it (only ever the `model_type` label a unified checkpoint gets wrong — never `text_config`/`quantization`), and a candidate must BE Gemma 4 12B at 48 layers / hidden 3840 / vocab 262144 / `k_eq_v`. `verified` gates a substitute out of BOTH lanes (picker AND download) until it has been A/B-rendered against its runtime's stock conditioner — required on every non-built-in entry and fail-closed on absence, so a new entry is unreachable until someone states a verdict; both ltx25 substitutes are `verified: false` today. `declaredVideoTextEncoders()` is the UNFILTERED table for shape/invariant checks only — never the render or download path, and `videoTextEncoderRuntimes()` enumerates the table's runtime keys so parity/shape tests cover every runtime rather than the one that happened to exist when they were written. | +| `pi.js` | Pi command identity, headless/TUI arguments, and positional prompt delivery. | | `providerModels.js` | Provider model resolution sentinels + helpers (`CODEX_CONFIGURED_DEFAULT` / `ANTIGRAVITY_CONFIGURED_DEFAULT` / `GROK_CONFIGURED_DEFAULT` / `KIMI_CONFIGURED_DEFAULT`, `resolveCliModel`, `filterSelectableModels`, Bedrock/OpenCode model mappers, `localRuntimeNamespace(provider)` — the OpenCode namespace only when it names a LOCAL daemon, i.e. the composed "namespace and not a hosted gateway" test that `cliChildEnv.js`, `localProviderRuntime.js` and `providerVendors.js` all key on, `CODEX_OSS_LOCAL_PROVIDERS` / `CODEX_OSS_MIN_VERSION` / `codexOssLocalProvider` / `codexUnsupportedLocalRuntime` — the codex half of that same axis: which local runtimes Codex's `--local-provider` can serve, and which marked runtime it cannot, kept here beside the namespace they wrap so `providerPrerequisites.js` classifies a codex record without importing `codex.js` (the argv emitter, `buildCodexOssArgs`, stays there), `OPENCODE_PUBLIC_REVIEW_AGENT` — the read-only OpenCode agent a no-tool public-review stage runs as, kept in this leaf because `providerVendors.js` must not import `opencodeConfig.js`, `parseOpencodeConfigContent` — the shared "is this stored OPENCODE_CONFIG_CONTENT usable?" read — plus `opencodeConfigIsLocalOnly` / `opencodeProviderIsLocalOnly`, the ONE locality rule `providerVendors.js` (gate eligibility) and `cliChildEnv.js` (public-review env allowlist) must not disagree about: if eligibility says yes where the allowlist strips the config, the stage spawns against the user's own ~/.config/opencode with tools intact while still reporting an enforced tool-free gate, `normalizeClaudeModelId` / `resolveClaudeCliModel` — the Claude-argv chokepoint that rewrites a dotted first-party version (`claude-fable-5.1`) to the dashed id Claude Code actually serves before the Bedrock mapping runs, model-flag scan helpers incl. `stripBrokenModelFlags`, `isCodexProvider`, `isKimiProvider`, `isAntigravityProvider`, `isCursorProvider`) plus reasoning-effort helpers for the claude/codex/agy/cursor CLIs (`CLAUDE_EFFORT_LEVELS` / `CODEX_EFFORT_LEVELS` / `ANTIGRAVITY_EFFORT_LEVELS` / `CURSOR_EFFORT_LEVELS` / `EFFORT_LEVELS`, `effortLevelsForProvider`, `resolveCliEffort` — clamps an out-of-range effort to the nearest level the target CLI accepts rather than dropping it, so a value saved against a wider ladder survives a provider switch — `hasEffortFlag`, `buildEffortArgs` — the one emitter of `--effort ` / `-c model_reasoning_effort=`, and deliberately silent for cursor — and `foldCursorEffortIntoModel`, which carries a cursor level inside `--model` as Cursor’s own variant syntax (`gpt-5[effort=max]`) because `cursor-agent` has no `--effort` flag) plus codex startup-arg helpers (`CODEX_EFFORT_KEY`, `CODEX_UPDATE_CHECK_KEY`, `hasCodexUpdateCheckConfig`, `buildCodexStartupArgs` — the one emitter of `-c check_for_update_on_startup=false`, spread by every codex spawn builder to disable the blocking startup update modal) plus `PORTOS_CLI_CONFIG_KEYS` / `isPortosSuppliedConfigKey` — the exhaustive list of `-c =` config keys PortOS injects, read by the `cli-config-invalid` error analyzer to tell a rejected PortOS override apart from a bad line in the user's own CLI config file. | | `providerVendors.js` | `PROVIDER_VENDORS` — one row per coding-agent CLI/TUI vendor (claude/codex/antigravity/opencode/grok/kimi/cursor, plus a deliberately-incomplete legacy `gemini-cli` row), consumed by every dispatch site that used to hand-roll its own vendor if-chain across ~8 branches in 5 files (#3618): `applyCommandDefaults`/`prepareCliPrompt` (re-exported from `tuiHandshake.js`/`cliProviderArgs.js`), `buildVendorCliArgs`/`buildVendorSpawnConfig` (consumed by `cliProviderArgs.js#buildCliArgs` / `agentCliSpawning.js#buildCliSpawnConfig`), `inferTuiCommand` (re-exported from `tuiHandshake.js`), and `injectTuiModelAndEffort` — the shared antigravity-validates-the-pair-vs-everyone-else `--model`/`--effort` injection used by both `tuiHandshake.js#buildTuiInvocation` and `agentTuiSpawning.js#buildTuiSpawnConfig`, replacing a second copy of that split that had already drifted once before this file existed. Doesn't rewrite any vendor's argv-building logic — that stays in `antigravity.js`/`grok.js`/`kimi.js`/`cursor.js`/`codex.js`. Dependency-light on purpose, mirroring those files. | | `modelCapabilityTests.js` | Catalog + scoring for the CAPABILITY tests on `/models/performance` (run by `services/modelCapabilityTests.js`): `CAPABILITY_TESTS` (sandbox repair / image analysis / story outline / fiction scene / rhetoric evaluator, each gated on the capability badges the install catalog already shows), `applicabilityFor` + `applicableTests` (`applicable` / `not-applicable` / `unknown` — an UNCLAIMED capability is never a failure, and `null` capabilities mean the runtime reported none, which is distinct from `[]`), `scoreKeywords` + `VISION_FIXTURE_KEYWORDS` (required vs bonus terms, word-boundary matched with a negation guard so "no dog" doesn't score a dog), `scoreStoryBeats` + `HEROS_JOURNEY_BEATS` (coverage AND ordering, judged only over the beats present), `scoreSandboxRepair` (verdict from observed disk facts — editing the test instead of the module fails outright), `formatAgentEvent` (one agent stream frame → a transcript line), `rollUpVerdict`, and the verbatim `CAPABILITY_TEST_PROMPTS` / `SANDBOX_TASK_PROMPT` the consent gate shows. Pure, so any stored transcript can be re-scored with no provider call. | @@ -552,5 +553,3 @@ pm` default, `NPM_CONFIG_PREFIX`, nvm/Volta) installed `codex` successfully and | `eidoverseCityLayout.js` | Native halls, rooftop landmark clearance and plinths, curated furniture, visitor chambers, and ordered signal bays for PortOS Commons. | | `eidoverseCitySurface.js` | Deterministic GLB island scenery, pedestrian paths, gathering terraces, and physical district signs, stored through Eidoverse's content-addressed upload API. | | `eidoverseIslandLandscape.js` | `appendEidoverseIslandLandscape` appends deterministic coastline, ocean, and distant mountain-island geometry to the Commons asset. | - -| `pi.js` | Pi command identity, headless/TUI arguments, and positional prompt delivery. | diff --git a/server/lib/aiToolkit/internal/modelFetchers.js b/server/lib/aiToolkit/internal/modelFetchers.js index eb09a93f3c..eed975380d 100644 --- a/server/lib/aiToolkit/internal/modelFetchers.js +++ b/server/lib/aiToolkit/internal/modelFetchers.js @@ -55,10 +55,6 @@ const displayName = (provider) => String(provider?.name || '').toLowerCase(); * gemini) exactly as the old chain did. */ export const MODEL_FETCHERS = [ - { - key: 'pi', cliMatch: (p) => isPiCommand(p?.command), - tuiMatch: (p) => isPiCommand(p?.command), fetch: '_fetchPiModels', - }, { key: 'ollama', // Not a command test: the marker can be `ollamaBacked`, an id, or an @@ -127,6 +123,10 @@ export const MODEL_FETCHERS = [ tuiMatch: isGatewayBackedProvider, fetch: '_fetchGatewayModels', }, + { + key: 'pi', cliMatch: (p) => isPiCommand(p?.command), + tuiMatch: (p) => isPiCommand(p?.command), fetch: '_fetchPiModels', + }, { key: 'cursor', // No `cliNameMatch` on purpose — see the column notes above. diff --git a/server/lib/aiToolkit/providers.js b/server/lib/aiToolkit/providers.js index 861e01acbe..d05a567c2d 100644 --- a/server/lib/aiToolkit/providers.js +++ b/server/lib/aiToolkit/providers.js @@ -967,6 +967,14 @@ export function createProviderService(config = {}) { // returning null so `null` keeps exactly ONE meaning out of this function: // the provider does not exist. That is what lets the route's 404 say // plainly "Provider not found" instead of guessing at a reason. + // Pi reports an unauthenticated install as an empty list. That is useful + // for first setup, but a lapsed login must not erase a populated catalog. + if (resolveModelFetcher(provider)?.key === 'pi' && Array.isArray(fetched) + && fetched.length === 0 && provider.models?.length) { + const error = new Error('Pi has no authenticated models. Use pi /login before refreshing the stored catalog.'); + error.status = 502; + throw error; + } const catalog = toModelCatalog(fetched); if (catalog === null) { const unsupported = new Error(`Model refresh returned nothing for provider '${provider.id}'`); @@ -1345,12 +1353,12 @@ export function createProviderService(config = {}) { const { stdout } = await pending.catch((err) => { const output = `${err.stdout || ''}\n${err.stderr || ''}`; if (!err.killed && isEmptyCatalog(output)) return { stdout: output }; - throw new Error(`'${bin} models' failed: ${err?.message || 'could not run the binary'}`); + throw new Error(`'${bin} ${listArgs.join(' ')}' failed: ${err?.message || 'could not run the binary'}`); }); const listed = parse(stdout); if (listed.length === 0 && !isEmptyCatalog(stdout)) { - throw new Error(`'${bin} models' returned no model ids`); + throw new Error(`'${bin} ${listArgs.join(' ')}' returned no model ids`); } return listed; }, diff --git a/server/lib/aiToolkit/providers.test.js b/server/lib/aiToolkit/providers.test.js index 9f28001c7e..4338df8e3f 100644 --- a/server/lib/aiToolkit/providers.test.js +++ b/server/lib/aiToolkit/providers.test.js @@ -37,6 +37,8 @@ describe('Provider Service', () => { await expect(providerService.refreshProviderModels(provider.id)).rejects.toThrow('failed'); expect((await providerService.getProviderById(provider.id)).models).toEqual(['example/model-a']); await emit('No models available. Use /login to authenticate.'); + await expect(providerService.refreshProviderModels(provider.id)).rejects.toThrow('no authenticated models'); + expect((await providerService.getProviderById(provider.id)).models).toEqual(['example/model-a']); expect(await providerService._fetchPiModels({ command })).toEqual([]); await emit('No models available. Use /login to authenticate.', 1); expect(await providerService._fetchPiModels({ command })).toEqual([]); diff --git a/server/lib/pi.test.js b/server/lib/pi.test.js index 02b2ccd821..e825827535 100644 --- a/server/lib/pi.test.js +++ b/server/lib/pi.test.js @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { buildCliArgs, prepareCliPrompt } from './cliProviderArgs.js'; import { isPiCommand, ensurePiHeadlessArgs } from './pi.js'; -import { PROVIDER_VENDORS, publicReviewRecipe, PUBLIC_REVIEW_NO_TOOL_POSTURE, PUBLIC_REVIEW_ACTIONS_POSTURE } from './providerVendors.js'; +import { PROVIDER_VENDORS, inferTuiCommand, publicReviewRecipe, PUBLIC_REVIEW_NO_TOOL_POSTURE, PUBLIC_REVIEW_ACTIONS_POSTURE } from './providerVendors.js'; import { parseHarnessModels } from './harnessOutput.js'; import { reviewerEffortArgs } from './reviewerConfig.js'; @@ -21,6 +21,8 @@ describe('Pi provider boundaries', () => { expect(ensurePiHeadlessArgs(args, 'example/other', 'high')).toEqual(args); expect(isPiCommand('/opt/bin/pi.exe')).toBe(true); expect(isPiCommand('pipeline')).toBe(false); + expect(inferTuiCommand('pi-tui')).toBe('pi'); + expect(inferTuiCommand('example-api-tui')).toBe('claude'); expect(PROVIDER_VENDORS.slice(-2).map(v => v.id)).toEqual(['pi', 'claude']); }); it('discards unsafe saved arguments in the no-tool posture and refuses action review', () => { diff --git a/server/lib/providerModels.js b/server/lib/providerModels.js index 218b4e639c..77eee37617 100644 --- a/server/lib/providerModels.js +++ b/server/lib/providerModels.js @@ -439,6 +439,8 @@ export const CODEX_EFFORT_KEY = 'model_reasoning_effort'; // provider args gets a SECOND, injected `--effort ` appended. Grok's // parser accepts the duplicate and takes the last one, so their explicit pin // would be silently overridden — the exact opposite of the contract below. +// Pi's --thinking is also a value-taking effort pin; shared stripping keeps +// per-run overrides consistent when switching providers. const EFFORT_FLAG_NAMES = Object.freeze(['--effort', '--reasoning-effort', '--thinking']); /** diff --git a/server/lib/providerVendors.js b/server/lib/providerVendors.js index b704936d9c..133a6a9034 100644 --- a/server/lib/providerVendors.js +++ b/server/lib/providerVendors.js @@ -554,6 +554,7 @@ const piCliArgs = (args, { model, effort }) => ensurePiHeadlessArgs(args, model, const PI = { id: 'pi', idFragment: 'pi-', + matchId: (id) => /^pi(?:-|$)/.test(id), inferredCommand: PI_COMMAND, matchCommand: isPiCommand, tuiArgs: ensurePiTuiArgs, @@ -825,7 +826,7 @@ export function publicReviewRecipe(provider, posture) { export function inferTuiCommand(id) { if (!id) return CLAUDE.inferredCommand; for (const vendor of PROVIDER_VENDORS) { - if (vendor.idFragment && id.includes(vendor.idFragment)) return vendor.inferredCommand; + if (vendor.matchId ? vendor.matchId(id) : vendor.idFragment && id.includes(vendor.idFragment)) return vendor.inferredCommand; } return CLAUDE.inferredCommand; } From 2605dff55557a3201dea8102f5d2ae851fa0ea39 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sun, 6 Sep 2026 00:49:48 +0000 Subject: [PATCH 3/7] fix: populate Pi reviewer model choices (#6350) --- client/src/hooks/useReviewerModelOptions.js | 3 ++- client/src/hooks/useReviewerModelOptions.test.jsx | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/client/src/hooks/useReviewerModelOptions.js b/client/src/hooks/useReviewerModelOptions.js index e69792de4b..95f3fba985 100644 --- a/client/src/hooks/useReviewerModelOptions.js +++ b/client/src/hooks/useReviewerModelOptions.js @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import * as api from '../services/api'; -import { filterSelectableModels, selectableModelsForProvider, isAntigravityProvider, isCursorProvider, isGrokBuildCli, isKimiProvider, antigravityModelEffortLevels } from '../utils/providers'; +import { filterSelectableModels, selectableModelsForProvider, commandBasename, isAntigravityProvider, isCursorProvider, isGrokBuildCli, isKimiProvider, antigravityModelEffortLevels } from '../utils/providers'; import { MODEL_SELECTABLE_REVIEWERS } from '../components/cos/constants'; import { reviewerEffortLevels, normalizeReviewerSlug } from '../lib/reviewerPins'; import { LOCAL_LLM_BACKENDS } from '../lib/localLlmBackends'; @@ -53,6 +53,7 @@ const REVIEWER_PROVIDER_MATCHERS = Object.freeze({ // default — the broad predicate follows it for an install that only kept the TUI. grok: [(p) => p.id === 'grok-cli', isGrokBuildCli], cursor: [(p) => p.id === 'cursor-cli', isCursorProvider], + pi: [(p) => p.id === 'pi-cli', (p) => ['cli', 'tui'].includes(p.type) && commandBasename(p.command) === 'pi'], kimi: [(p) => p.id === 'kimi-cli', isKimiProvider], opencode: [(p) => p.id === 'opencode-zen-cli', (p) => p.id === 'opencode-zen-tui'], mtplx: [(p) => p.id === 'mtplx'], diff --git a/client/src/hooks/useReviewerModelOptions.test.jsx b/client/src/hooks/useReviewerModelOptions.test.jsx index 394ae6120d..b35108a9eb 100644 --- a/client/src/hooks/useReviewerModelOptions.test.jsx +++ b/client/src/hooks/useReviewerModelOptions.test.jsx @@ -29,6 +29,7 @@ const providers = [ // shown DEFAULT, since the reviewer is spawned non-interactively. { id: 'grok-tui', type: 'tui', command: 'grok', models: ['tui-only-id'] }, { id: 'grok-cli', type: 'cli', command: 'grok', models: ['grok-configured-default', 'grok-code-fast-1'] }, + { id: 'pi-cli', type: 'cli', command: 'pi', models: ['example/model-a'], defaultModel: 'example/model-a' }, { id: 'cursor-cli', type: 'cli', command: 'cursor-agent', models: ['auto', 'gpt-5'] }, { id: 'mtplx', type: 'api', models: ['mtplx-qwen38-27b-optimized-speed'], defaultModel: 'mtplx-qwen38-27b-optimized-speed' }, // The seeded OpenCode Zen wrappers, whose namespaced ids the Harnesses page @@ -51,6 +52,7 @@ describe('useReviewerModelOptions', () => { it('offers options for every model-selectable reviewer', async () => { const { result } = renderHook(() => useReviewerModelOptions()); await waitFor(() => expect(result.current.loaded).toBe(true)); + expect(result.current.optionsByReviewer.pi).toEqual(['example/model-a']); for (const reviewer of MODEL_SELECTABLE_REVIEWERS) { expect(Array.isArray(result.current.optionsByReviewer[reviewer])).toBe(true); } From 0cfbefa718f85bb36a29c306fce8d4ea7dc52bff Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sun, 6 Sep 2026 00:50:37 +0000 Subject: [PATCH 4/7] fix: expose Pi model options in reviewer picker (#6350) --- client/src/hooks/useReviewerModelOptions.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/src/hooks/useReviewerModelOptions.js b/client/src/hooks/useReviewerModelOptions.js index 95f3fba985..61ba4872ea 100644 --- a/client/src/hooks/useReviewerModelOptions.js +++ b/client/src/hooks/useReviewerModelOptions.js @@ -208,6 +208,7 @@ export default function useReviewerModelOptions() { // regardless because grok, like every CLI reviewer, is free-text. grok: providerTiers('grok'), cursor: providerTiers('cursor'), + pi: providerTiers('pi'), // Legitimately empty, for grok's documented reason: the shipped kimi // provider carries only the configured-default sentinel, which // `filterSelectableModels` strips. Free-text keeps the cell usable. @@ -229,6 +230,8 @@ export default function useReviewerModelOptions() { antigravity: providerDefault('antigravity'), grok: providerDefault('grok'), cursor: providerDefault('cursor'), + pi: null, // A bare reviewer uses Pi's own configured default. + kimi: providerDefault('kimi'), // Deliberately null even though the Zen records carry one: the reviewer // spawns a BARE `opencode`, which falls back to whatever the user's own From be05b11c94240a7e462e99b4fcc9d3b9515c6f3f Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sun, 6 Sep 2026 00:57:58 +0000 Subject: [PATCH 5/7] perf: load harness catalog parsers on demand (#6350) --- server/lib/aiToolkit/internal/modelFetchers.js | 6 +++--- server/lib/aiToolkit/providers.js | 2 +- server/lib/importScoping.test.js | 8 +++++++- server/services/providerRuntimeInstaller.js | 2 +- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/server/lib/aiToolkit/internal/modelFetchers.js b/server/lib/aiToolkit/internal/modelFetchers.js index eed975380d..9c83a20f59 100644 --- a/server/lib/aiToolkit/internal/modelFetchers.js +++ b/server/lib/aiToolkit/internal/modelFetchers.js @@ -1,4 +1,4 @@ -import { isPiCommand } from './pi.js'; +import { commandBasename } from './commandBasename.js'; /** * The single per-vendor table behind model refresh. * @@ -124,8 +124,8 @@ export const MODEL_FETCHERS = [ fetch: '_fetchGatewayModels', }, { - key: 'pi', cliMatch: (p) => isPiCommand(p?.command), - tuiMatch: (p) => isPiCommand(p?.command), fetch: '_fetchPiModels', + key: 'pi', cliMatch: (p) => commandBasename(p?.command) === 'pi', + tuiMatch: (p) => commandBasename(p?.command) === 'pi', fetch: '_fetchPiModels', }, { key: 'cursor', diff --git a/server/lib/aiToolkit/providers.js b/server/lib/aiToolkit/providers.js index d05a567c2d..214b40fc7a 100644 --- a/server/lib/aiToolkit/providers.js +++ b/server/lib/aiToolkit/providers.js @@ -1,4 +1,3 @@ -import { PI_COMMAND, parsePiModelList } from './internal/pi.js'; import { readFile, rename } from 'fs/promises'; import { existsSync } from 'fs'; import { join, dirname, delimiter, isAbsolute } from 'path'; @@ -1364,6 +1363,7 @@ export function createProviderService(config = {}) { }, async _fetchPiModels(provider) { + const { PI_COMMAND, parsePiModelList } = await import('./internal/pi.js'); return this._execCliModelList(provider, PI_COMMAND, parsePiModelList, ['--list-models'], (stdout) => /No models available/i.test(stdout) && /\/login/.test(stdout)); }, diff --git a/server/lib/importScoping.test.js b/server/lib/importScoping.test.js index 763ac6b089..01f44733b2 100644 --- a/server/lib/importScoping.test.js +++ b/server/lib/importScoping.test.js @@ -39,6 +39,8 @@ const reaches = (entry, target) => staticImportClosure(abs(entry)).files.has(abs // Each row: the entry that was narrowed, the module it must no longer // statically reach, and why the entry only ever needed a slice of it. const NARROWED = [ + ['services/providerRuntimeInstaller.js', 'lib/harnessOutput.js', + 'loads version and catalog parsers only when probing a harness'], ['lib/db.js', 'lib/db/schema/index.js', 'the DDL composer is boot-only — ensureSchemaImpl() imports it lazily'], ['lib/pipelineValidation.js', 'lib/editorial/checkRegistry.js', @@ -173,7 +175,11 @@ describe('deferred imports stay deferred (#6156)', () => { * inch it up by a hundred each time. It stays thousands below what ONE eager * edge into a heavy subtree costs, which is what actually has to fail here. */ -const MAX_STATIC_INSTANTIATIONS = 88000; +// #6350: Pi's vendor leaf is necessarily reached by the shared dispatcher. +// Deferring catalog/version parsers removes 296 instantiations (88,360 → +// 88,064). Restore the documented ~1.5k allowance for ordinary leaf growth; +// keep the negative runtime-installer guard above so eager parsing cannot return. +const MAX_STATIC_INSTANTIATIONS = 89500; const SKIP_DIRS = new Set(['node_modules', 'coverage', 'dist', 'data']); const serverTestFiles = (dir = SERVER_DIR, out = []) => { diff --git a/server/services/providerRuntimeInstaller.js b/server/services/providerRuntimeInstaller.js index 9295f23be0..1859dfb380 100644 --- a/server/services/providerRuntimeInstaller.js +++ b/server/services/providerRuntimeInstaller.js @@ -40,7 +40,6 @@ import { spawn } from '../lib/childProcess.js'; import { killProcessTree, prepareCliSpawn } from '../lib/bufferedSpawn.js'; import { commandOutput } from '../lib/commandExists.js'; -import { parseHarnessVersion } from '../lib/harnessOutput.js'; import { adoptNpmGlobalBinDir } from '../lib/npmGlobalBin.js'; import { findCommandOnPath, safeChildProcessEnv, safeChildProcessOptions } from '../lib/processEnv.js'; import { PROVIDER_VENDORS } from '../lib/providerVendors.js'; @@ -236,6 +235,7 @@ async function probeRuntimeStatus(runtime, findCommand, probeCommand) { ? await probeCommand(versionProbe.command, versionProbe.args, { timeoutMs: PROBE_TIMEOUT_MS }) : null; const installed = typeof probed === 'string'; + const { parseHarnessVersion } = await import('../lib/harnessOutput.js'); const version = parseHarnessVersion(probed); // Windows-only gap: the script-installed vendors publish a PowerShell From 0d1b06fa964d1e13c4405397e6f0907a7ca0ee79 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sun, 6 Sep 2026 01:03:03 +0000 Subject: [PATCH 6/7] test: cover Pi reviewer defaults and binary availability (#6350) --- server/services/codeReview.test.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/server/services/codeReview.test.js b/server/services/codeReview.test.js index 1d05e0aa9c..25c799fc69 100644 --- a/server/services/codeReview.test.js +++ b/server/services/codeReview.test.js @@ -178,6 +178,7 @@ describe('codeReview helpers', () => { claudeModel: 'qwen2.5:7b', antigravityModel: 'gemini-3.6-flash', grokModel: 'grok-code-fast-1', + piModel: 'example/model', }, }) expect(out).toEqual({ @@ -196,6 +197,7 @@ describe('codeReview helpers', () => { claudeModel: 'qwen2.5:7b', antigravityModel: 'gemini-3.6-flash', grokModel: 'grok-code-fast-1', + piModel: 'example/model', cursorModel: null, opencodeModel: null, kimiModel: null, @@ -302,16 +304,17 @@ describe('codeReview helpers', () => { const probed = [] commandExistsMock.impl = async (binary) => { probed.push(binary); return binary !== 'agy' } const out = await getReviewerCliInstalled() - expect(out).toEqual({ claude: true, antigravity: false, codex: true, grok: true, cursor: true, opencode: true, kimi: true }) - expect(probed.sort()).toEqual(['agy', 'claude', 'codex', 'cursor-agent', 'grok', 'kimi', 'opencode']) + expect(out).toEqual({ claude: true, antigravity: false, codex: true, grok: true, cursor: true, opencode: true, kimi: true, pi: true }) + expect(probed.sort()).toEqual(['agy', 'claude', 'codex', 'cursor-agent', 'grok', 'kimi', 'opencode', 'pi']) }) it('caches the result within the TTL — a second call does not re-probe', async () => { let calls = 0 commandExistsMock.impl = async () => { calls += 1; return true } await getReviewerCliInstalled() + const initialCalls = calls await getReviewerCliInstalled() - expect(calls).toBe(7) // one probe per CLI reviewer, only on the first call + expect(calls).toBe(initialCalls) // one probe per CLI reviewer, only on the first call }) it('probes with the longer 15s timeout these heavier agentic CLIs need', async () => { From b097914ff9cc25b70df42d764bc2a8453452860c Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sun, 6 Sep 2026 02:28:16 +0000 Subject: [PATCH 7/7] test: include Pi in the harness-catalog rewritable-list gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pi's provider entries carry no local-runtime/gateway marker and pin modelsArgs, so usesHarnessCatalog() now correctly includes pi-cli/ pi-tui alongside the other self-listing harnesses — the shipped-list gate test just hadn't been updated for it, which failed CI. --- server/services/harnesses.test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/services/harnesses.test.js b/server/services/harnesses.test.js index 78d73d8702..48e1937b61 100644 --- a/server/services/harnesses.test.js +++ b/server/services/harnesses.test.js @@ -79,13 +79,14 @@ describe('the shipped records a harness refresh may rewrite', () => { .sort(); expect(rewritable).toEqual([ - // The three harnesses that can enumerate their own models, crossed with + // The four harnesses that can enumerate their own models, crossed with // the wrappers that run those models natively. Every OpenCode wrapper // pointed at a local daemon or a hosted gateway is correctly absent. 'antigravity-cli', 'antigravity-tui', 'cursor-cli', 'cursor-tui', 'grok-cli', 'grok-tui', 'opencode-zen-cli', 'opencode-zen-tui', + 'pi-cli', 'pi-tui', ]); }); });