Skip to content
Draft
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
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Sled 🛷

Use your desktop Claude Code, Codex or Gemini CLI coding agent from your phone. With voice.
Use your desktop Claude Code, Codex, Gemini CLI or OpenCode coding agent from your phone. With voice.

<p align="center">
<img src="https://assets.layercode.com/mockup.gif" alt="Sled demo" width="400">
Expand All @@ -10,7 +10,7 @@ Use your desktop Claude Code, Codex or Gemini CLI coding agent from your phone.

## Quick Overview

**What is it?** A web UI that runs locally on your computer. It spawns local Claude Code, Codex or Gemini CLI cli agents processes on your computer. This is the same coding cli you alreay use, but we start it in a headless API mode and wrap it in a web UI. We added transcription and text-to-speech so you can talk to it and hear its responses. The web UI works great on mobile, so you can share your localhost and code from anywhere.
**What is it?** A web UI that runs locally on your computer. It spawns local Claude Code, Codex, Gemini CLI or OpenCode agent processes on your computer. This is the same coding cli you already use, but we start it in a headless API mode and wrap it in a web UI. We added transcription and text-to-speech so you can talk to it and hear its responses. The web UI works great on mobile, so you can share your localhost and code from anywhere.

**Do I need to deploy anything?** No. Sled runs 100% on your machine. It's written in Typescript (and runs with wrangler locally). Nothing is deployed to the cloud.

Expand Down Expand Up @@ -40,6 +40,7 @@ That's why Sled exists.
| Claude Code | ✔ |
| OpenAI Codex | ✔ |
| Gemini CLI | ✔ |
| OpenCode | ✔ |

## Install

Expand Down Expand Up @@ -75,6 +76,11 @@ npm install -g @zed-industries/codex-acp
# Gemini CLI
npm install -g @google/gemini-cli@latest
# Gemini supports Agent Control Protocol natively

# OpenCode
npm install -g opencode-ai@latest
# OpenCode supports Agent Control Protocol natively
# Run `opencode auth login` to authenticate
```

Start Sled:
Expand Down Expand Up @@ -129,6 +135,7 @@ Use a strong password. This exposes your machine to the internet.
│ Phone │ ◄───Tailscale────► │ Sled │ ◄───ACP──────────► │ Claude Code │
│ (browser) │ │ (your Mac) │ │ Codex │
│ │ │ │ │ Gemini │
│ │ │ │ │ OpenCode │
└─────────────┘ └──────────────┘ └─────────────┘
```

Expand Down
5 changes: 5 additions & 0 deletions app/public/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,11 @@ main.container.player-view {
color: #065f46;
}

.agent-type-badge--opencode {
background: #e0e7ff;
color: #3730a3;
}

