diff --git a/src/main/hive.ts b/src/main/hive.ts index 4537c2bf6..117bd9a10 100644 --- a/src/main/hive.ts +++ b/src/main/hive.ts @@ -791,8 +791,7 @@ export class HiveManager { const claudeProvider = isClaudeProvider(meta.provider ?? 'claude'); - // Non-hive-aware providers (Antigravity's `agy`, OpenAI's `codex`, xAI's - // `grok`) don't + // Non-hive-aware providers (for example Antigravity, Codex, Grok and Pi) don't // understand Claude Code's flags (no `--append-system-prompt`, no telemetry, // no `--settings`). Instead: (1) the hive identity+protocol rides in as the // session's INITIAL prompt — the closest thing to `--append-system-prompt` @@ -803,7 +802,7 @@ export class HiveManager { // // How the prompt rides in differs by CLI: // - agy takes it under a flag (`agy -i ""`) → push [flag, prompt]. - // - codex/grok take it POSITIONALLY (`codex|grok ""`) → push the + // - codex/grok/pi take it POSITIONALLY (`codex|grok|pi ""`) → push the // bare prompt as a trailing arg (node-pty passes argv literally, so it // arrives as one positional argument after codex's own flags). if (!isHiveAwareProvider(meta.provider)) { @@ -930,7 +929,8 @@ export class HiveManager { // seedPrompt; the renderer types it into the TUI after boot (ondev-b). const deg = degraded ? { degraded } : {}; if (preset.seedDelivery === 'type-into-tui') return { args: [...preArgs], env, seedPrompt: prompt, ...deg }; - // If a provider somehow exposes neither a flag nor a positional prompt, spawn bare. + // Providers with no declared seed strategy intentionally spawn bare. Inbox-capable + // non-hive-aware presets are guarded by the provider contract tests. if (flag) return { args: [...preArgs, flag, prompt], env, ...deg }; if (preset.positionalInitialPrompt) return { args: [...preArgs, prompt], env, ...deg }; return { args: preArgs, env, ...deg }; diff --git a/src/shared/agentProvider.ts b/src/shared/agentProvider.ts index 1e5048d32..0c3e5fb5f 100644 --- a/src/shared/agentProvider.ts +++ b/src/shared/agentProvider.ts @@ -121,9 +121,9 @@ export interface AgentProviderPreset { /** For non-hive-aware CLIs that still take an INITIAL prompt to orient the * session (Antigravity's `agy -i ""`), the flag to pass it under. The * hive identity+protocol rides in as the first turn — the closest thing to - * Claude's `--append-system-prompt` these CLIs offer. undefined = the CLI - * takes its initial prompt POSITIONALLY (Codex: `codex ""`) and the - * injection branch appends it as a quoted trailing arg instead of a flag. */ + * Claude's `--append-system-prompt` these CLIs offer. undefined means this + * provider has no flag form; positional delivery must be declared explicitly + * with `positionalInitialPrompt`. */ initialPromptFlag?: string; /** How the hive protocol seed is delivered for a CLI that takes NEITHER a flag * nor a positional seed. `'type-into-tui'` = the CLI is a bare interactive TUI @@ -135,8 +135,8 @@ export interface AgentProviderPreset { * collide). Absent/undefined = today's flag-or-positional behavior. (ondev-b) */ seedDelivery?: 'type-into-tui'; /** This CLI accepts the initial hive prompt as a trailing positional argument. - * Codex does; Kimi/custom do not, so they must spawn bare when no prompt flag - * exists instead of receiving an invalid positional argument. */ + * Codex/Grok/Pi do; Kimi/custom do not, so they must spawn bare when no prompt + * flag exists instead of receiving an invalid positional argument. */ positionalInitialPrompt?: boolean; /** Flag to resume a prior session on respawn, given the recorded session id * (Claude `--resume `, Antigravity `--conversation `). undefined = no @@ -489,7 +489,10 @@ export const AGENT_PROVIDER_PRESETS: AgentProviderPreset[] = [ // or we lean on the renderer idle nudge) is UNVERIFIED pending keys. Renderer nudge // is the guaranteed drain fallback either way. canReceiveInbox: true, - initialPromptFlag: undefined, // positional, like codex: pi "" + initialPromptFlag: undefined, + // Pi's documented `pi [options] [messages...]` form accepts the hive protocol + // as its initial user message while leaving the interactive session alive. + positionalInitialPrompt: true, resumeFlag: '--session', // --ignore-scripts: don't run the package's postinstall on the user's machine. installCommand: 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent', diff --git a/test/hive-pi-bootstrap.test.cjs b/test/hive-pi-bootstrap.test.cjs new file mode 100644 index 000000000..52b3a6161 --- /dev/null +++ b/test/hive-pi-bootstrap.test.cjs @@ -0,0 +1,60 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const loadTs = require('./load-ts.cjs'); + +const { HiveManager } = loadTs('src/main/hive.ts'); + +function tmpHome() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'md-hive-pi-bootstrap-')); +} + +test('Pi fresh spawn receives exactly one positional hive bootstrap', async (t) => { + const home = tmpHome(); + t.after(() => fs.rmSync(home, { recursive: true, force: true })); + + const hive = new HiveManager(() => home); + const injection = await hive.ensureAgent({ + id: 'toby-pi-test', + name: 'Toby', + provider: 'pi', + cwd: home + }); + + assert.equal(injection.seedPrompt, undefined, 'Pi receives its bootstrap on argv, not via TUI typing'); + assert.equal(injection.args.length, 1, 'the bootstrap must remain one trailing positional argument'); + + const [prompt] = injection.args; + assert.match(prompt, /^You are "Toby" \(toby-pi-test\),/); + assert.match(prompt, /HIVE PROTOCOL/); + assert.ok(prompt.includes(path.join(home, 'hive', 'agents', 'toby-pi-test', 'inbox'))); + assert.ok(prompt.split('\n').length > 5, 'the multiline bootstrap must not be split into argv tokens'); +}); + +test('Pi bridge setup failure does not suppress its positional bootstrap', async (t) => { + const home = tmpHome(); + t.after(() => fs.rmSync(home, { recursive: true, force: true })); + + const hive = new HiveManager(() => home); + const errors = []; + t.mock.method(console, 'error', (...args) => errors.push(args)); + hive.installPiHooks = () => { throw new Error('synthetic bridge failure'); }; + + const injection = await hive.ensureAgent({ + id: 'pi-degraded-test', + name: 'Meredith', + provider: 'pi', + cwd: home + }); + + assert.equal(injection.env.PI_CODING_AGENT_DIR, undefined); + assert.equal(injection.args.length, 1); + assert.match(injection.args[0], /^You are "Meredith" \(pi-degraded-test\),/); + assert.match(injection.args[0], /HIVE PROTOCOL/); + assert.equal(errors.length, 1); + assert.match(String(errors[0][0]), /install hooks bridge failed/); +}); diff --git a/test/provider-config.test.cjs b/test/provider-config.test.cjs index a54898384..eab71c764 100644 --- a/test/provider-config.test.cjs +++ b/test/provider-config.test.cjs @@ -5,6 +5,7 @@ const assert = require('node:assert/strict'); const loadTs = require('./load-ts.cjs'); const { + AGENT_PROVIDER_PRESETS, inferAgentProvider, isAgentProvider, providerPreset @@ -58,6 +59,37 @@ test('Grok is a first-class inferred provider with hooks, resume, and always-app assert.equal(preset.resumeFlag, '--resume'); }); +test('Pi is a first-class inferred provider with hooks, positional bootstrap, and resume', () => { + assert.equal(isAgentProvider('pi'), true); + assert.equal(inferAgentProvider('pi --model anthropic/claude-sonnet-4-5'), 'pi'); + + const preset = providerPreset('pi'); + assert.equal(preset.defaultCommand, 'pi'); + assert.equal(preset.canReceiveInbox, true); + assert.deepEqual(preset.bridge, { kind: 'hooks', shim: 'pi' }); + assert.equal(preset.initialPromptFlag, undefined); + assert.equal(preset.positionalInitialPrompt, true); + assert.equal(preset.resumeFlag, '--session'); +}); + +test('every non-hive-aware inbox provider declares exactly one bootstrap delivery path', () => { + for (const preset of AGENT_PROVIDER_PRESETS) { + if (preset.hiveAware || !preset.canReceiveInbox) continue; + + const deliveries = [ + typeof preset.initialPromptFlag === 'string' && preset.initialPromptFlag.length > 0, + preset.positionalInitialPrompt === true, + preset.seedDelivery !== undefined + ].filter(Boolean).length; + + assert.equal( + deliveries, + 1, + `${preset.id} can receive hive inbox but declares ${deliveries} bootstrap delivery paths` + ); + } +}); + test('provider commands use matching models and equivalent bypass modes', () => { assert.equal( buildSpawnCommand(autoConfig, 'claude-sonnet-5', 'claude'),