From 2fecb5fb1d0fcbe3896c20e1fdf78ac79bdcd1da Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 26 May 2026 16:21:05 -0700 Subject: [PATCH 1/4] chore: delete agent/ workspace and publish-agent CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retire the remo-code-agent npm package. The Tauri supervisor desktop app (MSI release) is the only supported local client now. - Delete agent/ workspace (src, test, package.json, tsconfig). - Drop "agent" from root workspaces. - Delete .github/workflows/publish-agent.yml. Hub /ws/agent route is unchanged — it stays as the WebSocket endpoint the Tauri supervisor sidecar connects to. Phase 09 plan: .planning/phases/09-retire-npm-packages/PLAN.md --- .github/workflows/publish-agent.yml | 43 --- agent/package.json | 22 -- agent/src/claude-runner.ts | 389 ---------------------------- agent/src/cli-runner.ts | 34 --- agent/src/codex-jsonrpc.ts | 168 ------------ agent/src/codex-runner.ts | 294 --------------------- agent/src/config.ts | 85 ------ agent/src/hub-client.ts | 150 ----------- agent/src/index.ts | 262 ------------------- agent/src/local-ui.ts | 119 --------- agent/src/seed.ts | 48 ---- agent/src/types.ts | 94 ------- agent/src/usage-poller.ts | 156 ----------- agent/test/codex-jsonrpc.test.ts | 60 ----- agent/test/codex-runner.test.ts | 118 --------- agent/test/seed.test.ts | 82 ------ agent/test/usage-poller.test.ts | 123 --------- agent/tsconfig.json | 14 - bun.lock | 11 +- package.json | 2 +- 20 files changed, 2 insertions(+), 2272 deletions(-) delete mode 100644 .github/workflows/publish-agent.yml delete mode 100644 agent/package.json delete mode 100644 agent/src/claude-runner.ts delete mode 100644 agent/src/cli-runner.ts delete mode 100644 agent/src/codex-jsonrpc.ts delete mode 100644 agent/src/codex-runner.ts delete mode 100644 agent/src/config.ts delete mode 100644 agent/src/hub-client.ts delete mode 100644 agent/src/index.ts delete mode 100644 agent/src/local-ui.ts delete mode 100644 agent/src/seed.ts delete mode 100644 agent/src/types.ts delete mode 100644 agent/src/usage-poller.ts delete mode 100644 agent/test/codex-jsonrpc.test.ts delete mode 100644 agent/test/codex-runner.test.ts delete mode 100644 agent/test/seed.test.ts delete mode 100644 agent/test/usage-poller.test.ts delete mode 100644 agent/tsconfig.json diff --git a/.github/workflows/publish-agent.yml b/.github/workflows/publish-agent.yml deleted file mode 100644 index 85b823bc..00000000 --- a/.github/workflows/publish-agent.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Publish Agent to npm - -on: - push: - branches: [main] - paths: - - "agent/**" - - ".github/workflows/publish-agent.yml" - -jobs: - publish: - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write - - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: "24" - registry-url: "https://registry.npmjs.org" - - - name: Check if version changed - id: version-check - working-directory: agent - run: | - LOCAL_VERSION=$(node -p "require('./package.json').version") - PUBLISHED_VERSION=$(npm view remo-code-agent version 2>/dev/null || echo "0.0.0") - echo "local=$LOCAL_VERSION published=$PUBLISHED_VERSION" - if [ "$LOCAL_VERSION" = "$PUBLISHED_VERSION" ]; then - echo "changed=false" >> "$GITHUB_OUTPUT" - else - echo "changed=true" >> "$GITHUB_OUTPUT" - fi - - - name: Publish to npm - if: steps.version-check.outputs.changed == 'true' - working-directory: agent - run: npm publish --provenance --access public - env: - NODE_AUTH_TOKEN: "" diff --git a/agent/package.json b/agent/package.json deleted file mode 100644 index 75463618..00000000 --- a/agent/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "remo-code-agent", - "version": "0.4.1", - "description": "Local agent for Remo Code — streams Claude Code activity to the web UI", - "type": "module", - "bin": { - "remo-agent": "./src/index.ts", - "remo-code-agent": "./src/index.ts" - }, - "scripts": { - "start": "bun src/index.ts", - "dev": "bun --watch src/index.ts" - }, - "keywords": ["claude", "claude-code", "remo-code", "agent", "streaming"], - "license": "Apache-2.0", - "repository": { - "type": "git", - "url": "https://github.com/finedesignz/remo-code" - }, - "homepage": "https://app.remo-code.com", - "dependencies": {} -} diff --git a/agent/src/claude-runner.ts b/agent/src/claude-runner.ts deleted file mode 100644 index e467dc5f..00000000 --- a/agent/src/claude-runner.ts +++ /dev/null @@ -1,389 +0,0 @@ -import { spawn, type Subprocess } from 'bun' -import type { CliEvent } from './types' -import * as ui from './local-ui' -import type { CliRunner, RunnerEvent } from './cli-runner' - -// Barrel re-export so existing `import { RunnerEvent } from './claude-runner'` keeps compiling. -export type { RunnerEvent } from './cli-runner' - -type EventCallback = (event: RunnerEvent) => void - -/** Extract option labels from an elicitation schema (if it contains enum/oneOf) */ -function parseOptionsFromSchema(schema: unknown): Array<{ label: string; description?: string }> { - if (!schema || typeof schema !== 'object') return [] - const s = schema as Record - - // Check for properties with enum values (AskUserQuestion-style) - if (s.properties && typeof s.properties === 'object') { - const props = s.properties as Record - for (const key of Object.keys(props)) { - const prop = props[key] - if (prop?.enum && Array.isArray(prop.enum)) { - return prop.enum.map((v: string) => ({ label: String(v) })) - } - if (prop?.oneOf && Array.isArray(prop.oneOf)) { - return prop.oneOf.map((item: any) => ({ - label: item.const || item.title || String(item), - description: item.description, - })) - } - } - } - - // Direct enum at top level - if (s.enum && Array.isArray(s.enum)) { - return (s.enum as string[]).map((v) => ({ label: String(v) })) - } - - return [] -} - -/** - * Persistent Claude runner — keeps a single interactive process alive. - * Messages are sent via stdin in stream-json format, responses streamed from stdout. - */ -export class ClaudeRunner implements CliRunner { - readonly cliKind = 'claude' as const - private proc: Subprocess | null = null - private projectDir: string - private listener: EventCallback | null = null - private buffer = '' - private fullText = '' - private ready = false - private localOutput: boolean - - private resumeId: string | undefined - private systemPrompt: string | undefined - - constructor(projectDir: string, localOutput = false, resumeId?: string, systemPrompt?: string) { - this.projectDir = projectDir - this.localOutput = localOutput - this.resumeId = resumeId - this.systemPrompt = systemPrompt && systemPrompt.trim() ? systemPrompt : undefined - } - - /** Update the injected system prompt (applied on next process start) */ - setSystemPrompt(prompt: string | null | undefined) { - this.systemPrompt = prompt && prompt.trim() ? prompt : undefined - } - - /** Start the persistent Claude process */ - start(onEvent: EventCallback) { - this.listener = onEvent - - const cmd = [ - 'claude', - '--input-format', 'stream-json', - '--output-format', 'stream-json', - '--verbose', - ] - cmd.push('--dangerously-skip-permissions') - if (this.resumeId) { - cmd.push('--resume', this.resumeId) - } - if (this.systemPrompt) { - cmd.push('--append-system-prompt', this.systemPrompt) - } - - // Strip ANTHROPIC_API_KEY from env so Claude CLI uses OAuth subscription - // instead of potentially invalid project-specific API keys from .env files - const env = { ...process.env } - delete env.ANTHROPIC_API_KEY - - this.proc = spawn({ - cmd, - cwd: this.projectDir, - stdin: 'pipe', - stdout: 'pipe', - stderr: 'pipe', - env, - windowsHide: true, - }) - - console.log(`[runner] spawned claude pid=${this.proc.pid}`) - this.listener?.({ type: 'log', message: `Starting Claude... (pid ${this.proc.pid})` }) - - // Read stdout in background - this.readStream() - - // Read stderr for diagnostics - this.readStderr() - - // Mark ready after a short delay — interactive mode may not emit init - // until first user message, so we can't wait for it - setTimeout(() => { - if (!this.ready && this.proc) { - this.ready = true - console.log('[runner] ready (timeout-based)') - this.listener?.({ type: 'ready' }) - } - }, 3_000) - - // Monitor for unexpected exit — auto-restart after delay - this.proc.exited.then((code) => { - console.log(`[runner] claude exited with code ${code}`) - this.proc = null - this.ready = false - - // Auto-restart unless intentionally stopped - if (this.listener) { - const delay = 3_000 - console.log(`[runner] restarting claude in ${delay / 1000}s...`) - this.listener?.({ type: 'log', message: `Claude exited (code ${code}), restarting in ${delay / 1000}s...` }) - setTimeout(() => { - if (this.listener) { - console.log('[runner] restarting claude process...') - this.start(this.listener) - } - }, delay) - } - }) - } - - /** Send a user message to the running Claude process */ - sendMessage(content: string, images?: Array<{ media_type: string; data: string }>) { - if (!this.proc || !this.ready) { - console.error('[runner] process not ready, cannot send message') - this.listener?.({ type: 'error', message: 'Claude process not ready' }) - return - } - - this.fullText = '' - this.listener?.({ type: 'status', state: 'thinking' }) - - // Build content as array when images are present - let messageContent: string | Array - if (images && images.length > 0) { - const blocks: Array = images.map((img) => ({ - type: 'image', - source: { - type: 'base64', - media_type: img.media_type, - data: img.data, - }, - })) - if (content) { - blocks.push({ type: 'text', text: content }) - } - messageContent = blocks - } else { - messageContent = content - } - - const msg = JSON.stringify({ - type: 'user', - message: { role: 'user', content: messageContent }, - }) - - this.proc.stdin.write(msg + '\n') - this.proc.stdin.flush() - } - - /** Respond to a permission request from Claude */ - respondToPermission(requestId: string, approved: boolean) { - if (!this.proc) { - console.error('[runner] process not running, cannot respond to permission') - return - } - - const response = JSON.stringify({ - type: 'control_response', - request_id: requestId, - behavior: approved ? 'allow' : 'deny', - }) - - console.log(`[runner] permission ${approved ? 'approved' : 'denied'} for ${requestId}`) - this.proc.stdin.write(response + '\n') - this.proc.stdin.flush() - } - - /** Respond to a user question from Claude */ - respondToQuestion(requestId: string, answer: string) { - if (!this.proc) { - console.error('[runner] process not running, cannot respond to question') - return - } - - const response = JSON.stringify({ - type: 'control_response', - request_id: requestId, - response: { answer }, - }) - - console.log(`[runner] question answered for ${requestId}`) - this.proc.stdin.write(response + '\n') - this.proc.stdin.flush() - } - - /** Cancel the current request */ - cancel() { - if (this.proc) { - this.proc.kill('SIGINT') - } - } - - /** Stop the process entirely (no auto-restart) */ - stop() { - this.listener = null // prevent auto-restart - if (this.proc) { - this.proc.kill() - this.proc = null - } - this.ready = false - } - - /** Stop gracefully: SIGINT, wait up to 3s, then SIGKILL. */ - async stopGracefully(): Promise { - this.listener = null - const proc = this.proc - if (!proc) { this.ready = false; return } - try { proc.kill('SIGINT') } catch {} - const exited: Promise = (proc as any).exited ?? Promise.resolve() - await Promise.race([exited, new Promise(r => setTimeout(r, 3_000))]) - try { proc.kill('SIGKILL') } catch {} - this.proc = null - this.ready = false - } - - get isReady() { return this.ready } - - private async readStderr() { - if (!this.proc?.stderr) return - const reader = this.proc.stderr.getReader() - const decoder = new TextDecoder() - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - const text = decoder.decode(value, { stream: true }).trim() - if (text) console.error(`[runner:stderr] ${text}`) - } - } catch { /* process exited */ } - } - - private async readStream() { - if (!this.proc) return - const reader = this.proc.stdout.getReader() - const decoder = new TextDecoder() - - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - - this.buffer += decoder.decode(value, { stream: true }) - const lines = this.buffer.split('\n') - this.buffer = lines.pop() || '' - - for (const line of lines) { - if (!line.trim()) continue - try { - const event: CliEvent = JSON.parse(line) - this.handleEvent(event) - } catch { - // skip malformed lines - } - } - } - } catch (err: any) { - console.error('[runner] stream read error:', err.message) - } - } - - private handleEvent(event: CliEvent) { - // Mark ready after init event - if (event.type === 'system' && (event as any).subtype === 'init') { - this.ready = true - console.log(`[runner] ready, session=${(event as any).session_id}`) - this.listener?.({ type: 'log', message: `Claude ready (session ${((event as any).session_id as string).slice(0, 8)})` }) - this.listener?.({ type: 'ready' }) - return - } - - // Permission requests from Claude CLI - if (event.type === 'control_request' && (event as any).subtype === 'can_use_tool') { - const req = event as any - console.log(`[runner] permission requested: ${req.tool_name} (${req.request_id})`) - if (this.localOutput) ui.printPermissionRequest(req.tool_name, req.tool_input) - this.listener?.({ - type: 'permission_request', - request_id: req.request_id, - tool_name: req.tool_name, - tool_input: req.tool_input, - }) - return - } - - // User questions / elicitations from Claude CLI - // The request may be nested in a `request` field (SDK format) or flat (CLI format) - if (event.type === 'control_request') { - const raw = event as any - const inner = raw.request || raw // handle both nested and flat formats - const subtype = inner.subtype - const requestId = raw.request_id - - if (subtype === 'elicitation' || subtype === 'side_question') { - // Parse question text and options from various possible field names - const question = inner.message || inner.question || inner.text || 'Claude is asking a question' - const rawSchema = inner.requested_schema - const options = parseOptionsFromSchema(rawSchema) - console.log(`[runner] user question (${subtype}): ${question.slice(0, 80)} (${requestId})`) - if (this.localOutput) ui.printQuestion(question, options) - this.listener?.({ - type: 'user_question', - request_id: requestId, - question, - ...(options.length > 0 ? { options } : {}), - }) - return - } - - // Log unknown control_request subtypes for debugging - console.log(`[runner] unhandled control_request subtype=${subtype} request_id=${requestId}`) - return - } - - // Parse assistant content blocks - if (event.type === 'assistant' && 'message' in event) { - const msg = (event as any).message - if (!msg?.content) return - for (const block of msg.content) { - if (block.type === 'text' && block.text) { - this.listener?.({ type: 'status', state: 'writing' }) - this.listener?.({ type: 'text_delta', content: block.text }) - this.fullText += block.text - if (this.localOutput) ui.printTextDelta(block.text) - } - if (block.type === 'thinking' && block.thinking) { - this.listener?.({ type: 'status', state: 'thinking' }) - this.listener?.({ type: 'thinking', content: block.thinking }) - if (this.localOutput) ui.printThinking(block.thinking) - } - if (block.type === 'tool_use') { - this.listener?.({ type: 'status', state: 'tool_calling' }) - this.listener?.({ type: 'tool_use', tool: block.name, tool_id: block.id, input: block.input }) - if (this.localOutput) ui.printToolUse(block.name, block.input) - } - } - } - - // Tool results - if (event.type === 'tool_result') { - const tr = event as any - this.listener?.({ type: 'tool_result', tool_id: tr.tool_use_id, content: tr.content || '', is_error: tr.is_error }) - if (this.localOutput) ui.printToolResult(tr.content || '', tr.is_error) - } - - // Final result — emit assembled message and go idle - if (event.type === 'result') { - const r = event as any - if (this.fullText) { - this.listener?.({ type: 'assistant_message', content: this.fullText }) - this.fullText = '' - } - this.listener?.({ type: 'result', cost: r.total_cost_usd || 0, duration_ms: r.duration_ms || 0 }) - this.listener?.({ type: 'status', state: 'idle' }) - if (this.localOutput) ui.printResponseEnd(r.total_cost_usd || 0, r.duration_ms || 0) - } - } -} diff --git a/agent/src/cli-runner.ts b/agent/src/cli-runner.ts deleted file mode 100644 index 68b142f4..00000000 --- a/agent/src/cli-runner.ts +++ /dev/null @@ -1,34 +0,0 @@ -// RunnerEvent — unified event shape emitted by every CLI runner. -// Codex events (Plan 004) map onto this same union — do NOT add codex-specific variants here. -export type RunnerEvent = - | { type: 'thinking'; content: string } - | { type: 'text_delta'; content: string } - | { type: 'tool_use'; tool: string; tool_id: string; input: unknown } - | { type: 'tool_result'; tool_id: string; content: string; is_error?: boolean } - | { type: 'status'; state: 'idle' | 'thinking' | 'tool_calling' | 'writing' } - | { type: 'assistant_message'; content: string } - | { type: 'permission_request'; request_id: string; tool_name: string; tool_input: unknown } - | { - type: 'user_question' - request_id: string - question: string - options?: Array<{ label: string; description?: string }> - is_multi_select?: boolean - } - | { type: 'result'; cost: number; duration_ms: number } - | { type: 'error'; message: string } - | { type: 'log'; message: string } - | { type: 'ready' } - -export interface CliRunner { - readonly cliKind: 'claude' | 'codex' - readonly isReady: boolean - start(onEvent: (e: RunnerEvent) => void): void - sendMessage(prompt: string, images?: Array<{ media_type: string; data: string }>): void - respondToPermission(requestId: string, approved: boolean): void - respondToQuestion(requestId: string, answer: string): void - setSystemPrompt(prompt: string | null | undefined): void - cancel(): void - stop(): void - stopGracefully(): Promise -} diff --git a/agent/src/codex-jsonrpc.ts b/agent/src/codex-jsonrpc.ts deleted file mode 100644 index c4a49297..00000000 --- a/agent/src/codex-jsonrpc.ts +++ /dev/null @@ -1,168 +0,0 @@ -// JSON-RPC 2.0 client for `codex app-server` over child-process stdio. -// -// SPIKE STATUS (2026-05-25): Codex CLI not verified live on this host. -// Framing assumption A1: newline-delimited JSON. Implementation also -// supports LSP-style `Content-Length:` headers and auto-detects on the -// first non-whitespace byte from stdout. Verify on first live integration -// and trim the unused branch. - -import type { Subprocess } from 'bun' - -type PendingRequest = { - resolve: (value: any) => void - reject: (err: Error) => void -} - -export type NotificationHandler = (method: string, params: unknown) => void -export type ErrorHandler = (err: Error) => void - -export type Framing = 'ndjson' | 'lsp' | 'unknown' - -type FrameOut = { - frames: string[] - bufferRest: string - framing: Framing -} - -/** Pure framing parser — exported for tests. */ -export function readFrames(buffer: string, framing: Framing): FrameOut { - let detected = framing - if (detected === 'unknown') { - const trimmed = buffer.replace(/^\s+/, '') - if (!trimmed) return { frames: [], bufferRest: buffer, framing: 'unknown' } - detected = trimmed.startsWith('Content-Length:') ? 'lsp' : 'ndjson' - } - - const frames: string[] = [] - - if (detected === 'ndjson') { - const lines = buffer.split('\n') - const rest = lines.pop() ?? '' - for (const line of lines) { - const t = line.trim() - if (t) frames.push(t) - } - return { frames, bufferRest: rest, framing: detected } - } - - let rest = buffer - while (true) { - const headerEnd = rest.indexOf('\r\n\r\n') - if (headerEnd < 0) break - const header = rest.slice(0, headerEnd) - const m = /Content-Length:\s*(\d+)/i.exec(header) - if (!m) { - rest = rest.slice(headerEnd + 4) - continue - } - const len = parseInt(m[1]!, 10) - const bodyStart = headerEnd + 4 - if (rest.length < bodyStart + len) break - frames.push(rest.slice(bodyStart, bodyStart + len)) - rest = rest.slice(bodyStart + len) - } - return { frames, bufferRest: rest, framing: detected } -} - -export class JsonRpcClient { - private nextId = 1 - private pending = new Map() - private notifHandler: NotificationHandler | null = null - private errorHandler: ErrorHandler | null = null - private buffer = '' - private framing: Framing = 'unknown' - private closed = false - - constructor(private proc: Subprocess) { - void this.readLoop() - proc.exited.then((code) => { - this.fail(new Error(`codex process exited (code ${code})`)) - }) - } - - onNotification(handler: NotificationHandler) { this.notifHandler = handler } - onError(handler: ErrorHandler) { this.errorHandler = handler } - - request(method: string, params?: unknown): Promise { - if (this.closed) return Promise.reject(new Error('client closed')) - const id = this.nextId++ - const payload = JSON.stringify({ jsonrpc: '2.0', id, method, params }) - return new Promise((resolve, reject) => { - this.pending.set(id, { resolve, reject }) - this.write(payload) - }) - } - - notify(method: string, params?: unknown): void { - if (this.closed) return - const payload = JSON.stringify({ jsonrpc: '2.0', method, params }) - this.write(payload) - } - - close() { - this.closed = true - this.fail(new Error('client closed')) - } - - private write(payload: string) { - try { - const f = this.framing === 'unknown' ? 'ndjson' : this.framing - if (f === 'ndjson') { - this.proc.stdin.write(payload + '\n') - } else { - const header = `Content-Length: ${Buffer.byteLength(payload, 'utf8')}\r\n\r\n` - this.proc.stdin.write(header + payload) - } - this.proc.stdin.flush() - } catch (err: any) { - this.fail(err) - } - } - - private fail(err: Error) { - if (this.errorHandler) { - try { this.errorHandler(err) } catch {} - } - for (const p of this.pending.values()) p.reject(err) - this.pending.clear() - } - - private async readLoop() { - const reader = this.proc.stdout.getReader() - const decoder = new TextDecoder() - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - this.buffer += decoder.decode(value, { stream: true }) - const out = readFrames(this.buffer, this.framing) - this.buffer = out.bufferRest - this.framing = out.framing - for (const frame of out.frames) this.dispatch(frame) - } - } catch (err: any) { - this.fail(err) - } - } - - private dispatch(frame: string) { - let msg: any - try { - msg = JSON.parse(frame) - } catch { - console.error('[codex-jsonrpc] malformed frame:', frame.slice(0, 200)) - return - } - if (msg && typeof msg.id === 'number' && (msg.result !== undefined || msg.error !== undefined)) { - const p = this.pending.get(msg.id) - if (!p) return - this.pending.delete(msg.id) - if (msg.error) p.reject(new Error(`${msg.error.code}: ${msg.error.message}`)) - else p.resolve(msg.result) - return - } - if (typeof msg?.method === 'string') { - this.notifHandler?.(msg.method, msg.params) - } - } -} diff --git a/agent/src/codex-runner.ts b/agent/src/codex-runner.ts deleted file mode 100644 index 4d4dbc46..00000000 --- a/agent/src/codex-runner.ts +++ /dev/null @@ -1,294 +0,0 @@ -// CodexRunner — implements CliRunner over `codex app-server` stdio JSON-RPC. -// -// SPIKE STATUS (2026-05-25): The Codex app-server protocol detail below -// (method names, payload shapes) is derived from Plan 04 research §1.3 -// and is NOT yet verified against a live Codex binary on this host. The -// translate() function is pure and unit-testable; correctness against the -// real server is verified on first live integration. Adjust this file in -// place when actual protocol differs — it is the source of truth. - -import { spawn, type Subprocess } from 'bun' -import type { CliRunner, RunnerEvent } from './cli-runner' -import { JsonRpcClient } from './codex-jsonrpc' -import * as ui from './local-ui' - -type ItemKind = 'reasoning' | 'command_execution' | 'mcp_tool_call' | 'agent_message' | 'unknown' - -type EventCallback = (event: RunnerEvent) => void - -/** - * Pure translation of a Codex JSON-RPC notification into RunnerEvent emissions. - * Exported for unit tests. Mutates `itemKinds` to track currentItemId → kind. - */ -export function translate( - method: string, - params: any, - itemKinds: Map, - emit: EventCallback, -) { - switch (method) { - case 'item/started': { - const id: string = params?.id - const type: string = params?.type - if (!id) return - const kind: ItemKind = - type === 'reasoning' ? 'reasoning' : - type === 'command_execution' ? 'command_execution' : - type === 'mcp_tool_call' ? 'mcp_tool_call' : - type === 'agent_message' ? 'agent_message' : - 'unknown' - itemKinds.set(id, kind) - - if (kind === 'reasoning') { - emit({ type: 'status', state: 'thinking' }) - } else if (kind === 'command_execution') { - emit({ type: 'status', state: 'tool_calling' }) - emit({ - type: 'tool_use', - tool: 'bash', - tool_id: id, - input: { command: params?.command ?? '' }, - }) - } else if (kind === 'mcp_tool_call') { - emit({ type: 'status', state: 'tool_calling' }) - emit({ - type: 'tool_use', - tool: params?.name ?? 'mcp', - tool_id: id, - input: params?.arguments ?? {}, - }) - } else if (kind === 'agent_message') { - emit({ type: 'status', state: 'writing' }) - } - return - } - - case 'item/agentMessage/delta': { - const id: string = params?.id - const delta: string = params?.delta ?? '' - if (!delta) return - const kind = id ? itemKinds.get(id) ?? 'agent_message' : 'agent_message' - if (kind === 'reasoning') { - emit({ type: 'thinking', content: delta }) - } else { - emit({ type: 'text_delta', content: delta }) - } - return - } - - case 'item/completed': { - const id: string = params?.id - const kind = id ? itemKinds.get(id) ?? 'unknown' : 'unknown' - if (kind === 'command_execution') { - const stdout: string = params?.stdout ?? '' - const stderr: string = params?.stderr ?? '' - const exitCode: number = params?.exit_code ?? 0 - emit({ - type: 'tool_result', - tool_id: id, - content: stdout + (stderr ? '\n' + stderr : ''), - is_error: exitCode !== 0, - }) - } else if (kind === 'mcp_tool_call') { - emit({ - type: 'tool_result', - tool_id: id, - content: typeof params?.result === 'string' ? params.result : JSON.stringify(params?.result ?? null), - is_error: !!params?.is_error, - }) - } - if (id) itemKinds.delete(id) - return - } - - case 'turn/completed': { - const agentMessage: string = params?.agent_message ?? '' - if (agentMessage) emit({ type: 'assistant_message', content: agentMessage }) - emit({ type: 'status', state: 'idle' }) - return - } - - case 'approval/required': { - const requestId: string = params?.request_id ?? params?.id - emit({ - type: 'permission_request', - request_id: requestId, - tool_name: 'bash', - tool_input: { command: params?.command, cwd: params?.cwd }, - }) - return - } - - case 'error': { - emit({ type: 'log', message: `Codex error: ${params?.message ?? 'unknown'}` }) - return - } - } -} - -export class CodexRunner implements CliRunner { - readonly cliKind = 'codex' as const - private proc: Subprocess | null = null - private client: JsonRpcClient | null = null - private listener: EventCallback | null = null - private threadId: string | null = null - private ready = false - private workingDir: string - private localOutput: boolean - private resumeThreadId: string | undefined - private systemPrompt: string | undefined - private itemKinds = new Map() - private turnInFlight = false - - constructor(workingDir: string, localOutput = false, resume?: { threadId: string }, systemPrompt?: string) { - this.workingDir = workingDir - this.localOutput = localOutput - this.resumeThreadId = resume?.threadId - this.systemPrompt = systemPrompt && systemPrompt.trim() ? systemPrompt : undefined - } - - setSystemPrompt(prompt: string | null | undefined) { - const next = prompt && prompt.trim() ? prompt : undefined - if (this.ready) { - this.listener?.({ type: 'log', message: 'Codex: system prompt change requires runner restart; ignored' }) - return - } - this.systemPrompt = next - } - - start(onEvent: EventCallback) { - this.listener = onEvent - - this.proc = spawn({ - cmd: ['codex', 'app-server', '--cd', this.workingDir], - cwd: this.workingDir, - stdin: 'pipe', - stdout: 'pipe', - stderr: 'pipe', - windowsHide: true, - }) - - console.log(`[codex-runner] spawned codex pid=${this.proc.pid}`) - this.listener?.({ type: 'log', message: `Starting Codex... (pid ${this.proc.pid})` }) - - this.client = new JsonRpcClient(this.proc) - this.client.onError((err) => { - console.error('[codex-runner] client error:', err.message) - this.listener?.({ type: 'error', message: err.message }) - }) - this.client.onNotification((method, params) => { - if (!this.listener) return - translate(method, params, this.itemKinds, this.listener) - if (method === 'turn/completed') this.turnInFlight = false - }) - - void this.readStderr() - void this.initialize() - - this.proc.exited.then((code) => { - console.log(`[codex-runner] codex exited with code ${code}`) - this.proc = null - this.client = null - this.ready = false - this.turnInFlight = false - }) - } - - private async initialize() { - if (!this.client) return - try { - await this.client.request('initialize', { - client: { name: 'remo-code-agent', version: '0.4.x' }, - ...(this.systemPrompt ? { system_prompt: this.systemPrompt } : {}), - }) - this.client.notify('initialized', {}) - - if (this.resumeThreadId) { - const r = await this.client.request<{ thread_id: string }>('thread/resume', { - thread_id: this.resumeThreadId, - }) - this.threadId = r?.thread_id ?? this.resumeThreadId - } else { - const r = await this.client.request<{ thread_id: string }>('thread/start', {}) - this.threadId = r?.thread_id ?? null - } - this.ready = true - this.listener?.({ type: 'ready' }) - this.listener?.({ type: 'log', message: `Codex ready (thread ${this.threadId?.slice(0, 8) ?? 'unknown'})` }) - } catch (err: any) { - this.listener?.({ type: 'error', message: `Codex initialize failed: ${err?.message ?? String(err)}` }) - } - } - - sendMessage(prompt: string, _images?: Array<{ media_type: string; data: string }>): void { - if (!this.client || !this.ready || !this.threadId) { - this.listener?.({ type: 'error', message: 'Codex runner not ready' }) - return - } - if (_images?.length) { - this.listener?.({ type: 'log', message: 'Codex: image attachments not supported; sending text only' }) - } - this.turnInFlight = true - this.listener?.({ type: 'status', state: 'thinking' }) - this.client.notify('turn/start', { thread_id: this.threadId, input: prompt }) - if (this.localOutput) ui.printUserMessage(prompt) - } - - respondToPermission(requestId: string, approved: boolean): void { - this.client?.notify('approval/response', { - request_id: requestId, - decision: approved ? 'approve' : 'deny', - }) - } - - respondToQuestion(_requestId: string, _answer: string): void { - this.listener?.({ type: 'log', message: 'Codex does not support user_question; ignored' }) - } - - cancel(): void { - if (!this.client || !this.threadId) return - this.client.notify('turn/cancel', { thread_id: this.threadId }) - this.turnInFlight = false - } - - stop(): void { - this.listener = null - try { this.client?.close() } catch {} - if (this.proc) { - try { this.proc.kill() } catch {} - this.proc = null - } - this.ready = false - } - - async stopGracefully(): Promise { - this.listener = null - const proc = this.proc - if (!proc) { this.ready = false; return } - try { this.client?.notify('shutdown', {}) } catch {} - try { proc.kill('SIGTERM') } catch {} - const exited: Promise = (proc as any).exited ?? Promise.resolve() - await Promise.race([exited, new Promise((r) => setTimeout(r, 5_000))]) - try { proc.kill('SIGKILL') } catch {} - try { this.client?.close() } catch {} - this.proc = null - this.client = null - this.ready = false - } - - get isReady() { return this.ready && !this.turnInFlight } - - private async readStderr() { - if (!this.proc?.stderr) return - const reader = this.proc.stderr.getReader() - const decoder = new TextDecoder() - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - const text = decoder.decode(value, { stream: true }).trim() - if (text) console.error(`[codex-runner:stderr] ${text}`) - } - } catch { /* exited */ } - } -} diff --git a/agent/src/config.ts b/agent/src/config.ts deleted file mode 100644 index 3f33c222..00000000 --- a/agent/src/config.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { existsSync, readFileSync } from 'fs' -import { join } from 'path' -import { homedir } from 'os' - -export interface AgentConfig { - hubUrl: string - apiKey: string - projectDir: string - localOutput: boolean - resume: string | undefined - initialPrompt: string | undefined -} - -const CONFIG_PATH = join(homedir(), '.config', 'remo-code', 'config.json') -const CLAUDE_SETTINGS_PATH = join(homedir(), '.claude', 'settings.json') - -const DEFAULT_HUB_URL = 'https://app.remo-code.com' - -export function loadConfig(): AgentConfig { - const args = parseArgs(process.argv.slice(2)) - - // CLI args take priority - let hubUrl = args['--hub-url'] || process.env.REMO_HUB_URL || '' - let apiKey = args['--api-key'] || process.env.REMO_API_KEY || '' - - // Fall back to remo-code config file - if ((!hubUrl || !apiKey) && existsSync(CONFIG_PATH)) { - try { - const file = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8')) - hubUrl = hubUrl || file.hub_url || '' - apiKey = apiKey || file.api_key || '' - } catch {} - } - - // Fall back to Claude Code settings.json env vars - if ((!hubUrl || !apiKey) && existsSync(CLAUDE_SETTINGS_PATH)) { - try { - const settings = JSON.parse(readFileSync(CLAUDE_SETTINGS_PATH, 'utf-8')) - const env = settings.env || {} - hubUrl = hubUrl || env.REMO_HUB_URL || '' - apiKey = apiKey || env.REMO_API_KEY || '' - } catch {} - } - - // Default hub URL if still not set - hubUrl = hubUrl || DEFAULT_HUB_URL - - if (!apiKey) { - console.error('') - console.error(' Remo Code Agent - Missing API key') - console.error('') - console.error(' Provide your API key via one of:') - console.error(' npx remo-code-agent --api-key remokey_xxx') - console.error(' REMO_API_KEY=remokey_xxx npx remo-code-agent') - console.error(` ${CONFIG_PATH} (JSON: { "api_key": "remokey_xxx" })`) - console.error('') - console.error(' Get your API key at https://app.remo-code.com/settings') - console.error('') - process.exit(1) - } - - const projectDir = args['--project-dir'] || process.cwd() - const localOutput = '--local-output' in args || process.env.REMO_LOCAL_OUTPUT === '1' - const resume = args['--resume'] || undefined - const initialPrompt = args['--initial-prompt'] || undefined - - return { hubUrl, apiKey, projectDir, localOutput, resume, initialPrompt } -} - -function parseArgs(argv: string[]): Record { - const booleanFlags = new Set(['--local-output']) - const result: Record = {} - for (let i = 0; i < argv.length; i++) { - if (argv[i].startsWith('--') && argv[i].includes('=')) { - const [key, ...rest] = argv[i].split('=') - result[key] = rest.join('=') - } else if (booleanFlags.has(argv[i])) { - result[argv[i]] = 'true' - } else if (argv[i].startsWith('--') && i + 1 < argv.length) { - result[argv[i]] = argv[i + 1] - i++ - } - } - return result -} diff --git a/agent/src/hub-client.ts b/agent/src/hub-client.ts deleted file mode 100644 index 6693ab99..00000000 --- a/agent/src/hub-client.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { hostname, platform, release, arch, cpus, totalmem } from 'os' -import type { AgentToHub, HubToAgent } from './types' -import { printConnected, printDisconnected } from './local-ui' - -type MessageHandler = (msg: HubToAgent) => void - -export interface AgentInfo { - hostname: string - platform: string - os_release: string - arch: string - cpu_model?: string - cpu_cores: number - total_mem_bytes: number - node_version?: string - bun_version?: string - agent_version?: string -} - -function collectAgentInfo(agentVersion?: string): AgentInfo { - const cpuList = cpus() - return { - hostname: hostname(), - platform: platform(), - os_release: release(), - arch: arch(), - cpu_model: cpuList[0]?.model, - cpu_cores: cpuList.length, - total_mem_bytes: totalmem(), - node_version: process.versions.node, - bun_version: (process.versions as any).bun, - agent_version: agentVersion, - } -} - -export class HubClient { - private ws: WebSocket | null = null - private hubUrl: string - private apiKey: string - private projectDir: string - private hostnameName: string - private agentInfo: AgentInfo - private sessionId: string | null = null - private onMessage: MessageHandler - private reconnectTimer: ReturnType | null = null - private authenticated = false - private stopped = false - - constructor( - hubUrl: string, - apiKey: string, - projectDir: string, - onMessage: MessageHandler, - agentVersion?: string, - ) { - this.hubUrl = hubUrl - this.apiKey = apiKey - this.projectDir = projectDir.replace(/\\/g, '/') - this.hostnameName = hostname() - this.agentInfo = collectAgentInfo(agentVersion) - this.onMessage = onMessage - } - - get sessionIdValue() { return this.sessionId } - - connect() { - const wsUrl = this.hubUrl.replace('https://', 'wss://').replace('http://', 'ws://') + '/ws/agent' - this.ws = new WebSocket(wsUrl) - - this.ws.onopen = () => { - this.send({ - type: 'auth', - api_key: this.apiKey, - project_dir: this.projectDir, - hostname: this.hostnameName, - agent_info: this.agentInfo, - }) - } - - this.ws.onmessage = (event) => { - let msg: HubToAgent - try { - msg = JSON.parse(typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data)) - } catch { return } - - if (msg.type === 'auth_ok') { - this.authenticated = true - this.sessionId = (msg as any).session_id - printConnected(this.sessionId!) - } - - if (msg.type === 'auth_error') { - if (msg.error === 'session_disconnected') { - console.error('') - console.error(' Session was disconnected from the web UI.') - console.error(' Stopping agent. Run claude-remote again to start a fresh session.') - console.error('') - if (this.reconnectTimer) clearTimeout(this.reconnectTimer) - this.reconnectTimer = null - this.stopped = true - try { this.ws?.close() } catch {} - process.exit(0) - } - console.error('') - console.error(` Hub authentication failed: ${msg.error}`) - console.error('') - console.error(` Hub URL: ${this.hubUrl}`) - console.error(' Check that your API key is correct and not expired.') - console.error(' Get a new key at https://app.remo-code.com/settings') - console.error('') - if (this.reconnectTimer) clearTimeout(this.reconnectTimer) - this.reconnectTimer = null - this.stopped = true - this.ws?.close() - return - } - - if (msg.type === 'ping') { - this.send({ type: 'pong' }) - return - } - - this.onMessage(msg) - } - - this.ws.onclose = () => { - printDisconnected() - this.authenticated = false - if (this.stopped) return - this.reconnectTimer = setTimeout(() => this.connect(), 5000) - } - - this.ws.onerror = (err) => { - console.error(`[hub-client] Connection error to ${wsUrl}`) - console.error('[hub-client] Check that the hub URL is reachable and your API key is valid.') - } - } - - send(msg: AgentToHub) { - if (this.ws?.readyState === WebSocket.OPEN) { - this.ws.send(JSON.stringify(msg)) - } - } - - close() { - this.stopped = true - if (this.reconnectTimer) clearTimeout(this.reconnectTimer) - this.ws?.close() - } -} diff --git a/agent/src/index.ts b/agent/src/index.ts deleted file mode 100644 index c53dfbeb..00000000 --- a/agent/src/index.ts +++ /dev/null @@ -1,262 +0,0 @@ -#!/usr/bin/env bun -import { loadConfig } from './config' -import { HubClient } from './hub-client' -import { ClaudeRunner } from './claude-runner' -import { CodexRunner } from './codex-runner' -import type { CliRunner, RunnerEvent } from './cli-runner' -import type { HubToAgent } from './types' -import * as ui from './local-ui' -import { spawnSync } from 'child_process' -import { mkdirSync } from 'fs' -import { homedir } from 'os' -import { join } from 'path' -import { writeSeedFiles } from './seed' -import { startUsagePoller } from './usage-poller' - -const VERSION = '0.4.1' - -// --- Load config --- -const config = loadConfig() - -// --- Startup banner --- -ui.printBanner(VERSION, config.projectDir, config.hubUrl, config.resume) - -const hub = new HubClient(config.hubUrl, config.apiKey, config.projectDir, handleMessage, VERSION) - -// Per-session runner registry. Project session + 0..N rootless sessions coexist. -type SessionInfo = { cliKind: 'claude' | 'codex'; isRootless: boolean; workingDir: string } -const sessionMeta = new Map() -const runners = new Map() -const pendingSystemPrompts = new Map() -const preflightDone = new Set<'claude' | 'codex'>() -let primarySessionId: string | null = null - -function preflight(cliKind: 'claude' | 'codex') { - if (preflightDone.has(cliKind)) return - preflightDone.add(cliKind) - const check = spawnSync(cliKind, ['--version'], { - stdio: ['ignore', 'pipe', 'ignore'], - timeout: 10_000, - windowsHide: true, - shell: process.platform === 'win32', - }) - if (check.status !== 0 && !check.stdout?.toString().trim()) { - if (cliKind === 'claude') { - ui.printError('Claude Code CLI not found.') - console.error('') - console.error(' Install it from: https://claude.ai/code') - } else { - ui.printError('Codex CLI not found.') - console.error('') - console.error(' Install it with: npm i -g @openai/codex') - console.error(' Then run: codex login (or set OPENAI_API_KEY)') - } - console.error('') - process.exit(1) - } -} - -function sendLog(message: string, sessionId?: string) { - const sid = sessionId ?? primarySessionId ?? hub.sessionIdValue - if (!sid) return - hub.send({ type: 'agent_log', session_id: sid, message }) -} - -function makeHandler(sessionId: string) { - return (event: RunnerEvent) => { - if (event.type === 'result' || event.type === 'ready') return - if (event.type === 'log') { - sendLog(event.message, sessionId) - return - } - hub.send({ ...event, session_id: sessionId } as any) - } -} - -function getOrStartRunner(sessionId: string): CliRunner | null { - const existing = runners.get(sessionId) - if (existing) return existing - - const meta = sessionMeta.get(sessionId) - if (!meta) { - console.error(`[remo-agent] unknown session ${sessionId}; dropping`) - return null - } - - preflight(meta.cliKind) - try { mkdirSync(meta.workingDir, { recursive: true }) } catch {} - - let runner: CliRunner - if (meta.cliKind === 'codex') { - runner = new CodexRunner(meta.workingDir, config.localOutput) - } else { - runner = new ClaudeRunner( - meta.workingDir, - config.localOutput, - meta.isRootless ? undefined : config.resume, - ) - } - const sysPrompt = pendingSystemPrompts.get(sessionId) - if (sysPrompt) runner.setSystemPrompt(sysPrompt) - runner.start(makeHandler(sessionId)) - runners.set(sessionId, runner) - return runner -} - -function registerSession(sessionId: string, cliKind: 'claude' | 'codex', isRootless: boolean) { - if (sessionMeta.has(sessionId)) return - const workingDir = isRootless - ? join(homedir(), '.remo-code', 'rootless', cliKind) - : config.projectDir - sessionMeta.set(sessionId, { cliKind, isRootless, workingDir }) -} - -let projectStartScheduled = false -function startProjectRunnerOnce() { - if (projectStartScheduled) return - projectStartScheduled = true - if (primarySessionId) { - console.log('[remo-agent] Starting primary runner...') - getOrStartRunner(primarySessionId) - } -} - -function handleMessage(msg: HubToAgent) { - if (msg.type === 'auth_ok') { - const cliKind = msg.cli_kind ?? 'claude' - primarySessionId = msg.session_id - registerSession(msg.session_id, cliKind, false) - - // Register rootless sessions (lazy-start; only spawn on first user_message) - if (msg.rootless_session_ids) { - for (const kind of ['claude', 'codex'] as const) { - const sid = msg.rootless_session_ids[kind] - if (sid) registerSession(sid, kind, true) - } - } - - sendLog(`Remo Code Agent v${VERSION} connected — ${config.projectDir} (cli=${cliKind})`) - - // Phase 05: write any hub-provided seed files (create-if-absent; never overwrite) - writeSeedFiles((msg as any).seed_files, (m) => sendLog(m)) - - if (msg.system_prompt) { - pendingSystemPrompts.set(msg.session_id, msg.system_prompt) - sendLog(`Custom system prompt applied (${msg.system_prompt.length} chars)`) - } - - // Pre-flight only the CLIs we'll actually host - const cliKinds = new Set<'claude' | 'codex'>([cliKind]) - if (msg.rootless_session_ids) { - for (const k of ['claude', 'codex'] as const) { - if (msg.rootless_session_ids[k]) cliKinds.add(k) - } - } - for (const k of cliKinds) preflight(k) - - startProjectRunnerOnce() - return - } - - if (msg.type === 'user_message') { - const runner = getOrStartRunner(msg.session_id) - if (!runner) return - if (!runner.isReady) { - console.log('[remo-agent] runner not ready yet, queuing...') - const check = setInterval(() => { - if (runner.isReady) { - clearInterval(check) - sendUserMessage(runner, msg) - } - }, 500) - setTimeout(() => clearInterval(check), 30_000) - return - } - sendUserMessage(runner, msg) - return - } - - if (msg.type === 'permission_response') { - runners.get(msg.session_id)?.respondToPermission(msg.request_id, msg.approved) - return - } - if (msg.type === 'question_response') { - runners.get(msg.session_id)?.respondToQuestion(msg.request_id, msg.answer) - return - } - if (msg.type === 'cancel') { - runners.get(msg.session_id)?.cancel() - return - } - if (msg.type === 'shutdown') { - const reason = msg.reason ?? 'hub_requested' - console.log(`[remo-agent] Shutdown requested by hub (${reason}). Stopping runners...`) - Promise.all([...runners.values()].map((r) => r.stopGracefully())).finally(() => { - try { hub.close() } catch {} - process.exit(0) - }) - } -} - -function sendUserMessage(runner: CliRunner, msg: Extract) { - let prompt = '' - if (msg.attachments?.length) { - for (const att of msg.attachments) { - prompt += `[Attached file: ${att.filename}]\n${att.content}\n\n` - } - } - prompt += msg.content - if (config.localOutput) ui.printUserMessage(msg.content) - runner.sendMessage(prompt, msg.images) -} - -// Subscription quota poller — reads ~/.claude/.credentials.json and reports -// 5h + 7d utilization to the hub every 5 minutes (and immediately on startup). -const usagePoller = startUsagePoller((usage) => { - hub.send({ type: 'usage_report', usage }) -}) - -// Connect to hub — runners are started after auth_ok so system prompt + cli_kind -// are honored on the first spawn. Fallback timer in case auth_ok never arrives. -hub.connect() -setTimeout(() => { - if (!projectStartScheduled && primarySessionId) { - console.log('[remo-agent] auth_ok not received — starting primary runner without server prompt') - startProjectRunnerOnce() - } else if (!projectStartScheduled) { - // No session yet from hub — assume legacy Claude + start with project_dir as primary. - console.log('[remo-agent] auth_ok delayed — starting legacy Claude runner against project_dir') - const legacyId = 'legacy:local' - primarySessionId = legacyId - registerSession(legacyId, 'claude', false) - startProjectRunnerOnce() - } -}, 5_000) - -// If --initial-prompt was given, send it once both hub auth + primary runner are ready -if (config.initialPrompt) { - const initial = config.initialPrompt - const trySend = () => { - if (!primarySessionId) return false - const r = runners.get(primarySessionId) - if (hub.sessionIdValue && r?.isReady) { - console.log('[remo-agent] Sending initial prompt...') - r.sendMessage(initial) - return true - } - return false - } - const check = setInterval(() => { if (trySend()) clearInterval(check) }, 500) - setTimeout(() => clearInterval(check), 60_000) -} - -// Graceful shutdown -process.on('SIGINT', () => { - console.log('\n[remo-agent] Shutting down...') - try { usagePoller.stop() } catch {} - for (const r of runners.values()) { - try { r.stop() } catch {} - } - hub.close() - process.exit(0) -}) diff --git a/agent/src/local-ui.ts b/agent/src/local-ui.ts deleted file mode 100644 index 7f7190d6..00000000 --- a/agent/src/local-ui.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Local terminal UI — makes claude-remote feel like a native terminal experience. - * Colorized output, input prompt, status indicators, and clear message separation. - */ - -const RESET = '\x1b[0m' -const BOLD = '\x1b[1m' -const DIM = '\x1b[2m' -const ITALIC = '\x1b[3m' -const GREEN = '\x1b[32m' -const CYAN = '\x1b[36m' -const YELLOW = '\x1b[33m' -const RED = '\x1b[31m' -const MAGENTA = '\x1b[35m' -const BLUE = '\x1b[34m' -const BG_DIM = '\x1b[48;5;236m' - -// Box-drawing chars -const HR = '─'.repeat(60) - -export function printBanner(version: string, project: string, hub: string, resume?: string) { - console.log('') - console.log(` ${BOLD}${CYAN}Remo Code Agent${RESET} ${DIM}v${version}${RESET}`) - console.log(` ${DIM}${HR}${RESET}`) - console.log(` ${DIM}Project:${RESET} ${project}`) - console.log(` ${DIM}Hub:${RESET} ${hub}`) - if (resume) console.log(` ${DIM}Resume:${RESET} ${resume}`) - console.log('') -} - -export function printConnected(sessionId: string) { - console.log(` ${GREEN}●${RESET} Connected ${DIM}(session ${sessionId.slice(0, 8)})${RESET}`) - console.log(` ${DIM}Send messages from the web UI or type below${RESET}`) - console.log(` ${DIM}${HR}${RESET}`) - console.log('') -} - -export function printUserMessage(content: string) { - console.log(`${GREEN}${BOLD}You:${RESET} ${content}`) - console.log('') -} - -export function printThinking(content: string) { - // Thinking in dim italic - process.stdout.write(`${DIM}${ITALIC}${content}${RESET}`) -} - -export function printThinkingEnd() { - console.log('') - console.log('') -} - -export function printToolUse(tool: string, input: unknown) { - // Tool name in cyan with icon - const inputStr = typeof input === 'object' ? JSON.stringify(input) : String(input) - const preview = inputStr.length > 80 ? inputStr.slice(0, 80) + '...' : inputStr - console.log(` ${CYAN}⚡ ${tool}${RESET} ${DIM}${preview}${RESET}`) -} - -export function printToolResult(content: string, isError?: boolean) { - if (isError) { - const preview = content.slice(0, 200) - console.log(` ${RED}✗ ${preview}${RESET}`) - } else { - const lines = content.split('\n') - const preview = lines.length > 3 ? lines.slice(0, 3).join('\n') + `\n ${DIM}... (${lines.length} lines)${RESET}` : content - if (preview.trim()) { - console.log(` ${DIM}✓ ${preview.split('\n').join(`\n ${DIM}`)}${RESET}`) - } else { - console.log(` ${DIM}✓ Done${RESET}`) - } - } - console.log('') -} - -export function printTextDelta(content: string) { - process.stdout.write(content) -} - -export function printResponseEnd(cost: number, durationMs: number) { - console.log('') - console.log(` ${DIM}$${cost.toFixed(4)} · ${(durationMs / 1000).toFixed(1)}s${RESET}`) - console.log('') -} - -export function printStatus(state: string) { - if (state === 'thinking') { - process.stdout.write(`${DIM}${ITALIC}`) - } -} - -export function printDisconnected() { - console.log(` ${YELLOW}● Disconnected${RESET} ${DIM}— reconnecting...${RESET}`) -} - -export function printPermissionRequest(toolName: string, toolInput: unknown) { - const inputStr = typeof toolInput === 'object' ? JSON.stringify(toolInput) : String(toolInput) - const preview = inputStr.length > 120 ? inputStr.slice(0, 120) + '...' : inputStr - console.log(` ${YELLOW}⚠ Permission needed:${RESET} ${BOLD}${toolName}${RESET}`) - console.log(` ${DIM}${preview}${RESET}`) - console.log(` ${DIM}Approve from web UI at https://app.remo-code.com${RESET}`) - console.log('') -} - -export function printQuestion(question: string, options?: Array<{ label: string; description?: string }>) { - console.log(` ${MAGENTA}? ${BOLD}Question:${RESET} ${question}`) - if (options?.length) { - for (const opt of options) { - const desc = opt.description ? ` ${DIM}— ${opt.description}${RESET}` : '' - console.log(` ${MAGENTA}•${RESET} ${opt.label}${desc}`) - } - } - console.log(` ${DIM}Answer from web UI at https://app.remo-code.com${RESET}`) - console.log('') -} - -export function printError(message: string) { - console.log(` ${RED}✗ ${message}${RESET}`) -} diff --git a/agent/src/seed.ts b/agent/src/seed.ts deleted file mode 100644 index c761a9cf..00000000 --- a/agent/src/seed.ts +++ /dev/null @@ -1,48 +0,0 @@ -// Phase 05 seed-file writer: receives instruction blobs from hub via auth_ok -// and writes them to disk ONLY IF the local file does not already exist. -// Never overwrites. On sha mismatch, logs a drift warning and leaves the file. - -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' -import { createHash } from 'crypto' -import { homedir } from 'os' -import { dirname, resolve } from 'path' - -// MUST MATCH hub/src/ws/protocol.ts auth_ok.seed_files entry shape -export type SeedFile = { - path: string // may start with ~ - content: string - sha256: string - mode: 'create_if_absent' | 'sync_if_unchanged' -} - -function expand(p: string): string { - if (p === '~') return homedir() - if (p.startsWith('~/') || p.startsWith('~\\')) return resolve(homedir(), p.slice(2)) - return resolve(p) -} -function sha(s: string): string { - return createHash('sha256').update(s).digest('hex') -} - -export function writeSeedFiles(files: SeedFile[] | undefined, log: (msg: string) => void): void { - if (!files || files.length === 0) return - for (const f of files) { - try { - const abs = expand(f.path) - if (!existsSync(abs)) { - mkdirSync(dirname(abs), { recursive: true }) - writeFileSync(abs, f.content, { encoding: 'utf8' }) - log(`Seeded ${f.path} from hub (${f.content.length} bytes)`) - continue - } - // File exists — never overwrite. Compare sha and warn on drift. - const localSha = sha(readFileSync(abs, 'utf8')) - if (localSha !== f.sha256) { - log(`Local ${f.path} differs from hub version — keeping local. Reconcile in Settings → Instructions.`) - } - // else: identical, silent no-op - } catch (e: any) { - log(`Failed to seed ${f.path}: ${e?.message ?? String(e)}`) - } - } -} diff --git a/agent/src/types.ts b/agent/src/types.ts deleted file mode 100644 index 6c2126be..00000000 --- a/agent/src/types.ts +++ /dev/null @@ -1,94 +0,0 @@ -// Events the agent sends TO the hub (parsed from Claude CLI stream-json) -export type AgentToHub = - | { type: 'auth'; api_key: string; project_dir: string; hostname: string; - agent_info?: { - hostname?: string; platform?: string; os_release?: string; arch?: string; - cpu_model?: string; cpu_cores?: number; total_mem_bytes?: number; - node_version?: string; bun_version?: string; agent_version?: string; - } } - | { type: 'thinking'; session_id: string; content: string } - | { type: 'text_delta'; session_id: string; content: string } - | { type: 'tool_use'; session_id: string; tool: string; tool_id: string; input: unknown } - | { type: 'tool_result'; session_id: string; tool_id: string; content: string; is_error?: boolean } - | { type: 'status'; session_id: string; state: 'idle' | 'thinking' | 'tool_calling' | 'writing' } - | { type: 'assistant_message'; session_id: string; content: string } - | { type: 'permission_request'; session_id: string; request_id: string; tool_name: string; tool_input: unknown } - | { type: 'user_question'; session_id: string; request_id: string; question: string; - options?: Array<{ label: string; description?: string }>; is_multi_select?: boolean } - | { type: 'agent_log'; session_id: string; message: string } - | { type: 'usage_report'; usage: { - five_hour: { utilization: number; resets_at: string } - seven_day: { utilization: number; resets_at: string } - seven_day_opus?: { utilization: number; resets_at: string } | null - seven_day_oauth_apps?: { utilization: number; resets_at: string } | null - } } - | { type: 'pong' } - -// Events the hub sends TO the agent -export type HubToAgent = - | { - type: 'auth_ok' - session_id: string - system_prompt?: string | null - cli_kind?: 'claude' | 'codex' - seed_files?: Array<{ path: string; content: string; sha256: string; mode: 'create_if_absent' | 'sync_if_unchanged' }> - rootless_session_ids?: { claude?: string; codex?: string } - } - | { type: 'auth_error'; error: string } - | { type: 'user_message'; session_id: string; id: string; content: string; - images?: Array<{ media_type: string; data: string }>; - attachments?: Array<{ filename: string; content: string }> } - | { type: 'permission_response'; session_id: string; request_id: string; approved: boolean } - | { type: 'question_response'; session_id: string; request_id: string; answer: string } - | { type: 'cancel'; session_id: string } - | { type: 'shutdown'; reason?: string } - | { type: 'ping' } - -// Claude CLI stream-json event shapes (subset we care about) -export interface CliInitEvent { - type: 'system' - subtype: 'init' - session_id: string - cwd: string - tools: string[] -} - -export interface CliAssistantEvent { - type: 'assistant' - message: { - content: Array< - | { type: 'text'; text: string } - | { type: 'thinking'; thinking: string } - | { type: 'tool_use'; id: string; name: string; input: unknown } - > - } - session_id: string -} - -export interface CliToolResultEvent { - type: 'tool_result' - tool_use_id: string - content: string - is_error?: boolean - session_id: string -} - -export interface CliResultEvent { - type: 'result' - subtype: 'success' | 'error' - result: string - duration_ms: number - total_cost_usd: number - session_id: string -} - -export interface CliControlRequestEvent { - type: 'control_request' - request_id: string - subtype: 'can_use_tool' - tool_name: string - tool_input: unknown - session_id: string -} - -export type CliEvent = CliInitEvent | CliAssistantEvent | CliToolResultEvent | CliResultEvent | CliControlRequestEvent | { type: string; [key: string]: unknown } diff --git a/agent/src/usage-poller.ts b/agent/src/usage-poller.ts deleted file mode 100644 index 5207db5c..00000000 --- a/agent/src/usage-poller.ts +++ /dev/null @@ -1,156 +0,0 @@ -// Polls Anthropic's Claude subscription quota endpoint and reports back via a -// callback. Reads the OAuth access token from ~/.claude/.credentials.json on -// EVERY tick — Claude Code refreshes it on its own and we never want to cache. -// -// Errors (missing creds file, 401, network, malformed JSON) are non-fatal: -// log a warning and try again next interval. The agent must keep running. - -import { readFileSync } from 'fs' -import { homedir } from 'os' -import { join } from 'path' - -const QUOTA_URL = 'https://api.anthropic.com/api/oauth/usage' -const USER_AGENT = 'claude-code/2.0.15' -const ANTHROPIC_BETA = 'oauth-2025-04-20' - -export interface UsageWindow { - utilization: number - resets_at: string -} - -export interface UsagePayload { - five_hour: UsageWindow - seven_day: UsageWindow - seven_day_opus?: UsageWindow | null - seven_day_oauth_apps?: UsageWindow | null -} - -interface CredentialsFile { - claudeAiOauth?: { accessToken?: string } -} - -function isUsageWindow(v: unknown): v is UsageWindow { - if (!v || typeof v !== 'object') return false - const o = v as Record - return typeof o.utilization === 'number' && typeof o.resets_at === 'string' -} - -function isNullableUsageWindow(v: unknown): v is UsageWindow | null | undefined { - return v == null || isUsageWindow(v) -} - -export function parseUsagePayload(raw: unknown): UsagePayload | null { - if (!raw || typeof raw !== 'object') return null - const o = raw as Record - if (!isUsageWindow(o.five_hour)) return null - if (!isUsageWindow(o.seven_day)) return null - if (!isNullableUsageWindow(o.seven_day_opus)) return null - if (!isNullableUsageWindow(o.seven_day_oauth_apps)) return null - return { - five_hour: o.five_hour, - seven_day: o.seven_day, - seven_day_opus: (o.seven_day_opus as UsageWindow | null | undefined) ?? null, - seven_day_oauth_apps: (o.seven_day_oauth_apps as UsageWindow | null | undefined) ?? null, - } -} - -export function readAccessToken(path?: string): string | null { - const credPath = path ?? join(homedir(), '.claude', '.credentials.json') - try { - const raw = readFileSync(credPath, 'utf8') - const parsed = JSON.parse(raw) as CredentialsFile - const tok = parsed?.claudeAiOauth?.accessToken - return typeof tok === 'string' && tok.length > 0 ? tok : null - } catch { - return null - } -} - -export interface PollDeps { - fetchFn?: typeof fetch - readToken?: () => string | null - logger?: (msg: string) => void -} - -/** Performs ONE poll cycle. Resolves with payload or null on any failure. */ -export async function pollOnce(deps: PollDeps = {}): Promise { - const fetchFn = deps.fetchFn ?? fetch - const readToken = deps.readToken ?? (() => readAccessToken()) - const log = deps.logger ?? ((m) => console.warn(`[usage-poll] ${m}`)) - - const token = readToken() - if (!token) { - log('no access token in ~/.claude/.credentials.json (skipping)') - return null - } - - let res: Response - try { - res = await fetchFn(QUOTA_URL, { - method: 'GET', - headers: { - Authorization: `Bearer ${token}`, - 'anthropic-beta': ANTHROPIC_BETA, - 'User-Agent': USER_AGENT, - Accept: 'application/json, text/plain, */*', - 'Content-Type': 'application/json', - }, - }) - } catch (err: any) { - log(`network error: ${err?.message ?? err}`) - return null - } - - if (!res.ok) { - log(`HTTP ${res.status} (token may be expired; will retry)`) - return null - } - - let body: unknown - try { - body = await res.json() - } catch (err: any) { - log(`malformed JSON: ${err?.message ?? err}`) - return null - } - - const parsed = parseUsagePayload(body) - if (!parsed) { - log('payload failed schema validation') - return null - } - return parsed -} - -export interface PollerHandle { - stop: () => void - trigger: () => Promise -} - -/** - * Start a poller that fires immediately + every `intervalMs` (default 5m). - * `onUsage` is invoked with each successful payload. Failures are silent - * (logged via logger) so callers stay simple. - */ -export function startUsagePoller( - onUsage: (u: UsagePayload) => void, - opts: PollDeps & { intervalMs?: number } = {}, -): PollerHandle { - const intervalMs = opts.intervalMs ?? 5 * 60 * 1000 - let stopped = false - - const tick = async () => { - if (stopped) return - const payload = await pollOnce(opts) - if (!stopped && payload) onUsage(payload) - } - - // Fire immediately, then on interval. - void tick() - const handle = setInterval(() => { void tick() }, intervalMs) - - return { - stop: () => { stopped = true; clearInterval(handle) }, - trigger: tick, - } -} diff --git a/agent/test/codex-jsonrpc.test.ts b/agent/test/codex-jsonrpc.test.ts deleted file mode 100644 index 8ecacdf4..00000000 --- a/agent/test/codex-jsonrpc.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, test } from 'bun:test' -import { readFrames } from '../src/codex-jsonrpc' - -describe('readFrames (ndjson)', () => { - test('parses complete newline-delimited frames', () => { - const out = readFrames('{"a":1}\n{"b":2}\n', 'unknown') - expect(out.framing).toBe('ndjson') - expect(out.frames).toEqual(['{"a":1}', '{"b":2}']) - expect(out.bufferRest).toBe('') - }) - - test('buffers partial trailing frame', () => { - const out = readFrames('{"a":1}\n{"b":', 'unknown') - expect(out.frames).toEqual(['{"a":1}']) - expect(out.bufferRest).toBe('{"b":') - }) - - test('skips blank lines', () => { - const out = readFrames('\n{"a":1}\n\n', 'ndjson') - expect(out.frames).toEqual(['{"a":1}']) - }) -}) - -describe('readFrames (LSP framing)', () => { - test('auto-detects Content-Length header on first byte', () => { - const body = '{"a":1}' - const buf = `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}` - const out = readFrames(buf, 'unknown') - expect(out.framing).toBe('lsp') - expect(out.frames).toEqual([body]) - expect(out.bufferRest).toBe('') - }) - - test('buffers incomplete LSP body', () => { - const body = '{"a":1}' - const buf = `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n{"a":` - const out = readFrames(buf, 'lsp') - expect(out.frames).toEqual([]) - // bufferRest preserves the entire header + partial body for next chunk - expect(out.bufferRest.length).toBeGreaterThan(0) - }) - - test('parses multiple LSP frames in one buffer', () => { - const b1 = '{"a":1}' - const b2 = '{"b":2}' - const buf = - `Content-Length: ${b1.length}\r\n\r\n${b1}` + - `Content-Length: ${b2.length}\r\n\r\n${b2}` - const out = readFrames(buf, 'lsp') - expect(out.frames).toEqual([b1, b2]) - }) -}) - -describe('readFrames (whitespace)', () => { - test('returns unknown framing when only whitespace seen', () => { - const out = readFrames(' \n ', 'unknown') - expect(out.framing).toBe('unknown') - expect(out.frames).toEqual([]) - }) -}) diff --git a/agent/test/codex-runner.test.ts b/agent/test/codex-runner.test.ts deleted file mode 100644 index 17f1e930..00000000 --- a/agent/test/codex-runner.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { describe, expect, test } from 'bun:test' -import { translate } from '../src/codex-runner' -import type { RunnerEvent } from '../src/cli-runner' - -function collect(): { events: RunnerEvent[]; emit: (e: RunnerEvent) => void } { - const events: RunnerEvent[] = [] - return { events, emit: (e) => events.push(e) } -} - -describe('codex translate()', () => { - test('reasoning deltas route to thinking events', () => { - const itemKinds = new Map() - const { events, emit } = collect() - translate('item/started', { id: 'r1', type: 'reasoning' }, itemKinds, emit) - translate('item/agentMessage/delta', { id: 'r1', delta: 'first' }, itemKinds, emit) - translate('item/agentMessage/delta', { id: 'r1', delta: 'second' }, itemKinds, emit) - expect(events[0]).toEqual({ type: 'status', state: 'thinking' }) - expect(events[1]).toEqual({ type: 'thinking', content: 'first' }) - expect(events[2]).toEqual({ type: 'thinking', content: 'second' }) - }) - - test('agent_message deltas route to text_delta events', () => { - const itemKinds = new Map() - const { events, emit } = collect() - translate('item/started', { id: 'm1', type: 'agent_message' }, itemKinds, emit) - translate('item/agentMessage/delta', { id: 'm1', delta: 'hello ' }, itemKinds, emit) - translate('item/agentMessage/delta', { id: 'm1', delta: 'world' }, itemKinds, emit) - expect(events[0]).toEqual({ type: 'status', state: 'writing' }) - expect(events[1]).toEqual({ type: 'text_delta', content: 'hello ' }) - expect(events[2]).toEqual({ type: 'text_delta', content: 'world' }) - }) - - test('command_execution pair → tool_use + tool_result with exit_code mapping', () => { - const itemKinds = new Map() - const { events, emit } = collect() - translate('item/started', - { id: 'c1', type: 'command_execution', command: 'ls -la' }, itemKinds, emit) - translate('item/completed', - { id: 'c1', type: 'command_execution', exit_code: 1, stdout: 'x', stderr: 'oops' }, - itemKinds, emit) - expect(events).toContainEqual({ - type: 'tool_use', tool: 'bash', tool_id: 'c1', input: { command: 'ls -la' }, - }) - expect(events).toContainEqual({ - type: 'tool_result', tool_id: 'c1', content: 'x\noops', is_error: true, - }) - }) - - test('successful command yields is_error false', () => { - const itemKinds = new Map() - const { events, emit } = collect() - translate('item/started', - { id: 'c2', type: 'command_execution', command: 'echo hi' }, itemKinds, emit) - translate('item/completed', - { id: 'c2', type: 'command_execution', exit_code: 0, stdout: 'hi' }, - itemKinds, emit) - expect(events).toContainEqual({ - type: 'tool_result', tool_id: 'c2', content: 'hi', is_error: false, - }) - }) - - test('mcp_tool_call pair → tool_use + tool_result', () => { - const itemKinds = new Map() - const { events, emit } = collect() - translate('item/started', - { id: 'm', type: 'mcp_tool_call', name: 'read_file', arguments: { path: '/x' } }, - itemKinds, emit) - translate('item/completed', - { id: 'm', type: 'mcp_tool_call', result: 'file contents' }, itemKinds, emit) - expect(events).toContainEqual({ - type: 'tool_use', tool: 'read_file', tool_id: 'm', input: { path: '/x' }, - }) - expect(events).toContainEqual({ - type: 'tool_result', tool_id: 'm', content: 'file contents', is_error: false, - }) - }) - - test('turn/completed emits assistant_message + idle status', () => { - const itemKinds = new Map() - const { events, emit } = collect() - translate('turn/completed', { agent_message: 'done' }, itemKinds, emit) - expect(events).toEqual([ - { type: 'assistant_message', content: 'done' }, - { type: 'status', state: 'idle' }, - ]) - }) - - test('approval/required → permission_request', () => { - const itemKinds = new Map() - const { events, emit } = collect() - translate('approval/required', - { request_id: 'r1', command: 'rm -rf /', cwd: '/tmp' }, itemKinds, emit) - expect(events).toEqual([ - { - type: 'permission_request', - request_id: 'r1', - tool_name: 'bash', - tool_input: { command: 'rm -rf /', cwd: '/tmp' }, - }, - ]) - }) - - test('error notification → log RunnerEvent', () => { - const itemKinds = new Map() - const { events, emit } = collect() - translate('error', { message: 'boom' }, itemKinds, emit) - expect(events).toEqual([{ type: 'log', message: 'Codex error: boom' }]) - }) - - test('itemKinds cleared on completed', () => { - const itemKinds = new Map() - const { emit } = collect() - translate('item/started', { id: 'x', type: 'command_execution', command: 'ls' }, itemKinds, emit) - expect(itemKinds.has('x')).toBe(true) - translate('item/completed', { id: 'x', type: 'command_execution', exit_code: 0 }, itemKinds, emit) - expect(itemKinds.has('x')).toBe(false) - }) -}) diff --git a/agent/test/seed.test.ts b/agent/test/seed.test.ts deleted file mode 100644 index f932d461..00000000 --- a/agent/test/seed.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, test } from 'bun:test' -import { writeSeedFiles, type SeedFile } from '../src/seed' -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' -import { createHash } from 'crypto' -import { join } from 'path' -import { tmpdir } from 'os' - -function sha(s: string) { - return createHash('sha256').update(s).digest('hex') -} - -function withTempDir(fn: (dir: string) => void) { - const dir = mkdtempSync(join(tmpdir(), 'remo-seed-')) - try { fn(dir) } finally { rmSync(dir, { recursive: true, force: true }) } -} - -describe('writeSeedFiles', () => { - test('writes when local file absent', () => { - withTempDir((dir) => { - const path = join(dir, 'nested', 'CLAUDE.md') - const content = 'hello' - const logs: string[] = [] - const files: SeedFile[] = [{ path, content, sha256: sha(content), mode: 'create_if_absent' }] - writeSeedFiles(files, (m) => logs.push(m)) - expect(existsSync(path)).toBe(true) - expect(readFileSync(path, 'utf8')).toBe(content) - expect(logs[0]).toMatch(/^Seeded /) - }) - }) - - test('NEVER overwrites when local file exists with same sha', () => { - withTempDir((dir) => { - const path = join(dir, 'CLAUDE.md') - writeFileSync(path, 'local-content') - const logs: string[] = [] - const files: SeedFile[] = [{ - path, content: 'local-content', sha256: sha('local-content'), - mode: 'create_if_absent', - }] - writeSeedFiles(files, (m) => logs.push(m)) - expect(readFileSync(path, 'utf8')).toBe('local-content') - expect(logs).toEqual([]) - }) - }) - - test('NEVER overwrites on sha drift, emits warning', () => { - withTempDir((dir) => { - const path = join(dir, 'CLAUDE.md') - writeFileSync(path, 'local-content') - const logs: string[] = [] - const files: SeedFile[] = [{ - path, content: 'hub-content', sha256: sha('hub-content'), - mode: 'create_if_absent', - }] - writeSeedFiles(files, (m) => logs.push(m)) - expect(readFileSync(path, 'utf8')).toBe('local-content') - expect(logs[0]).toMatch(/differs from hub version/) - }) - }) - - test('per-file errors do not abort the loop', () => { - withTempDir((dir) => { - const goodPath = join(dir, 'good.md') - const badPath = '\0/illegal/null/byte' - const logs: string[] = [] - const files: SeedFile[] = [ - { path: badPath, content: 'x', sha256: sha('x'), mode: 'create_if_absent' }, - { path: goodPath, content: 'y', sha256: sha('y'), mode: 'create_if_absent' }, - ] - writeSeedFiles(files, (m) => logs.push(m)) - expect(existsSync(goodPath)).toBe(true) - expect(logs.some((l) => l.startsWith('Failed to seed'))).toBe(true) - }) - }) - - test('undefined / empty input is a silent no-op', () => { - const logs: string[] = [] - writeSeedFiles(undefined, (m) => logs.push(m)) - writeSeedFiles([], (m) => logs.push(m)) - expect(logs).toEqual([]) - }) -}) diff --git a/agent/test/usage-poller.test.ts b/agent/test/usage-poller.test.ts deleted file mode 100644 index 297aaf86..00000000 --- a/agent/test/usage-poller.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, expect, test } from 'bun:test' -import { parseUsagePayload, pollOnce } from '../src/usage-poller' - -const VALID_BODY = { - five_hour: { utilization: 42.5, resets_at: '2026-05-25T20:00:00Z' }, - seven_day: { utilization: 12.0, resets_at: '2026-06-01T00:00:00Z' }, - seven_day_opus: null, - seven_day_oauth_apps: { utilization: 5, resets_at: '2026-06-01T00:00:00Z' }, -} - -function fakeFetch(status: number, body: unknown): typeof fetch { - return (async () => { - return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }) - }) as any -} - -describe('parseUsagePayload', () => { - test('accepts a valid payload', () => { - const p = parseUsagePayload(VALID_BODY) - expect(p).not.toBeNull() - expect(p!.five_hour.utilization).toBe(42.5) - expect(p!.seven_day_opus).toBeNull() - }) - - test('rejects missing five_hour', () => { - expect(parseUsagePayload({ seven_day: VALID_BODY.seven_day })).toBeNull() - }) - - test('rejects bad types', () => { - expect(parseUsagePayload({ five_hour: { utilization: 'x', resets_at: 'y' }, seven_day: VALID_BODY.seven_day })).toBeNull() - }) - - test('accepts omitted optional fields', () => { - const p = parseUsagePayload({ five_hour: VALID_BODY.five_hour, seven_day: VALID_BODY.seven_day }) - expect(p).not.toBeNull() - expect(p!.seven_day_opus).toBeNull() - expect(p!.seven_day_oauth_apps).toBeNull() - }) -}) - -describe('pollOnce', () => { - test('returns payload on 200 with valid body', async () => { - const result = await pollOnce({ - fetchFn: fakeFetch(200, VALID_BODY), - readToken: () => 'sk-tok', - logger: () => {}, - }) - expect(result).not.toBeNull() - expect(result!.five_hour.utilization).toBe(42.5) - }) - - test('returns null + logs warning when no token', async () => { - const logs: string[] = [] - const result = await pollOnce({ - fetchFn: fakeFetch(200, VALID_BODY), - readToken: () => null, - logger: (m) => logs.push(m), - }) - expect(result).toBeNull() - expect(logs.length).toBe(1) - expect(logs[0]).toMatch(/no access token/) - }) - - test('returns null on 401', async () => { - const logs: string[] = [] - const result = await pollOnce({ - fetchFn: fakeFetch(401, { error: 'expired' }), - readToken: () => 'sk-tok', - logger: (m) => logs.push(m), - }) - expect(result).toBeNull() - expect(logs[0]).toMatch(/HTTP 401/) - }) - - test('returns null on network error', async () => { - const logs: string[] = [] - const result = await pollOnce({ - fetchFn: (async () => { throw new Error('econnreset') }) as any, - readToken: () => 'sk-tok', - logger: (m) => logs.push(m), - }) - expect(result).toBeNull() - expect(logs[0]).toMatch(/network error.*econnreset/) - }) - - test('returns null on malformed JSON', async () => { - const logs: string[] = [] - const result = await pollOnce({ - fetchFn: (async () => new Response('not-json{', { status: 200 })) as any, - readToken: () => 'sk-tok', - logger: (m) => logs.push(m), - }) - expect(result).toBeNull() - expect(logs[0]).toMatch(/malformed JSON/) - }) - - test('returns null when payload fails schema', async () => { - const logs: string[] = [] - const result = await pollOnce({ - fetchFn: fakeFetch(200, { five_hour: 'not-an-object' }), - readToken: () => 'sk-tok', - logger: (m) => logs.push(m), - }) - expect(result).toBeNull() - expect(logs[0]).toMatch(/schema/) - }) - - test('sends correct Anthropic headers', async () => { - let sawHeaders: Headers | null = null - const result = await pollOnce({ - fetchFn: (async (_url: any, init: any) => { - sawHeaders = new Headers(init.headers) - return new Response(JSON.stringify(VALID_BODY), { status: 200 }) - }) as any, - readToken: () => 'sk-abc', - logger: () => {}, - }) - expect(result).not.toBeNull() - expect(sawHeaders!.get('authorization')).toBe('Bearer sk-abc') - expect(sawHeaders!.get('anthropic-beta')).toBe('oauth-2025-04-20') - expect(sawHeaders!.get('user-agent')).toMatch(/^claude-code\//) - }) -}) diff --git a/agent/tsconfig.json b/agent/tsconfig.json deleted file mode 100644 index ac3c17e9..00000000 --- a/agent/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "module": "ESNext", - "moduleResolution": "bundler", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "outDir": "dist", - "rootDir": "src", - "types": ["bun-types"] - }, - "include": ["src"] -} diff --git a/bun.lock b/bun.lock index 7a18c00f..921a3265 100644 --- a/bun.lock +++ b/bun.lock @@ -12,13 +12,6 @@ "pg": "^8.20.0", }, }, - "agent": { - "name": "remo-code-agent", - "version": "0.4.1", - "bin": { - "remo-agent": "./src/index.ts", - }, - }, "hub": { "name": "remo-code-hub", "version": "0.0.1", @@ -43,7 +36,7 @@ }, "supervisor": { "name": "remo-code-supervisor", - "version": "0.3.0", + "version": "0.3.1", "bin": { "remo-code-supervisor": "src/index.ts", }, @@ -666,8 +659,6 @@ "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], - "remo-code-agent": ["remo-code-agent@workspace:agent"], - "remo-code-hub": ["remo-code-hub@workspace:hub"], "remo-code-supervisor": ["remo-code-supervisor@workspace:supervisor"], diff --git a/package.json b/package.json index e7b9bb5a..c6ce43b6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "remo-code", "private": true, - "workspaces": ["hub", "web", "agent", "supervisor"], + "workspaces": ["hub", "web", "supervisor"], "scripts": { "dev:hub": "cd hub && bun run dev", "dev:web": "cd web && bun run dev", From 8f83d5245656dd83fef97812bd8e888d42ee30bc Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 26 May 2026 16:22:42 -0700 Subject: [PATCH 2/4] chore: drop supervisor npm publishing surface (keep Tauri sidecar) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remo-code-supervisor npm package is retired. supervisor/src/ stays intact — the Tauri MSI build still compiles it as the sidecar binary. - Delete supervisor/package.json and supervisor/README.md. - Drop "supervisor" from root workspaces. - Keep supervisor/src/ (Tauri sidecar source). - Keep supervisor/tauri/ (Tauri project). - Keep .github/workflows/release-supervisor.yml (Tauri MSI release). Phase 09 plan: .planning/phases/09-retire-npm-packages/PLAN.md --- bun.lock | 9 ------ package.json | 2 +- supervisor/README.md | 64 ----------------------------------------- supervisor/package.json | 27 ----------------- 4 files changed, 1 insertion(+), 101 deletions(-) delete mode 100644 supervisor/README.md delete mode 100644 supervisor/package.json diff --git a/bun.lock b/bun.lock index 921a3265..1014ab34 100644 --- a/bun.lock +++ b/bun.lock @@ -34,13 +34,6 @@ "@types/jsonwebtoken": "^9.0.10", }, }, - "supervisor": { - "name": "remo-code-supervisor", - "version": "0.3.1", - "bin": { - "remo-code-supervisor": "src/index.ts", - }, - }, "web": { "name": "remo-code-web", "version": "0.0.1", @@ -661,8 +654,6 @@ "remo-code-hub": ["remo-code-hub@workspace:hub"], - "remo-code-supervisor": ["remo-code-supervisor@workspace:supervisor"], - "remo-code-web": ["remo-code-web@workspace:web"], "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], diff --git a/package.json b/package.json index c6ce43b6..6a35015d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "remo-code", "private": true, - "workspaces": ["hub", "web", "supervisor"], + "workspaces": ["hub", "web"], "scripts": { "dev:hub": "cd hub && bun run dev", "dev:web": "cd web && bun run dev", diff --git a/supervisor/README.md b/supervisor/README.md deleted file mode 100644 index 274d5a9f..00000000 --- a/supervisor/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# remo-code-supervisor (runtime) - -This directory is the **runtime** for the supervisor. It is NOT a stand-alone -CLI — it ships exclusively as the sidecar inside the Remo Code tray app at -[`supervisor/tauri/`](./tauri/). - -For end users: download the Windows .msi from -**[GitHub Releases](https://github.com/finedesignz/remo-code/releases/latest)**. -The tray app handles install, auto-start, configuration, and lifecycle. - -For history on the migration from the old `npx remo-code-supervisor install` -NSSM-backed CLI to the tray app, see [`MIGRATION.md`](./MIGRATION.md). - -## What's in here - -| File | Purpose | -| --- | --- | -| `src/index.ts` | Foreground supervisor entrypoint. Subcommands: `run` (used by Tauri sidecar), `scan` (diagnostic). | -| `src/hub-client.ts` | WS client that connects to the Remo Code hub. | -| `src/process-manager.ts` | Spawns `claude` per session, enforces sandbox/concurrency caps. | -| `src/sandbox.ts` | Allowed-folders + git-only gates. | -| `src/audit.ts` | Append-only JSONL audit log. | -| `src/repo-scanner.ts` | Discovers git repos under configured roots. | -| `src/commands/` | Built-in supervisor commands (run, kill, status, etc.). | -| `src/commands-scanner.ts` | Discovers user/plugin slash-commands. | -| `src/git-ops.ts` | Git-ops helpers used by Coolify self-heal companion. | -| `src/watchdog.ts` | Self-heal watchdog for spawned claude processes. | -| `src/config.ts` | Reads/writes `%APPDATA%\remo-code\supervisor.json`. | -| `test/` | Bun test suite. | - -## Running directly (developer inner-loop) - -The Tauri tray app spawns this with `bun src/index.ts run`. If you want to run -the runtime directly while developing the tray app (or against a hand-written -config file), the same command works: - -```powershell -bun src/index.ts run -``` - -`bun src/index.ts scan` prints the list of git repos discovered under the -configured `roots` and exits — useful for sanity-checking sandbox config. - -## Config - -`%APPDATA%\remo-code\supervisor.json` (Windows). The tray app writes this for -you via its first-run wizard. - -```json -{ - "api_key": "olx_xxx", - "hub_url": "https://app.remo-code.com", - "roots": ["C:\\Users\\you\\GitHub"], - "max_concurrent": 1 -} -``` - -## Logs - -`%LOCALAPPDATA%\remo-code-supervisor\supervisor.log` (rotates at 5 MB → `supervisor.log.1`). - -## License - -MIT diff --git a/supervisor/package.json b/supervisor/package.json deleted file mode 100644 index 3aba0f0d..00000000 --- a/supervisor/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "remo-code-supervisor", - "version": "0.3.1", - "private": true, - "description": "Local supervisor runtime spawned by the Remo Code tray app (supervisor/tauri/). Not published to npm.", - "type": "module", - "main": "src/index.ts", - "files": [ - "src", - "README.md", - "MIGRATION.md" - ], - "scripts": { - "dev": "bun --watch src/index.ts run", - "start": "bun src/index.ts run" - }, - "engines": { - "bun": ">=1.0.0" - }, - "keywords": ["claude", "claude-code", "remote", "supervisor", "remo-code"], - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/finedesignz/remo-code.git", - "directory": "supervisor" - } -} From 4c6ea3fd462f146ca80de9135dcdcd64a5f4c05e Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 26 May 2026 16:26:26 -0700 Subject: [PATCH 3/4] chore(hub): remove dead /ws/channel route and verifyChannelToken The channel WebSocket path was for the legacy claude-code-channel plugin which is fully retired. The Tauri supervisor uses /ws/agent. /ws/client is the browser path. /ws/channel had no remaining consumers. - Delete hub/src/ws/channel.ts. - Drop /ws/channel from the upgrade router, wsData dispatch, and the open/message/close handlers in hub/src/index.ts. - Delete ChannelAuth, AssistantMessage, ChannelStatus, ChannelInbound Zod schemas from hub/src/ws/protocol.ts. - Delete verifyChannelToken DAL helper from hub/src/db/dal.ts. Hub builds clean, tests pass (same 5 pre-existing test-pollution failures on origin/main, unrelated). Web build clean. Phase 09 plan: .planning/phases/09-retire-npm-packages/PLAN.md --- hub/src/db/dal.ts | 6 -- hub/src/index.ts | 10 +-- hub/src/ws/channel.ts | 170 ----------------------------------------- hub/src/ws/protocol.ts | 27 ------- 4 files changed, 1 insertion(+), 212 deletions(-) delete mode 100644 hub/src/ws/channel.ts diff --git a/hub/src/db/dal.ts b/hub/src/db/dal.ts index c7a05df9..029640c6 100644 --- a/hub/src/db/dal.ts +++ b/hub/src/db/dal.ts @@ -919,9 +919,3 @@ export async function recordAuthEvent(opts: { `; } -// ── Channel token ───────────────────────────────────────────────────────────── - -export async function verifyChannelToken(sessionId: string) { - const rows = await sql`SELECT token_hash, user_id FROM sessions WHERE id = ${sessionId}`; - return rows[0] ?? null; -} diff --git a/hub/src/index.ts b/hub/src/index.ts index 0b293ee0..dcc57699 100644 --- a/hub/src/index.ts +++ b/hub/src/index.ts @@ -44,9 +44,6 @@ import { requireAdmin } from './auth/require-admin' import { adminRouter } from './api/admin' import { recordAuthEvent } from './db/dal' import { parseSessionCookieFromHeader } from './session' -import { - createChannelWsData, handleChannelOpen, handleChannelMessage, handleChannelClose, -} from './ws/channel' import { createClientWsData, handleClientOpen, handleClientMessage, handleClientClose, } from './ws/client' @@ -304,7 +301,7 @@ const server = Bun.serve({ const url = new URL(req.url) // WebSocket upgrades — with origin validation (C2 fix) and connection limits - if (url.pathname === '/ws/channel' || url.pathname === '/ws/client' || url.pathname === '/ws/agent') { + if (url.pathname === '/ws/client' || url.pathname === '/ws/agent') { // Origin check for browser clients if (url.pathname === '/ws/client') { const origin = req.headers.get('origin') @@ -325,8 +322,6 @@ const server = Bun.serve({ let wsData: any if (url.pathname === '/ws/agent') { wsData = { type: 'agent' as const, ip, ...createAgentWsData() } - } else if (url.pathname === '/ws/channel') { - wsData = { type: 'channel' as const, ip, ...createChannelWsData() } } else { // Phase 07-C: extract __Host-remo_sid + csrf_token cookies at the // upgrade so /ws/client can authenticate from cookie alone (preferred @@ -392,14 +387,12 @@ const server = Bun.serve({ maxPayloadLength: 10 * 1024 * 1024, // 10 MB (supports image attachments) open(ws) { if (ws.data.type === 'agent') handleAgentOpen(ws as any) - else if (ws.data.type === 'channel') handleChannelOpen(ws as any) else if (ws.data.type === 'client') handleClientOpen(ws as any) }, async message(ws, raw) { const text = typeof raw === 'string' ? raw : new TextDecoder().decode(raw) if (ws.data.type === 'agent') await handleAgentMessage(ws as any, text) - else if (ws.data.type === 'channel') await handleChannelMessage(ws as any, text) else if (ws.data.type === 'client') await handleClientMessage(ws as any, text) }, close(ws) { @@ -407,7 +400,6 @@ const server = Bun.serve({ if (ip) decrementIp(ip) if (ws.data.type === 'agent') handleAgentClose(ws as any) - else if (ws.data.type === 'channel') handleChannelClose(ws as any) else if (ws.data.type === 'client') handleClientClose(ws as any) }, }, diff --git a/hub/src/ws/channel.ts b/hub/src/ws/channel.ts deleted file mode 100644 index 18dc5919..00000000 --- a/hub/src/ws/channel.ts +++ /dev/null @@ -1,170 +0,0 @@ -import type { ServerWebSocket } from 'bun' -import { timingSafeEqual } from 'crypto' -import { ChannelInbound } from './protocol' -import { verifyChannelToken, updateSessionStatus as setSessionStatus, insertMessage } from '../db/dal' -import { registerChannel, unregisterChannel, broadcastToSubscribers, broadcastToUser } from './registry' -import { listSessions } from '../db/dal' -import { hashToken } from '../lib/crypto' - -const AUTH_TIMEOUT_MS = 5_000 -const HEARTBEAT_INTERVAL_MS = 30_000 -const MSG_RATE_WINDOW_MS = 10_000 -const MSG_RATE_MAX = 60 // channels send more (assistant messages can be frequent) - -interface ChannelWsData { - authenticated: boolean - sessionId: string | null - userId: string | null - authTimer: ReturnType | null - heartbeatTimer: ReturnType | null - msgCount: number - msgWindowStart: number -} - -export function createChannelWsData(): ChannelWsData { - return { - authenticated: false, - sessionId: null, - userId: null, - authTimer: null, - heartbeatTimer: null, - msgCount: 0, - msgWindowStart: Date.now(), - } -} - -export function handleChannelOpen(ws: ServerWebSocket) { - const data = ws.data - console.log('[channel] connection opened') - // Require auth within 5 seconds - data.authTimer = setTimeout(() => { - if (!data.authenticated) { - console.log('[channel] auth timeout, closing') - ws.close(4000, 'auth timeout') - } - }, AUTH_TIMEOUT_MS) -} - -export async function handleChannelMessage(ws: ServerWebSocket, raw: string) { - const data = ws.data - - let parsed: unknown - try { parsed = JSON.parse(raw) } catch (e: any) { - console.error('[channel] JSON parse error:', e.message, '| raw:', raw.slice(0, 200)) - return - } - - const result = ChannelInbound.safeParse(parsed) - if (!result.success) return - - const msg = result.data - - if (msg.type === 'auth') { - if (data.authenticated) return - - const session = await verifyChannelToken(msg.session_id) - if (!session) { - ws.send(JSON.stringify({ type: 'auth_error', error: 'invalid' })) - ws.close(4001, 'auth failed') - return - } - - const tokenHash = await hashToken(msg.token) - // Timing-safe comparison to prevent side-channel attacks (H1 fix) - const a = Buffer.from(tokenHash, 'utf8') - const b = Buffer.from(session.token_hash, 'utf8') - if (a.length !== b.length || !timingSafeEqual(a, b)) { - ws.send(JSON.stringify({ type: 'auth_error', error: 'invalid' })) - ws.close(4001, 'auth failed') - return - } - - // Authenticated - data.authenticated = true - data.sessionId = session.id - data.userId = session.user_id - if (data.authTimer) clearTimeout(data.authTimer) - - console.log(`[channel] authenticated session=${session.id} user=${session.user_id}`) - registerChannel(session.id, session.user_id, ws) - await setSessionStatus(session.id, 'online') - - ws.send(JSON.stringify({ type: 'auth_ok' })) - - // Broadcast status to subscribed clients - broadcastToSubscribers(session.id, { - type: 'session_status', - session_id: session.id, - status: 'online', - }) - - // Push updated session list to all browser clients for this user - // (handles new sessions the client hasn't seen yet) - pushSessionList(session.user_id) - - // Start heartbeat - data.heartbeatTimer = setInterval(() => { - try { ws.send(JSON.stringify({ type: 'ping' })) } catch {} - }, HEARTBEAT_INTERVAL_MS) - - return - } - - if (!data.authenticated || !data.sessionId) return - - // Per-connection message rate limiting - const now = Date.now() - if (now - data.msgWindowStart > MSG_RATE_WINDOW_MS) { - data.msgCount = 0 - data.msgWindowStart = now - } - data.msgCount++ - if (data.msgCount > MSG_RATE_MAX) return // silently drop - - if (msg.type === 'assistant_message') { - console.log(`[channel] assistant_message session=${data.sessionId} len=${msg.content.length}`) - const message = await insertMessage(data.sessionId, 'assistant', msg.content) - broadcastToSubscribers(data.sessionId, { - type: 'message', - session_id: data.sessionId, - message, - }) - } - - if (msg.type === 'status') { - const status = msg.status === 'thinking' ? 'thinking' : 'online' - await setSessionStatus(data.sessionId, status as any) - broadcastToSubscribers(data.sessionId, { - type: 'session_status', - session_id: data.sessionId, - status, - }) - } -} - -export async function handleChannelClose(ws: ServerWebSocket) { - const data = ws.data - console.log(`[channel] closed session=${data.sessionId}`) - if (data.authTimer) clearTimeout(data.authTimer) - if (data.heartbeatTimer) clearInterval(data.heartbeatTimer) - - if (data.sessionId) { - unregisterChannel(data.sessionId) - await setSessionStatus(data.sessionId, 'offline') - broadcastToSubscribers(data.sessionId, { - type: 'session_status', - session_id: data.sessionId, - status: 'offline', - }) - // Push updated session list to user's browser clients - if (data.userId) pushSessionList(data.userId) - } -} - -// Fetch and broadcast the full session list to all browser clients for a user -async function pushSessionList(userId: string) { - try { - const sessions = await listSessions(userId) - broadcastToUser(userId, { type: 'session_list', sessions }) - } catch {} -} diff --git a/hub/src/ws/protocol.ts b/hub/src/ws/protocol.ts index b000575e..f4f41471 100644 --- a/hub/src/ws/protocol.ts +++ b/hub/src/ws/protocol.ts @@ -1,32 +1,5 @@ import { z } from 'zod' -// -- Channel <-> Hub -- - -export const ChannelAuth = z.object({ - type: z.literal('auth'), - session_id: z.string().min(1).max(256), - token: z.string().regex(/^remo_[A-Za-z0-9_\-]{43}$/), -}) - -export const AssistantMessage = z.object({ - type: z.literal('assistant_message'), - id: z.string().min(1), - content: z.string().min(1).max(65536), - ts: z.string(), -}) - -export const ChannelStatus = z.object({ - type: z.literal('status'), - status: z.enum(['thinking', 'idle']), -}) - -export const ChannelInbound = z.discriminatedUnion('type', [ - ChannelAuth, - AssistantMessage, - ChannelStatus, - z.object({ type: z.literal('pong') }), -]) - // -- Client <-> Hub -- // Phase 07-C: token is now OPTIONAL. When the upgrade carried a valid From a04ba52fb4ae0a74d3aca029db830ea0dc25f7e5 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 26 May 2026 16:36:29 -0700 Subject: [PATCH 4/4] docs+web: retire npm-package install paths, link Tauri MSI release Update every user-facing surface to point at the Tauri Supervisor MSI release as the only supported local app. Web UI: - ConnectModal: rewrite as Tauri-MSI-only (drop npx remo-code-agent / npx remo-code-supervisor commands + claude-remote alias snippet). - ApiKeyModal: drop the legacy agent
fallback block. - SettingsPage: drop the legacy agent
fallback block. - Sidebar: drop claude-remote wording from title tooltips. Repo docs: - README.md: rewrite Quick Start step 6 + Architecture + Project Structure to describe the Tauri Supervisor desktop app. Drop the shell-alias setup section and the hosted-version npx example. - CLAUDE.md: rewrite Commands + Local Supervisor + WebSocket Protocol + Key Design Decisions to describe the supervisor instead of the agent. Drop the /ws/channel protocol entry. - docs/agent.md: delete. Planning + historical specs: - .planning/codebase/*, .planning/STATE.md, .planning/ROADMAP.md, .planning/REQUIREMENTS.md, .planning/debug/cannot-connect-new-servers.md, several historical phase plans (04, 05, 06, 07, merge-self-heal): prepend a Phase 09 banner noting the retirement. Historical content is preserved. - docs/superpowers/specs/2026-05-22-supervisor-remote-control-design.md - docs/superpowers/specs/2026-03-28-streaming-agent-architecture-design.md - docs/superpowers/plans/2026-03-28-streaming-agent-architecture.md - docs/superpowers/plans/2026-04-27-migrate-supabase-to-postgres.md: same Phase 09 banner. Web build clean. Hub tests unchanged from origin/main baseline. Phase 09 plan: .planning/phases/09-retire-npm-packages/PLAN.md --- .planning/REQUIREMENTS.md | 3 + .planning/ROADMAP.md | 3 + .planning/STATE.md | 3 + .planning/codebase/ARCHITECTURE.md | 3 + .planning/codebase/CONCERNS.md | 3 + .planning/codebase/CONVENTIONS.md | 3 + .planning/codebase/INTEGRATIONS.md | 3 + .planning/codebase/STACK.md | 3 + .planning/codebase/STRUCTURE.md | 3 + .planning/codebase/TESTING.md | 3 + .planning/debug/cannot-connect-new-servers.md | 3 + .../ARCHITECTURE-REVIEW.md | 3 + .../05-PLAN-002-protocol-cli-kind.md | 3 + .../05-PLAN-005-rootless-ui-and-seed.md | 3 + .../05-RESEARCH.md | 3 + .../05-SUMMARY.md | 3 + .../06-PLAN-005-protocol-enforcement.md | 3 + .../07-titanium-auth-cutover/TEST-MATRIX.md | 3 + .planning/phases/merge-self-heal/CONTEXT.md | 3 + .planning/phases/merge-self-heal/PLAN.md | 3 + .planning/phases/merge-self-heal/RESEARCH.md | 3 + CLAUDE.md | 66 ++++----- README.md | 126 ++++-------------- docs/agent.md | 76 ----------- ...2026-03-28-streaming-agent-architecture.md | 2 + ...2026-04-27-migrate-supabase-to-postgres.md | 2 + ...-28-streaming-agent-architecture-design.md | 4 +- ...-05-22-supervisor-remote-control-design.md | 4 +- web/src/components/ApiKeyModal.tsx | 27 +--- web/src/components/ConnectModal.tsx | 86 ++++-------- web/src/components/SettingsPage.tsx | 27 +--- web/src/components/Sidebar.tsx | 4 +- 32 files changed, 160 insertions(+), 327 deletions(-) delete mode 100644 docs/agent.md diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index a4a2a8cd..ef5fd6ed 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -1,6 +1,9 @@ # Requirements +> **Note (Phase 09, 2026-05-26):** The agent/ workspace and channel/ plugin are retired. The local CLI runner now lives in supervisor/src/ and ships exclusively as a Tauri MSI desktop app. The hub /ws/agent route is unchanged. References below to agent/, npx remo-code-agent, claude-remote, or /ws/channel are historical. See .planning/phases/09-retire-npm-packages/. + + Project: **remo-code** Numbered, testable requirements. Each requirement is referenced by phase ROADMAP entries and by PLAN frontmatter `requirements:` arrays. Add new requirements as `RNN` with monotonically increasing numbers — never renumber. diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 2397b521..f1088bdd 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -1,6 +1,9 @@ # Roadmap +> **Note (Phase 09, 2026-05-26):** The agent/ workspace and channel/ plugin are retired. The local CLI runner now lives in supervisor/src/ and ships exclusively as a Tauri MSI desktop app. The hub /ws/agent route is unchanged. References below to agent/, npx remo-code-agent, claude-remote, or /ws/channel are historical. See .planning/phases/09-retire-npm-packages/. + + Project: **remo-code** Owner: jsmithfd@gmail.com Source of truth for phase ordering, status, and dependencies. The GSD SDK parses this file — keep the `Phase NN: ` heading and the `Status:` / `Goal:` / `Depends on:` / `Requirements:` lines exactly as shown. diff --git a/.planning/STATE.md b/.planning/STATE.md index c984706b..d7e9ccbc 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -1,6 +1,9 @@ # Project State — remo-code +> **Note (Phase 09, 2026-05-26):** The agent/ workspace and channel/ plugin are retired. The local CLI runner now lives in supervisor/src/ and ships exclusively as a Tauri MSI desktop app. The hub /ws/agent route is unchanged. References below to agent/, npx remo-code-agent, claude-remote, or /ws/channel are historical. See .planning/phases/09-retire-npm-packages/. + + ## What it is Remo Code is a web app that lets a user chat with their local Claude Code CLI sessions from any browser or phone. A local **agent** (`npx remo-code-agent`) spawns Claude Code with `--input-format stream-json --output-format stream-json` and relays activity (thinking, tool calls, streaming text, permission prompts) to a **hub** (Bun + Hono on port 3040) over a WebSocket. The browser subscribes to one or more sessions and renders the live activity feed. diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md index 55261636..d9e4ad56 100644 --- a/.planning/codebase/ARCHITECTURE.md +++ b/.planning/codebase/ARCHITECTURE.md @@ -1,6 +1,9 @@ # Architecture +> **Note (Phase 09, 2026-05-26):** The agent/ workspace and channel/ plugin are retired. The local CLI runner now lives in supervisor/src/ and ships exclusively as a Tauri MSI desktop app. The hub /ws/agent route is unchanged. References below to agent/, npx remo-code-agent, claude-remote, or /ws/channel are historical. See .planning/phases/09-retire-npm-packages/. + + **Analysis Date:** 2026-05-22 ## System Overview diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md index 24749cf4..4746e52e 100644 --- a/.planning/codebase/CONCERNS.md +++ b/.planning/codebase/CONCERNS.md @@ -1,5 +1,8 @@ # Codebase Concerns +> **Note (Phase 09, 2026-05-26):** The agent/ workspace and channel/ plugin are retired. The local CLI runner now lives in supervisor/src/ and ships exclusively as a Tauri MSI desktop app. The hub /ws/agent route is unchanged. References below to agent/, npx remo-code-agent, claude-remote, or /ws/channel are historical. See .planning/phases/09-retire-npm-packages/. + + **Analysis Date:** 2026-05-22 ## Tech Debt diff --git a/.planning/codebase/CONVENTIONS.md b/.planning/codebase/CONVENTIONS.md index 085ab6e4..0c90dc7a 100644 --- a/.planning/codebase/CONVENTIONS.md +++ b/.planning/codebase/CONVENTIONS.md @@ -1,5 +1,8 @@ # Coding Conventions +> **Note (Phase 09, 2026-05-26):** The agent/ workspace and channel/ plugin are retired. The local CLI runner now lives in supervisor/src/ and ships exclusively as a Tauri MSI desktop app. The hub /ws/agent route is unchanged. References below to agent/, npx remo-code-agent, claude-remote, or /ws/channel are historical. See .planning/phases/09-retire-npm-packages/. + + **Analysis Date:** 2026-05-22 ## Naming Patterns diff --git a/.planning/codebase/INTEGRATIONS.md b/.planning/codebase/INTEGRATIONS.md index a089c47d..be8614e2 100644 --- a/.planning/codebase/INTEGRATIONS.md +++ b/.planning/codebase/INTEGRATIONS.md @@ -1,5 +1,8 @@ # External Integrations +> **Note (Phase 09, 2026-05-26):** The agent/ workspace and channel/ plugin are retired. The local CLI runner now lives in supervisor/src/ and ships exclusively as a Tauri MSI desktop app. The hub /ws/agent route is unchanged. References below to agent/, npx remo-code-agent, claude-remote, or /ws/channel are historical. See .planning/phases/09-retire-npm-packages/. + + **Analysis Date:** 2026-05-22 ## APIs & External Services diff --git a/.planning/codebase/STACK.md b/.planning/codebase/STACK.md index 6547cb89..de19a6e6 100644 --- a/.planning/codebase/STACK.md +++ b/.planning/codebase/STACK.md @@ -1,5 +1,8 @@ # Technology Stack +> **Note (Phase 09, 2026-05-26):** The agent/ workspace and channel/ plugin are retired. The local CLI runner now lives in supervisor/src/ and ships exclusively as a Tauri MSI desktop app. The hub /ws/agent route is unchanged. References below to agent/, npx remo-code-agent, claude-remote, or /ws/channel are historical. See .planning/phases/09-retire-npm-packages/. + + **Analysis Date:** 2026-05-22 ## Languages diff --git a/.planning/codebase/STRUCTURE.md b/.planning/codebase/STRUCTURE.md index 136d1475..7bf2576a 100644 --- a/.planning/codebase/STRUCTURE.md +++ b/.planning/codebase/STRUCTURE.md @@ -1,5 +1,8 @@ # Codebase Structure +> **Note (Phase 09, 2026-05-26):** The agent/ workspace and channel/ plugin are retired. The local CLI runner now lives in supervisor/src/ and ships exclusively as a Tauri MSI desktop app. The hub /ws/agent route is unchanged. References below to agent/, npx remo-code-agent, claude-remote, or /ws/channel are historical. See .planning/phases/09-retire-npm-packages/. + + **Analysis Date:** 2026-05-22 ## Directory Layout diff --git a/.planning/codebase/TESTING.md b/.planning/codebase/TESTING.md index a0c7af52..e85ebd6f 100644 --- a/.planning/codebase/TESTING.md +++ b/.planning/codebase/TESTING.md @@ -1,5 +1,8 @@ # Testing Patterns +> **Note (Phase 09, 2026-05-26):** The agent/ workspace and channel/ plugin are retired. The local CLI runner now lives in supervisor/src/ and ships exclusively as a Tauri MSI desktop app. The hub /ws/agent route is unchanged. References below to agent/, npx remo-code-agent, claude-remote, or /ws/channel are historical. See .planning/phases/09-retire-npm-packages/. + + **Analysis Date:** 2026-05-22 ## Test Framework diff --git a/.planning/debug/cannot-connect-new-servers.md b/.planning/debug/cannot-connect-new-servers.md index dabfa3be..386db249 100644 --- a/.planning/debug/cannot-connect-new-servers.md +++ b/.planning/debug/cannot-connect-new-servers.md @@ -1,3 +1,6 @@ + +> **Note (Phase 09, 2026-05-26):** The agent/ workspace and channel/ plugin are retired. The local CLI runner now lives in supervisor/src/ and ships exclusively as a Tauri MSI desktop app. The hub /ws/agent route is unchanged. References below to agent/, npx remo-code-agent, claude-remote, or /ws/channel are historical. See .planning/phases/09-retire-npm-packages/. + --- status: investigating trigger: "I cannot connect new servers" diff --git a/.planning/phases/04-coolify-dev-supervisor/ARCHITECTURE-REVIEW.md b/.planning/phases/04-coolify-dev-supervisor/ARCHITECTURE-REVIEW.md index b36329b6..15e82c9a 100644 --- a/.planning/phases/04-coolify-dev-supervisor/ARCHITECTURE-REVIEW.md +++ b/.planning/phases/04-coolify-dev-supervisor/ARCHITECTURE-REVIEW.md @@ -1,5 +1,8 @@ # Phase 04 — Coolify Dev Supervisor — Architecture Review +> **Note (Phase 09, 2026-05-26):** This historical phase plan references the retired agent/ workspace and channel/ plugin. See .planning/phases/09-retire-npm-packages/ for the retirement details. + + **Reviewer:** Backend Architect **Date:** 2026-05-25 **Status:** Opinionated. Incorporate before planning. diff --git a/.planning/phases/05-codex-cli-and-rootless-sessions/05-PLAN-002-protocol-cli-kind.md b/.planning/phases/05-codex-cli-and-rootless-sessions/05-PLAN-002-protocol-cli-kind.md index bf30bcb0..84aa1267 100644 --- a/.planning/phases/05-codex-cli-and-rootless-sessions/05-PLAN-002-protocol-cli-kind.md +++ b/.planning/phases/05-codex-cli-and-rootless-sessions/05-PLAN-002-protocol-cli-kind.md @@ -1,3 +1,6 @@ + +> **Note (Phase 09, 2026-05-26):** This historical phase plan references the retired agent/ workspace and channel/ plugin. See .planning/phases/09-retire-npm-packages/ for the retirement details. + --- phase: 05-codex-cli-and-rootless-sessions plan: 02 diff --git a/.planning/phases/05-codex-cli-and-rootless-sessions/05-PLAN-005-rootless-ui-and-seed.md b/.planning/phases/05-codex-cli-and-rootless-sessions/05-PLAN-005-rootless-ui-and-seed.md index 773c602f..ed1454d9 100644 --- a/.planning/phases/05-codex-cli-and-rootless-sessions/05-PLAN-005-rootless-ui-and-seed.md +++ b/.planning/phases/05-codex-cli-and-rootless-sessions/05-PLAN-005-rootless-ui-and-seed.md @@ -1,3 +1,6 @@ + +> **Note (Phase 09, 2026-05-26):** This historical phase plan references the retired agent/ workspace and channel/ plugin. See .planning/phases/09-retire-npm-packages/ for the retirement details. + --- phase: 05-codex-cli-and-rootless-sessions plan: 05 diff --git a/.planning/phases/05-codex-cli-and-rootless-sessions/05-RESEARCH.md b/.planning/phases/05-codex-cli-and-rootless-sessions/05-RESEARCH.md index 292aca00..87fc8716 100644 --- a/.planning/phases/05-codex-cli-and-rootless-sessions/05-RESEARCH.md +++ b/.planning/phases/05-codex-cli-and-rootless-sessions/05-RESEARCH.md @@ -1,5 +1,8 @@ # Phase 05: codex-cli-and-rootless-sessions — Research +> **Note (Phase 09, 2026-05-26):** This historical phase plan references the retired agent/ workspace and channel/ plugin. See .planning/phases/09-retire-npm-packages/ for the retirement details. + + **Researched:** 2026-05-25 **Domain:** CLI agent integration, session schema, cross-host config seeding **Confidence:** MEDIUM-HIGH (Codex protocol facts cited from official docs; integration design assumed) diff --git a/.planning/phases/05-codex-cli-and-rootless-sessions/05-SUMMARY.md b/.planning/phases/05-codex-cli-and-rootless-sessions/05-SUMMARY.md index 9a2befe4..ad3f8413 100644 --- a/.planning/phases/05-codex-cli-and-rootless-sessions/05-SUMMARY.md +++ b/.planning/phases/05-codex-cli-and-rootless-sessions/05-SUMMARY.md @@ -1,5 +1,8 @@ # Phase 05 — Codex CLI + Rootless Ambient Sessions — Summary +> **Note (Phase 09, 2026-05-26):** This historical phase plan references the retired agent/ workspace and channel/ plugin. See .planning/phases/09-retire-npm-packages/ for the retirement details. + + **Status:** ~85% shipped on `feat/scheduler-enhancements`. Hub + agent + protocol + docs landed. Web UI surface needs re-application (drafted, reverted by concurrent commits — see Risks). ## What landed (commits, in order) diff --git a/.planning/phases/06-supervisor-tray-app/06-PLAN-005-protocol-enforcement.md b/.planning/phases/06-supervisor-tray-app/06-PLAN-005-protocol-enforcement.md index cfc521c3..7e90ee63 100644 --- a/.planning/phases/06-supervisor-tray-app/06-PLAN-005-protocol-enforcement.md +++ b/.planning/phases/06-supervisor-tray-app/06-PLAN-005-protocol-enforcement.md @@ -15,6 +15,9 @@ requirements: [R-06-03, R-06-09] # Plan 06-005 — Protocol-level enforcement of security toggles (sandbox gate, dangerous-cap, concurrency, git-only, audit, kill-switch) +> **Note (Phase 09, 2026-05-26):** This historical phase plan references the retired agent/ workspace and channel/ plugin. See .planning/phases/09-retire-npm-packages/ for the retirement details. + + diff --git a/.planning/phases/07-titanium-auth-cutover/TEST-MATRIX.md b/.planning/phases/07-titanium-auth-cutover/TEST-MATRIX.md index 9e973fa9..5f9969ce 100644 --- a/.planning/phases/07-titanium-auth-cutover/TEST-MATRIX.md +++ b/.planning/phases/07-titanium-auth-cutover/TEST-MATRIX.md @@ -1,5 +1,8 @@ # Phase 07 — Titanium Auth Cutover: 16-Row Test Matrix +> **Note (Phase 09, 2026-05-26):** This historical phase plan references the retired agent/ workspace and channel/ plugin. See .planning/phases/09-retire-npm-packages/ for the retirement details. + + **Audience:** human operator running the cutover smoke before D0 deploy, D14 cutover, and any rollback. **Not:** a unit-test contract. Plans A–G already shipped unit + middleware tests in `hub/test/`. This matrix is the **end-to-end + manual smoke** that exercises real flows against a deployed hub (staging or prod). diff --git a/.planning/phases/merge-self-heal/CONTEXT.md b/.planning/phases/merge-self-heal/CONTEXT.md index cbb3adba..520b9c36 100644 --- a/.planning/phases/merge-self-heal/CONTEXT.md +++ b/.planning/phases/merge-self-heal/CONTEXT.md @@ -1,5 +1,8 @@ # Phase: upstream-fixes-merge +> **Note (Phase 09, 2026-05-26):** This historical phase plan references the retired agent/ workspace and channel/ plugin. See .planning/phases/09-retire-npm-packages/ for the retirement details. + + ## Goal Resolve stale PR #1 (`upstream-fixes`, b2f1870, open ~14 days). Cherry-pick fixes still valid on current main; drop the rest; close the PR. Branch diverged 126 files / -11640 lines vs main — a straight merge is not viable. diff --git a/.planning/phases/merge-self-heal/PLAN.md b/.planning/phases/merge-self-heal/PLAN.md index a7a0150a..88447e4e 100644 --- a/.planning/phases/merge-self-heal/PLAN.md +++ b/.planning/phases/merge-self-heal/PLAN.md @@ -1,5 +1,8 @@ # Plan: upstream-fixes-merge +> **Note (Phase 09, 2026-05-26):** This historical phase plan references the retired agent/ workspace and channel/ plugin. See .planning/phases/09-retire-npm-packages/ for the retirement details. + + Reference: CONTEXT.md. Branch off `main` as `chore/upstream-fixes-replay`. One commit per task. ## Wave 1 — Investigate (VERIFY_THEN_APPLY items) diff --git a/.planning/phases/merge-self-heal/RESEARCH.md b/.planning/phases/merge-self-heal/RESEARCH.md index 80545313..2a83d004 100644 --- a/.planning/phases/merge-self-heal/RESEARCH.md +++ b/.planning/phases/merge-self-heal/RESEARCH.md @@ -1,5 +1,8 @@ # Research: merge-self-heal +> **Note (Phase 09, 2026-05-26):** This historical phase plan references the retired agent/ workspace and channel/ plugin. See .planning/phases/09-retire-npm-packages/ for the retirement details. + + ## Source repos surveyed - `C:\Users\artic\GitHub\remo-code` — target platform (this repo) diff --git a/CLAUDE.md b/CLAUDE.md index 3554689f..2e29c199 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,16 +38,15 @@ Browser (React SPA) ↕ WebSocket /ws/client + REST /api/* Hub Server (Bun + Hono, port 3040) ↕ WebSocket /ws/agent -Local Agent (Bun, runs on dev machine) +Remo Code Supervisor desktop app (Tauri MSI, one per host) ↕ subprocess stdin/stdout (stream-json) -Claude Code CLI (persistent interactive process) +Claude Code CLI / Codex CLI (one persistent process per session) ``` -Four packages in a Bun workspace: -- **hub/** — Bun + Hono HTTP/WS server. Authenticates users via Supabase JWT, manages sessions, relays messages and activity events between web clients and agents. +Three packages in a Bun workspace: +- **hub/** — Bun + Hono HTTP/WS server. Authenticates users via Titanium Licensing magic-link + opaque cookie sessions, manages sessions, relays messages and activity events between web clients and supervisors. - **web/** — React 19 + Vite + Tailwind CSS 4 SPA. Connects to hub via WebSocket for real-time chat with activity feed (thinking blocks, tool call indicators, streaming text). -- **agent/** — Local streaming agent. Runs on the dev machine, spawns a persistent Claude Code CLI process, parses stream-json events, and relays them to the hub. Authenticates with an API key. -- **channel/** — (Legacy) Claude Code channel plugin. Kept for backward compatibility but no longer the recommended connection method. +- **supervisor/** — Local supervisor. `supervisor/src/` is the Bun TypeScript source; the Tauri build (`supervisor/tauri/`) compiles it via `bun build --compile` into a sidecar binary and bundles a Windows MSI installer. One supervisor per host. Connects to `/ws/agent` with an API key, spawns Claude/Codex CLIs on demand, parses stream-json events, and relays them to the hub. ## Commands @@ -64,46 +63,38 @@ bun run dev:web # Build web for production bun run build:web -# Run the local agent (recommended: set up a shell alias) -# alias claude-remote='npx remo-code-agent --api-key --local-output' -claude-remote - -# Or run directly (connects to production hub, output to terminal + web) -npx remo-code-agent --api-key --local-output - -# Connect to local hub for development -npx remo-code-agent --hub-url http://localhost:3040 --api-key --local-output - -# Web UI only (no terminal output) -npx remo-code-agent --api-key +# Build the Tauri Supervisor desktop app (produces a Windows .msi installer) +cd supervisor/tauri && cargo tauri build ``` -## Local Agent (Recommended Connection Method) +The legacy `npx remo-code-agent` / `claude-remote` shell-alias flow is retired as of 2026-05-26. Install the Tauri Supervisor MSI from https://github.com/finedesignz/remo-code/releases/latest instead. -The agent (`agent/src/index.ts`) runs on the same machine as Claude Code. It: +## Local Supervisor (only supported connection) -1. Connects to the hub via WebSocket at `/ws/agent`, authenticates with an API key -2. Spawns Claude Code CLI: `claude --input-format stream-json --output-format stream-json --verbose` -3. Keeps a single persistent Claude process alive (full conversation memory) -4. Receives user messages from the hub, writes them to Claude's stdin as JSON -5. Parses Claude's stdout stream-json events and relays to the hub in real-time -6. Hub broadcasts activity events (thinking, text_delta, tool_use, tool_result) to subscribed browsers +The supervisor (`supervisor/src/index.ts`, compiled into the Tauri sidecar binary) runs on the dev machine as a tray app. It: -**Session resume:** The agent reuses existing sessions by matching `project_dir`. Restarting the agent in the same directory reconnects to the same session with full message history. +1. Connects to the hub via WebSocket at `/ws/agent`, authenticates with an API key. +2. Hosts one CLI subprocess per active session (Claude Code or Codex), spawning `claude --input-format stream-json --output-format stream-json --verbose` (or `codex app-server`) lazily on first user message. +3. Keeps each CLI process alive between messages (full conversation memory per session). +4. Receives user messages from the hub, writes them to the CLI's stdin as JSON. +5. Parses the CLI's stdout stream-json events and relays them to the hub in real-time. +6. Hub broadcasts activity events (thinking, text_delta, tool_use, tool_result) to subscribed browsers. -**Config priority:** CLI args > env vars (`REMO_HUB_URL`, `REMO_API_KEY`) > config file (`~/.config/remo-code/config.json`) +**Session resume:** The supervisor reuses existing sessions by matching `project_dir`. Restarting the supervisor reconnects to the same sessions with full message history. + +**Config:** stored in `%LOCALAPPDATA%\remo-code-supervisor\config.json` on Windows (managed by the Tauri first-run wizard). ## Database Uses **PostgreSQL** (self-hosted). Schema in `hub/src/db/schema.sql` — run once on a fresh database. -Tables: `users` (email + bcrypt password, role), `sessions` (Claude Code sessions), `messages` (chat history), `api_keys` (agent authentication). All queries are scoped by `user_id` with explicit WHERE clauses. +Tables: `users` (email + bcrypt password, role), `sessions` (Claude Code sessions), `messages` (chat history), `api_keys` (supervisor authentication). All queries are scoped by `user_id` with explicit WHERE clauses. ## WebSocket Protocol -**`/ws/agent`** (local agent connects here): +**`/ws/agent`** (supervisor connects here): - Auth: `{ type: "auth", api_key, project_dir, hostname }` → API key verified via SHA-256 hash, session found-or-created by project_dir -- Agent sends: `thinking`, `text_delta`, `tool_use`, `tool_result`, `status`, `assistant_message` +- Supervisor sends: `thinking`, `text_delta`, `tool_use`, `tool_result`, `status`, `assistant_message` - Hub sends: `user_message` (with optional `images`/`attachments`), `cancel`, `ping` - 30s heartbeat ping/pong @@ -113,22 +104,19 @@ Tables: `users` (email + bcrypt password, role), `sessions` (Claude Code session - Hub sends `message`, `session_status`, `session_list`, plus activity events (`thinking`, `text_delta`, `tool_use`, `tool_result`, `status`) - Both endpoints have 5s auth timeout, per-IP connection limits (20), per-connection message rate limits -**`/ws/channel`** (legacy channel plugin): -- Kept for backward compatibility. Same protocol as before. - All WS messages validated with Zod schemas in `hub/src/ws/protocol.ts` and `hub/src/ws/agent-protocol.ts`. ## Key Design Decisions -- Agent spawns Claude CLI with `--input-format stream-json --output-format stream-json` for full activity streaming -- Persistent Claude process per agent (conversation memory preserved across messages) -- Session resume by project_dir (agent reconnects to existing session on restart) +- Supervisor spawns Claude CLI with `--input-format stream-json --output-format stream-json` for full activity streaming +- Persistent CLI process per session (conversation memory preserved across messages) +- Session resume by project_dir (supervisor reconnects to existing sessions on restart) - Activity events (thinking, tool use) are ephemeral — only the final assistant_message is persisted - File attachments: text files embedded in message content, images as base64 data URIs - Light/dark theme via CSS custom properties (--bg-primary, --text-primary, etc.) - Session tokens use `remo_` prefix + 32 random bytes (base64url), stored as SHA-256 hashes - The hub serves the built web SPA as static files (no separate web server in production) -- Subscription quota (5h + 7d Anthropic utilization) is polled by the **local agent**, not the hub — the OAuth access token lives only in `~/.claude/.credentials.json` on the dev machine and never leaves it. Hub keeps a per-user in-memory snapshot (`hub/src/usage/store.ts`) and rebroadcasts to web clients via WS event `subscription_usage`. See [docs/agent.md](docs/agent.md). +- Subscription quota (5h + 7d Anthropic utilization) is polled by the **supervisor**, not the hub — the OAuth access token lives only in `~/.claude/.credentials.json` on the dev machine and never leaves it. Hub keeps a per-user in-memory snapshot (`hub/src/usage/store.ts`) and rebroadcasts to web clients via WS event `subscription_usage`. ## Environment Variables @@ -136,7 +124,7 @@ All WS messages validated with Zod schemas in `hub/src/ws/protocol.ts` and `hub/ **web/.env**: `VITE_HUB_URL` -**Agent config**: CLI args, env vars (`REMO_HUB_URL`, `REMO_API_KEY`), or `~/.config/remo-code/config.json` +**Supervisor config**: managed by the Tauri first-run wizard; stored at `%LOCALAPPDATA%\remo-code-supervisor\config.json` on Windows. Holds hub URL, API key, repo roots. **Scheduled tasks (optional):** - `REMO_PUBLIC_URL` — prefix for `{{run_url}}` template var in post-run actions (default `https://app.remo-code.com`). diff --git a/README.md b/README.md index 8485691f..2927ebef 100644 --- a/README.md +++ b/README.md @@ -84,82 +84,19 @@ Open `http://localhost:5173` and create your account on the setup form. ### 6. Connect Claude Code -Generate an API key in Settings, then run the agent in your project directory: +Generate an API key in Settings, then install the **Remo Code Supervisor** desktop app on the Windows machine you want to control: -```bash -npx remo-code-agent --api-key YOUR_API_KEY --local-output -``` - -That's it. The agent spawns a Claude Code process, and everything streams to your browser in real-time — thinking, tool calls, file edits, and responses. You get both terminal output and web UI. - -**Each agent = one Claude Code session.** To connect multiple projects, run the agent in each project directory. Each gets its own session in the web UI. Sessions auto-resume by project directory. - -### Set up a shell alias (recommended) - -Add an alias so you can run `claude-remote` instead of the full command: - -
-Windows (PowerShell) - -Add to your PowerShell profile (`$PROFILE`): -```powershell -# Open profile in editor (creates it if it doesn't exist) -if (!(Test-Path $PROFILE)) { New-Item -Path $PROFILE -Force } -notepad $PROFILE -``` - -Add this line: -```powershell -function claude-remote { npx remo-code-agent --api-key YOUR_API_KEY --local-output } -``` - -Reload: `. $PROFILE` or open a new terminal. -
- -
-macOS / Linux (bash) - -Add to `~/.bashrc` (or `~/.bash_profile` on macOS): -```bash -alias claude-remote='npx remo-code-agent --api-key YOUR_API_KEY --local-output' -``` - -Reload: `source ~/.bashrc` -
- -
-macOS / Linux (zsh) - -Add to `~/.zshrc`: -```bash -alias claude-remote='npx remo-code-agent --api-key YOUR_API_KEY --local-output' -``` +- Download the latest `.msi` from [GitHub Releases](https://github.com/finedesignz/remo-code/releases/latest). +- Run the installer. +- Paste the API key into the first-run wizard. Pick the repo roots the supervisor should scan (typically `C:\Users\you\GitHub`). -Reload: `source ~/.zshrc` -
+The supervisor auto-starts at login, watches your repo roots, and lets you launch Claude Code (or Codex) sessions remotely from the web UI. One supervisor connects all your repos — sessions auto-register and auto-resume by repo. -
-fish - -Add to `~/.config/fish/config.fish`: -```fish -alias claude-remote 'npx remo-code-agent --api-key YOUR_API_KEY --local-output' -``` - -Reload: `source ~/.config/fish/config.fish` -
- -Then just run `claude-remote` in any project directory — same as running `claude` but with remote streaming to the web UI. +> The legacy `npx remo-code-agent` / `claude-remote` shell-alias flow has been retired as of 2026-05-26. The Tauri Supervisor desktop app is the only supported local app. ### Using the hosted version -If you don't want to self-host, use the hosted hub at [app.remo-code.com](https://app.remo-code.com): - -```bash -npx remo-code-agent --api-key YOUR_API_KEY --local-output -``` - -The default hub URL is `https://app.remo-code.com` — no `--hub-url` needed. +If you don't want to self-host, use the hosted hub at [app.remo-code.com](https://app.remo-code.com). Sign up, generate an API key in Settings, then install the [Remo Code Supervisor desktop app](https://github.com/finedesignz/remo-code/releases/latest) — same flow as above. The supervisor's default hub URL is `https://app.remo-code.com`. ## Architecture @@ -167,33 +104,32 @@ The default hub URL is `https://app.remo-code.com` — no `--hub-url` needed. Browser (React SPA) ↕ WebSocket + REST API Hub Server (Bun + Hono) - ↕ WebSocket -Local Agent (one per project) + ↕ WebSocket /ws/agent +Supervisor desktop app (Tauri MSI — one per host) ↕ subprocess stdin/stdout (stream-json) -Claude Code CLI +Claude Code CLI / Codex CLI (one process per session) ``` -Four packages in a Bun workspace: +Three packages in a Bun workspace: - **hub/** — Bun + Hono server handling auth (Titanium Licensing magic-link + opaque cookie sessions — see [docs/auth.md](docs/auth.md)), message relay, and session management. Broadcasts Claude's activity events (thinking, tool use, text) to subscribed browsers. - **web/** — React 19 + Vite + Tailwind CSS 4 chat UI with activity feed, session switching, file attachments, light/dark theme, and unread badges. -- **agent/** — Local streaming agent that runs on your dev machine. Spawns a persistent Claude Code CLI process with `--input-format stream-json --output-format stream-json`, parses events, and relays to the hub. Published as [`remo-code-agent`](https://www.npmjs.com/package/remo-code-agent) on npm. -- **channel/** — (Legacy) Claude Code channel plugin. Kept for backward compatibility. +- **supervisor/** — Local supervisor source. `supervisor/src/` is compiled by `bun build --compile` into the sidecar binary that `supervisor/tauri/` bundles into a Windows MSI installer (Rust + WebView2 tray app). Each running host has exactly one supervisor; the supervisor hosts every session for that machine. ## How It Works -1. You run `npx remo-code-agent --api-key xxx` in your project directory -2. The agent connects to the hub via WebSocket and registers a session -3. The agent spawns `claude --input-format stream-json --output-format stream-json --verbose` -4. When you send a message in the web UI, the hub forwards it to the agent -5. The agent writes the message to Claude's stdin as JSON -6. Claude responds — thinking, tool calls, text stream out via stdout -7. The agent parses the stream-json events and relays them to the hub -8. The hub broadcasts to all subscribed browsers in real-time +1. You install the Remo Code Supervisor MSI on the Windows machine you want to control, paste your API key into the first-run wizard, and pick repo roots. +2. The supervisor connects to the hub via WebSocket (`/ws/agent`) and registers as an agent for your account. +3. When you click "Start session" for a repo from the web UI, the supervisor spawns `claude --input-format stream-json --output-format stream-json --verbose` (or `codex app-server` for Codex sessions) in that repo directory. +4. When you send a message in the web UI, the hub forwards it to the supervisor. +5. The supervisor writes the message to the CLI's stdin as JSON. +6. The CLI responds — thinking, tool calls, text stream out via stdout. +7. The supervisor parses the stream-json events and relays them to the hub. +8. The hub broadcasts to all subscribed browsers in real-time. -**Session resume:** The agent reuses existing sessions by matching the project directory. Restart the agent and it reconnects to the same session with full message history. +**Session resume:** The supervisor reuses existing sessions by matching the repo directory. Restart the supervisor and it reconnects to the same sessions with full message history. -**Conversation memory:** The agent keeps a single persistent Claude process — full conversation context is maintained across messages, just like the terminal. +**Conversation memory:** The supervisor keeps each session's CLI process alive between messages — full conversation context is maintained across messages, just like the terminal. ## Features @@ -206,7 +142,7 @@ Four packages in a Bun workspace: - **Grid View** — watch up to 12 Claude Code sessions side-by-side at `#/grid`. User-named tabs persist per account (`chat_tabs` + `chat_tab_sessions`), each with a layout mode (`3x3`, `4x3`, or `auto-fit`). One WebSocket subscribes to many sessions in one frame, message lists are virtualized, and streaming text is RAF-coalesced. On phones the grid auto-swaps to a single-pane accordion (only one chat mounted at a time). See [docs/grid-view.md](docs/grid-view.md). - **Coolify deployment self-heal (Phase 06, partial)** — a public HMAC-signed webhook endpoint (`POST /api/coolify/webhook/:user_id`) turns `deployment.failed` events into structured `triage` runs that ask Claude to emit a typed `TriageResult` (error_type, severity, root_cause, suggested_fix, confidence). A `github_issue` post-run action then files a labelled issue on the failing repo via the gateway-pair credentials (no `GITHUB_TOKEN` on the hub) with 24-hour idempotency. Webhook ingress, secret rotation, triage schema, and the GitHub-issue action are shipped; the final session-routing wire-up (`pickSessionTarget`) is pending the Phase 04 self-heal-routing plan landing. See [docs/coolify-webhook-migration.md](docs/coolify-webhook-migration.md) and the "Coolify webhook ingress" / "GitHub-issue post-run action" sections in [docs/scheduled-tasks.md](docs/scheduled-tasks.md). - **Codex CLI + rootless ambient sessions** — sessions can run either Claude Code or [Codex](https://github.com/openai/codex) (`cli_kind` column). Each agent can also host "ambient" rootless sessions (one Claude + one Codex per host) with no project directory required, lazy-spawned on first message. Global instruction files (`~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, `~/.codex/config.toml`) sync from the hub on connect via `create_if_absent` — the agent never overwrites local files. Edit blobs in Settings → Instructions. See [docs/codex-and-rootless.md](docs/codex-and-rootless.md). Requires `npm i -g @openai/codex` + `codex login` (or `OPENAI_API_KEY`). -- **Subscription quota strip** — the header shows live Anthropic Claude subscription utilization (5-hour + 7-day windows) reported by the local agent's poll of `/api/oauth/usage`. Hover for exact %, reset countdowns, and Opus / OAuth-app sub-quotas when present. See [docs/agent.md](docs/agent.md). +- **Subscription quota strip** — the header shows live Anthropic Claude subscription utilization (5-hour + 7-day windows) reported by the supervisor's poll of `/api/oauth/usage`. Hover for exact %, reset countdowns, and Opus / OAuth-app sub-quotas when present. - **Unread badges** — know when sessions have new messages - **Light/dark theme** — toggle in the header - **Mobile-first** — responsive design with safe-area support for notched devices @@ -225,7 +161,7 @@ docker run -p 3040:3040 \ remo-code ``` -The Docker image builds the web frontend and serves it from the hub — one container, one port. The agent runs on your dev machine, not on the server. +The Docker image builds the web frontend and serves it from the hub — one container, one port. The supervisor runs on your dev machine, not on the server. ## Project Structure @@ -237,20 +173,14 @@ The Docker image builds the web frontend and serves it from the hub — one cont │ ├── db/ # Supabase clients and data access layer │ ├── middleware/ # Rate limiting │ ├── utils/ # Shared utilities (token generation) -│ └── ws/ # WebSocket handlers (agent, channel, client) + Zod schemas +│ └── ws/ # WebSocket handlers (agent, client) + Zod schemas ├── web/ # React 19 + Vite + Tailwind CSS 4 SPA │ └── src/ │ ├── components/ # Layout, ChatPanel, ActivityFeed, Sidebar, etc. │ └── hooks/ # useAuth, useWebSocket, useSessions, useChat, useActivity -├── agent/ # Local streaming agent (npm: remo-code-agent) -│ └── src/ -│ ├── index.ts # Entry point — wires hub client, Claude runner -│ ├── claude-runner.ts # Persistent Claude CLI process management -│ ├── hub-client.ts # WebSocket client to hub -│ └── config.ts # Config loading (CLI args, env vars, config file) -├── channel/ # (Legacy) Claude Code channel plugin -├── supervisor/ # Local supervisor (Bun) + Tauri 2 desktop tray app (Phase 06) -│ └── tauri/ # Windows tray shell (Rust + WebView2) — wraps Bun supervisor as sidecar +├── supervisor/ # Local supervisor — Tauri 2 desktop tray app +│ ├── src/ # Bun TypeScript source (compiled to sidecar binary by Tauri) +│ └── tauri/ # Windows tray shell (Rust + WebView2) → MSI installer ├── supabase/ # Database migrations └── Dockerfile # Multi-stage production build ``` diff --git a/docs/agent.md b/docs/agent.md deleted file mode 100644 index 52ce1ef6..00000000 --- a/docs/agent.md +++ /dev/null @@ -1,76 +0,0 @@ -# Local Agent - -The `remo-code-agent` package (`agent/`) runs on the developer's machine and -streams Claude Code / Codex activity to the hub. See `CLAUDE.md` for the -architecture overview. - -## Subscription quota polling - -On startup AND every 5 minutes the agent polls Anthropic's Claude subscription -quota endpoint and reports the result to the hub: - -``` -GET https://api.anthropic.com/api/oauth/usage -Headers: - Authorization: Bearer - anthropic-beta: oauth-2025-04-20 - User-Agent: claude-code/2.0.15 - Accept: application/json, text/plain, */* - Content-Type: application/json -``` - -Response shape (Zod-validated on both sides): - -```ts -{ - five_hour: { utilization: number, resets_at: string }, - seven_day: { utilization: number, resets_at: string }, - seven_day_opus?: { utilization: number, resets_at: string } | null, - seven_day_oauth_apps?: { utilization: number, resets_at: string } | null -} -``` - -The access token is **re-read from `~/.claude/.credentials.json` on every -tick** — Claude Code refreshes it on its own and we never cache. Failures -(missing file, 401, network, malformed JSON, schema mismatch) are logged at -WARN level and the next tick retries; the agent never crashes on a poll -failure. - -On a successful poll the agent sends: - -```ts -{ type: 'usage_report', usage: { ...above... } } -``` - -The hub: - -1. Validates against `AgentUsageReport` in `hub/src/ws/agent-protocol.ts`. -2. Stores the latest snapshot per `user_id` in an in-memory map - (`hub/src/usage/store.ts`) — cleared on hub restart, re-converges within - 5 minutes. -3. Broadcasts `subscription_usage` to all of that user's connected web - clients via `broadcastToUser`. -4. On a new client WS auth, sends the current snapshot once (if any). - -The web UI subscribes via `useSubscriptionUsage` (`web/src/hooks/`) and -renders the inline 5h+7d strip in `Layout.tsx` (`UsageStrip.tsx`). While no -snapshot has arrived yet (e.g. first 5 minutes after agent connect) the -strip shows a subtle `—` placeholder rather than stale data. - -The legacy `GET /api/profile/cost-today` endpoint is unchanged — it still -powers the per-user daily cost cap visible in Settings. Only the visual -strip in the header was replaced. - -## Implementation files - -- `agent/src/usage-poller.ts` — fetch + Zod-equivalent runtime validation + - interval handle. Wired in `agent/src/index.ts`. -- `agent/test/usage-poller.test.ts` — unit tests with mocked fetch. -- `hub/src/usage/store.ts` — per-user in-memory snapshot store. -- `hub/test/usage-store.test.ts` — unit tests. -- `hub/src/ws/agent-protocol.ts` — `AgentUsageReport` Zod schema. -- `hub/src/ws/agent.ts` — `usage_report` handler. -- `hub/src/ws/client.ts` — sends current snapshot on client auth. -- `hub/src/ws/protocol.ts` — `subscription_usage` outbound type. -- `web/src/hooks/useSubscriptionUsage.ts` — WS subscriber hook. -- `web/src/components/UsageStrip.tsx` — replaces the old cost-today strip. diff --git a/docs/superpowers/plans/2026-03-28-streaming-agent-architecture.md b/docs/superpowers/plans/2026-03-28-streaming-agent-architecture.md index 4853697e..534b8a85 100644 --- a/docs/superpowers/plans/2026-03-28-streaming-agent-architecture.md +++ b/docs/superpowers/plans/2026-03-28-streaming-agent-architecture.md @@ -2,6 +2,8 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **Note (Phase 09, 2026-05-26):** This plan is historical. The `agent/` workspace and `channel/` plugin it describes have been retired. The CLI streaming runner is now in `supervisor/src/` and ships as a Tauri MSI desktop app. The `/ws/agent` protocol is unchanged. See `.planning/phases/09-retire-npm-packages/`. + **Goal:** Replace the channel plugin with a local streaming agent that spawns Claude Code CLI, giving the web UI full visibility into Claude's activity (thinking, tool calls, text), plus file attachments and unread messages. **Architecture:** Local agent spawns `claude -p --output-format stream-json --verbose`, parses the JSON event stream, and relays events to the hub via WebSocket. The hub broadcasts activity events to subscribed browser clients. Frontend renders thinking blocks, tool call indicators, and streaming text. diff --git a/docs/superpowers/plans/2026-04-27-migrate-supabase-to-postgres.md b/docs/superpowers/plans/2026-04-27-migrate-supabase-to-postgres.md index a18e17e3..05e888b6 100644 --- a/docs/superpowers/plans/2026-04-27-migrate-supabase-to-postgres.md +++ b/docs/superpowers/plans/2026-04-27-migrate-supabase-to-postgres.md @@ -2,6 +2,8 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **Note (Phase 09, 2026-05-26):** References to `verifyChannelToken` and the `channel/` plugin in this plan are historical. Phase 09 removed the dead `/ws/channel` route and `verifyChannelToken` DAL helper. See `.planning/phases/09-retire-npm-packages/`. + **Goal:** Replace Supabase (hosted DB + Auth) with a self-hosted PostgreSQL on Coolify and a custom JWT auth system. **Architecture:** The hub currently uses Supabase for two things: database storage (via the JS client with RLS) and authentication (Supabase Auth / JWT verification). We replace the DB client with `postgres.js` pointing at a local PG instance, add a `users` table with bcrypt-hashed passwords, issue our own JWTs with `jsonwebtoken`, and replace the Supabase Auth UI in the frontend with a custom login form. RLS is dropped entirely — all queries gain explicit `WHERE user_id = $1` clauses instead. diff --git a/docs/superpowers/specs/2026-03-28-streaming-agent-architecture-design.md b/docs/superpowers/specs/2026-03-28-streaming-agent-architecture-design.md index d16412eb..b642fa95 100644 --- a/docs/superpowers/specs/2026-03-28-streaming-agent-architecture-design.md +++ b/docs/superpowers/specs/2026-03-28-streaming-agent-architecture-design.md @@ -1,7 +1,9 @@ # Remo Code v2: Streaming Agent Architecture **Date:** 2026-03-28 -**Status:** Draft — awaiting approval +**Status:** Historical (Phase 09, 2026-05-26) + +> **Note (Phase 09, 2026-05-26):** The `remo-code-agent` npm package and `channel/` plugin described in this spec have been retired. The local CLI runner now lives in `supervisor/src/` and is shipped as a Tauri MSI desktop app. The hub-side streaming protocol over `/ws/agent` is unchanged. See `.planning/phases/09-retire-npm-packages/`. ## Summary diff --git a/docs/superpowers/specs/2026-05-22-supervisor-remote-control-design.md b/docs/superpowers/specs/2026-05-22-supervisor-remote-control-design.md index 44de0022..e5f47eb3 100644 --- a/docs/superpowers/specs/2026-05-22-supervisor-remote-control-design.md +++ b/docs/superpowers/specs/2026-05-22-supervisor-remote-control-design.md @@ -1,9 +1,11 @@ # Supervisor — Remote Control of Local Claude Code Sessions **Date:** 2026-05-22 -**Status:** Approved +**Status:** Historical (Phase 09, 2026-05-26) **Author:** brainstorming session +> **Note (Phase 09, 2026-05-26):** The `npx remo-code-supervisor install` / NSSM distribution model documented in this spec has been retired. The supervisor now ships exclusively as a Tauri Windows MSI from https://github.com/finedesignz/remo-code/releases/latest. The supervisor protocol over `/ws/agent` is unchanged; only the install/distribution surface changed. See `supervisor/MIGRATION.md` and `.planning/phases/09-retire-npm-packages/`. + ## Problem User wants to leave the house and have Claude Code working on chosen local repos, manageable from the web UI. Needs auto-restart on crash, GitHub repo selection, and a single long-running supervisor process on the local machine that survives reboots. diff --git a/web/src/components/ApiKeyModal.tsx b/web/src/components/ApiKeyModal.tsx index 9e6ad097..aece1557 100644 --- a/web/src/components/ApiKeyModal.tsx +++ b/web/src/components/ApiKeyModal.tsx @@ -33,7 +33,6 @@ export function ApiKeyModal({ token, onClose }: Props) { } const trayAppReleaseUrl = 'https://github.com/finedesignz/remo-code/releases/latest' - const agentCmd = newKey ? `npx remo-code-agent --api-key ${newKey} --local-output` : '' return (
@@ -45,7 +44,7 @@ export function ApiKeyModal({ token, onClose }: Props) {

- Your API key authenticates the Remo Code tray app (or the agent fallback) when connecting Claude Code sessions. One key connects all your projects. + Your API key authenticates the Remo Code Supervisor desktop app when connecting Claude Code sessions. One key connects all your projects.

{loading ? ( @@ -85,32 +84,8 @@ export function ApiKeyModal({ token, onClose }: Props) { > Download .msi from GitHub Releases → -

- If the latest release doesn't yet include a .msi asset, the first signed build is being prepared — use the agent fallback below in the meantime. -

- {/* SECONDARY: legacy agent */} -
- - Alternative: run the agent manually per project - -
-

- Runs a single agent in the foreground tied to the current directory. Useful for quick tests; not recommended for daily use. -

-
-
{agentCmd}
- -
-
-
- - {/* No key yet — prompt to generate first */} - {!displayKey && ( -
-

- You'll need an API key first. Replace YOUR_API_KEY below, or generate one now: + {/* Step 1: API key */} + {!displayKey ? ( +

+

+ You need an API key first. Generate one now:

+ ) : ( +
+

Your API key (shown once — copy it now):

+
+ + {displayKey} + + +
+
)} - {/* PRIMARY: Tray app download */} + {/* Step 2: Tray app */}
- Recommended -

Download the Remo Code tray app

+ Step 2 +

Install the Remo Code Supervisor desktop app

- Windows tray app that runs in the background, watches your repo roots, and lets you launch Claude Code sessions remotely from this web UI. First-run wizard takes the API key below and configures everything for you — no PowerShell, no NSSM, no Bun install required. + Windows tray app. Download the latest .msi, run the installer, and paste the API key from Step 1 into the first-run wizard. The supervisor auto-starts at login, watches your repo roots, and lets you launch Claude Code sessions remotely from this web UI.

+ Download .msi from GitHub Releases → -

- The first signed .msi is being prepared — if the latest release doesn't yet include a .msi asset, watch the releases page or use the agent fallback below in the meantime. -

- {/* SECONDARY: legacy agent (collapsed) */} -
- - Alternative: run the agent manually per project - -
-

- Runs a single agent in the foreground tied to the current directory. Useful for quick tests; not recommended for daily use. -

- -
-
{agentCmd}
- -
- -
-

- Or add a shell alias so you can just run claude-remote: -

-
-
{aliasCmd}
- -
-

- Add to ~/.bashrc or ~/.zshrc, reload your shell, then run claude-remote. -

-
-
-
- {/* Hub URL info */}

diff --git a/web/src/components/SettingsPage.tsx b/web/src/components/SettingsPage.tsx index e1b459e8..3b026274 100644 --- a/web/src/components/SettingsPage.tsx +++ b/web/src/components/SettingsPage.tsx @@ -404,7 +404,6 @@ function ApiKeyTab({ token }: { token: string }) { } const trayAppReleaseUrl = 'https://github.com/finedesignz/remo-code/releases/latest' - const agentCmd = newKey ? `npx remo-code-agent --api-key ${newKey} --local-output` : '' if (loading) { return

Loading...

@@ -415,7 +414,7 @@ function ApiKeyTab({ token }: { token: string }) {

API Key

- Your API key authenticates the Remo Code tray app (or the agent fallback) when connecting Claude Code sessions. One key connects all your projects. + Your API key authenticates the Remo Code Supervisor desktop app when connecting Claude Code sessions. One key connects all your projects.

{newKey ? ( @@ -451,31 +450,7 @@ function ApiKeyTab({ token }: { token: string }) { > Download .msi from GitHub Releases → -

- If the latest release doesn't yet include a .msi asset, the first signed build is being prepared — use the agent fallback below in the meantime. -

- - {/* SECONDARY: legacy agent */} -
- - Alternative: run the agent manually per project - -
-

- Runs a single agent in the foreground tied to the current directory. Useful for quick tests; not recommended for daily use. -

-
-
{agentCmd}
- -
-
-
) : activeKey ? (
diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 18deefd1..d560d834 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -226,7 +226,7 @@ export function Sidebar({ @@ -241,7 +241,7 @@ export function Sidebar({