.agents-empty {
text-align: center;
padding: 3rem 1.5rem;
Expand Down
33 changes: 27 additions & 6 deletions app/src/agentProtocolSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export interface AgentProtocolSessionOptions {
onInitializeError?: (error: unknown, message: Record<string, unknown>) => void;
onSessionReady?: (sessionId: string) => void;
onSessionError?: (error: unknown, message: Record<string, unknown>) => void;
/** Called when agent requires authentication (e.g., opencode-login) */
onAuthenticationRequired?: (authMethod: { id: string; name: string; description: string }) => void;
onPromptQueued?: (requestId: string, text: string, metadata: unknown) => void;
onPromptSent?: (requestId: string, text: string, metadata: unknown) => void;
onPromptResult?: (requestId: string, result: Record<string, unknown>, metadata: unknown) => void;
Expand Down Expand Up @@ -92,11 +94,20 @@ export class AgentProtocolSession {
}
this.initializationComplete = true;
// Check if agent advertises auth methods that require explicit authenticate call
const authMethods = readAuthMethodIds(payload.result);
const authMethods = readAuthMethods(payload.result);
const authMethodIds = authMethods?.map((m) => m.id) ?? [];
// Only call authenticate for gemini-api-key; claude-login is informational only
// (Claude Code ACP doesn't implement authenticate - API key passed via env)
if (authMethods && authMethods.includes("gemini-api-key")) {
if (authMethodIds.includes("gemini-api-key")) {
this.dispatchAuthenticate("gemini-api-key");
} else if (authMethodIds.includes("opencode-login")) {
// OpenCode requires user to run `opencode auth login` in terminal
const method = authMethods?.find((m) => m.id === "opencode-login");
if (method) {
this.options.onAuthenticationRequired?.(method);
}
// Don't proceed to session/new - agent can't work without auth
return true;
} else {
this.dispatchSessionNew();
}
Expand Down Expand Up @@ -477,18 +488,28 @@ function readArray(value: unknown): unknown[] | null {
return Array.isArray(value) ? value : null;
}

function readAuthMethodIds(result: unknown): string[] | null {
interface AuthMethod {
id: string;
name: string;
description: string;
}

function readAuthMethods(result: unknown): AuthMethod[] | null {
const obj = readObject(result);
if (!obj) return null;
const methods = readArray(obj.authMethods);
if (!methods || methods.length === 0) return [];
const ids: string[] = [];
const authMethods: AuthMethod[] = [];
for (const m of methods) {
const rec = readObject(m);
const id = rec ? readString(rec.id) : null;
if (id) ids.push(id);
const name = rec ? readString(rec.name) : null;
const description = rec ? readString(rec.description) : null;
if (id) {
authMethods.push({ id, name: name ?? id, description: description ?? "" });
}
}
return ids;
return authMethods;
}

function readPermissionOptions(value: unknown): PermissionRequestOption[] | null {
Expand Down
13 changes: 10 additions & 3 deletions app/src/chatSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ interface ChatSessionOptions {
sessionCwd?: string;
debug?: boolean;
/** Agent type for customizing error messages */
agentType?: "claude" | "gemini" | "codex";
agentType?: "claude" | "gemini" | "codex" | "opencode";
/** Session ID to resume (for Claude Code agents). When provided, the agent will attempt to resume the previous session. */
resumeSessionId?: string;
onNewMessage?: (role: "user" | "assistant", content: string) => void;
Expand Down Expand Up @@ -96,7 +96,7 @@ export class ChatSession {
private readonly createId: CreateId;
private readonly protocol: AgentProtocolSession;
private initialPermissionMode: string;
private readonly agentType: "claude" | "gemini" | "codex";
private readonly agentType: "claude" | "gemini" | "codex" | "opencode";
private readonly onNewMessage?: (role: "user" | "assistant", content: string) => void;
private readonly onToolCall?: (toolCall: ToolCallData) => void;
private readonly onSessionReady?: (sessionId: string) => void;
Expand Down Expand Up @@ -156,6 +156,12 @@ export class ChatSession {
this.pushSnippet(renderChatStatusSnippet("Session error", "error"));
this.pushSnippet(renderChatErrorSnippet(details, this.errorId()));
},
onAuthenticationRequired: (authMethod) => {
// Show authentication required message with instructions from the agent
const message = `**Authentication Required**\n\n${authMethod.description}\n\nAfter authenticating, come back and create a new agent.`;
this.pushSnippet(renderChatStatusSnippet("Authentication required", "error"));
this.pushSnippet(renderChatErrorSnippet(message, this.errorId()));
},
onPromptResult: (_requestId, result, metadata) => {
const promptMeta = asPromptMetadata(metadata);
if (!promptMeta) {
Expand Down Expand Up @@ -703,9 +709,10 @@ export class ChatSession {
claude: "claude",
gemini: "gemini",
codex: "codex",
opencode: "opencode auth login",
};
const command = agentCommands[this.agentType] || this.agentType;
return `**Authentication Required**\n\nPlease run \`${command}\` in your terminal and login. Then come back and create a new agent.`;
return `**Authentication Required**\n\nPlease run \`${command}\` in your terminal to login. Then come back and create a new agent.`;
}

return `Agent error: ${stringify(error)}`;
Expand Down
4 changes: 2 additions & 2 deletions app/src/chatUiRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ function renderPermissionPromptArticle(data: PermissionPromptData, swap?: "outer

interface SidebarAgentState {
agentId: string;
agentType: "claude" | "gemini" | "codex";
agentType: "claude" | "gemini" | "codex" | "opencode";
isWorking: boolean;
isRunning: boolean;
attentionType: AttentionType;
Expand Down Expand Up @@ -468,7 +468,7 @@ export function renderSidebarAgentStateSnippet(state: SidebarAgentState): string
{state.isRunning ? "Running" : "Stopped"}
</span>
{/* Agent type */}
<span class="sidebar-agent-item__type">{state.agentType === "claude" ? "Claude" : state.agentType === "codex" ? "Codex" : "Gemini"}</span>
<span class="sidebar-agent-item__type">{state.agentType === "claude" ? "Claude" : state.agentType === "codex" ? "Codex" : state.agentType === "opencode" ? "OpenCode" : "Gemini"}</span>
{state.isWorking && activityText && (
<div class="sidebar-agent-item__tool-call">
<span class="sidebar-agent-item__spinner"></span>
Expand Down
7 changes: 4 additions & 3 deletions app/src/durableObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export class SledAgent implements DurableObject {
private isWorking = false;
private isRunning = false; // Set from local agent manager status
private agentId: string | null = null; // Set on first sidebar/chat connection
private agentType: "claude" | "gemini" | "codex" = "claude"; // Set on chat connection
private agentType: "claude" | "gemini" | "codex" | "opencode" = "claude"; // Set on chat connection
// Track attention type in memory for broadcasts (also persisted in storage)
private attentionType: AttentionType = null;

Expand Down Expand Up @@ -375,7 +375,7 @@ export class SledAgent implements DurableObject {
if (!this.agentId) this.agentId = agentId;
const agentType = (request.headers.get("X-AGENT-TYPE") || "claude") as AgentType;
// Store agentType for sidebar broadcasts
this.agentType = agentType === "claude" || agentType === "gemini" || agentType === "codex" ? agentType : "claude";
this.agentType = agentType === "claude" || agentType === "gemini" || agentType === "codex" || agentType === "opencode" ? agentType : "claude";
const apiKey = request.headers.get("X-API-KEY") || "";
const yolo = request.headers.get("X-YOLO") === "1";
const resolvedApiKey = apiKey.trim();
Expand All @@ -397,9 +397,10 @@ export class SledAgent implements DurableObject {
if (resolvedApiKey) {
if (agentType === "claude" || agentType === "codex") {
envVars.ANTHROPIC_API_KEY = resolvedApiKey;
} else {
} else if (agentType === "gemini") {
envVars.GEMINI_API_KEY = resolvedApiKey;
}
// OpenCode uses its own configuration system, no API key env var needed
}
if (runtime.cwd) {
envVars.AGENT_CWD = runtime.cwd;
Expand Down
2 changes: 1 addition & 1 deletion app/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ app.post("/agents/new", async (c) => {
const form = await c.req.parseBody();
const name = (form["name"] ? String(form["name"]) : null) || null;
const typeRaw = form["type"] ? String(form["type"]) : "gemini";
const type = typeRaw === "claude" ? "claude" : typeRaw === "codex" ? "codex" : "gemini";
const type = typeRaw === "claude" ? "claude" : typeRaw === "codex" ? "codex" : typeRaw === "opencode" ? "opencode" : "gemini";
const yolo = form["yolo"] === "on" || form["yolo"] === "1";
// Determine voice: use selected voice, or user's default, or "Clive"
const voiceRaw = form["voice"] ? String(form["voice"]).trim() : null;
Expand Down
9 changes: 5 additions & 4 deletions app/src/pages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export const AppShell = ({ agents, currentAgentId, runningAgentIds, voiceWorkerB
{isRunning ? "Running" : "Stopped"}
</span>
<span class="sidebar-agent-item__type">
{a.type === "claude" ? "Claude" : a.type === "codex" ? "Codex" : "Gemini"}
{a.type === "claude" ? "Claude" : a.type === "codex" ? "Codex" : a.type === "opencode" ? "OpenCode" : "Gemini"}
</span>
</div>
</div>
Expand Down Expand Up @@ -179,6 +179,7 @@ export const NewAgentPage = ({ defaultVoice, voiceWorkerBaseUrl }: NewAgentPageP
<option value="claude">Claude Code</option>
<option value="gemini">Gemini CLI</option>
<option value="codex">Codex</option>
<option value="opencode">OpenCode</option>
</select>
</div>
<div class="new-agent-form__option">
Expand Down Expand Up @@ -237,7 +238,7 @@ type AgentChatPageProps = {

export const AgentChatPage = ({ agentId, agentType, title, yolo, voice, workdir, wsPath, voiceWsUrl, debug }: AgentChatPageProps) => {
const cid = crypto.randomUUID();
const typeLabel = agentType === "claude" ? "Claude" : agentType === "codex" ? "Codex" : "Gemini";
const typeLabel = agentType === "claude" ? "Claude" : agentType === "codex" ? "Codex" : agentType === "opencode" ? "OpenCode" : "Gemini";
const displayTitle = title || "New session";
const workdirLabel = workdir ? workdir : null;

Expand All @@ -264,11 +265,11 @@ export const AgentChatPage = ({ agentId, agentType, title, yolo, voice, workdir,
</div>
<div class="chat-app__status" style="display:flex; align-items:center; gap:0.75rem;">
<span
class={`agent-type-badge ${agentType === "claude" ? "agent-type-badge--claude" : agentType === "codex" ? "agent-type-badge--codex" : "agent-type-badge--gemini"}`}
class={`agent-type-badge ${agentType === "claude" ? "agent-type-badge--claude" : agentType === "codex" ? "agent-type-badge--codex" : agentType === "opencode" ? "agent-type-badge--opencode" : "agent-type-badge--gemini"}`}
>
{typeLabel}
</span>
{(agentType === "claude" || agentType === "codex") && (
{(agentType === "claude" || agentType === "codex" || agentType === "opencode") && (
<select
id="permission-mode"
class="chat-app__select"
Expand Down
2 changes: 1 addition & 1 deletion app/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const DEFAULT_VOICE = "Clive";

export type Voice = string;

export type AgentType = "gemini" | "claude" | "codex";
export type AgentType = "gemini" | "claude" | "codex" | "opencode";

export type UserRow = { id: string; email: string; created_at: string };

Expand Down
9 changes: 7 additions & 2 deletions server-client/src/acp-ws-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const DEFAULT_PORT = 3050;
const DEFAULT_HOST = "0.0.0.0";

// Agent type configurations
type AgentType = "gemini" | "claude" | "codex";
type AgentType = "gemini" | "claude" | "codex" | "opencode";
function normalizeEnvValue(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
Expand All @@ -48,6 +48,7 @@ const AGENT_CONFIGS: Record<AgentType, { command: string; args: string[] }> = {
gemini: { command: "gemini", args: ["--experimental-acp"] },
claude: { command: "claude-code-acp", args: [] },
codex: { command: "codex-acp", args: [] },
opencode: { command: "opencode", args: ["acp"] },
};

type SpawnEnvConfig = {
Expand Down Expand Up @@ -147,7 +148,11 @@ export function startProxy(options: ProxyOptions = {}): ProxyHandles {

// Determine agent type from envVars (sent by client via /config)
const agentTypeRaw = envVars?.AGENT_TYPE || "gemini";
const agentType: AgentType = agentTypeRaw === "claude" ? "claude" : agentTypeRaw === "codex" ? "codex" : "gemini";
const agentType: AgentType =
agentTypeRaw === "claude" ? "claude" :
agentTypeRaw === "codex" ? "codex" :
agentTypeRaw === "opencode" ? "opencode" :
"gemini";
const yoloMode = envVars?.YOLO_MODE === "1";
console.log(`${prefix} Config: agentType=${agentType}, yoloMode=${yoloMode}`);
const agentConfig = AGENT_CONFIGS[agentType];
Expand Down