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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,28 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Added

- **pi is now an officially supported session backend** (`providerId: "pi"`,
[docs/providers-pi.md](docs/providers-pi.md)). One codeoid session = one
warm `pi --mode rpc` subprocess; pi's own session file is the backing id,
so daemon restarts resume the same pi conversation. What flows through:
- **pi extensions work end-to-end** — hooks run inside pi; extension
dialogs surface as codeoid `session.ui_request` dialogs, notifications
become transcript rows, and extension/prompt/skill slash commands feed
`session.commands` (invoke as `/name args` from any client).
- **codeoid's approval gate covers pi tools**: an injected bridge
extension routes every pi `tool_call` through `canUseTool` (modes,
budgets, `session.approve`, audit). pi has no native permission system,
so a missing bridge fails turns CLOSED, and any tool that executes
ungated is flagged loudly.
- Steering (`now`/`next` → pi steer, `later` → follow-up), model
switching (`provider/model-id`), per-turn usage deltas, rotation via
pi `new_session`.
- Config: `providers.pi.{enabled, command}`.
- **Provider selection is user-facing**: `session.create` accepts
`providerId` (fail-closed on unknown ids), `auth.ok` advertises the
daemon's registered `providers` (default first), and the web UI's
new-session modal grew a backend picker.

- **Provider extension surface** — the wire-additive groundwork for
non-Claude backends (pi harness next) to expose their full feature set
through codeoid:
Expand Down
60 changes: 60 additions & 0 deletions docs/providers-pi.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# pi as a codeoid backend

