diff --git a/README.md b/README.md index ade300d..c283415 100644 --- a/README.md +++ b/README.md @@ -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.

Sled demo @@ -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. @@ -40,6 +40,7 @@ That's why Sled exists. | Claude Code | βœ” | | OpenAI Codex | βœ” | | Gemini CLI | βœ” | +| OpenCode | βœ” | ## Install @@ -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: @@ -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 β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` diff --git a/app/public/css/app.css b/app/public/css/app.css index b7369e9..c4b30b7 100644 --- a/app/public/css/app.css +++ b/app/public/css/app.css @@ -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; diff --git a/app/src/agentProtocolSession.ts b/app/src/agentProtocolSession.ts index 6e4f519..710ac82 100644 --- a/app/src/agentProtocolSession.ts +++ b/app/src/agentProtocolSession.ts @@ -47,6 +47,8 @@ export interface AgentProtocolSessionOptions { onInitializeError?: (error: unknown, message: Record) => void; onSessionReady?: (sessionId: string) => void; onSessionError?: (error: unknown, message: Record) => 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, metadata: unknown) => void; @@ -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(); } @@ -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 { diff --git a/app/src/chatSession.ts b/app/src/chatSession.ts index 93617f7..558b7f1 100644 --- a/app/src/chatSession.ts +++ b/app/src/chatSession.ts @@ -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; @@ -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; @@ -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) { @@ -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)}`; diff --git a/app/src/chatUiRenderer.tsx b/app/src/chatUiRenderer.tsx index 397e9d9..e25947e 100644 --- a/app/src/chatUiRenderer.tsx +++ b/app/src/chatUiRenderer.tsx @@ -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; @@ -468,7 +468,7 @@ export function renderSidebarAgentStateSnippet(state: SidebarAgentState): string {state.isRunning ? "Running" : "Stopped"} {/* Agent type */} - {state.agentType === "claude" ? "Claude" : state.agentType === "codex" ? "Codex" : "Gemini"} + {state.agentType === "claude" ? "Claude" : state.agentType === "codex" ? "Codex" : state.agentType === "opencode" ? "OpenCode" : "Gemini"} {state.isWorking && activityText && (

@@ -179,6 +179,7 @@ export const NewAgentPage = ({ defaultVoice, voiceWorkerBaseUrl }: NewAgentPageP +
@@ -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; @@ -264,11 +265,11 @@ export const AgentChatPage = ({ agentId, agentType, title, yolo, voice, workdir,
{typeLabel} - {(agentType === "claude" || agentType === "codex") && ( + {(agentType === "claude" || agentType === "codex" || agentType === "opencode") && (