Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
# T3 Code

T3 Code is a minimal web GUI for coding agents (currently Codex, Claude, Cursor, and OpenCode, more coming soon).
T3 Code is a minimal web GUI for coding agents (currently Codex, Claude, Cursor, OpenCode, and Pi, more coming soon).

## Installation

> [!WARNING]
> T3 Code currently supports Codex, Claude, Cursor, and OpenCode.
> T3 Code currently supports Codex, Claude, Cursor, OpenCode, and Pi.
> Install and authenticate at least one provider before use:
>
> - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login`
> - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login`
> - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `cursor-agent login`
> - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login`
> - Pi: install the Pi CLI and configure a provider API key (Pi is Early Access and disabled by default — see [docs/providers/pi.md](./docs/providers/pi.md))

### Run without installing

Expand Down
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"node-pty": "^1.1.0"
},
"devDependencies": {
"@earendil-works/pi-coding-agent": "^0.80.2",
"@effect/vitest": "catalog:",
"@t3tools/contracts": "workspace:*",
"@t3tools/shared": "workspace:*",
Expand Down
11 changes: 11 additions & 0 deletions apps/server/scripts/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,17 @@ const buildCmd = Command.make(
const webDist = path.join(repoRoot, "apps/web/dist");
const clientTarget = path.join(serverDir, "dist/client");

// Pi loads the approval-gate extension at runtime; vp pack won't emit it, so copy it next to the bundle.
const piExtensionSource = path.join(serverDir, "src/provider/assets/pi/t3-approvals.ts");
const piAssetTargetDir = path.join(serverDir, "dist/assets/pi");
if (yield* fs.exists(piExtensionSource)) {
yield* fs.makeDirectory(piAssetTargetDir, { recursive: true });
yield* fs.copyFile(piExtensionSource, path.join(piAssetTargetDir, "t3-approvals.ts"));
yield* Effect.log("[cli] Bundled Pi approval extension into dist/assets/pi");
} else {
yield* Effect.logWarning("[cli] Pi approval extension not found — skipping asset copy.");
}

if (yield* fs.exists(webDist)) {
yield* fs.copy(webDist, clientTarget);
yield* applyDevelopmentIconOverrides(repoRoot, serverDir);
Expand Down
118 changes: 118 additions & 0 deletions apps/server/scripts/pi-mock-rpc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
#!/usr/bin/env node
// @effect-diagnostics nodeBuiltinImport:off
// Fake `pi --mode rpc` for tests; driven by `PI_MOCK_*` env vars.
import * as NodeReadline from "node:readline";

const assistantText = process.env["PI_MOCK_ASSISTANT_TEXT"] ?? '{"title":"Mock title"}';
const emitInvalidJson = process.env["PI_MOCK_EMIT_INVALID_JSON"] === "1";
const lastTextFails = process.env["PI_MOCK_LAST_TEXT_FAILS"] === "1";
const exitOnPrompt = process.env["PI_MOCK_EXIT_ON_PROMPT"] === "1";

const replyText = emitInvalidJson
? "Sure — here is the answer, with no JSON at all."
: assistantText;
let lastAssistantText: string | null = null;

function write(obj: unknown): void {
process.stdout.write(`${JSON.stringify(obj)}\n`);
}

const rl = NodeReadline.createInterface({ input: process.stdin });

rl.on("line", (line: string) => {
const trimmed = line.trim();
if (!trimmed) return;
let command: { type?: string; id?: string };
try {
command = JSON.parse(trimmed) as { type?: string; id?: string };
} catch {
return;
}

switch (command.type) {
case "prompt":
case "steer":
case "follow_up": {
if (exitOnPrompt) {
process.exit(17);
}
write({ type: "agent_start" });
write({ type: "turn_start" });
write({
type: "message_update",
assistantMessageEvent: { type: "text_delta", delta: replyText },
});
lastAssistantText = replyText;
write({ type: "message_end" });
write({ type: "turn_end" });
write({ type: "agent_end" });
return;
}
case "get_last_assistant_text": {
write(
lastTextFails
? {
type: "response",
id: command.id,
command: "get_last_assistant_text",
success: false,
error: "no assistant text",
}
: {
type: "response",
id: command.id,
command: "get_last_assistant_text",
success: true,
data: { text: lastAssistantText },
},
);
return;
}
case "get_state": {
write({
type: "response",
id: command.id,
command: "get_state",
success: true,
data: {
sessionId: "mock-session",
sessionFile: "/tmp/pi-mock-session.json",
thinkingLevel: "off",
isStreaming: false,
isCompacting: false,
steeringMode: "all",
followUpMode: "all",
autoCompactionEnabled: false,
messageCount: 0,
pendingMessageCount: 0,
},
});
return;
}
case "get_commands": {
write({
type: "response",
id: command.id,
command: "get_commands",
success: true,
data: { commands: [] },
});
return;
}
default: {
if (command.id !== undefined) {
write({
type: "response",
id: command.id,
command: command.type ?? "unknown",
success: true,
});
}
return;
}
}
});

rl.on("close", () => {
process.exit(0);
});
167 changes: 167 additions & 0 deletions apps/server/src/provider/Drivers/PiDriver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { PiSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
import * as Crypto from "effect/Crypto";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import { HttpClient } from "effect/unstable/http";
import { ChildProcessSpawner } from "effect/unstable/process";

import { ServerConfig } from "../../config.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import { makePiTextGeneration } from "../../textGeneration/PiTextGeneration.ts";
import { ProviderDriverError } from "../Errors.ts";
import { makePiAdapter } from "../Layers/PiAdapter.ts";
import { buildInitialPiProviderSnapshot, checkPiProviderStatus } from "../Layers/PiProvider.ts";
import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
import {
defaultProviderContinuationIdentity,
type ProviderDriver,
type ProviderInstance,
} from "../ProviderDriver.ts";
import type { ServerProviderDraft } from "../providerSnapshot.ts";
import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
import {
enrichProviderSnapshotWithVersionAdvisory,
makePackageManagedProviderMaintenanceResolver,
resolveProviderMaintenanceCapabilitiesEffect,
} from "../providerMaintenance.ts";
import {
haveProviderSnapshotSettingsChanged,
makeProviderSnapshotSettingsSource,
type ProviderSnapshotSettings,
} from "../providerUpdateSettings.ts";

const decodePiSettings = Schema.decodeSync(PiSettings);

const DRIVER_KIND = ProviderDriverKind.make("pi");
const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5);

const UPDATE = makePackageManagedProviderMaintenanceResolver({
provider: DRIVER_KIND,
npmPackageName: "@earendil-works/pi-coding-agent",
homebrewFormula: null,
nativeUpdate: null,
});

export type PiDriverEnv =
| ChildProcessSpawner.ChildProcessSpawner
| Crypto.Crypto
| FileSystem.FileSystem
| HttpClient.HttpClient
| Path.Path
| ProviderEventLoggers
| ServerConfig
| ServerSettingsService;

const withInstanceIdentity =
(input: {
readonly instanceId: ProviderInstance["instanceId"];
readonly displayName: string | undefined;
readonly accentColor: string | undefined;
readonly continuationGroupKey: string;
}) =>
(snapshot: ServerProviderDraft): ServerProvider => ({
...snapshot,
instanceId: input.instanceId,
driver: DRIVER_KIND,
...(input.displayName ? { displayName: input.displayName } : {}),
...(input.accentColor ? { accentColor: input.accentColor } : {}),
continuation: { groupKey: input.continuationGroupKey },
});

export const PiDriver: ProviderDriver<PiSettings, PiDriverEnv> = {
driverKind: DRIVER_KIND,
metadata: {
displayName: "Pi",
supportsMultipleInstances: true,
},
configSchema: PiSettings,
defaultConfig: (): PiSettings => decodePiSettings({}),
create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const httpClient = yield* HttpClient.HttpClient;
const serverSettings = yield* ServerSettingsService;
const serverConfig = yield* ServerConfig;
const processEnv = mergeProviderInstanceEnvironment(environment);
const continuationIdentity = defaultProviderContinuationIdentity({
driverKind: DRIVER_KIND,
instanceId,
});
const stampIdentity = withInstanceIdentity({
instanceId,
displayName,
accentColor,
continuationGroupKey: continuationIdentity.continuationKey,
});
const effectiveConfig = { ...config, enabled } satisfies PiSettings;
const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
binaryPath: effectiveConfig.binaryPath,
env: processEnv,
});

const adapter = yield* makePiAdapter(effectiveConfig, {
instanceId,
environment: processEnv,
});
const textGeneration = yield* makePiTextGeneration(effectiveConfig, processEnv);

const checkProvider = checkPiProviderStatus(
effectiveConfig,
serverConfig.cwd,
processEnv,
).pipe(
Effect.map(stampIdentity),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
);

const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<PiSettings>>({
maintenanceCapabilities,
getSettings: snapshotSettings.getSettings,
streamSettings: snapshotSettings.streamSettings,
haveSettingsChanged: haveProviderSnapshotSettingsChanged,
initialSnapshot: (settings) =>
buildInitialPiProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)),
checkProvider,
enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) =>
enrichProviderSnapshotWithVersionAdvisory(currentSnapshot, maintenanceCapabilities, {
enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
}).pipe(
Effect.provideService(HttpClient.HttpClient, httpClient),
Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
Effect.catchCause((cause) =>
Effect.logWarning("Pi version advisory enrichment failed", { cause }).pipe(
Effect.asVoid,
),
),
),
refreshInterval: SNAPSHOT_REFRESH_INTERVAL,
}).pipe(
Effect.mapError(
(cause) =>
new ProviderDriverError({
driver: DRIVER_KIND,
instanceId,
detail: `Failed to build Pi snapshot: ${cause.message ?? String(cause)}`,
cause,
}),
),
);

return {
instanceId,
driverKind: DRIVER_KIND,
continuationIdentity,
displayName,
accentColor,
enabled,
snapshot,
adapter,
textGeneration,
} satisfies ProviderInstance;
}),
};
Loading
Loading