codeoid can run sessions on [pi](https://pi.dev) — the extensible coding agent from earendil-works — as an alternative to Claude Code.
One codeoid session maps to one warm `pi --mode rpc` subprocess; pi keeps its own durable session tree on disk and codeoid resumes it across daemon restarts.

## Setup

1. Install pi and log a provider in (`pi /login`), or export an API key pi understands:

```bash
npm install -g @earendil-works/pi-coding-agent
pi # first run: pick a provider, sign in
```

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

- **Web UI**: New session → Backend → `pi`
- **Wire**: `session.create` with `providerId: "pi"`

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

```jsonc
{
"providers": {
"pi": {
"enabled": true, // false removes pi from the catalog
"command": "pi" // wrapper script or absolute path
}
}
}
```

## What works

| pi feature | codeoid surface |
| --- | --- |
| Streaming text + thinking | Normal transcript rows |
| Tool calls | codeoid's approval flow — modes (interactive/guarded/autonomous), budgets, `session.approve`, audit log |
| **pi extensions** (`~/.pi/agent/extensions`, `.pi/extensions`) | Hooks run inside pi unchanged. Extension dialogs (`ctx.ui.select/confirm/input/editor`) surface as codeoid dialogs (`session.ui_request`); `ctx.ui.notify` becomes an info/system row |
| Extension slash commands, prompt templates, skills | `session.commands` catalogs → the `/` palette; `/name args` passes through for pi to expand |
| Model switching | `/model provider/model-id` (catalog reported after the first turn) |
| Mid-turn sends | codeoid `now`/`next` → pi steering; `later` → pi follow-up |
| Session resume | pi's session file is the backing id; daemon restarts `switch_session` back into it |
| Rotation (`/rotate`) | `new_session` — fresh pi context, same codeoid session |
| Usage/cost | Per-turn deltas from pi's session stats |

## How tool approval works (the bridge)

pi ships **no built-in permission system** — gating is delegated to extensions.
codeoid injects a small bridge extension (`pi -e …`, regenerated per session) that hooks pi's `tool_call` event and routes every tool through codeoid's `canUseTool` gate before pi executes it.
Denials block the tool inside pi; approval-form patches (`patchableKeys`) merge into pi's live tool input.
The bridge announces itself on session start; **if it fails to load, turns fail closed** rather than running tools ungated, and any tool that somehow executes without passing the gate is flagged loudly in the transcript.

## Limitations

- pi extension **custom TUI components** (`ctx.ui.custom()`, custom renderers/editors/themes) don't cross RPC — pi degrades them itself; everything logic-level keeps working.
- The model catalog and command list populate after the first turn of a pi session (no idle subprocess just to list them).
- The pi subprocess inherits the daemon's environment (pi needs its keys); env hardening parity with the Claude subprocess is a follow-up.
- With an Anthropic **subscription** (OAuth) login, pi sends Claude-Code identity headers and pins the first system block — that's pi upstream behavior, not codeoid's.
- codeoid memory/recall and the conductor fleet tools are Claude-session features today.
8 changes: 7 additions & 1 deletion packages/protocol/src/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,13 @@ test("every ClientMessage variant has a schema and vice versa (compile-time)", (

const samples: { [T in ClientTypes]: Extract<ClientMessage, { type: T }> } = {
ping: { type: "ping", id: "r1" },
"session.create": { type: "session.create", id: "r2", name: "demo", workdir: "/tmp/w" },
"session.create": {
type: "session.create",
id: "r2",
name: "demo",
workdir: "/tmp/w",
providerId: "pi",
},
"session.list": { type: "session.list", id: "r3" },
"session.attach": { type: "session.attach", id: "r4", sessionId: "s1" },
"session.detach": { type: "session.detach", id: "r5", sessionId: "s1" },
Expand Down
7 changes: 7 additions & 0 deletions packages/protocol/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ export const sessionCreateSchema = z.object({
* wire contract.
*/
role: z.string().max(LIMITS.NAME_MAX).optional(),
/**
* Backend id, validated as a bounded string (not an enum) on purpose: the
* frame must PARSE for a provider this daemon doesn't know — the daemon
* then fail-closes with a clear "unknown provider" error instead of the
* schema opaquely rejecting the whole create.
*/
providerId: z.string().min(1).max(64).optional(),
});

export const sessionListSchema = z.object({ ...base, type: z.literal("session.list") });
Expand Down
15 changes: 15 additions & 0 deletions packages/protocol/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -725,6 +725,14 @@ export interface SessionCreateMsg extends BaseClientMsg {
* daemon rejects roles it doesn't implement.
*/
role?: string;
/**
* Backend for this session (e.g. "claude", "pi"). Must be one of the ids
* the daemon advertised in `AuthOkMsg.providers` — an id this daemon
* doesn't have registered is rejected with `invalid_request` (fail-closed:
* asking for pi must never silently hand back a claude session). Absent =
* the daemon default.
*/
providerId?: string;
}

/**
Expand Down Expand Up @@ -1424,6 +1432,13 @@ export interface AuthOkMsg {
* daemons that predate capability negotiation.
*/
capabilities?: string[];
/**
* Provider ids registered on this daemon (e.g. ["claude", "gemini",
* "openai", "pi"]), first entry = the default. Feed the new-session
* provider picker from this; absent on daemons that predate multi-provider
* session creation (assume claude-only).
*/
providers?: string[];
}

export interface ResponseOkMsg {
Expand Down
27 changes: 27 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,21 @@ const DispatchSchema = z
retryBaseMs: 15_000,
});

/** Per-backend provider settings. Append-only — one optional block per provider. */
const ProvidersSchema = z
.object({
/** pi coding agent (https://pi.dev) driven over `pi --mode rpc`. */
pi: z
.object({
/** Register the pi backend in the provider catalog. */
enabled: z.boolean().default(true),
/** Binary to spawn — override for a wrapper script or absolute path. */
command: z.string().default("pi"),
})
.default({ enabled: true, command: "pi" }),
})
.default({ pi: { enabled: true, command: "pi" } });

const RootSchema = z.object({
daemonUrl: z.string().default("ws://127.0.0.1:7400"),
dbPath: z.string().default("codeoid.db"),
Expand All @@ -388,6 +403,7 @@ const RootSchema = z.object({
session: SessionSchema,
conductor: ConductorSchema,
dispatch: DispatchSchema,
providers: ProvidersSchema,
});

type ParsedConfig = z.infer<typeof RootSchema>;
Expand Down Expand Up @@ -492,6 +508,16 @@ export interface CodeoidConfig {
workerToolBudget: number;
retryBaseMs: number;
};
/**
* Per-backend provider settings. Optional in the type so hand-built test
* configs stay minimal; loadConfig always populates it (schema defaults).
*/
providers?: {
pi: {
enabled: boolean;
command: string;
};
};
}

// ── Env-var override map ─────────────────────────────────────────────────
Expand Down Expand Up @@ -721,6 +747,7 @@ export function loadConfig(opts: LoadOptions = {}): CodeoidConfig {
session: parsed.session,
conductor: parsed.conductor,
dispatch: parsed.dispatch,
providers: parsed.providers,
};
}

Expand Down
86 changes: 86 additions & 0 deletions src/daemon/providers/pi/bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* The codeoid↔pi bridge extension.
*
* pi ships NO built-in permission system — tool gating is explicitly
* delegated to extensions (see pi-mono README "Permissions &
* Containerization"). This extension IS codeoid's gate: it hooks pi's
* `tool_call` event and routes every tool invocation through codeoid's
* unified approval flow before pi executes it.
*
* Transport trick: in `--mode rpc`, pi marshals `ctx.ui.input()` as an
* `extension_ui_request` frame and blocks the tool until the client answers.
* codeoid is that client — PiProvider recognises the reserved title
* `codeoid:tool-approval`, runs the payload through `canUseTool` (the same
* gate Claude sessions use: modes, budgets, session.approve), and answers
* with a JSON decision. Real user-extension dialogs (any other title) pass
* through to codeoid's `session.ui_request` surface untouched.
*
* The source is written to a temp file at spawn time and loaded with
* `pi -e <path>` — plain JS (no TS syntax) so it loads under any pi version
* without depending on jiti transforms.
*/

import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

/** Reserved dialog title — the provider treats these as approval requests. */
export const APPROVAL_TITLE = "codeoid:tool-approval";
/** Status key/value the bridge sets on session_start — the readiness handshake. */
export const BRIDGE_STATUS_KEY = "codeoid";
export const BRIDGE_READY_VALUE = "bridge-ready";

export const BRIDGE_EXTENSION_SOURCE = `/**
* codeoid bridge — injected by the codeoid daemon (PiProvider). Do not edit:
* regenerated on every session spawn.
*/
export default function (pi) {
// Readiness handshake: PiProvider fails a turn closed if this status
// never arrives (a missing gate must not mean "everything runs ungated").
pi.on("session_start", (_event, ctx) => {
if (ctx.hasUI) ctx.ui.setStatus(${JSON.stringify(BRIDGE_STATUS_KEY)}, ${JSON.stringify(BRIDGE_READY_VALUE)});
});

pi.on("tool_call", async (event, ctx) => {
if (!ctx.hasUI) {
return { block: true, reason: "codeoid bridge has no UI channel; tool blocked" };
}
const payload = JSON.stringify({
toolCallId: event.toolCallId,
toolName: event.toolName,
input: event.input,
});
const raw = await ctx.ui.input(${JSON.stringify(APPROVAL_TITLE)}, payload);
if (raw === undefined || raw === null || raw === "") {
return { block: true, reason: "Denied by codeoid (no decision)" };
}
let decision;
try {
decision = JSON.parse(raw);
} catch {
return { block: true, reason: "Denied by codeoid (malformed decision)" };
}
if (!decision || decision.behavior !== "allow") {
return { block: true, reason: (decision && decision.message) || "Denied by user" };
}
if (decision.updatedInput && typeof decision.updatedInput === "object") {
// pi contract: mutations to event.input feed the actual execution.
for (const key of Object.keys(decision.updatedInput)) {
event.input[key] = decision.updatedInput[key];
}
}
return undefined; // allow
});
}
`;

/**
* Write the bridge to a fresh temp dir and return its path. A new file per
* provider instance keeps concurrent sessions from racing on one path.
*/
export function writeBridgeExtension(): string {
const dir = mkdtempSync(join(tmpdir(), "codeoid-pi-bridge-"));
const path = join(dir, "codeoid-bridge.js");
writeFileSync(path, BRIDGE_EXTENSION_SOURCE, "utf8");
return path;
}
Loading
Loading