Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
243 changes: 236 additions & 7 deletions bun.lock

Large diffs are not rendered by default.

30 changes: 22 additions & 8 deletions docs/providers-pi.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,31 @@ One codeoid session maps to one warm `pi --mode rpc` subprocess; pi keeps its ow

## Setup

1. Install pi and log a provider in (`pi /login`), or export an API key pi understands:
None required — codeoid **bundles pi** as a pinned dependency
(`@earendil-works/pi-coding-agent`), so the `pi` backend works out of the box.
Sign a model provider in once (`pi /login` from any pi, or export an API key
pi understands, e.g. `ANTHROPIC_API_KEY`) and create a session with it:

```bash
npm install -g @earendil-works/pi-coding-agent
pi # first run: pick a provider, sign in
```
- **Web UI**: New session → Backend → `pi`
- **Wire**: `session.create` with `providerId: "pi"`

2. Nothing else. The `pi` backend is registered by default; create a session with it:
### Which pi binary runs

- **Web UI**: New session → Backend → `pi`
- **Wire**: `session.create` with `providerId: "pi"`
Resolved once at daemon startup, first match wins:

1. an explicit `providers.pi.command` (verified at startup — a typo'd path
shows up as "unavailable" in the logs, not as a first-turn spawn failure)
2. a system `pi` on `PATH` (`npm install -g @earendil-works/pi-coding-agent`)
3. the **bundled** copy, run via the daemon's own runtime

The bundle is pinned deliberately: the injected approval bridge is coupled to
pi's RPC + extension API, and the lockfile freezes the exact pi version the
bridge was tested against. Use a system install or `command` override when you
want a different pi — you own the compatibility of that pairing.

If no binary resolves anywhere (bundled install failed and nothing on PATH),
pi is reported as *supported but unavailable*: the daemon logs the fix at
startup and `session.set_provider` returns the same actionable hint.

Config knobs (all optional, `~/.codeoid/config.json`):

Expand Down
13 changes: 9 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
{
"name": "codeoid",
"version": "0.2.0",
"description": "Identity-first control plane for AI coding agents multi-session, multi-frontend, with cross-session memory",
"description": "Identity-first control plane for AI coding agents \u2014 multi-session, multi-frontend, with cross-session memory",
"type": "module",
"workspaces": ["packages/*"],
"workspaces": [
"packages/*"
],
"bin": {
"codeoid": "./src/cli.ts"
},
Expand Down Expand Up @@ -77,5 +79,8 @@
"@types/react": "^19.2.14",
"typescript": "^5.7.0"
},
"license": "MIT"
}
"license": "MIT",
"optionalDependencies": {
"@earendil-works/pi-coding-agent": "0.80.6"
}
}
7 changes: 6 additions & 1 deletion src/daemon/providers/pi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@ export interface PiProviderInit {
/** pi session FILE from a previous run (absolute .jsonl path), or the
* codeoid session id on first run (nothing to resume). */
initialBackingId: string;
/** Binary/wrapper from config `providers.pi.command`. */
/** Resolved binary/wrapper (see pi/resolve.ts — config → PATH → bundled). */
command: string;
/** argv entries preceding `--mode rpc` (bundled: the cli entry path). */
argsPrefix?: string[];
store: Store;
onModels?: (
models: ReadonlyArray<{ value: string; displayName: string; description?: string }>,
Expand All @@ -76,6 +78,7 @@ export class PiProvider implements SessionProvider {
#sessionId: string;
#backingSessionId: string;
#command: string;
#argsPrefix: string[];
#store: Store;
#onModels?: PiProviderInit["onModels"];

Expand All @@ -100,6 +103,7 @@ export class PiProvider implements SessionProvider {
this.#sessionId = init.sessionId;
this.#backingSessionId = init.initialBackingId;
this.#command = init.command;
this.#argsPrefix = init.argsPrefix ?? [];
this.#store = init.store;
this.#onModels = init.onModels;
}
Expand Down Expand Up @@ -250,6 +254,7 @@ export class PiProvider implements SessionProvider {
this.#bridgeReady = false;
this.#proc = new PiRpcProcess({
command: this.#command,
argsPrefix: this.#argsPrefix,
args,
cwd: opts.workdir,
// Allowlisted env (GHSA-38vh): pi gets its credentials (~/.pi via
Expand Down
84 changes: 84 additions & 0 deletions src/daemon/providers/pi/resolve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* pi binary resolution — config override → system PATH → bundled fallback.
*
* pi ships with codeoid as a pinned optionalDependency
* (@earendil-works/pi-coding-agent), so the backend works out of the box
* with zero user action. The pin matters beyond convenience: the injected
* bridge extension (bridge.ts) is coupled to pi's RPC + extension API, and
* the fail-closed approval gate is only as trustworthy as the pi version
* it was tested against — the lockfile freezes exactly that version. A
* system install or an explicit `providers.pi.command` still wins for
* users who want their own build.
*
* Resolution is verified up front (existsSync / PATH lookup) so a missing
* binary is a "not installed" entry in the provider catalog with an
* actionable hint, instead of a spawn failure on the user's first turn.
*/

import { existsSync } from "node:fs";
import { dirname, join } from "node:path";

export const BUNDLED_PI_PACKAGE = "@earendil-works/pi-coding-agent";

/** Shown when no pi can be found anywhere (bundled install failed AND no
* system pi) or a configured command doesn't exist. */
export const PI_INSTALL_HINT =
`no pi binary found — reinstall codeoid's dependencies (bundles ${BUNDLED_PI_PACKAGE}), ` +
`install pi system-wide (npm i -g ${BUNDLED_PI_PACKAGE}), or point providers.pi.command at a binary`;

export interface PiCommandResolution {
/** Executable to spawn — an absolute path, or the runtime for `bundled`. */
command: string;
/** argv entries that must PRECEDE pi's own flags (bundled: the cli entry). */
argsPrefix: string[];
source: "config" | "path" | "bundled";
}

/**
* Resolve the pi command. `configured` is `providers.pi.command` when the
* user set it to something other than the default "pi". Returns null when
* nothing runnable was found — callers surface PI_INSTALL_HINT.
*/
export function resolvePiCommand(
configured: string | undefined,
env: Record<string, string | undefined> = process.env,
): PiCommandResolution | null {
// 1. Explicit config override. Verified rather than trusted blindly so a
// typo'd path shows up at startup, not on the first turn.
if (configured !== undefined && configured !== "pi") {
if (configured.includes("/")) {
return existsSync(configured)
? { command: configured, argsPrefix: [], source: "config" }
: null;
}
const found = Bun.which(configured, { PATH: env.PATH ?? "" });
return found ? { command: found, argsPrefix: [], source: "config" } : null;
}

// 2. System pi on PATH.
const onPath = Bun.which("pi", { PATH: env.PATH ?? "" });
if (onPath) return { command: onPath, argsPrefix: [], source: "path" };

// 3. Bundled optionalDependency, run via the daemon's own runtime (bun)
// so the fallback doesn't additionally require node on PATH.
const entry = bundledPiEntry();
if (entry) return { command: process.execPath, argsPrefix: [entry], source: "bundled" };

return null;
}

/**
* Absolute path to the bundled pi CLI entry (`dist/cli.js`), or null when
* the optional dependency isn't installed. The package's exports map has
* no "./package.json" subpath, so locate the main entry and derive the
* sibling cli entry from it (both live in dist/ — bin: {"pi": "dist/cli.js"}).
*/
export function bundledPiEntry(): string | null {
try {
const mainEntry = Bun.resolveSync(BUNDLED_PI_PACKAGE, import.meta.dir);
const cli = join(dirname(mainEntry), "cli.js");
return existsSync(cli) ? cli : null;
} catch {
return null;
}
}
7 changes: 6 additions & 1 deletion src/daemon/providers/pi/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ export type PiFrame = Record<string, unknown> & { type: string };
export interface PiSpawnOptions {
/** Binary or wrapper script (config `providers.pi.command`). */
command: string;
/**
* argv entries placed BEFORE `--mode rpc` — the bundled fallback spawns
* the runtime with pi's cli entry as the first arg (see pi/resolve.ts).
*/
argsPrefix?: string[];
/** Extra CLI args (mode/rpc is always appended by this wrapper). */
args?: string[];
cwd: string;
Expand Down Expand Up @@ -52,7 +57,7 @@ export class PiRpcProcess {

constructor(opts: PiSpawnOptions) {
this.#onEvent = opts.onEvent;
this.#proc = spawn(opts.command, ["--mode", "rpc", ...(opts.args ?? [])], {
this.#proc = spawn(opts.command, [...(opts.argsPrefix ?? []), "--mode", "rpc", ...(opts.args ?? [])], {
cwd: opts.cwd,
stdio: ["pipe", "pipe", "pipe"],
env: opts.env,
Expand Down
64 changes: 51 additions & 13 deletions src/daemon/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { ClaudeProvider } from "./claude/index.js";
import { GeminiProvider } from "./gemini/index.js";
import { OpenAIProvider } from "./openai/index.js";
import { PiProvider } from "./pi/index.js";
import { PI_INSTALL_HINT, resolvePiCommand } from "./pi/resolve.js";
import { StatelessSessionProvider } from "./stateless.js";

/**
Expand Down Expand Up @@ -60,6 +61,13 @@ export interface ProviderFactory {

export class ProviderRegistry {
readonly #factories = new Map<string, ProviderFactory>();
/**
* Backends codeoid supports but could not activate at startup (binary
* missing, etc.) — id → actionable hint. Lets `session.set_provider`
* answer "supported but not installed, here's how" instead of a bare
* "unknown provider".
*/
readonly #unavailable = new Map<string, string>();
/** Id used when a session doesn't carry a provider selection. */
readonly defaultId: string;

Expand All @@ -78,6 +86,21 @@ export class ProviderRegistry {
return this.#factories.has(id);
}

/** Record a supported-but-unactivatable backend with an actionable hint. */
markUnavailable(id: string, hint: string): void {
this.#unavailable.set(id, hint);
}

/** Hint for a supported backend that isn't activated, if any. */
unavailableHint(id: string): string | undefined {
return this.#unavailable.get(id);
}

/** All supported-but-unactivated backends (startup diagnostics). */
unavailableEntries(): Array<{ id: string; hint: string }> {
return [...this.#unavailable.entries()].map(([id, hint]) => ({ id, hint }));
}

get(id: string): ProviderFactory | undefined {
return this.#factories.get(id);
}
Expand Down Expand Up @@ -159,19 +182,34 @@ export function createDefaultProviderRegistry(config?: CodeoidConfig): ProviderR
),
});
if (config?.providers?.pi?.enabled !== false) {
const command = config?.providers?.pi?.command ?? "pi";
registry.register({
id: "pi",
displayName: "pi (pi.dev)",
create: (init) =>
new PiProvider({
sessionId: init.sessionId,
initialBackingId: init.initialBackingId,
command: init.config?.providers?.pi?.command ?? command,
store: init.store,
onModels: init.onModels,
}),
});
// Resolve once at startup: explicit config command → system PATH →
// the bundled optionalDependency (see pi/resolve.ts). A verified
// resolution means picking pi can't fail on a missing binary; no
// resolution means the catalog says "not installed" with the fix.
const configured = config?.providers?.pi?.command;
const resolution = resolvePiCommand(configured === "pi" ? undefined : configured);
if (resolution) {
registry.register({
id: "pi",
displayName: "pi (pi.dev)",
create: (init) =>
new PiProvider({
sessionId: init.sessionId,
initialBackingId: init.initialBackingId,
command: resolution.command,
argsPrefix: resolution.argsPrefix,
store: init.store,
onModels: init.onModels,
}),
});
} else {
registry.markUnavailable(
"pi",
configured !== undefined && configured !== "pi"
? `providers.pi.command (${JSON.stringify(configured)}) does not exist or is not on PATH`
: PI_INSTALL_HINT,
);
}
}
return registry;
}
5 changes: 5 additions & 0 deletions src/daemon/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,11 @@ export class DaemonServer {
{ config: config.fullConfig, compressionRegistry, hooks },
);

console.log(`[codeoid] providers: ${this.#manager.providerIds().join(", ")}`);
for (const { id, hint } of this.#manager.unavailableProviders()) {
console.warn(`[codeoid] provider ${id} unavailable: ${hint}`);
}

// Register cleanup functions. ShutdownManager runs them LIFO, so the
// LAST registered runs FIRST. Order matters: sessions must DRAIN (their
// final audit/usage writes land in store + memory) BEFORE store/memory
Expand Down
5 changes: 5 additions & 0 deletions src/daemon/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,11 @@ export class SessionManager {
return [def, ...ids.filter((id) => id !== def)];
}

/** Supported backends that couldn't activate at startup (diagnostics). */
unavailableProviders(): Array<{ id: string; hint: string }> {
return this.#providers.unavailableEntries();
}

/** Start the dispatcher loop. Call AFTER resumeSessions so surviving
* workers are back in #sessions before the boot-time reclaim pass runs. */
startDispatcher(): void {
Expand Down
5 changes: 4 additions & 1 deletion src/daemon/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -689,10 +689,13 @@ export class Session {
const registry =
this.#providersRegistry ?? createDefaultProviderRegistry(this.#config);
if (!registry.has(requested)) {
const hint = registry.unavailableHint(requested);
return {
ok: false,
code: "invalid_request",
error: `Unknown provider "${requested}" — available: ${registry.ids().join(", ")}`,
error: hint
? `Provider "${requested}" is supported but not available: ${hint}`
: `Unknown provider "${requested}" — available: ${registry.ids().join(", ")}`,
};
}
if (this.#provider.id === requested) {
Expand Down
Loading
Loading