diff --git a/.metadata b/.metadata index fce0b83..5f17414 100644 --- a/.metadata +++ b/.metadata @@ -21,10 +21,6 @@ migration: - platform: ios create_revision: 22533d12113808f5d00ec197ca42350b312289b0 base_revision: 22533d12113808f5d00ec197ca42350b312289b0 - - platform: web - create_revision: 22533d12113808f5d00ec197ca42350b312289b0 - base_revision: 22533d12113808f5d00ec197ca42350b312289b0 - # User provided section # List of Local paths (relative to this file) that should be diff --git a/README.md b/README.md index 6393b5b..82c522e 100644 --- a/README.md +++ b/README.md @@ -1,189 +1,220 @@ -# remote_multi_agent - -A Flutter mobile client for local coding agents. Connects to a Node.js gateway -on your laptop, streams normalized agent events via SSE, and renders Claude Code, -Codex, and OpenCode sessions in one unified project workspace. - -## Architecture - -```text -Phone (Flutter app) - │ HTTPS / SSE - ▼ -Gateway (Node.js · localhost:4096) - │ - ├── Claude Code CLI - ├── Codex CLI - └── OpenCode CLI -``` - -The gateway owns local project directories, sessions, CLI processes, event -normalization, and filesystem/git operations. The Flutter app is a **thin -client** — no model keys, no shell commands, no direct filesystem access. - -Credentials live in **gateway profiles** (`~/.gateway/profiles.json`). Multiple -profiles are supported, one active at a time. On first launch nothing is -auto-discovered — the user explicitly imports a credential (Anthropic / OpenAI) -from one of three sources via the settings page: - -1. **Local config files** — `~/.claude/settings.json` (Claude) or - `~/.codex/auth.json` (Codex) -2. **CC-Switch** — pick any Claude or Codex provider from - `~/.cc-switch/cc-switch.db` -3. **Manual** — paste API key + base URL for any provider - -## Features - -- **Multi-agent chat** — Claude Code, Codex, OpenCode in one app -- **Real-time streaming** — SSE event stream with tool use, reasoning, diffs -- **Project workspace** — multiple projects, each with multiple sessions -- **Git operations** — status, diff, commit, pull, push from the app -- **File browser** — recursive file tree with syntax-highlighted viewer -- **Attachment support** — send images/files with messages -- **Model discovery** — auto-fetch available models from API provider -- **Material 3 UI** — monochrome theme, dark/light mode, haptic feedback - -## Tech stack - -| Layer | Stack | -|-------|-------| -| App | Flutter 3.27+, Dart ^3.5.0 | -| State | Riverpod | -| Networking | Dio + http (SSE) | -| UI | Material 3, flutter_markdown_plus, flutter_highlight | -| Gateway | Node.js (plain JS), JSON file-based store | - -## Quick start - -### Gateway - -```bash -cd gateway -npm install # no external deps beyond Node 20+ -GATEWAY_HOST=0.0.0.0 node src/index.js -# Listening on http://0.0.0.0:4096 -``` - -### Flutter app (development) - -```bash -flutter pub get -flutter run -d chrome # or connect a device -``` - -### iOS build (CI) - -```bash -git push # triggers .github/workflows/ios.yml -gh run watch # tail the build log -gh run download --name ios-ipa # pull the unsigned .ipa -# → Sideloadly / AltStore → install to iPhone -``` - -## Project layout - -``` -lib/ -├── main.dart -├── api/ -│ ├── gateway_client.dart # REST + SSE client for the gateway -│ ├── git_client.dart # Git operations via gateway -│ └── sse_stream.dart # SSE subscriber with auto-reconnect -├── models/ -│ ├── project.dart # Gateway project (working directory) -│ ├── gateway_session.dart # Session within a project -│ ├── gateway_event.dart # SSE event types -│ ├── message.dart # Chat message -│ ├── part.dart # text / reasoning / tool / step / image -│ ├── agent.dart # Agent metadata -│ └── session.dart # Legacy session model (used by file viewer) -├── state/ -│ ├── settings_store.dart # SharedPreferences-backed config -│ ├── project_store.dart # Project list controller -│ ├── gateway_session_store.dart # Session list per project -│ ├── gateway_chat_store.dart # SSE → ChatState reducer -│ ├── gateway_client_provider.dart # Riverpod client provider -│ ├── gateway_providers.dart # Riverpod glue for gateway stores -│ ├── agent_catalog_store.dart # Available agents & models -│ └── notification_service.dart # In-app notifications -├── ui/ -│ ├── app.dart -│ ├── pages/ -│ │ ├── home_page.dart # Bottom nav: Projects / Git / Files / Settings -│ │ ├── project_list_page.dart # All projects -│ │ ├── project_detail_page.dart # Sessions within a project -│ │ ├── gateway_chat_page.dart # Chat with streaming + attachments -│ │ ├── agent_group_page.dart # Create session with agent/model picker -│ │ ├── git_page.dart # Git status, diff, commit, pull, push -│ │ ├── files_page.dart # File tree browser + viewer -│ │ ├── diff_page.dart # Side-by-side diff viewer -│ │ ├── search_page.dart # Full-text search across sessions -│ │ └── settings_page.dart # Server URL, theme, connection test -│ └── widgets/ -│ ├── message_bubble.dart # Chat bubble with context menu -│ ├── attachment_picker.dart # Image/file picker + preview strip -│ ├── agent_badge.dart # Monochrome agent label -│ ├── session_status_chip.dart # Animated status indicator -│ ├── model_picker.dart # Model selection dropdown -│ ├── directory_picker.dart # Remote directory browser -│ ├── shimmer_skeleton.dart # Loading skeleton animation -│ └── parts/ # Message part renderers -│ ├── text_part_view.dart -│ ├── reasoning_part_view.dart -│ ├── tool_part_view.dart -│ ├── step_part_view.dart -│ └── image_part_view.dart -└── theme.dart # Material 3 monochrome light/dark themes - -gateway/ -└── src/ - ├── index.js # Entry point - ├── server.js # HTTP server + route handlers - ├── agents.js # Agent adapters (Claude Code, Codex, OpenCode) - ├── store.js # JSON file-based session/message store - ├── cli.js # CLI process spawner - ├── events.js # SSE event bus - ├── fs_routes.js # /git/* and /files/* endpoints - └── opencode_server.js # OpenCode-specific server adapter -``` - -## App settings - -| Field | Example | Notes | -|-------|---------|-------| -| Server URL | `http://10.x.x.x:4096` | Gateway address (LAN / Tailscale) | -| Bearer token | *(optional)* | For gateway auth if configured | - -The app never holds upstream API keys — they are stored in the gateway's -profile store (`~/.gateway/profiles.json`) and imported on demand from one of -the three sources described above. - -## Gateway API - -| Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/health` | Server status + available agents | -| GET | `/projects` | List projects | -| POST | `/projects` | Create project | -| GET | `/projects/:id/sessions` | List sessions | -| POST | `/sessions` | Create session | -| POST | `/sessions/:id/message` | Send message (starts SSE stream) | -| GET | `/sessions/:id/events` | SSE event stream | -| GET | `/agents` | List available agents | -| GET | `/agents/:id/models` | List models for agent | -| GET | `/git/status?path=...` | Git status | -| GET | `/git/diff?path=...` | Git diff | -| POST | `/git/commit` | Git add + commit | -| POST | `/git/pull` | Git pull | -| POST | `/git/push` | Git push | -| GET | `/files?path=...` | Recursive file tree | -| GET | `/files/read?path=...` | Read file content | -| GET | `/search?q=...` | Full-text search | -| GET | `/settings/profiles` | List credential profiles (keys masked) | -| POST | `/settings/profiles` | Create profile manually | -| PATCH | `/settings/profiles/:id` | Update profile | -| DELETE | `/settings/profiles/:id` | Delete profile | -| POST | `/settings/profiles/:id/activate` | Make profile active | -| POST | `/settings/profiles/import` | Import from `official` or `cc-switch` | -| GET | `/settings/credential-sources/official` | Preview `~/.claude/settings.json` | -| GET | `/settings/credential-sources/cc-switch` | List CC-Switch Claude providers | +# remote_multi_agent + +A Flutter mobile client for local coding agents. It connects to a Node.js +gateway on your laptop, streams normalized agent events via SSE, and renders +Claude Code, Codex, and OpenCode sessions in one unified project workspace. + +## Architecture + +```text +Phone (Flutter mobile app) + | HTTP / SSE + v +Gateway (Node.js, localhost:4096 by default) + | + +-- Claude Code CLI + +-- Codex CLI + +-- OpenCode CLI / server +``` + +The gateway owns local project directories, sessions, CLI processes, event +normalization, and filesystem/git operations. The Flutter app is a thin client: +no model keys, no shell commands, and no direct filesystem access. + +Credentials live in gateway profiles (`~/.gateway/profiles.json`). Multiple +profiles are supported, one active at a time. On first launch nothing is +auto-discovered; the user explicitly imports a credential from the settings +page or gateway settings endpoints. + +## Gateway Access Model + +The first version has no gateway authentication. Run the gateway on a trusted +LAN or Tailscale network only. The default bind host is `127.0.0.1`; use +`GATEWAY_HOST=0.0.0.0` only when the phone must reach the laptop over a trusted +network. + +Web is not a supported target in v1. The app uses native/mobile-only APIs for +streaming and attachments. + +## Features + +- Multi-agent chat: Claude Code, Codex, and OpenCode in one app. +- Real-time streaming: SSE event stream with tool use, reasoning, diffs, and + status updates. +- Project workspace: multiple projects, each with multiple sessions. +- Git operations: status, diff, commit, pull, and push from the app. +- File browser: recursive file tree with syntax-highlighted viewer. +- Attachment support: send images/files with messages when the agent supports + them. +- Model discovery: fetch available models from the gateway. +- Material 3 UI: monochrome theme, dark/light mode, and haptic feedback. + +## Tech Stack + +| Layer | Stack | +| --- | --- | +| App | Flutter 3.27+, Dart ^3.5.0 | +| State | Riverpod | +| Networking | Dio + http (SSE) | +| UI | Material 3, flutter_markdown_plus, flutter_highlight | +| Gateway | Node.js, JSON file-based store | + +## Quick Start + +### Gateway + +```bash +cd gateway +npm install +GATEWAY_HOST=0.0.0.0 node src/index.js +# Listening on http://0.0.0.0:4096 +``` + +Use `GATEWAY_HOST=0.0.0.0` only on a trusted LAN or Tailscale network. For local +testing, keep the default `127.0.0.1` bind. + +### Flutter app + +```bash +flutter pub get +flutter test +``` + +Build and device runs target mobile platforms. iOS packaging is handled by CI. + +### iOS build (CI) + +```bash +git push +gh run watch +gh run download --name ios-ipa +``` + +Install the unsigned IPA with Sideloadly or AltStore. + +## Project Layout + +```text +lib/ + main.dart + api/ + gateway_client.dart # REST + SSE client for the gateway + git_client.dart # Git operations via gateway + sse_stream.dart # SSE subscriber with auto-reconnect + models/ + project.dart # Gateway project + gateway_session.dart # Session within a project + gateway_event.dart # SSE event types + message.dart # Chat message + part.dart # text / reasoning / tool / step / image + agent.dart # Agent metadata + session.dart # Legacy session model used by file viewer + state/ + settings_store.dart + project_store.dart + gateway_session_store.dart + gateway_chat_store.dart + gateway_client_provider.dart + gateway_providers.dart + agent_catalog_store.dart + notification_service.dart + ui/ + app.dart + pages/ + home_page.dart + project_list_page.dart + project_detail_page.dart + gateway_chat_page.dart + agent_group_page.dart + git_page.dart + files_page.dart + diff_page.dart + search_page.dart + settings_page.dart + widgets/ + message_bubble.dart + attachment_picker.dart + agent_badge.dart + session_status_chip.dart + model_picker.dart + directory_picker.dart + shimmer_skeleton.dart + parts/ + text_part_view.dart + reasoning_part_view.dart + tool_part_view.dart + step_part_view.dart + image_part_view.dart + theme.dart + +gateway/ + src/ + index.js # Entry point + server.js # HTTP server + route handlers + agents/ + index.js # Agent adapter registry + registry.js # Registry composition + claude_code.js # Claude Code adapter + codex.js # Codex adapter + opencode.js # OpenCode adapter + command_helpers.js # Command metadata and discovery helpers + json_cli.js # JSON CLI runner and parsing helpers + model_cache.js # Shared model-list cache + opencode_helpers.js # OpenCode event/model normalization helpers + store.js # JSON file-based session/message store + cli.js # CLI process spawner + events.js # SSE event bus + fs_routes.js # /git/* and /files/* endpoints + opencode_server.js # OpenCode server adapter +``` + +Agent helpers handle command metadata/discovery, JSON CLI parsing, model-list +caching, and OpenCode event/model normalization. + +## App Settings + +| Field | Example | Notes | +| --- | --- | --- | +| Server URL | `http://10.x.x.x:4096` | Gateway address on trusted LAN or Tailscale | + +The app never holds upstream API keys. They are stored in the gateway profile +store (`~/.gateway/profiles.json`) and imported on demand. + +## Gateway API + +| Method | Endpoint | Description | +| --- | --- | --- | +| GET | `/health` | Server status + available agents | +| GET | `/projects` | List projects | +| POST | `/projects` | Create project | +| GET | `/projects/:projectId` | Get project | +| DELETE | `/projects/:projectId` | Delete project | +| GET | `/projects/:projectId/sessions` | List sessions | +| POST | `/projects/:projectId/sessions` | Create session | +| GET | `/sessions/:sessionId` | Get session | +| PATCH | `/sessions/:sessionId` | Update session | +| DELETE | `/sessions/:sessionId` | Delete session | +| GET | `/sessions/:sessionId/messages` | List messages | +| POST | `/sessions/:sessionId/messages` | Send message | +| DELETE | `/sessions/:sessionId/messages/:messageId` | Delete message | +| POST | `/sessions/:sessionId/abort` | Abort running session | +| GET | `/sessions/:sessionId/events` | SSE event stream | +| GET | `/sessions/:sessionId/export?format=markdown|json` | Export messages | +| GET | `/sessions/:sessionId/diff` | Git diff for session directory | +| GET | `/agents` | List available agents | +| GET | `/agents/:id/models` | List models for agent | +| GET | `/agents/:id/commands` | List commands for agent | +| GET | `/git/status?path=...` | Git status | +| GET | `/git/diff?path=...` | Git diff | +| POST | `/git/commit` | Git add + commit | +| POST | `/git/pull` | Git pull | +| POST | `/git/push` | Git push | +| GET | `/files?path=...` | Recursive file tree | +| GET | `/files/read?path=...` | Read file content | +| GET | `/search?q=...` | Full-text search | +| GET | `/settings/profiles` | List credential profiles | +| POST | `/settings/profiles` | Create profile manually | +| PATCH | `/settings/profiles/:id` | Update profile | +| DELETE | `/settings/profiles/:id` | Delete profile | +| POST | `/settings/profiles/:id/activate` | Make profile active | +| POST | `/settings/profiles/import` | Import from official config or CC-Switch | diff --git a/TODO.md b/TODO.md index 91e5e9d..0253afc 100644 --- a/TODO.md +++ b/TODO.md @@ -1,75 +1,28 @@ -# Remote Multi-Agent — Feature Roadmap - -## P0: 核心体验提升 - -### 1. Session 自动标题 -- 用第一条用户消息前 30 字符自动命名 session -- Agent 返回的 title(如 Codex 的 thread title)自动同步更新 -- 涉及:`server.js` startTurn 逻辑 + `store.js` updateSession - -### 2. 消息长按菜单 -- 长按消息弹出菜单:复制文本、复制 Markdown、删除消息 -- 删除需要 gateway API:`DELETE /sessions/:id/messages/:messageId` -- 涉及:`gateway_chat_page.dart` MessageBubble + `server.js` 新端点 + `store.js` deleteMessage - -### 3. 深色/浅色主题 + 前端全面优化 -- 设置页加主题切换开关(跟随系统 / 浅色 / 深色),持久化到 SharedPreferences -- 全面优化 UI 细节,做成一个真正精致的 app: - - 统一色彩体系、间距、圆角 - - 消息气泡区分用户/助手样式,代码块语法高亮 - - 工具调用折叠/展开卡片,状态图标动画 - - 空状态插图、加载骨架屏 - - 输入栏动效(打字中、发送中、引导中) - - 页面转场动画 - - 响应式布局(平板/桌面宽屏适配) -- 涉及:`main.dart` ThemeData + `settings_page.dart` + 全局 widget 优化 - -### 4. 通知推送 -- Agent 长任务完成后发送系统通知(app 在后台也能收到) -- 使用 `flutter_local_notifications` 插件 -- SSE 监听 session.completed 事件触发通知 -- 涉及:新增 notification service + `gateway_chat_store.dart` 事件监听 - ---- - -## P1: 信息与效率 - -### 5. Context 用量条 -- 显示当前 session 的 context window 使用率(token 数 / 上限) -- 从 agent JSON 事件中提取 usage 数据(Codex: `usage`, Claude: `usage`, OpenCode: token count) -- 接近上限时在输入栏上方显示警告并建议使用 `/compact` -- 涉及:`agents.js` 提取 usage → SSE 事件 `session.usage` + 新 Flutter widget - -### 6. Diff 查看器 -- Agent 修改文件后在 app 内查看 git diff -- Gateway 已有 git 相关端点,需要确认/扩展 -- App 新增 diff 查看页,支持文件级 diff 展示(增/删/改高亮) -- 可从聊天页工具调用卡片跳转到 diff 页 -- 涉及:gateway git API + 新 `diff_page.dart` - -### 7. Session 搜索 -- 跨 session 全文搜索历史对话 -- Gateway 新增 `GET /search?q=...&projectId=...` 端点,搜索所有 session 的消息文本 -- App 新增搜索页,搜索结果可跳转到对应 session 和消息 -- 涉及:`store.js` 搜索逻辑 + `server.js` 新端点 + 新 `search_page.dart` - -### 8. 消息导出 -- 导出整个 session 为 Markdown 或 JSON 文件 -- Gateway 新增 `GET /sessions/:id/export?format=md|json` 端点 -- App 使用 share 插件分享导出文件 -- 涉及:`server.js` 导出端点 + Flutter `share_plus` 插件 + 导出按钮(session 菜单或聊天页 AppBar) - ---- - -## 实现状态 - -| # | 功能 | 状态 | -|---|------|------| -| 1 | Session 自动标题 | ✅ 已完成 | -| 2 | 消息长按菜单 | ✅ 已完成 | -| 3 | 深色/浅色主题 + UI 优化 | ✅ 已完成 | -| 4 | 通知推送 | ✅ 已完成 | -| 5 | Context 用量条 | ✅ 已完成 | -| 6 | Diff 查看器 | ✅ 已完成 | -| 7 | Session 搜索 | ✅ 已完成 | -| 8 | 消息导出 | ✅ 已完成 | +# Remote Multi-Agent Roadmap + +## V1 Boundary + +- Mobile/iOS app only; Web is unsupported. +- Gateway has no authentication; use trusted LAN or Tailscale. +- App does not execute code and does not read project files directly. +- Gateway owns project directories, agent CLIs, filesystem, git, and credentials. + +## Near-Term Cleanup + +- Split gateway agent adapters into one file per agent. +- Keep command discovery dynamic through gateway metadata. +- Remove UI controls that imply unsupported gateway authentication. +- Keep documentation free of mojibake and aligned with the current product. + +## Functional Follow-Up + +- Decide whether approve/reject/handoff should be implemented or hidden. +- Add contract tests for any API endpoint surfaced in the app. +- Add CI checks for docs encoding and mobile test commands. + +## Later Product Work + +- Expand agent-specific command palettes only when the gateway exposes matching + capabilities. +- Improve streaming, attachment, and diff rendering tests. +- Document any future authentication model before adding UI for it. diff --git a/docs/development-spec.md b/docs/development-spec.md index 4df4c35..9cab605 100644 --- a/docs/development-spec.md +++ b/docs/development-spec.md @@ -6,7 +6,7 @@ This document describes the intended full product scope. Do not treat it as a re ## Product Goal -Build an iOS client for coding agents that can work with multiple project directories and multiple official agent backends: +Build a mobile/iOS client for coding agents that can work with multiple project directories and multiple official agent backends: - OpenCode - Claude Code @@ -18,19 +18,21 @@ The app should feel like one product, but each agent must keep its own official The system is split into two separately developed and separately deployed parts: -- iOS app +- mobile/iOS app - Server gateway The app is closed-source and can be distributed as a paid product. The gateway can be open-source to increase user trust because it is the only component that talks to local files, project directories, shells, and official agent CLIs. -First gateway version does not need authentication. - ## Security Boundary The app must not execute code. +The first version intentionally does not implement gateway authentication. +The supported deployment model is trusted LAN or Tailscale access. The app UI +must not present a bearer-token field until the gateway validates such tokens. + The app is responsible for: - Selecting project directories exposed by the gateway. @@ -356,6 +358,8 @@ These are explicit boundaries, not scope reductions: - The app must not read local project files directly. - The app must not execute shell commands. - The app must not hard-code behavior that belongs to a specific official CLI when the gateway can report it dynamically. +- Flutter Web support for v1. +- Gateway authentication for v1. ## Implementation Priority @@ -370,4 +374,3 @@ Keep the full scope, but implement in dependency order: 7. Claude Code adapter. 8. Agent-specific command palettes and chat actions. 9. Advanced permissions, MCP, skills, hooks, plugins, custom commands, and share/export surfaces. - diff --git a/docs/optimization-plan.md b/docs/optimization-plan.md index d3aeb28..073d68d 100644 --- a/docs/optimization-plan.md +++ b/docs/optimization-plan.md @@ -1,228 +1,50 @@ -# App 优化需求 - -日期: 2026-05-22 - -## 一、多 Profile 配置系统 - -类似 CC-Switch,支持多套 API key 配置,可随时切换。 - -### 数据模型 - -```json -{ - "profiles": [ - { - "id": "uuid", - "name": "公司代理", - "isCurrent": true, - "keys": { - "anthropic": { "key": "sk-ant-...", "baseUrl": "https://proxy.example.com" }, - "openai": { "key": "sk-...", "baseUrl": null } - }, - "defaultModel": { - "claude-code": "claude-sonnet-4-20250514", - "codex": "gpt-5.5", - "opencode": "anthropic/claude-sonnet-4-20250514" - }, - "createdAt": 1779177600000 - } - ] -} -``` - -### 网关接口 - -| 方法 | 路径 | 说明 | -|------|------|------| -| GET | `/settings/profiles` | 列出所有 profile(key 脱敏显示) | -| POST | `/settings/profiles` | 创建 profile | -| PATCH | `/settings/profiles/:id` | 修改 profile | -| DELETE | `/settings/profiles/:id` | 删除 profile | -| POST | `/settings/profiles/:id/activate` | 切换激活 profile | -| GET | `/settings/active-profile` | 获取当前激活 profile | - -### 网关持久化 - -- 文件: `~/.gateway/profiles.json` -- 新建 `gateway/src/config.js` 负责读写 -- Adapter 读 key 优先级: `env 变量 → active profile → CC-Switch DB → ~/.claude/settings.json → fallback` -- 运行 agent CLI 时将 active profile 的 key 注入子进程环境变量 - -### App 侧 - -- 设置页新增 "Profiles" 管理区域(增删改 profile,配置各 provider 的 key 和 baseUrl) -- App 本地不存储 key,只存 gateway URL 和 bearer token -- 聊天页某处显示当前 profile 名称,支持快速切换 -- 切换 profile 后刷新模型列表 - ---- - -## 二、每个 Profile 可设置默认模型 - -- 每个 profile 内按 agent 存储默认 model ID -- 新建 session 时自动使用对应 agent 的默认 model,无需每次手动选 -- 设置页 profile 编辑界面中可为每个 agent 选择默认 model -- 如果 profile 没设默认 model,新建 session 时仍弹 model picker - ---- - -## 三、聊天内命令全面优化 - -### 问题 - -当前所有 `/command` 都直接发给网关,网关转发给 agent CLI。CLI 返回纯文本,在手机上不可读、不可交互。所有命令都应该有对应的原生 UI 体验。 - -### 设计原则 - -每个命令按交互类型分为四类: -- **picker 类** — 需要从列表中选择(弹 bottom sheet) -- **confirm 类** — 需要确认才执行(弹对话框) -- **action 类** — 直接执行,结果用 toast/snackbar 展示(不需要 CLI 文本输出) -- **passthrough 类** — 无法在 app 侧处理,发给网关但结果格式化展示 - -### 全部命令拦截表 - -#### 通用命令(所有 agent 共享) - -| 命令 | 类型 | App 行为 | -|------|------|----------| -| `/model` `/models` | picker | 弹 model picker,选择后发 `/model ` 给网关 | -| `/fast` | action | 从 agent metadata 取 fast model,直接切换,toast 提示 | -| `/compact` `/summarize` | confirm | "压缩上下文将减少历史细节,继续?" → 发给网关 | -| `/clear` | confirm | "清空当前对话?" → 发给网关 | -| `/new` | confirm | "创建新对话?" → 创建 session 并跳转 | -| `/status` | action | App 本地组装状态信息(session/agent/model/token usage),显示为卡片 | -| `/help` | action | 显示当前 agent 的命令列表(已有数据),用 bottom sheet 展示 | -| `/diff` `/copy` | action | 调用已有的 diff 页面 / 复制到剪贴板 | -| `/export` | picker | 弹选择 "Markdown / JSON",然后复制或分享 | -| `/undo` | confirm | "撤销上次更改?" → 发给网关 | -| `/redo` | confirm | "重做?" → 发给网关 | -| `/exit` `/quit` `/q` | confirm | "结束当前会话?" → 关闭 session,返回列表 | - -#### Claude Code 专属 - -| 命令 | 类型 | App 行为 | -|------|------|----------| -| `/permissions` | picker | 显示权限模式列表(plan/acceptEdits/bypassPermissions),选择后发给网关 | -| `/memory` | passthrough | 发给网关,结果格式化为 markdown 卡片展示 | -| `/cost` | action | 从 session.usage 本地展示 token 用量和费用估算 | -| `/review` | confirm | "对当前更改进行 code review?" → 发给网关 | -| `/mcp` | passthrough | 发给网关,结果格式化展示 | -| `/config` | passthrough | 发给网关,结果格式化展示 | -| `/doctor` | passthrough | 发给网关,结果格式化展示 | -| `/pr_comments` | action | 发给网关,toast 提示 "Loading PR comments..." | -| `/add-dir` | picker | 弹目录选择器(已有 directory_picker),选择后发给网关 | -| `/agents` | passthrough | 发给网关,结果格式化展示 | -| `/init` | confirm | "初始化项目配置?" → 发给网关 | -| `/login` `/logout` | passthrough | 发给网关(需要 CLI 交互,结果展示) | -| `/bug` | passthrough | 发给网关 | -| `/terminal-setup` `/vim` | action | 不适用于移动端,toast 提示 "Not available on mobile" | - -#### Codex 专属 - -| 命令 | 类型 | App 行为 | -|------|------|----------| -| `/permissions` | picker | 显示 sandbox 模式列表(full-auto/workspace-write/read/locked) | -| `/sandbox-add-read-dir` | picker | 弹目录选择器 | -| `/plan` `/goal` | passthrough | 发给网关(需要后续文本输入) | -| `/fork` `/side` | confirm | "创建分支/侧对话?" → 发给网关 | -| `/approve` | action | 发给网关,toast 提示 | -| `/memories` `/skills` | passthrough | 发给网关,结果格式化展示 | -| `/personality` | passthrough | 发给网关(需要后续文本输入) | -| `/feedback` `/review` | passthrough | 发给网关 | -| `/ps` | action | 发给网关,结果用列表卡片展示 | -| `/stop` | action | 调用 abort 接口 | -| `/mcp` `/hooks` `/plugins` `/apps` `/agent` | passthrough | 发给网关,结果格式化展示 | -| `/ide` `/keymap` `/vim` | action | 不适用于移动端,toast 提示 | -| `/init` | confirm | "初始化项目配置?" → 发给网关 | -| `/experimental` | passthrough | 发给网关 | -| `/debug-config` | passthrough | 发给网关,结果格式化展示 | -| `/raw` | passthrough | 发给网关(后面跟的是 prompt 内容) | -| `/mention` | picker | 弹文件选择器,选择后插入到输入框 | -| `$` | passthrough | shell 命令,直接发给网关 | - -#### OpenCode 专属 - -| 命令 | 类型 | App 行为 | -|------|------|----------| -| `/sessions` `/resume` `/continue` | picker | 从网关拉 session 列表,弹选择器,选择后跳转 | -| `/share` | action | 发给网关,toast 提示分享链接 | -| `/unshare` | confirm | "取消分享?" → 发给网关 | -| `/details` | action | 本地组装 session 详情卡片展示 | -| `/editor` | action | 不适用于移动端,toast 提示 | -| `/themes` | action | 不适用于移动端(app 有自己的主题设置) | -| `/init` | confirm | "初始化项目配置?" → 发给网关 | - -#### `$` Shell 命令 - -| 命令 | 类型 | App 行为 | -|------|------|----------| -| `$` | passthrough | 直接发给网关执行,结果在终端视图展示 | - -### passthrough 命令的优化 - -即使是 passthrough 类命令,也不应该直接显示 CLI 原始文本。网关应该: -- 尽量解析 CLI 输出为结构化数据 -- 返回 `{ type: "command.result", format: "markdown" | "json" | "list", data: ... }` -- App 根据 format 用对应的渲染组件展示(markdown 卡片、JSON 树、列表) - -### 实现方式 - -1. 在 `gateway_chat_page.dart` 的 `_send()` 中,建立命令路由表 -2. 每个命令对应一个处理函数(`_handleModelCommand`、`_handleClearCommand` 等) -3. 处理函数负责弹 UI → 收集用户选择 → 发最终指令给网关 -4. passthrough 命令仍走 `sendSlashCommand`,但 UI 侧对返回的 `command.result` 事件做格式化渲染 - ---- - -## 四、交互优化 - -### 4.1 新建对话简化 - -- 记住上次选择的 agent + model(存 SharedPreferences) -- 新建对话时默认选中上次的 agent/model -- 如果只有一个 project,跳过项目列表直接进入 project detail - -### 4.2 启动恢复 - -- 记住上次打开的 session ID(SharedPreferences) -- 启动时如果有活跃 session,直接进入聊天页 - -### 4.3 底部导航精简(可选) - -- 考虑将 Git/Files 降级为聊天页内入口(已在 AppBar 有入口) -- 底部 nav 简化为: Projects / Settings,或完全去掉改用抽屉 - ---- - -## 五、涉及文件 - -### 网关 - -| 文件 | 改动 | -|------|------| -| 新建 `gateway/src/config.js` | Profile 读写、key 管理 | -| `gateway/src/server.js` | 新增 `/settings/*` 路由 | -| `gateway/src/agents.js` | Adapter 读 key 时接入 config;CLI 启动时注入环境变量 | - -### App - -| 文件 | 改动 | -|------|------| -| `lib/api/gateway_client.dart` | 新增 profile CRUD 方法 | -| `lib/state/settings_store.dart` | 新增 lastAgentId/lastModelId/lastSessionId;profile 状态 | -| `lib/ui/pages/settings_page.dart` | 新增 Profiles 管理 UI | -| `lib/ui/pages/gateway_chat_page.dart` | `_send()` 中加入命令拦截逻辑;profile 切换入口 | -| `lib/ui/pages/agent_group_page.dart` | 默认选中上次 agent/model | -| `lib/ui/pages/project_list_page.dart` | 单 project 时自动跳转 | -| `lib/main.dart` | 启动时恢复上次 session | - ---- - -## 六、实现顺序 - -1. **网关 `config.js` + `/settings` 路由** — profile 基础设施 -2. **网关 Adapter 接入 profile key** — 让模型列表和 agent 运行能用 profile 的 key -3. **App 设置页 Profile 管理 UI** — 创建/编辑/切换 profile -4. **App 命令全面拦截** — 所有命令都有原生 UI(picker/confirm/action/passthrough 格式化) -5. **App 交互优化** — 记住选择、启动恢复、流程简化 +# Optimization Plan + +## Product Boundaries + +1. Keep v1 mobile/iOS-only. Do not reintroduce Web-facing setup, routes, or + product promises. +2. Keep gateway access limited to trusted LAN or Tailscale. V1 has no gateway + authentication, so documentation and UI must not imply otherwise. +3. Keep the app as a thin client. Project directories, agent CLIs, filesystem, + git, and credentials remain gateway-owned. + +## Near-Term Technical Priorities + +1. Keep the agent adapter split stable: + - `codex.js`, `claude_code.js`, and `opencode.js` own agent-specific logic. + - `registry.js` composes adapters. + - `command_helpers.js`, `json_cli.js`, `model_cache.js`, and + `opencode_helpers.js` hold shared support code. +2. Add endpoint contract tests for every route surfaced in the app, especially + project session creation, message send, SSE events, abort, export, diff, and + credential profile routes. +3. Add regression tests for streaming event normalization and adapter model + discovery so CLI output changes are caught close to the gateway. + +## Profile and Model Follow-Up + +1. Make gateway profiles the single source of upstream API credentials. +2. Add per-profile default model settings for Codex, Claude Code, and OpenCode. +3. Keep model discovery dynamic through gateway metadata, with cached model + lists refreshed on profile changes. +4. Ensure the app never stores upstream keys and only displays masked profile + metadata returned by the gateway. + +## Command Routing Follow-Up + +1. Keep command discovery dynamic through `/agents/:agentId/commands`. +2. Route commands by capability instead of hard-coding app behavior where the + gateway can report support. +3. Decide whether approve, reject, handoff, and permission actions are + implemented in v1 or hidden until the gateway exposes a complete contract. +4. Prefer structured command result events over raw CLI text when commands need + native mobile rendering. + +## Documentation and CI Targets + +- Keep documentation readable UTF-8 and aligned with the actual v1 boundary. +- Add a docs encoding check to CI. +- Add CI coverage for gateway tests and the mobile Flutter test command. +- Keep README endpoint tables synchronized with `gateway/README.md` and + `docs/development-spec.md`. diff --git a/docs/requirements.md b/docs/requirements.md index dbd1827..86ae370 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -2,7 +2,7 @@ ## Goal -The iOS app should support three agent backends in one workspace: +The mobile/iOS app should support three agent backends in one workspace: - OpenCode - Claude Code @@ -45,10 +45,13 @@ The session structure inside each project should be: ## App and gateway split -- The iOS app and the server gateway should be implemented and deployed separately. +- The mobile/iOS app and the server gateway should be implemented and deployed separately. - The app is only responsible for sending and receiving messages and rendering conversation state. - The app must not have code execution capability. - All agent execution logic lives in the gateway. - The gateway can be open source to build user trust. - The app can remain closed source and paid. - For the first version, the gateway can ship without authentication. +- V1 targets mobile/iOS. Flutter Web is not supported. +- V1 gateway access is trusted-network only. It does not require or validate a bearer token. +- Authentication remains outside the v1 implementation scope. diff --git a/docs/superpowers/plans/2026-05-23-mobile-gateway-cleanup.md b/docs/superpowers/plans/2026-05-23-mobile-gateway-cleanup.md new file mode 100644 index 0000000..9026272 --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-mobile-gateway-cleanup.md @@ -0,0 +1,1116 @@ +# Mobile-Only Gateway Cleanup Implementation Plan + +> **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. + +**Goal:** Make the app explicitly mobile/iOS-only for v1, keep the gateway LAN-only with no authentication, split the gateway agent adapters into one file per agent, and repair the project documentation. + +**Architecture:** Keep the current Flutter app plus Node gateway architecture. Remove Web-facing project promises and unused gateway-auth UI, but do not add authentication. Split `gateway/src/agents.js` into a small facade plus focused modules under `gateway/src/agents/`, with `codex.js`, `claude_code.js`, and `opencode.js` each owning one adapter. + +**Tech Stack:** Flutter 3.27/Dart, Riverpod, Node.js CommonJS, Node built-in test runner, PowerShell verification commands. + +--- + +## Scope Check + +This plan covers four tightly related cleanup tasks: + +- Product target cleanup: v1 is mobile/iOS-oriented, not Web. +- Gateway access model cleanup: v1 is unauthenticated and intended for trusted LAN/Tailscale use. +- Gateway code organization: each agent adapter gets its own file. +- Documentation cleanup: docs must match those decisions and remove mojibake. + +Authentication is a non-goal for this plan. Web compatibility is a non-goal for this plan. New agent features such as handoff, approve, or reject endpoints are outside this cleanup and should be planned separately. + +## File Structure + +Create: + +- `gateway/src/agents/index.js` - public exports for the gateway agent module. +- `gateway/src/agents/registry.js` - `AgentRegistry` composition only. +- `gateway/src/agents/model_cache.js` - shared model cache helper. +- `gateway/src/agents/command_helpers.js` - shared command list and custom command helpers. +- `gateway/src/agents/json_cli.js` - shared JSON CLI runner and parsing helpers. +- `gateway/src/agents/codex.js` - Codex adapter and Codex argument builder. +- `gateway/src/agents/claude_code.js` - Claude Code adapter. +- `gateway/src/agents/opencode.js` - OpenCode adapter. +- `gateway/src/agents/opencode_helpers.js` - OpenCode event/model normalization helpers. +- `gateway/test/agents_split.test.js` - regression tests for new module boundaries. + +Modify: + +- `gateway/src/agents.js` - replace with compatibility facade. +- `lib/state/settings_store.dart` - remove gateway bearer-token state from v1 settings. +- `lib/state/gateway_client_provider.dart` - stop passing a bearer token from app settings. +- `lib/ui/pages/git_page.dart` - stop passing a bearer token from app settings. +- `lib/ui/pages/project_list_page.dart` - stop passing a bearer token to the directory picker. +- `lib/ui/pages/gateway_chat_page.dart` - stop passing a bearer token to the directory picker. +- `lib/ui/widgets/directory_picker.dart` - remove the bearer-token parameter and Authorization header. +- `lib/ui/pages/settings_page.dart` - remove the gateway bearer-token text field and controller wiring. +- `pubspec.yaml` - update description to mobile/iOS client. +- `README.md` - remove Web run instructions and document trusted LAN v1. +- `gateway/README.md` - clarify no gateway auth in v1 and trusted LAN/Tailscale operation. +- `docs/requirements.md` - clarify mobile-only v1 and no gateway auth. +- `docs/development-spec.md` - align target and security notes. +- `docs/workflow.md` - rewrite corrupted text as readable Chinese. +- `TODO.md` - rewrite corrupted roadmap as readable Chinese. +- `docs/optimization-plan.md` - rewrite or replace corrupted optimization plan with readable text. + +Delete: + +- `web/` - remove Flutter Web target files because Web is unsupported in v1. + +Do not modify: + +- `gateway/src/server.js` authentication behavior. It remains unauthenticated in this plan. +- `lib/api/gateway_client.dart`, `lib/api/git_client.dart`, and `lib/api/sse_stream.dart` bearer-token constructor support. Those optional parameters can remain as dormant client capability, but app settings should not expose or pass tokens in v1. + +--- + +### Task 1: Remove Web Target and Gateway Auth UI From App Settings + +**Files:** + +- Delete: `web/**` +- Modify: `pubspec.yaml` +- Modify: `lib/state/settings_store.dart` +- Modify: `lib/state/gateway_client_provider.dart` +- Modify: `lib/ui/pages/git_page.dart` +- Modify: `lib/ui/pages/project_list_page.dart` +- Modify: `lib/ui/pages/gateway_chat_page.dart` +- Modify: `lib/ui/widgets/directory_picker.dart` +- Modify: `lib/ui/pages/settings_page.dart` + +- [ ] **Step 1: Write the failing verification commands** + +Run these before changing code. They should fail because Web files and bearer-token UI still exist. + +```powershell +if (Test-Path web) { + throw 'web directory still exists' +} +``` + +Expected: FAIL with `web directory still exists`. + +Search `lib/state` and `lib/ui` for removed gateway credential state, +controller names, settings keys, and auth-header plumbing. + +Expected: FAIL with matches in `settings_store.dart`, `settings_page.dart`, `git_page.dart`, `gateway_chat_page.dart`, `project_list_page.dart`, and `directory_picker.dart`. + +- [ ] **Step 2: Delete the Flutter Web target files** + +Run: + +```powershell +git rm -r web +``` + +Expected: Git stages deletion of `web/index.html`, `web/manifest.json`, icons, and favicon files. + +- [ ] **Step 3: Update `pubspec.yaml` description** + +Replace the current description with: + +```yaml +description: A mobile client for local coding agents through a trusted LAN gateway. +``` + +- [ ] **Step 4: Remove `bearerToken` from `AppSettings`** + +In `lib/state/settings_store.dart`, change the settings model and persistence code to this shape: + +```dart +@immutable +class AppSettings { + const AppSettings({ + required this.baseUrl, + required this.providerId, + required this.modelId, + this.themeMode = ThemeMode.system, + this.lastAgentId = '', + this.lastModelId = '', + this.lastSessionId = '', + this.lastProjectId = '', + }); + + final String baseUrl; + final String providerId; + final String modelId; + final ThemeMode themeMode; + final String lastAgentId; + final String lastModelId; + final String lastSessionId; + final String lastProjectId; + + bool get isConfigured => + baseUrl.isNotEmpty && providerId.isNotEmpty && modelId.isNotEmpty; + + AppSettings copyWith({ + String? baseUrl, + String? providerId, + String? modelId, + ThemeMode? themeMode, + String? lastAgentId, + String? lastModelId, + String? lastSessionId, + String? lastProjectId, + }) => + AppSettings( + baseUrl: baseUrl ?? this.baseUrl, + providerId: providerId ?? this.providerId, + modelId: modelId ?? this.modelId, + themeMode: themeMode ?? this.themeMode, + lastAgentId: lastAgentId ?? this.lastAgentId, + lastModelId: lastModelId ?? this.lastModelId, + lastSessionId: lastSessionId ?? this.lastSessionId, + lastProjectId: lastProjectId ?? this.lastProjectId, + ); + + static const empty = AppSettings( + baseUrl: 'http://127.0.0.1:4096', + providerId: 'opencode', + modelId: 'big-pickle', + ); +} +``` + +Update `_load` and `update` so they no longer read or write `_kToken`: + +```dart +static AppSettings _load(SharedPreferences p) { + final themeModeIndex = p.getInt(_kThemeMode); + return AppSettings( + baseUrl: p.getString(_kBaseUrl) ?? AppSettings.empty.baseUrl, + providerId: p.getString(_kProvider) ?? AppSettings.empty.providerId, + modelId: p.getString(_kModel) ?? AppSettings.empty.modelId, + themeMode: themeModeIndex != null && themeModeIndex < ThemeMode.values.length + ? ThemeMode.values[themeModeIndex] + : ThemeMode.system, + lastAgentId: p.getString(_kLastAgent) ?? '', + lastModelId: p.getString(_kLastModel) ?? '', + lastSessionId: p.getString(_kLastSession) ?? '', + lastProjectId: p.getString(_kLastProject) ?? '', + ); +} + +Future update(AppSettings next) async { + state = next; + await Future.wait([ + _prefs.setString(_kBaseUrl, next.baseUrl), + _prefs.setString(_kProvider, next.providerId), + _prefs.setString(_kModel, next.modelId), + _prefs.setInt(_kThemeMode, next.themeMode.index), + _prefs.setString(_kLastAgent, next.lastAgentId), + _prefs.setString(_kLastModel, next.lastModelId), + _prefs.setString(_kLastSession, next.lastSessionId), + _prefs.setString(_kLastProject, next.lastProjectId), + ]); +} +``` + +Remove this constant: + +```dart +static const _kToken = 'oc.bearerToken'; +``` + +Also update the file header to: + +```dart +/// Persistent connection settings for the trusted LAN gateway. +/// +/// Stored in SharedPreferences so the app remembers them across launches. +``` + +- [ ] **Step 5: Stop passing bearer tokens from providers and pages** + +Replace `lib/state/gateway_client_provider.dart` with: + +```dart +library; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/gateway_client.dart'; +import 'settings_store.dart'; + +final gatewayClientProvider = Provider((ref) { + final settings = ref.watch(settingsControllerProvider); + final client = GatewayClient( + baseUrl: Uri.parse(settings.baseUrl), + ); + ref.onDispose(client.close); + return client; +}); +``` + +In `lib/ui/pages/git_page.dart`, update the provider to: + +```dart +final gitClientProvider = Provider((ref) { + final s = ref.watch(settingsControllerProvider); + final client = GitClient(baseUrl: Uri.parse(s.baseUrl)); + ref.onDispose(client.close); + return client; +}); +``` + +In `lib/ui/pages/project_list_page.dart`, update the directory picker call to: + +```dart +final directory = await showDirectoryPicker( + context, + gatewayBaseUrl: settings.baseUrl, + initialPath: 'D:\\', +); +``` + +In `lib/ui/pages/gateway_chat_page.dart`, update the directory picker call to: + +```dart +final path = await showDirectoryPicker( + context, + gatewayBaseUrl: settings.baseUrl, + initialPath: widget.project.directory, +); +``` + +- [ ] **Step 6: Remove bearer-token support from `directory_picker.dart` UI plumbing** + +Update the public function signature to: + +```dart +Future showDirectoryPicker( + BuildContext context, { + required String gatewayBaseUrl, + String? initialPath, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + useSafeArea: true, + builder: (_) => _DirectoryPickerSheet( + gatewayBaseUrl: gatewayBaseUrl, + initialPath: initialPath ?? 'D:\\', + ), + ); +} +``` + +Update `_DirectoryPickerSheet` to remove `bearerToken`: + +```dart +class _DirectoryPickerSheet extends StatefulWidget { + const _DirectoryPickerSheet({ + required this.gatewayBaseUrl, + required this.initialPath, + }); + + final String gatewayBaseUrl; + final String initialPath; + + @override + State<_DirectoryPickerSheet> createState() => _DirectoryPickerSheetState(); +} +``` + +Update the `Dio` initialization to: + +```dart +_dio = Dio( + BaseOptions( + baseUrl: widget.gatewayBaseUrl.replaceAll(RegExp(r'/$'), ''), + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + ), +); +``` + +- [ ] **Step 7: Remove bearer-token UI from `settings_page.dart`** + +Make these edits: + +- Remove the `_tokenCtrl` field. +- Remove `_tokenCtrl = TextEditingController(...)` from `initState`. +- Remove `_tokenCtrl.dispose()` from `dispose`. +- Remove every `bearerToken: _tokenCtrl.text.trim(),` argument. +- Remove the `TextField` whose label is the legacy gateway token setting. +- Remove `bearerToken` from `_ProfileEditorPage` constructor and usages if it is only passed through from the old settings field. + +After editing, searching `lib/state` and `lib/ui` for removed gateway credential +state, controller names, settings keys, and auth-header plumbing should print +no matches. + +- [ ] **Step 8: Run verification for Task 1** + +Run: + +```powershell +if (Test-Path web) { + throw 'web directory still exists' +} +``` + +Expected: PASS with no output. + +Search `lib/state` and `lib/ui` for removed gateway credential state, +controller names, settings keys, and auth-header plumbing. + +Expected: no matches and exit code 1. + +Run: + +```powershell +MSYS_NO_PATHCONV=1 docker run --rm -v "D:\Code\WorkSpace\remote-multi-agent:/app" -w /app ghcr.io/cirruslabs/flutter:3.27.1 bash -lc "flutter pub get && flutter test" +``` + +Expected: Flutter tests pass. If Docker is unavailable, run `flutter test` in a Flutter 3.27+ environment and record that Docker was unavailable. + +- [ ] **Step 9: Commit Task 1** + +```powershell +git add pubspec.yaml lib/state/settings_store.dart lib/state/gateway_client_provider.dart lib/ui/pages/git_page.dart lib/ui/pages/project_list_page.dart lib/ui/pages/gateway_chat_page.dart lib/ui/widgets/directory_picker.dart lib/ui/pages/settings_page.dart web +git commit -m "chore: make v1 mobile-only and remove gateway auth UI" +``` + +--- + +### Task 2: Add Module-Boundary Tests for Agent Split + +**Files:** + +- Create: `gateway/test/agents_split.test.js` + +- [ ] **Step 1: Write the failing tests** + +Create `gateway/test/agents_split.test.js`: + +```javascript +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +test('agent facade exports registry and adapter utilities', () => { + const agents = require('../src/agents'); + + assert.equal(typeof agents.AgentRegistry, 'function'); + assert.equal(typeof agents.CodexAdapter, 'function'); + assert.equal(typeof agents.ClaudeCodeAdapter, 'function'); + assert.equal(typeof agents.OpenCodeAdapter, 'function'); + assert.equal(typeof agents.buildCodexArgs, 'function'); + assert.equal(typeof agents.normalizeOpenCodeEvent, 'function'); + assert.equal(typeof agents.runJsonCli, 'function'); +}); + +test('each agent adapter is importable from its dedicated file', () => { + const { CodexAdapter, buildCodexArgs } = require('../src/agents/codex'); + const { ClaudeCodeAdapter } = require('../src/agents/claude_code'); + const { OpenCodeAdapter } = require('../src/agents/opencode'); + + assert.equal(new CodexAdapter().id, 'codex'); + assert.equal(new ClaudeCodeAdapter().id, 'claude-code'); + assert.equal(new OpenCodeAdapter({ + server: { + externalBaseUrl: 'http://127.0.0.1:1234', + baseUrl: null, + request() { + throw new Error('not used'); + }, + close() {}, + }, + }).id, 'opencode'); + + assert.deepEqual( + buildCodexArgs({ + directory: 'D:\\Code\\WorkSpace\\remote-multi-agent', + modelId: 'gpt-5.3-codex', + agentSessionId: null, + raw: { sandbox: 'workspace-write' }, + }), + [ + 'exec', + '--json', + '--color', + 'never', + '--cd', + 'D:\\Code\\WorkSpace\\remote-multi-agent', + '--sandbox', + 'workspace-write', + '--skip-git-repo-check', + '--model', + 'gpt-5.3-codex', + '-', + ], + ); +}); +``` + +- [ ] **Step 2: Run the tests and verify they fail** + +```powershell +npm test --prefix gateway -- agents_split.test.js +``` + +Expected: FAIL because `../src/agents/codex`, `../src/agents/claude_code`, and `../src/agents/opencode` do not exist yet. + +- [ ] **Step 3: Commit the failing tests** + +```powershell +git add gateway/test/agents_split.test.js +git commit -m "test: cover gateway agent module boundaries" +``` + +--- + +### Task 3: Extract Shared Gateway Agent Helpers + +**Files:** + +- Create: `gateway/src/agents/model_cache.js` +- Create: `gateway/src/agents/command_helpers.js` +- Create: `gateway/src/agents/json_cli.js` +- Create: `gateway/src/agents/opencode_helpers.js` +- Modify: `gateway/src/agents.js` + +- [ ] **Step 1: Create `model_cache.js`** + +Create `gateway/src/agents/model_cache.js`: + +```javascript +'use strict'; + +const MODEL_CACHE_TTL = 5 * 60 * 1000; +const modelCache = new Map(); + +function cachedModels(key, fetchFn) { + const entry = modelCache.get(key); + if (entry && Date.now() - entry.ts < MODEL_CACHE_TTL) return entry.promise; + const promise = fetchFn().then((models) => { + modelCache.set(key, { ts: Date.now(), promise: Promise.resolve(models) }); + return models; + }).catch((err) => { + modelCache.delete(key); + throw err; + }); + modelCache.set(key, { ts: Date.now(), promise }); + return promise; +} + +module.exports = { cachedModels, modelCache }; +``` + +- [ ] **Step 2: Create `command_helpers.js`** + +Move `commands`, `markdownCommands`, `opencodeJsonCommands`, and `publicCommand` from `gateway/src/agents.js` into `gateway/src/agents/command_helpers.js`. + +The file must export exactly: + +```javascript +module.exports = { + commands, + markdownCommands, + opencodeJsonCommands, + publicCommand, +}; +``` + +The moved implementations must keep the same behavior: + +- `commands(items)` deduplicates slash commands. +- `markdownCommands(directory)` recursively reads `.md` command files. +- `opencodeJsonCommands(projectDirectory)` reads `opencode.json`. +- `publicCommand(command)` returns `{ command, prefixArgs, shell }`. + +- [ ] **Step 3: Create `json_cli.js`** + +Move these functions from `gateway/src/agents.js` into `gateway/src/agents/json_cli.js`: + +- `runJsonCli` +- `extractTextDelta` +- `rememberEmittedText` +- `suffixDelta` +- `contentArrayText` +- `extractToolCall` +- `extractUsage` +- `extractAgentSessionId` +- `parseJsonLine` +- `tryParseJson` + +At the top of the new file, import the CLI helpers: + +```javascript +'use strict'; + +const { + killProcessTree, + readLines, + spawnCli, +} = require('../cli'); +``` + +The file must export: + +```javascript +module.exports = { + runJsonCli, + extractTextDelta, + extractToolCall, + extractUsage, + extractAgentSessionId, + parseJsonLine, + tryParseJson, +}; +``` + +- [ ] **Step 4: Create `opencode_helpers.js`** + +Move these functions from `gateway/src/agents.js` into `gateway/src/agents/opencode_helpers.js`: + +- `providerModels` +- `compactOpenCodeModel` +- `splitOpenCodeModel` +- `normalizeOpenCodeEvent` +- `openCodeEventSessionId` +- `openCodeTerminalResult` +- `openCodeErrorMessage` + +The file must export: + +```javascript +module.exports = { + providerModels, + splitOpenCodeModel, + normalizeOpenCodeEvent, + openCodeEventSessionId, + openCodeTerminalResult, +}; +``` + +- [ ] **Step 5: Run gateway tests** + +```powershell +npm test --prefix gateway +``` + +Expected: Existing tests still pass or fail only because adapter files have not been created. If syntax errors appear in the new helper modules, fix those before continuing. + +- [ ] **Step 6: Commit shared helper extraction** + +```powershell +git add gateway/src/agents/model_cache.js gateway/src/agents/command_helpers.js gateway/src/agents/json_cli.js gateway/src/agents/opencode_helpers.js +git commit -m "refactor: extract shared gateway agent helpers" +``` + +--- + +### Task 4: Move Each Agent Adapter Into Its Own File + +**Files:** + +- Create: `gateway/src/agents/codex.js` +- Create: `gateway/src/agents/claude_code.js` +- Create: `gateway/src/agents/opencode.js` +- Create: `gateway/src/agents/registry.js` +- Create: `gateway/src/agents/index.js` +- Modify: `gateway/src/agents.js` + +- [ ] **Step 1: Create `codex.js`** + +Move `CODEX_COMMANDS`, `CodexAdapter`, `buildCodexArgs`, and `compactCodexModel` from `gateway/src/agents.js` into `gateway/src/agents/codex.js`. + +Use these imports: + +```javascript +'use strict'; + +const { + commandExists, + resolveCodexCommand, + runCapture, +} = require('../cli'); +const { cachedModels } = require('./model_cache'); +const { commands, publicCommand } = require('./command_helpers'); +const { runJsonCli } = require('./json_cli'); +``` + +The file must end with: + +```javascript +module.exports = { + CodexAdapter, + CODEX_COMMANDS, + buildCodexArgs, +}; +``` + +- [ ] **Step 2: Create `claude_code.js`** + +Move `CLAUDE_COMMANDS` and `ClaudeCodeAdapter` from `gateway/src/agents.js` into `gateway/src/agents/claude_code.js`. + +Use these imports: + +```javascript +'use strict'; + +const os = require('node:os'); +const path = require('node:path'); + +const { + commandExists, + resolveClaudeCommand, +} = require('../cli'); +const { cachedModels } = require('./model_cache'); +const { commands, markdownCommands, publicCommand } = require('./command_helpers'); +const { runJsonCli } = require('./json_cli'); +``` + +The file must end with: + +```javascript +module.exports = { + ClaudeCodeAdapter, + CLAUDE_COMMANDS, +}; +``` + +- [ ] **Step 3: Create `opencode.js`** + +Move `OPENCODE_COMMANDS` and `OpenCodeAdapter` from `gateway/src/agents.js` into `gateway/src/agents/opencode.js`. + +Use these imports: + +```javascript +'use strict'; + +const path = require('node:path'); + +const { + commandExists, + resolveOpenCodeCommand, + runCapture, +} = require('../cli'); +const { OpenCodeServerManager } = require('../opencode_server'); +const { cachedModels } = require('./model_cache'); +const { commands, markdownCommands, opencodeJsonCommands, publicCommand } = require('./command_helpers'); +const { runJsonCli } = require('./json_cli'); +const { + providerModels, + splitOpenCodeModel, + normalizeOpenCodeEvent, + openCodeEventSessionId, + openCodeTerminalResult, +} = require('./opencode_helpers'); +``` + +The file must end with: + +```javascript +module.exports = { + OpenCodeAdapter, + OPENCODE_COMMANDS, + normalizeOpenCodeEvent, +}; +``` + +- [ ] **Step 4: Create `registry.js`** + +Create `gateway/src/agents/registry.js`: + +```javascript +'use strict'; + +const { CodexAdapter } = require('./codex'); +const { ClaudeCodeAdapter } = require('./claude_code'); +const { OpenCodeAdapter } = require('./opencode'); + +class AgentRegistry { + constructor({ openCodeServer, profileStore } = {}) { + this.profileStore = profileStore || null; + this.adapters = new Map( + [ + new CodexAdapter({ profileStore }), + new ClaudeCodeAdapter({ profileStore }), + new OpenCodeAdapter({ server: openCodeServer, profileStore }), + ].map((adapter) => [adapter.id, adapter]), + ); + } + + get(agentId) { + return this.adapters.get(agentId) || null; + } + + async list(projectDirectory) { + return Promise.all( + [...this.adapters.values()].map((adapter) => adapter.metadata(projectDirectory)), + ); + } + + close() { + for (const adapter of this.adapters.values()) { + adapter.close?.(); + } + } +} + +module.exports = { AgentRegistry }; +``` + +- [ ] **Step 5: Create `index.js` and facade** + +Create `gateway/src/agents/index.js`: + +```javascript +'use strict'; + +const { AgentRegistry } = require('./registry'); +const { CodexAdapter, buildCodexArgs } = require('./codex'); +const { ClaudeCodeAdapter } = require('./claude_code'); +const { OpenCodeAdapter, normalizeOpenCodeEvent } = require('./opencode'); +const { runJsonCli } = require('./json_cli'); + +module.exports = { + AgentRegistry, + CodexAdapter, + ClaudeCodeAdapter, + OpenCodeAdapter, + buildCodexArgs, + normalizeOpenCodeEvent, + runJsonCli, +}; +``` + +Replace `gateway/src/agents.js` with: + +```javascript +'use strict'; + +module.exports = require('./agents/index'); +``` + +- [ ] **Step 6: Run split tests** + +```powershell +npm test --prefix gateway -- agents_split.test.js +``` + +Expected: PASS. + +- [ ] **Step 7: Run all gateway tests** + +```powershell +npm test --prefix gateway +``` + +Expected: PASS. + +- [ ] **Step 8: Commit adapter split** + +```powershell +git add gateway/src/agents.js gateway/src/agents gateway/test/agents_split.test.js +git commit -m "refactor: split gateway agent adapters" +``` + +--- + +### Task 5: Repair Documentation and Align Product Boundaries + +**Files:** + +- Modify: `README.md` +- Modify: `gateway/README.md` +- Modify: `docs/requirements.md` +- Modify: `docs/development-spec.md` +- Modify: `docs/workflow.md` +- Modify: `TODO.md` +- Modify: `docs/optimization-plan.md` + +- [ ] **Step 1: Write the failing mojibake check** + +Search `README.md`, `TODO.md`, `docs`, and `gateway/README.md` for the known +garbled UTF-8 fragments recorded in the Task 5 review notes. + +Expected: FAIL with matches in existing documentation. + +- [ ] **Step 2: Update `README.md` product target and quick start** + +Make these concrete content changes: + +- Replace the architecture diagram with an ASCII-only diagram. +- Replace "Phone (Flutter app) HTTPS / SSE" with "iPhone / mobile Flutter app HTTP(S) / SSE". +- Remove stale mobile-target run instructions. +- Add this v1 access note: + +```markdown +## Gateway Access Model + +The first version has no gateway authentication. Run the gateway on a trusted +LAN or Tailscale network only. The default bind host is `127.0.0.1`; use +`GATEWAY_HOST=0.0.0.0` only when the phone must reach the laptop over a trusted +network. + +Web is not a supported target in v1. Web target files have been removed, and +the app uses native/mobile-only APIs for streaming and attachments. +``` + +Replace the Flutter development section with: + +```markdown +### Flutter app + +```bash +flutter pub get +flutter test +``` + +Build and device runs target mobile platforms. iOS packaging is handled by CI. +``` +``` + +- [ ] **Step 3: Update `gateway/README.md` access wording** + +Replace the current no-auth paragraph with: + +```markdown +The first gateway version has no authentication. This is intentional for v1: +the gateway is meant to run on the user's machine and be reachable only from a +trusted LAN or Tailscale network. Keep the default `127.0.0.1` bind for local +testing. Use `GATEWAY_HOST=0.0.0.0` only when a trusted phone needs LAN access. +``` + +Remove any statement that implies a bearer token can protect the gateway in v1. + +- [ ] **Step 4: Update `docs/requirements.md`** + +Add these bullets under "App and gateway split": + +```markdown +- V1 targets mobile/iOS. Flutter Web is not supported. +- V1 gateway access is trusted-network only. It does not require or validate a + bearer token. +- Authentication remains outside the v1 implementation scope. +``` + +- [ ] **Step 5: Update `docs/development-spec.md`** + +In "Core Architecture", replace `iOS app` with `mobile/iOS app`. + +In "Security Boundary", add: + +```markdown +The first version intentionally does not implement gateway authentication. +The supported deployment model is trusted LAN or Tailscale access. The app UI +must not present a bearer-token field until the gateway validates such tokens. +``` + +In "Non-Goals", add: + +```markdown +- Flutter Web support for v1. +- Gateway authentication for v1. +``` + +- [ ] **Step 6: Rewrite corrupted `docs/workflow.md`** + +Replace the file with readable Chinese content that covers: + +- 项目结构 +- Node gateway 本地运行 +- Flutter 测试和分析方式 +- iOS CI 打包方式 +- 常用命令 + +Use this exact top section: + +```markdown +# 开发工作流 + +## 项目结构概览 + +```text +lib/ + api/ REST、SSE、Git 客户端 + models/ 消息、Part、会话、项目、Agent 数据模型 + state/ Riverpod 状态管理 + ui/ 页面与组件 +gateway/src/ Node.js gateway +docs/ 产品和开发文档 +test/ Flutter 单元测试 +gateway/test/ Node.js gateway 测试 +``` + +## 本地运行 gateway + +```powershell +cd gateway +npm install +$env:GATEWAY_HOST='0.0.0.0' +npm start +``` + +第一版 gateway 不做认证,只应在可信局域网或 Tailscale 中暴露。 +``` +``` + +- [ ] **Step 7: Rewrite corrupted `TODO.md`** + +Replace the file with a readable roadmap. Include these sections: + +```markdown +# Remote Multi-Agent Roadmap + +## V1 Boundary + +- Mobile/iOS app only; Web is unsupported. +- Gateway has no authentication; use trusted LAN or Tailscale. +- App does not execute code and does not read project files directly. +- Gateway owns project directories, agent CLIs, filesystem, git, and credentials. + +## Near-Term Cleanup + +- Split gateway agent adapters into one file per agent. +- Keep command discovery dynamic through gateway metadata. +- Remove UI controls that imply unsupported gateway authentication. +- Keep documentation free of mojibake and aligned with the current product. + +## Functional Follow-Up + +- Decide whether approve/reject/handoff should be implemented or hidden. +- Add contract tests for any API endpoint surfaced in the app. +- Add CI checks for docs encoding and mobile test commands. +``` +``` + +- [ ] **Step 8: Rewrite `docs/optimization-plan.md`** + +Replace the corrupted text with a concise optimization plan: + +```markdown +# Optimization Plan + +## Current Priorities + +1. Keep v1 mobile-only and remove Web-facing expectations. +2. Keep gateway access limited to trusted LAN/Tailscale without adding auth. +3. Split `gateway/src/agents.js` into focused modules. +4. Align app UI with implemented gateway capabilities. +5. Add focused tests around streaming, agent adapters, and endpoint contracts. + +## Code Health Targets + +- One adapter file per agent: Codex, Claude Code, OpenCode. +- Shared helpers live under `gateway/src/agents/`. +- UI pages should delegate command routing and sheets to smaller widgets or + controllers when they are next modified. +- Documentation should be readable UTF-8 and describe the actual v1 boundary. +``` +``` + +- [ ] **Step 9: Run documentation verification** + +Search the listed documentation files for the known garbled UTF-8 fragments +recorded in the Task 5 review notes. + +Expected: PASS with no output. + +Search `README.md`, `gateway/README.md`, `docs`, and `TODO.md` for stale +mobile-target run instructions and unsupported gateway credential UI wording. + +Expected: no matches, except a permitted sentence that says Web is unsupported without describing Web target files as supported. + +- [ ] **Step 10: Commit documentation updates** + +```powershell +git add README.md gateway/README.md docs/requirements.md docs/development-spec.md docs/workflow.md TODO.md docs/optimization-plan.md +git commit -m "docs: align v1 mobile and trusted LAN scope" +``` + +--- + +### Task 6: Final Verification + +**Files:** + +- No new files. + +- [ ] **Step 1: Run gateway tests** + +```powershell +npm test --prefix gateway +``` + +Expected: all Node gateway tests pass. + +- [ ] **Step 2: Run Flutter tests** + +```powershell +MSYS_NO_PATHCONV=1 docker run --rm -v "D:\Code\WorkSpace\remote-multi-agent:/app" -w /app ghcr.io/cirruslabs/flutter:3.27.1 bash -lc "flutter pub get && flutter test" +``` + +Expected: all Flutter tests pass. If Docker is unavailable, run `flutter test` in a Flutter 3.27+ environment. + +- [ ] **Step 3: Run static analysis** + +```powershell +MSYS_NO_PATHCONV=1 docker run --rm -v "D:\Code\WorkSpace\remote-multi-agent:/app" -w /app ghcr.io/cirruslabs/flutter:3.27.1 bash -lc "flutter pub get && flutter analyze" +``` + +Expected: analysis succeeds with no errors. + +- [ ] **Step 4: Check final repository state** + +```powershell +git status --short +``` + +Expected: no uncommitted files after the task commits, or only intentional files awaiting the final integration commit. + +Search `lib/state` and `lib/ui` for removed gateway credential state, +controller names, settings keys, and auth-header plumbing. + +Expected: no matches. + +```powershell +if (Test-Path web) { + throw 'web directory still exists' +} +``` + +Expected: PASS with no output. + +Search the listed documentation files for the known garbled UTF-8 fragments +recorded in the Task 5 review notes. + +Expected: no matches. + +- [ ] **Step 5: Final integration commit if needed** + +If the previous tasks were not committed individually, make one final commit: + +```powershell +git add . +git commit -m "chore: clean up mobile v1 gateway structure" +``` + +--- + +## Self-Review + +Spec coverage: + +- Mobile-only v1 is covered by Task 1 and Task 5. +- No gateway authentication in v1 is covered by Task 1 and Task 5. +- LAN/Tailscale-only access guidance is covered by Task 5. +- One agent per file is covered by Task 2, Task 3, and Task 4. +- Existing documentation updates are covered by Task 5. + +Placeholder scan: + +- No undecided implementation sections remain. +- The root roadmap file name contains the word `TODO`, but no plan step uses it as an unfinished placeholder. + +Type consistency: + +- `AgentRegistry`, `CodexAdapter`, `ClaudeCodeAdapter`, `OpenCodeAdapter`, `buildCodexArgs`, `normalizeOpenCodeEvent`, and `runJsonCli` are exported consistently from `gateway/src/agents/index.js`. +- App settings remove `bearerToken` from `AppSettings`; optional bearer-token constructor arguments remain only in lower-level API clients. diff --git a/docs/workflow.md b/docs/workflow.md index 793f287..d4754e2 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -1,146 +1,91 @@ -# 开发工作流程 - -## 项目结构概览 - -``` -lib/ -├── api/ # 网络层:REST 客户端、SSE 流、Git 客户端 -├── models/ # 数据模型:消息、Part、会话、项目 -├── state/ # 状态管理(Riverpod):聊天、会话、项目、设置 -├── ui/ -│ ├── pages/ # 页面:聊天、项目列表、Git、文件浏览、设置 -│ └── widgets/ # 组件:消息气泡、工具卡片、状态栏等 -└── theme.dart # Material 3 主题 - -gateway/src/ # Node.js 网关服务端 -docs/ # 文档 -test/ # 单元测试 -``` - -## 功能模块对应文件 - -| 功能 | 关键文件 | -|------|----------| -| 聊天流式输出 | `lib/state/gateway_chat_store.dart`, `lib/ui/pages/gateway_chat_page.dart` | -| 消息渲染 | `lib/ui/widgets/message_bubble.dart` | -| 工具调用显示 | `lib/ui/widgets/parts/tool_part_view.dart` | -| 文本/Markdown 渲染 | `lib/ui/widgets/parts/text_part_view.dart` | -| 推理/思考显示 | `lib/ui/widgets/parts/reasoning_part_view.dart` | -| Agent 活动状态栏 | `lib/ui/widgets/agent_activity_bar.dart` | -| SSE 事件流 | `lib/api/sse_stream.dart` | -| REST API 客户端 | `lib/api/gateway_client.dart` | -| Git 操作 | `lib/ui/pages/git_page.dart`, `lib/api/git_client.dart` | -| 文件浏览 | `lib/ui/pages/files_page.dart` | -| 项目管理 | `lib/state/project_store.dart`, `lib/ui/pages/project_list_page.dart` | -| 会话管理 | `lib/state/gateway_session_store.dart` | -| 设置 | `lib/state/settings_store.dart`, `lib/ui/pages/settings_page.dart` | -| 主题 | `lib/theme.dart` | -| 网关服务 | `gateway/src/server.js`, `gateway/src/agents.js` | - -## 本地开发环境 - -- Flutter SDK 不在本机 PATH,通过 Docker 运行 -- Node.js 网关直接本地运行 -- GitHub CI 负责 iOS IPA 打包 - -## 运行 Flutter Analyze - -修改代码后,使用 Docker 运行静态分析: - -```bash -cd D:\Code\WorkSpace\remote-multi-agent - -# 仅分析 -MSYS_NO_PATHCONV=1 docker run --rm \ - -v "D:\Code\WorkSpace\remote-multi-agent:/app" \ - -w /app \ - ghcr.io/cirruslabs/flutter:3.27.1 \ - bash -c "flutter pub get && flutter analyze" - -# 运行测试 -MSYS_NO_PATHCONV=1 docker run --rm \ - -v "D:\Code\WorkSpace\remote-multi-agent:/app" \ - -w /app \ - ghcr.io/cirruslabs/flutter:3.27.1 \ - bash -c "flutter pub get && flutter test" +# 开发工作流 + +## 项目结构 + +```text +lib/ Flutter 移动端应用 + api/ Gateway REST、SSE、Git 客户端 + models/ Project、Session、Message、Part、Agent 等模型 + state/ Riverpod 状态管理 + ui/ 页面和组件 +gateway/ Node.js 本地网关 + src/ HTTP 服务、存储、事件总线、文件和 Git 路由 + src/agents/ Codex、Claude Code、OpenCode 适配器和共享工具 + test/ Gateway 单元测试 +docs/ 产品、需求和开发文档 +test/ Flutter 单元测试 ``` -## 打包 IPA(通过 GitHub CI) +V1 只支持移动端和 iOS。Flutter Web 不是支持目标,应用依赖移动端能力来处理 +流式输出和附件。 -### 触发构建 +## Gateway 本地运行 -推送到 main 分支即自动触发: +默认只监听本机,适合开发和测试: -```bash -git add -git commit -m "feat: ..." -git push +```powershell +cd gateway +npm install +npm start ``` -CI workflow 文件:`.github/workflows/ios.yml` +需要让手机访问电脑上的 gateway 时,只在可信局域网或 Tailscale 网络中开放: -### 监控构建 - -```bash -# 查看最近的 workflow 运行 -gh run list --limit 3 - -# 监控指定 run(找 "iOS unsigned IPA" 那个) -gh run watch - -# 或查看状态 -gh run view --json status,conclusion +```powershell +cd gateway +$env:GATEWAY_HOST='0.0.0.0' +$env:GATEWAY_PORT='4096' +npm start ``` -### 下载 IPA - -构建成功后: +V1 gateway 不实现认证,也不校验访问令牌。不要把它直接暴露到公网;保持 +`127.0.0.1` 用于本机测试,只在手机必须访问电脑时才使用 `0.0.0.0`。 -```bash -gh run download --name ios-ipa -``` +## Flutter 测试和分析 -IPA 下载到当前目录: +本机安装 Flutter 3.27 或更新版本时: +```powershell +flutter pub get +flutter analyze +flutter test ``` -D:\Code\WorkSpace\remote-multi-agent\opencode_mobile-.ipa -``` - -### 安装到 iPhone -使用 Sideloadly 或 AltStore 侧载安装 unsigned IPA。 +如果本机没有 Flutter SDK,可以用 Docker 运行同样的检查: -## 启动网关 - -```bash -cd gateway -npm install -GATEWAY_HOST=0.0.0.0 node src/index.js -# 监听 http://0.0.0.0:4096 +```powershell +docker run --rm ` + -v "D:\Code\WorkSpace\remote-multi-agent:/app" ` + -w /app ` + ghcr.io/cirruslabs/flutter:3.27.1 ` + bash -c "flutter pub get && flutter analyze && flutter test" ``` -## Git 提交规范 +## iOS CI 打包 -参考已有 commit 风格: +iOS 打包由 GitHub Actions 处理,workflow 位于 `.github/workflows/ios.yml`。 -``` -feat: 新功能描述 -fix: 修复描述 -ci: CI 相关改动 +```powershell +git push +gh run list --limit 3 +gh run watch +gh run download --name ios-ipa ``` -## 常用命令速查 +下载后的 unsigned IPA 可通过 Sideloadly 或 AltStore 安装到 iPhone。 -```bash -# 静态分析 -MSYS_NO_PATHCONV=1 docker run --rm -v "D:\Code\WorkSpace\remote-multi-agent:/app" -w /app ghcr.io/cirruslabs/flutter:3.27.1 bash -c "flutter pub get && flutter analyze" +## 常用命令 -# 推送触发 CI -git push +```powershell +# 查看工作区状态 +git status --short -# 查看 CI 状态 -gh run list --limit 3 +# 运行 gateway 测试 +npm test --prefix gateway + +# 运行 Flutter 测试 +flutter test -# 下载最新 IPA -gh run download $(gh run list --workflow=ios.yml --limit=1 --json databaseId -q '.[0].databaseId') --name ios-ipa +# 运行 Flutter 静态分析 +flutter analyze ``` diff --git a/gateway/README.md b/gateway/README.md index 922602d..bd20fa2 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -1,148 +1,167 @@ -# Remote Multi Agent Gateway - -Local HTTP/SSE gateway for the Flutter client. It owns filesystem access and -agent execution; the app only talks to this server. - -## Supported Agents - -- Codex: `codex exec --json` -- Claude Code: `claude -p --output-format stream-json --verbose` -- OpenCode: `opencode serve` HTTP/SSE proxy, with `opencode run --format json` - fallback when server mode is unavailable - -The gateway holds all API credentials itself, in -`~/.gateway/profiles.json`. There is no implicit fallback to environment -variables, CC-Switch, or `~/.claude/settings.json` at agent run time — -credentials must be **explicitly imported** through the settings UI or the -`/settings/profiles*` endpoints documented below. - -Multiple profiles are supported; exactly one is active at a time. - -## Run - -```powershell -cd gateway -npm start -``` - -Default URL: - -```text -http://127.0.0.1:4096 -``` - -For LAN or Tailscale access: - -```powershell -$env:GATEWAY_HOST='0.0.0.0' -$env:GATEWAY_PORT='4096' -npm start -``` - -The first gateway version has no authentication, matching -`docs/development-spec.md`. Bind to `127.0.0.1` by default, and expose -`0.0.0.0` only behind a trusted network such as Tailscale. - -## Configuration - -| Variable | Purpose | -| --- | --- | -| `GATEWAY_HOST` | Bind host, default `127.0.0.1`. | -| `GATEWAY_PORT` | Bind port, default `4096`. | -| `GATEWAY_DATA_FILE` | JSON store path, default `gateway/.data/store.json`. | -| `GATEWAY_DIRECTORIES` | Extra roots returned by `GET /directories`, separated by OS path delimiter. | -| `CODEX_BIN` | Override Codex executable path. | -| `CODEX_SANDBOX` | Codex sandbox mode, default `workspace-write`. | -| `CLAUDE_CODE_BIN` | Override Claude Code executable path. | -| `CLAUDE_CODE_MODELS` | Comma-separated Claude model aliases to show in the picker. | -| `CLAUDE_CODE_PERMISSION_MODE` | Optional Claude permission mode, for example `acceptEdits` or `dontAsk`. | -| `OPENCODE_BIN` | Override OpenCode executable path. | -| `OPENCODE_SERVER_URL` | Use an existing OpenCode server instead of starting `opencode serve`. | -| `OPENCODE_SERVER_PASSWORD` | Password for an existing OpenCode server; sent with OpenCode's Basic auth scheme. Gateway-started OpenCode is local-only and does not set one by default. | -| `OPENCODE_SERVER_HOST` | Host for gateway-started OpenCode server, default `127.0.0.1`. | -| `OPENCODE_SERVER_PORT` | Port for gateway-started OpenCode server, default is a free port. | -| `OPENCODE_SERVER_START_TIMEOUT_MS` | Startup wait for `opencode serve`, default `45000`. | -| `OPENCODE_DEFAULT_MODEL` | Fallback model id when the app did not choose one, default `opencode/big-pickle`. | -| `OPENCODE_MODE` | OpenCode message mode, default `build`. | - -For OpenCode, the gateway creates a native OpenCode session through -`POST /session?directory=...`, stores that id as `agentSessionId`, sends turns -through `POST /session/:id/message`, and bridges the global `/event` SSE stream -back into the gateway's per-session event endpoint. - -## API - -The gateway implements the app contract from `docs/development-spec.md`: - -```text -GET /health -GET /projects -POST /projects -GET /projects/:projectId -DELETE /projects/:projectId -GET /directories -GET /files/dirs?path= -POST /files/mkdir -GET /agents -GET /agents/:agentId -GET /agents/:agentId/models -GET /agents/:agentId/commands -GET /projects/:projectId/sessions -POST /projects/:projectId/sessions -GET /sessions/:sessionId -PATCH /sessions/:sessionId -DELETE /sessions/:sessionId -GET /sessions/:sessionId/messages -POST /sessions/:sessionId/messages -POST /sessions/:sessionId/abort -GET /sessions/:sessionId/events -``` - -### Credentials - -```text -GET /settings/active-profile -GET /settings/profiles -POST /settings/profiles -PATCH /settings/profiles/:profileId -DELETE /settings/profiles/:profileId -POST /settings/profiles/:profileId/activate -POST /settings/profiles/import -GET /settings/credential-sources/official -GET /settings/credential-sources/cc-switch -``` - -- **`/settings/profiles`** — CRUD over the gateway-owned credential store. - Keys are returned masked. -- **`/settings/credential-sources/official`** — preview entries discoverable - in known per-provider config files. Currently: - - Claude: `~/.claude/settings.json` (`provider: "anthropic"`) - - Codex: `~/.codex/auth.json` (`provider: "openai"`) -- **`/settings/credential-sources/cc-switch`** — preview entries discoverable - in `~/.cc-switch/cc-switch.db`. Lists **every** supported provider regardless - of `app_type` (`claude` → `anthropic`, `codex` → `openai`); the active - provider per `app_type` is flagged via `isCurrent: true`. Returns `[]` if - `node:sqlite` is unavailable (Node < 22). -- **`/settings/profiles/import`** — body - `{ name, source, sourceId?, makeActive? }` where `source` is `"official"` or - `"cc-switch"`. Creates a profile populated with the discovered credential - under the entry's declared `provider` slot. - -`/sessions/:sessionId/events` is SSE. Each event uses the normalized envelope: - -```json -{ - "type": "message.delta", - "sessionId": "session-id", - "agentId": "codex", - "timestamp": 1779177600000, - "data": {}, - "raw": {} -} -``` - -## Test - -```powershell -npm test --prefix gateway -``` +# Remote Multi Agent Gateway + +Local HTTP/SSE gateway for the Flutter mobile client. It owns filesystem +access, project directories, git operations, credentials, and agent execution; +the app only talks to this server. + +## Supported Agents + +- Codex: `codex exec --json` +- Claude Code: `claude -p --output-format stream-json --verbose` +- OpenCode: `opencode serve` HTTP/SSE proxy, with `opencode run --format json` + fallback when server mode is unavailable + +The gateway holds all API credentials itself in `~/.gateway/profiles.json`. +There is no implicit fallback to environment variables, CC-Switch, or +`~/.claude/settings.json` at agent run time; credentials must be explicitly +imported through the settings UI or the `/settings/profiles*` endpoints +documented below. + +Multiple profiles are supported; exactly one is active at a time. + +## Run + +```powershell +cd gateway +npm start +``` + +Default URL: + +```text +http://127.0.0.1:4096 +``` + +For LAN or Tailscale access: + +```powershell +$env:GATEWAY_HOST='0.0.0.0' +$env:GATEWAY_PORT='4096' +npm start +``` + +The first gateway version has no authentication. This is intentional for v1: +the gateway is meant to run on the user's machine and be reachable only from a +trusted LAN or Tailscale network. Keep the default `127.0.0.1` bind for local +testing. Use `GATEWAY_HOST=0.0.0.0` only when a trusted phone needs LAN access. + +## Configuration + +| Variable | Purpose | +| --- | --- | +| `GATEWAY_HOST` | Bind host, default `127.0.0.1`. | +| `GATEWAY_PORT` | Bind port, default `4096`. | +| `GATEWAY_DATA_FILE` | JSON store path, default `gateway/.data/store.json`. | +| `GATEWAY_DIRECTORIES` | Extra roots returned by `GET /directories`, separated by OS path delimiter. | +| `CODEX_BIN` | Override Codex executable path. | +| `CODEX_SANDBOX` | Codex sandbox mode, default `workspace-write`. | +| `CLAUDE_CODE_BIN` | Override Claude Code executable path. | +| `CLAUDE_CODE_MODELS` | Comma-separated Claude model aliases to show in the picker. | +| `CLAUDE_CODE_PERMISSION_MODE` | Optional Claude permission mode, for example `acceptEdits` or `dontAsk`. | +| `OPENCODE_BIN` | Override OpenCode executable path. | +| `OPENCODE_SERVER_URL` | Use an existing OpenCode server instead of starting `opencode serve`. | +| `OPENCODE_SERVER_PASSWORD` | Password for an existing OpenCode server, sent with OpenCode's Basic auth scheme. | +| `OPENCODE_SERVER_HOST` | Host for gateway-started OpenCode server, default `127.0.0.1`. | +| `OPENCODE_SERVER_PORT` | Port for gateway-started OpenCode server, default is a free port. | +| `OPENCODE_SERVER_START_TIMEOUT_MS` | Startup wait for `opencode serve`, default `45000`. | +| `OPENCODE_DEFAULT_MODEL` | Fallback model id when the app did not choose one, default `opencode/big-pickle`. | +| `OPENCODE_MODE` | OpenCode message mode, default `build`. | + +## Agent Adapter Layout + +Gateway agent adapters are split by agent: + +```text +gateway/src/agents/ + index.js + registry.js + claude_code.js + codex.js + opencode.js + command_helpers.js + json_cli.js + model_cache.js + opencode_helpers.js +``` + +Shared helpers live under `gateway/src/agents/`: they handle command metadata +and discovery, JSON CLI parsing, model caching, and OpenCode event/model +normalization. The registry exposes the normalized metadata, model lists, +command lists, and message execution contract used by the app. + +For OpenCode, the gateway creates a native OpenCode session through +`POST /session?directory=...`, stores that id as `agentSessionId`, sends turns +through `POST /session/:id/message`, and bridges the global `/event` SSE stream +back into the gateway's per-session event endpoint. + +## API + +The gateway implements the app contract from `docs/development-spec.md`: + +```text +GET /health +GET /projects +POST /projects +GET /projects/:projectId +DELETE /projects/:projectId +GET /directories +GET /files/dirs?path= +POST /files/mkdir +GET /agents +GET /agents/:agentId +GET /agents/:agentId/models +GET /agents/:agentId/commands +GET /projects/:projectId/sessions +POST /projects/:projectId/sessions +GET /sessions/:sessionId +PATCH /sessions/:sessionId +DELETE /sessions/:sessionId +GET /sessions/:sessionId/messages +POST /sessions/:sessionId/messages +POST /sessions/:sessionId/abort +GET /sessions/:sessionId/events +``` + +### Credentials + +```text +GET /settings/active-profile +GET /settings/profiles +POST /settings/profiles +PATCH /settings/profiles/:profileId +DELETE /settings/profiles/:profileId +POST /settings/profiles/:profileId/activate +POST /settings/profiles/import +GET /settings/credential-sources/official +GET /settings/credential-sources/cc-switch +``` + +- `/settings/profiles`: CRUD over the gateway-owned credential store. Keys are + returned masked. +- `/settings/credential-sources/official`: preview entries discoverable in + known per-provider config files. Currently Claude uses + `~/.claude/settings.json` and Codex uses `~/.codex/auth.json`. +- `/settings/credential-sources/cc-switch`: preview entries discoverable in + `~/.cc-switch/cc-switch.db`. Returns `[]` if `node:sqlite` is unavailable. +- `/settings/profiles/import`: body + `{ name, source, sourceId?, makeActive? }`, where `source` is `"official"` or + `"cc-switch"`. + +`/sessions/:sessionId/events` is SSE. Each event uses the normalized envelope: + +```json +{ + "type": "message.delta", + "sessionId": "session-id", + "agentId": "codex", + "timestamp": 1779177600000, + "data": {}, + "raw": {} +} +``` + +## Test + +```powershell +npm test --prefix gateway +``` diff --git a/gateway/src/agents.js b/gateway/src/agents.js index aa92830..66ba8ea 100644 --- a/gateway/src/agents.js +++ b/gateway/src/agents.js @@ -1,1284 +1,3 @@ 'use strict'; -const fs = require('node:fs/promises'); -const os = require('node:os'); -const path = require('node:path'); - -const { - commandExists, - killProcessTree, - readLines, - resolveClaudeCommand, - resolveCodexCommand, - resolveOpenCodeCommand, - runCapture, - spawnCli, -} = require('./cli'); -const { OpenCodeServerManager } = require('./opencode_server'); - -const MODEL_CACHE_TTL = 5 * 60 * 1000; // 5 minutes -const modelCache = new Map(); - -function cachedModels(key, fetchFn) { - const entry = modelCache.get(key); - if (entry && Date.now() - entry.ts < MODEL_CACHE_TTL) return entry.promise; - const promise = fetchFn().then((models) => { - modelCache.set(key, { ts: Date.now(), promise: Promise.resolve(models) }); - return models; - }).catch((err) => { - modelCache.delete(key); - throw err; - }); - modelCache.set(key, { ts: Date.now(), promise }); - return promise; -} - -const CODEX_COMMANDS = [ - { name: '/mcp', description: 'Show MCP server status' }, - { name: '/personality', description: 'Set personality' }, - { name: '/review', description: 'Code review' }, - { name: '/side', description: 'Start a side conversation in a temporary branch' }, - { name: '/compact', description: 'Compress this thread context' }, - { name: '/feedback', description: 'Submit feedback' }, - { name: '/model', description: 'Switch model' }, - { name: '/fast', description: 'Switch to fast model' }, - { name: '/plan', description: 'Plan a goal' }, - { name: '/goal', description: 'Set a goal for the session' }, - { name: '/fork', description: 'Fork to local branch or new worktree' }, - { name: '/status', description: 'Show session ID, context usage, and rate limits' }, - { name: '/permissions', description: 'Manage sandbox permissions' }, - { name: '/sandbox-add-read-dir', description: 'Add a read-only directory to sandbox' }, - { name: '/ide', description: 'IDE integration settings' }, - { name: '/keymap', description: 'Switch keymap' }, - { name: '/vim', description: 'Toggle vim mode' }, - { name: '/agent', description: 'Manage agents' }, - { name: '/apps', description: 'Manage apps' }, - { name: '/plugins', description: 'Manage plugins' }, - { name: '/hooks', description: 'Manage hooks' }, - { name: '/clear', description: 'Clear screen' }, - { name: '/copy', description: 'Copy last response' }, - { name: '/diff', description: 'Show diff of changes' }, - { name: '/experimental', description: 'Toggle experimental features' }, - { name: '/approve', description: 'Approve pending actions' }, - { name: '/memories', description: 'View or manage memories' }, - { name: '/skills', description: 'View learned skills' }, - { name: '/init', description: 'Initialize project config' }, - { name: '/logout', description: 'Log out' }, - { name: '/mention', description: 'Mention a file or symbol' }, - { name: '/ps', description: 'Show running processes' }, - { name: '/stop', description: 'Stop running process' }, - { name: '/raw', description: 'Send raw prompt' }, - { name: '/debug-config', description: 'Show debug config' }, - { name: '/exit', description: 'Exit session' }, - { name: '/quit', description: 'Quit session' }, - { name: '$', description: 'Run a shell command' }, -]; - -const CLAUDE_COMMANDS = [ - { name: '/mcp', description: 'Show MCP server status' }, - { name: '/model', description: 'Switch model' }, - { name: '/compact', description: 'Compress this thread context' }, - { name: '/review', description: 'Code review' }, - { name: '/memory', description: 'View or edit memory' }, - { name: '/status', description: 'Show session ID, context usage, and rate limits' }, - { name: '/permissions', description: 'Manage permissions' }, - { name: '/agents', description: 'Show available agents' }, - { name: '/bug', description: 'Report a bug' }, - { name: '/clear', description: 'Clear conversation' }, - { name: '/config', description: 'Show or edit config' }, - { name: '/cost', description: 'Show token usage and cost' }, - { name: '/doctor', description: 'Diagnose setup issues' }, - { name: '/help', description: 'Show help' }, - { name: '/init', description: 'Initialize project config' }, - { name: '/login', description: 'Log in' }, - { name: '/logout', description: 'Log out' }, - { name: '/pr_comments', description: 'Load PR comments' }, - { name: '/add-dir', description: 'Add a directory to context' }, - { name: '/terminal-setup', description: 'Setup terminal integration' }, - { name: '/vim', description: 'Toggle vim mode' }, -]; - -const OPENCODE_COMMANDS = [ - { name: '/models', description: 'Show or switch models' }, - { name: '/compact', description: 'Compress this thread context' }, - { name: '/summarize', description: 'Summarize conversation' }, - { name: '/help', description: 'Show help' }, - { name: '/new', description: 'Start a new session' }, - { name: '/clear', description: 'Clear conversation' }, - { name: '/sessions', description: 'List sessions' }, - { name: '/resume', description: 'Resume a session' }, - { name: '/continue', description: 'Continue last session' }, - { name: '/share', description: 'Share session' }, - { name: '/unshare', description: 'Unshare session' }, - { name: '/details', description: 'Show session details' }, - { name: '/editor', description: 'Open in editor' }, - { name: '/export', description: 'Export conversation' }, - { name: '/themes', description: 'Change theme' }, - { name: '/init', description: 'Initialize project config' }, - { name: '/undo', description: 'Undo last change' }, - { name: '/redo', description: 'Redo last change' }, - { name: '/exit', description: 'Exit session' }, - { name: '/quit', description: 'Quit session' }, - { name: '/q', description: 'Quit session' }, -]; - -class AgentRegistry { - constructor({ openCodeServer, profileStore } = {}) { - this.profileStore = profileStore || null; - this.adapters = new Map( - [ - new CodexAdapter({ profileStore }), - new ClaudeCodeAdapter({ profileStore }), - new OpenCodeAdapter({ server: openCodeServer, profileStore }), - ].map((adapter) => [adapter.id, adapter]), - ); - } - - get(agentId) { - return this.adapters.get(agentId) || null; - } - - async list(projectDirectory) { - return Promise.all( - [...this.adapters.values()].map((adapter) => adapter.metadata(projectDirectory)), - ); - } - - close() { - for (const adapter of this.adapters.values()) { - adapter.close?.(); - } - } -} - -class CodexAdapter { - constructor({ profileStore } = {}) { - this.id = 'codex'; - this.displayName = 'Codex'; - this.command = resolveCodexCommand(); - this.profileStore = profileStore || null; - } - - async metadata(projectDirectory) { - return { - id: this.id, - displayName: this.displayName, - supportsModels: true, - supportsSlashCommands: true, - supportsAttachments: false, - supportsPermissions: true, - sessionKind: 'thread', - commands: commands(CODEX_COMMANDS), - raw: { - available: commandExists(this.command), - command: publicCommand(this.command), - projectDirectory, - }, - }; - } - - models() { - return cachedModels('codex', () => this._fetchModels()); - } - - async _fetchModels() { - const result = await runCapture(this.command, ['debug', 'models', '--bundled']); - if (result.exitCode === 0) { - try { - const parsed = JSON.parse(result.stdout); - const models = Array.isArray(parsed.models) ? parsed.models : []; - return models - .filter((model) => model.visibility !== 'hidden') - .map((model) => ({ - id: model.slug, - displayName: model.display_name || model.slug, - raw: compactCodexModel(model), - })); - } catch (_) { - // Fall through to static list. - } - } - return [ - 'gpt-5.5', - 'gpt-5.4', - 'gpt-5.4-mini', - 'gpt-5.3-codex', - 'gpt-5.2', - ].map((id) => ({ id, displayName: id, raw: { id } })); - } - - async commands() { - return commands(CODEX_COMMANDS); - } - - run({ session, prompt, onEvent, onText, onAgentSessionId, onExit }) { - const args = buildCodexArgs(session); - - const profileKey = this.profileStore?.getKeyForProviderById( - session.raw?.profileId, 'openai'); - const extraEnv = {}; - if (profileKey?.key) { - extraEnv.OPENAI_API_KEY = profileKey.key; - if (profileKey.baseUrl) extraEnv.OPENAI_BASE_URL = profileKey.baseUrl; - } - - // Codex `exec ... -` reads the prompt from stdin until EOF; keeping - // stdin open would block codex from starting work. - return runJsonCli({ - command: this.command, - args, - cwd: session.directory, - env: extraEnv, - stdin: prompt, - agentId: this.id, - onEvent, - onText, - onAgentSessionId, - onExit, - }); - } -} - -function buildCodexArgs(session) { - const sandbox = (session.raw && session.raw.sandbox) || process.env.CODEX_SANDBOX || 'workspace-write'; - const args = session.agentSessionId - ? ['exec', 'resume', '--json', '--skip-git-repo-check'] - : [ - 'exec', - '--json', - '--color', - 'never', - '--cd', - session.directory, - '--sandbox', - sandbox, - '--skip-git-repo-check', - ]; - if (session.modelId) args.push('--model', session.modelId); - if (session.agentSessionId) args.push(session.agentSessionId); - args.push('-'); - return args; -} - -class ClaudeCodeAdapter { - constructor({ profileStore } = {}) { - this.id = 'claude-code'; - this.displayName = 'Claude Code'; - this.command = resolveClaudeCommand(); - this.profileStore = profileStore || null; - } - - async metadata(projectDirectory) { - return { - id: this.id, - displayName: this.displayName, - supportsModels: true, - supportsSlashCommands: true, - supportsAttachments: true, - supportsPermissions: true, - sessionKind: 'thread', - commands: await this.commands(projectDirectory), - raw: { - available: commandExists(this.command), - command: publicCommand(this.command), - projectDirectory, - }, - }; - } - - models() { - return cachedModels("claude-code", () => this._fetchModels()); - } - - async _fetchModels() { - // 1. Explicit env var override (list of model IDs, not credentials). - const envModels = (process.env.CLAUDE_CODE_MODELS || '') - .split(',') - .map((value) => value.trim()) - .filter(Boolean); - if (envModels.length > 0) { - return envModels.map((id) => ({ id, displayName: id, raw: { id } })); - } - - // 2. Fetch from Anthropic API using the active profile's credentials. - // Credentials live only in the gateway profile store — no implicit - // fallback to env vars, CC-Switch, or ~/.claude/settings.json. To pull - // a live model list, the user must first import a profile via the - // /settings/credential-sources/* + /settings/profiles/import flow. - const profileKey = this.profileStore?.getKeyForProvider('anthropic'); - const apiKey = profileKey?.key || null; - const baseUrl = profileKey?.baseUrl || null; - if (apiKey) { - try { - const url = (baseUrl || 'https://api.anthropic.com').replace(/\/+$/, ''); - const res = await fetch(`${url}/v1/models`, { - headers: { - 'x-api-key': apiKey, - 'anthropic-version': '2023-06-01', - }, - signal: AbortSignal.timeout(8000), - }); - if (res.ok) { - const body = await res.json(); - const models = (body.data || []) - .filter((m) => m.id && /claude/i.test(m.id)) - .sort((a, b) => { - // Newest first (by created_at if available) - const ca = a.created_at || ''; - const cb = b.created_at || ''; - return cb.localeCompare(ca); - }) - .map((m) => ({ - id: m.id, - displayName: m.display_name || m.id, - raw: m, - })); - if (models.length > 0) return models; - } - } catch (err) { - console.warn(`[claude-code] Failed to fetch models from API: ${err.message}`); - } - } - - // 3. Fallback defaults - const defaults = [ - 'claude-sonnet-4-20250514', - 'claude-opus-4-20250514', - 'claude-3-7-sonnet-20250219', - 'claude-3-5-sonnet-20241022', - 'claude-3-5-haiku-20241022', - ]; - return defaults.map((id) => ({ id, displayName: id, raw: { id } })); - } - - async commands(projectDirectory) { - return commands([ - ...CLAUDE_COMMANDS, - ...(await markdownCommands(path.join(projectDirectory || '', '.claude', 'commands'))), - ...(await markdownCommands(path.join(os.homedir(), '.claude', 'commands'))), - ]); - } - - run({ session, prompt, onEvent, onText, onAgentSessionId, onExit }) { - const isSlashCommand = prompt.trim().startsWith('/'); - const withResume = Boolean(session.agentSessionId); - if (isSlashCommand && !withResume) { - onEvent({ - type: 'command.updated', - data: { source: 'claude-code', eventType: 'slash-command', command: prompt.trim() }, - raw: { command: prompt.trim(), hasSession: false }, - }); - } - return this._runOnce({ - session, - prompt, - withResume, - onEvent, - onText, - onAgentSessionId, - onExit, - }); - } - - _runOnce({ session, prompt, withResume, onEvent, onText, onAgentSessionId, onExit }) { - const args = [ - '-p', - '--output-format', - 'stream-json', - '--verbose', - '--include-partial-messages', - ]; - // Claude `-p` (print) mode cannot prompt interactively. If we don't pass - // a permission-mode it defaults to "ask" and stalls waiting for input. - // 'acceptEdits' is the closest match to Codex's 'workspace-write' default. - const permissionMode = (session.raw && session.raw.permissionMode) || - process.env.CLAUDE_CODE_PERMISSION_MODE || - 'acceptEdits'; - args.push('--permission-mode', permissionMode); - if (session.modelId) args.push('--model', session.modelId); - if (withResume && session.agentSessionId) { - args.push('--resume', session.agentSessionId); - } - - const profileKey = this.profileStore?.getKeyForProviderById( - session.raw?.profileId, 'anthropic'); - const extraEnv = {}; - if (profileKey?.key) { - extraEnv.ANTHROPIC_API_KEY = profileKey.key; - if (profileKey.baseUrl) extraEnv.ANTHROPIC_BASE_URL = profileKey.baseUrl; - } - - let retried = false; - const handle = {}; - const wrappedExit = (result) => { - // Detect stale --resume: Claude says "No conversation found". - const stale = withResume && - !retried && - typeof result.error === 'string' && - /no conversation found/i.test(result.error); - if (stale) { - retried = true; - console.log(`[claude] stale resume id ${session.agentSessionId} - retrying fresh`); - session.agentSessionId = null; - const retryHandle = this._runOnce({ - session, - prompt, - withResume: false, - onEvent, - onText, - onAgentSessionId, - onExit, - }); - Object.assign(handle, retryHandle); - return; - } - onExit(result); - }; - - const inner = runJsonCli({ - command: this.command, - args, - cwd: session.directory, - env: extraEnv, - stdin: prompt, - agentId: this.id, - onEvent, - onText, - onAgentSessionId, - onExit: wrappedExit, - }); - Object.assign(handle, inner); - return handle; - } -} - -class OpenCodeAdapter { - constructor({ command, server, profileStore } = {}) { - this.id = 'opencode'; - this.displayName = 'OpenCode'; - this.command = command || resolveOpenCodeCommand(); - this.profileStore = profileStore || null; - this._explicitServer = server || null; - this._server = null; - } - - get server() { - if (!this._server) { - this._server = this._explicitServer || new OpenCodeServerManager({ - command: this.command, - extraEnv: this._buildProfileEnv(), - }); - } - return this._server; - } - - async metadata(projectDirectory) { - return { - id: this.id, - displayName: this.displayName, - supportsModels: true, - supportsSlashCommands: true, - supportsAttachments: true, - supportsPermissions: true, - sessionKind: 'session', - commands: await this.commands(projectDirectory), - raw: { - available: commandExists(this.command) || Boolean(this.server.externalBaseUrl), - command: publicCommand(this.command), - serverUrl: this.server.baseUrl || this.server.externalBaseUrl || null, - projectDirectory, - }, - }; - } - - models() { - return cachedModels("opencode", () => this._fetchModels()); - } - - async _fetchModels() { - try { - const providers = await this.server.request('/provider'); - const models = providerModels(providers); - if (models.length > 0) return models; - } catch (_) { - // Fall back to the CLI's static model list when server mode is unavailable. - } - const result = await runCapture(this.command, ['models']); - if (result.exitCode === 0) { - return result.stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => /^[^/\s]+\/[^/\s]+$/.test(line)) - .map((id) => ({ id, displayName: id, raw: { id } })); - } - return [{ id: 'opencode/big-pickle', displayName: 'opencode/big-pickle', raw: {} }]; - } - - async commands(projectDirectory) { - return commands([ - ...OPENCODE_COMMANDS, - ...(await markdownCommands(path.join(projectDirectory || '', '.opencode', 'commands'))), - ...(await opencodeJsonCommands(projectDirectory)), - ]); - } - - async createSession({ project, title }) { - const query = project?.directory - ? `?directory=${encodeURIComponent(project.directory)}` - : ''; - const raw = await this.server.request(`/session${query}`, { - method: 'POST', - body: {}, - }); - const agentSessionId = raw && typeof raw.id === 'string' ? raw.id : null; - if (!agentSessionId) throw new Error('OpenCode did not return a session id'); - return { - agentSessionId, - title: - typeof raw.title === 'string' && raw.title.trim() - ? raw.title - : title || 'OpenCode session', - raw, - }; - } - - async listMessages(session) { - if (!session.agentSessionId) return null; - return await this.server.request( - `/session/${encodeURIComponent(session.agentSessionId)}/message`, - ); - } - - async abort(session) { - if (!session.agentSessionId) return false; - await this.server.request( - `/session/${encodeURIComponent(session.agentSessionId)}/abort`, - { method: 'POST' }, - ); - return true; - } - - async deleteSession(session) { - if (!session.agentSessionId) return false; - await this.server.request( - `/session/${encodeURIComponent(session.agentSessionId)}`, - { method: 'DELETE' }, - ); - return true; - } - - async injectMessage(session, text, parts = []) { - if (!session.agentSessionId) return false; - const { providerId, modelId } = splitOpenCodeModel(session.modelId); - const messageParts = [ - ...(text.trim() ? [{ type: 'text', text }] : []), - ...parts.filter((part) => part && typeof part === 'object'), - ]; - await this.server.request( - `/session/${encodeURIComponent(session.agentSessionId)}/message`, - { - method: 'POST', - body: { - providerID: providerId, - modelID: modelId, - directory: session.directory, - mode: (session.raw && session.raw.permissionMode) || process.env.OPENCODE_MODE || 'build', - parts: messageParts, - }, - }, - ); - return true; - } - - async runNative({ session, prompt, parts = [], onEvent, onExit }) { - if (!session.agentSessionId) return null; - - const abortController = new AbortController(); - let settled = false; - let sent = false; - let completionTimer = null; - const finish = (result) => { - if (settled) return; - settled = true; - clearTimeout(completionTimer); - abortController.abort(); - onExit(result); - }; - const markCompletedSoon = (result = { exitCode: 0 }) => { - clearTimeout(completionTimer); - completionTimer = setTimeout(() => finish(result), 250); - }; - - const stream = this.server.openEventStream({ - signal: abortController.signal, - onEvent: (raw, eventName) => { - const event = normalizeOpenCodeEvent(raw, eventName); - if (!event) return; - const remoteSessionId = openCodeEventSessionId(raw); - if (remoteSessionId && remoteSessionId !== session.agentSessionId) return; - onEvent(event); - const terminal = openCodeTerminalResult(raw); - if (terminal) markCompletedSoon(terminal); - }, - }); - await stream.opened; - - const { providerId, modelId } = splitOpenCodeModel(session.modelId); - const messageParts = [ - ...(prompt.trim() ? [{ type: 'text', text: prompt }] : []), - ...parts.filter((part) => part && typeof part === 'object'), - ]; - sent = true; - this.server - .request(`/session/${encodeURIComponent(session.agentSessionId)}/message`, { - method: 'POST', - body: { - providerID: providerId, - modelID: modelId, - directory: session.directory, - mode: (session.raw && session.raw.permissionMode) || process.env.OPENCODE_MODE || 'build', - parts: messageParts, - }, - signal: abortController.signal, - }) - .then(() => {}) - .catch((error) => { - if (!abortController.signal.aborted) { - finish({ exitCode: -1, error: error.message }); - } - }); - stream.done.catch((error) => { - if (!abortController.signal.aborted && sent) { - finish({ exitCode: -1, error: error.message }); - } - }); - - return { - pid: null, - abort: () => { - this.abort(session).catch(() => {}); - finish({ exitCode: -1, error: 'aborted' }); - }, - }; - } - - run({ session, prompt, onEvent, onText, onAgentSessionId, onExit }) { - const args = ['run', '--format', 'json', '--dir', session.directory]; - if (session.modelId) args.push('--model', session.modelId); - if (session.agentSessionId) args.push('--session', session.agentSessionId); - args.push(prompt); - - const extraEnv = this._buildProfileEnv(session.raw?.profileId); - - return runJsonCli({ - command: this.command, - args, - cwd: session.directory, - env: extraEnv, - stdin: null, - agentId: this.id, - onEvent, - onText, - onAgentSessionId, - onExit, - }); - } - - _buildProfileEnv(profileId) { - const extraEnv = {}; - const anthropicKey = this.profileStore?.getKeyForProviderById(profileId, 'anthropic'); - if (anthropicKey?.key) { - extraEnv.ANTHROPIC_API_KEY = anthropicKey.key; - if (anthropicKey.baseUrl) extraEnv.ANTHROPIC_BASE_URL = anthropicKey.baseUrl; - } - const openaiKey = this.profileStore?.getKeyForProviderById(profileId, 'openai'); - if (openaiKey?.key) { - extraEnv.OPENAI_API_KEY = openaiKey.key; - if (openaiKey.baseUrl) extraEnv.OPENAI_BASE_URL = openaiKey.baseUrl; - } - const googleKey = this.profileStore?.getKeyForProviderById(profileId, 'google'); - if (googleKey?.key) { - extraEnv.GOOGLE_API_KEY = googleKey.key; - if (googleKey.baseUrl) extraEnv.GOOGLE_BASE_URL = googleKey.baseUrl; - } - return extraEnv; - } - - close() { - this.server.close?.(); - } -} - -function providerModels(payload) { - const all = Array.isArray(payload?.all) ? payload.all : []; - const configured = []; - const unconfigured = []; - for (const provider of all) { - if (!provider || typeof provider !== 'object') continue; - const providerId = provider.id || provider.providerID; - if (!providerId) continue; - const providerName = provider.name || providerId; - const models = provider.models || {}; - // A provider is "configured" if it has an API key or env set - const isConfigured = Boolean( - provider.configured || provider.apiKey || provider.api_key || provider.env, - ); - const target = isConfigured ? configured : unconfigured; - for (const [modelId, model] of Object.entries(models)) { - target.push({ - id: `${providerId}/${modelId}`, - displayName: `${providerName} / ${model?.name || modelId}`, - raw: compactOpenCodeModel(providerId, modelId, model), - }); - } - } - // Configured providers first, then unconfigured - return [...configured, ...unconfigured]; -} - -function compactOpenCodeModel(providerId, modelId, model) { - return { - providerID: providerId, - modelID: modelId, - name: model?.name, - toolCall: model?.tool_call, - attachment: model?.attachment, - reasoning: model?.reasoning, - limit: model?.limit, - }; -} - -function splitOpenCodeModel(value) { - const fallback = process.env.OPENCODE_DEFAULT_MODEL || 'opencode/big-pickle'; - const text = String(value || fallback); - const slash = text.indexOf('/'); - if (slash === -1) { - return { - providerId: process.env.OPENCODE_DEFAULT_PROVIDER || 'opencode', - modelId: text, - }; - } - return { - providerId: text.slice(0, slash), - modelId: text.slice(slash + 1), - }; -} - -function normalizeOpenCodeEvent(raw, eventName = 'message') { - if (!raw || typeof raw !== 'object') return null; - const type = raw.type || eventName; - const properties = raw.properties || raw.data || {}; - switch (type) { - case 'message.updated': { - const info = properties.info || raw.info || raw.message || null; - return { - type, - data: info ? { info } : properties, - raw, - }; - } - case 'message.part.updated': { - const part = properties.part || raw.part || null; - return { - type, - data: part ? { part } : properties, - raw, - }; - } - case 'message.part.delta': - return { - type, - data: { - sessionID: properties.sessionID || raw.sessionID, - messageID: properties.messageID || raw.messageID, - partID: properties.partID || raw.partID, - field: properties.field || raw.field || 'text', - delta: properties.delta ?? raw.delta ?? '', - }, - raw, - }; - case 'session.updated': - return { - type: 'status.updated', - data: { - status: 'running', - source: 'opencode', - eventType: type, - session: properties.info || properties.session || raw.session || properties, - }, - raw, - }; - case 'session.error': - return { - type, - data: { error: openCodeErrorMessage(raw) }, - raw, - }; - case 'session.idle': - return { - type: 'status.updated', - data: { - status: 'idle', - source: 'opencode', - eventType: type, - session: properties.info || properties.session || raw.session || properties, - }, - raw, - }; - default: - return { - type: 'command.updated', - data: { source: 'opencode', eventType: type, properties }, - raw, - }; - } -} - -function openCodeEventSessionId(raw) { - const properties = raw.properties || raw.data || {}; - return ( - properties.sessionID || - raw.sessionID || - properties.sessionId || - raw.sessionId || - properties.session?.id || - raw.session?.id || - properties.info?.sessionID || - raw.info?.sessionID || - raw.message?.sessionID || - properties.part?.sessionID || - raw.part?.sessionID || - (String(raw.type || '').startsWith('session.') ? properties.info?.id : null) || - null - ); -} - -function openCodeTerminalResult(raw) { - const type = raw?.type; - const properties = raw?.properties || raw?.data || {}; - const info = properties.info || raw?.info || raw?.message || {}; - if (type === 'session.error') { - return { exitCode: -1, error: openCodeErrorMessage(raw) }; - } - if (info.status === 'error') { - return { exitCode: -1, error: openCodeErrorMessage(raw) }; - } - if (type === 'session.idle' || type === 'session.completed') { - return { exitCode: 0 }; - } - if (info.role === 'assistant' && info.status === 'completed') { - return { exitCode: 0 }; - } - return null; -} - -function openCodeErrorMessage(raw) { - const properties = raw?.properties || raw?.data || {}; - const error = properties.error || raw?.error || {}; - return error.message || raw?.message || 'OpenCode error'; -} - -function runJsonCli({ - command, - args, - cwd, - env, - stdin, - keepStdinOpen = false, - agentId, - onEvent, - onText, - onToolCall, - onUsage, - onAgentSessionId, - onExit, -}) { - let child; - try { - child = spawnCli(command, args, { cwd, env }); - } catch (error) { - onExit({ - exitCode: -1, - error: error.message, - }); - return { - pid: null, - abort() {}, - }; - } - const state = { - lastFullTextByKey: new Map(), - sawText: false, - stderrLines: [], - }; - readLines(child.stdout, (line) => { - const raw = parseJsonLine(line); - if (!raw) { - onText(line.endsWith('\n') ? line : `${line}\n`); - return; - } - const eventType = raw.type || raw.event || 'cli.event'; - onEvent({ - type: 'command.updated', - data: { stream: 'stdout', eventType }, - raw, - }); - const agentSessionId = extractAgentSessionId(raw); - if (agentSessionId) onAgentSessionId(agentSessionId, raw); - const delta = extractTextDelta(raw, state); - if (delta) { - state.sawText = true; - onText(delta); - } - if (onToolCall) { - const toolCall = extractToolCall(raw); - if (toolCall) onToolCall(toolCall); - } - if (onUsage) { - const usage = extractUsage(raw); - if (usage) onUsage(usage); - } - }); - readLines(child.stderr, (line) => { - state.stderrLines.push(line); - if (state.stderrLines.length > 80) state.stderrLines.shift(); - onEvent({ - type: 'command.updated', - data: { stream: 'stderr', text: line }, - raw: { line }, - }); - }); - if (stdin !== null && stdin !== undefined) { - child.stdin.write(stdin + '\n'); - } - // Close stdin unless the adapter wants to keep it open for later injection - // (e.g. Codex which reads more lines from stdin as the user types). - // Otherwise CLIs like Claude/OpenCode wait for EOF and emit - // 'no stdin data received in 3s' warnings. - if (!keepStdinOpen && child.stdin.writable) { - child.stdin.end(); - } - let settled = false; - const finish = (result) => { - if (settled) return; - settled = true; - onExit(result); - }; - child.on('error', (error) => { - finish({ - exitCode: -1, - error: error.message, - }); - }); - child.on('close', (exitCode) => { - const stderr = state.stderrLines.join('\n').trim(); - finish({ - exitCode, - error: exitCode === 0 ? null : stderr || `agent exited with code ${exitCode}`, - }); - }); - return { - pid: child.pid, - write(text) { - if (!settled && child.stdin.writable) { - child.stdin.write(text + '\n'); - return true; - } - return false; - }, - abort() { - killProcessTree(child); - }, - }; -} - -function extractTextDelta(raw, state) { - if (typeof raw.delta === 'string') { - rememberEmittedText(raw.delta, state); - return raw.delta; - } - if (typeof raw.text_delta === 'string') { - rememberEmittedText(raw.text_delta, state); - return raw.text_delta; - } - if (typeof raw.content_delta === 'string') { - rememberEmittedText(raw.content_delta, state); - return raw.content_delta; - } - - const properties = raw.properties || raw.data || {}; - const part = properties.part || raw.part; - if (part && typeof part.text === 'string') { - return suffixDelta(`part:${part.id || raw.type || 'text'}`, part.text, state); - } - - if (raw.type === 'assistant' && raw.message) { - const text = contentArrayText(raw.message.content); - if (text) return suffixDelta('assistant', text, state); - } - - if (raw.item && raw.item.role === 'assistant') { - const text = contentArrayText(raw.item.content); - if (text) return suffixDelta(`item:${raw.item.id || raw.type || 'assistant'}`, text, state); - } - - if (raw.item && typeof raw.item.text === 'string' && raw.item.text) { - return suffixDelta(`item:${raw.item.id || raw.type || 'agent_message'}`, raw.item.text, state); - } - - if (raw.message && raw.message.role === 'assistant') { - const text = - typeof raw.message.content === 'string' - ? raw.message.content - : contentArrayText(raw.message.content); - if (text) return suffixDelta(`message:${raw.message.id || raw.type || 'assistant'}`, text, state); - } - - if (raw.role === 'assistant') { - const text = - typeof raw.content === 'string' ? raw.content : contentArrayText(raw.content); - if (text) return suffixDelta(`assistant:${raw.id || raw.type || 'content'}`, text, state); - } - - if (!state.sawText && typeof raw.result === 'string') return raw.result; - return ''; -} - -function rememberEmittedText(delta, state) { - const previous = state.lastFullTextByKey.get('assistant') || ''; - state.lastFullTextByKey.set('assistant', previous + delta); -} - -function suffixDelta(key, fullText, state) { - const previous = state.lastFullTextByKey.get(key) || ''; - state.lastFullTextByKey.set(key, fullText); - if (!previous) return fullText; - if (fullText.startsWith(previous)) return fullText.slice(previous.length); - // Non-prefix case: agent sent reformatted/reset text. Skip to avoid - // emitting the entire text as a delta (which would duplicate content). - return ''; -} - -function contentArrayText(content) { - if (typeof content === 'string') return content; - if (!Array.isArray(content)) return ''; - return content - .map((item) => { - if (typeof item === 'string') return item; - if (!item || typeof item !== 'object') return ''; - if (typeof item.text === 'string') return item.text; - if (typeof item.content === 'string') return item.content; - return ''; - }) - .join(''); -} - -/** - * Extract tool call info from agent JSON events. - * - * Codex: { type: 'function_call', name: '...', arguments: '...' } - * or item.content[].type === 'function_call' - * Claude: { type: 'tool_use', name: '...', input: { ... } } - * or content[].type === 'tool_use' - * OpenCode: handled natively via SSE part events. - */ -function extractToolCall(raw) { - // Codex function_call at top level - if (raw.type === 'function_call' && raw.name) { - return { - name: raw.name, - input: tryParseJson(raw.arguments) || raw.arguments || '', - status: raw.status || 'running', - callId: raw.call_id, - }; - } - // Codex function_call_output - if (raw.type === 'function_call_output') { - return { - name: raw.name || 'function_call', - output: raw.output, - status: 'completed', - callId: raw.call_id, - }; - } - // Claude tool_use in content array - if (raw.type === 'content_block_start' && raw.content_block?.type === 'tool_use') { - return { - name: raw.content_block.name, - input: '', - status: 'running', - toolUseId: raw.content_block.id, - }; - } - if (raw.type === 'tool_use' && raw.name) { - return { - name: raw.name, - input: raw.input || {}, - status: 'running', - toolUseId: raw.id, - }; - } - if (raw.type === 'tool_result') { - return { - name: raw.name || 'tool', - output: raw.content, - status: raw.is_error ? 'error' : 'completed', - toolUseId: raw.tool_use_id, - }; - } - // Codex item-level tool calls - if (raw.item && Array.isArray(raw.item.content)) { - for (const block of raw.item.content) { - if (block.type === 'function_call' && block.name) { - return { - name: block.name, - input: block.arguments || '', - status: block.status || 'completed', - callId: block.call_id, - }; - } - } - } - return null; -} - -/** - * Extract token usage info from agent JSON events. - * Returns { inputTokens, outputTokens, totalTokens } or null. - */ -function extractUsage(raw) { - // OpenAI / Codex: { usage: { input_tokens, output_tokens, total_tokens } } - const usage = raw.usage || raw.token_usage; - if (usage && typeof usage === 'object') { - const input = usage.input_tokens || usage.prompt_tokens || 0; - const output = usage.output_tokens || usage.completion_tokens || 0; - const total = usage.total_tokens || input + output; - if (total > 0) return { inputTokens: input, outputTokens: output, totalTokens: total }; - } - // Claude: { message: { usage: ... } } - if (raw.message?.usage) { - const u = raw.message.usage; - const input = u.input_tokens || 0; - const output = u.output_tokens || 0; - return { inputTokens: input, outputTokens: output, totalTokens: input + output }; - } - // response.completed with usage at top level - if (raw.type === 'response.completed' && raw.response?.usage) { - const u = raw.response.usage; - const input = u.input_tokens || u.prompt_tokens || 0; - const output = u.output_tokens || u.completion_tokens || 0; - return { inputTokens: input, outputTokens: output, totalTokens: input + output }; - } - return null; -} - -function extractAgentSessionId(raw) { - if (raw.thread_id) return raw.thread_id; - if (raw.threadId) return raw.threadId; - if (raw.session_id) return raw.session_id; - if (raw.sessionId) return raw.sessionId; - if (raw.conversation_id) return raw.conversation_id; - if (raw.conversationId) return raw.conversationId; - if (raw.id && /session|thread|conversation/.test(String(raw.type || ''))) { - return raw.id; - } - return null; -} - -function parseJsonLine(line) { - try { - const parsed = JSON.parse(line); - return parsed && typeof parsed === 'object' ? parsed : null; - } catch (_) { - return null; - } -} - -function tryParseJson(value) { - if (typeof value !== 'string') return null; - try { - const parsed = JSON.parse(value); - return parsed && typeof parsed === 'object' ? parsed : null; - } catch (_) { - return null; - } -} - -function commands(items) { - const seen = new Set(); - const out = []; - for (const item of items) { - const name = typeof item === 'string' ? item : item.name; - if (!name) continue; - const normalized = name.startsWith('/') || name.startsWith('$') ? name : `/${name}`; - if (seen.has(normalized)) continue; - seen.add(normalized); - out.push({ - name: normalized, - description: typeof item === 'object' ? item.description || '' : '', - }); - } - return out; -} - -async function markdownCommands(directory) { - if (!directory) return []; - const entries = await fs.readdir(directory, { withFileTypes: true }).catch(() => []); - const out = []; - for (const entry of entries) { - if (entry.isDirectory()) { - const nested = await markdownCommands(path.join(directory, entry.name)); - out.push(...nested.map((command) => `${entry.name}:${command}`)); - continue; - } - if (!entry.name.endsWith('.md')) continue; - out.push(entry.name.slice(0, -3)); - } - return out; -} - -async function opencodeJsonCommands(projectDirectory) { - if (!projectDirectory) return []; - const file = path.join(projectDirectory, 'opencode.json'); - try { - const parsed = JSON.parse(await fs.readFile(file, 'utf8')); - if (Array.isArray(parsed.commands)) { - return parsed.commands.map((command) => - typeof command === 'string' ? command : command.name || command.id || '', - ); - } - if (parsed.commands && typeof parsed.commands === 'object') { - return Object.keys(parsed.commands); - } - } catch (_) { - // Ignore invalid or missing project config. - } - return []; -} - -function publicCommand(command) { - return { - command: command.command, - prefixArgs: command.prefixArgs || [], - shell: Boolean(command.shell), - }; -} - -function compactCodexModel(model) { - return { - id: model.slug, - description: model.description, - defaultReasoningLevel: model.default_reasoning_level, - supportedReasoningLevels: model.supported_reasoning_levels, - additionalSpeedTiers: model.additional_speed_tiers, - serviceTiers: model.service_tiers, - }; -} - -module.exports = { - AgentRegistry, - buildCodexArgs, - OpenCodeAdapter, - normalizeOpenCodeEvent, - runJsonCli, -}; +module.exports = require('./agents/index'); diff --git a/gateway/src/agents/claude_code.js b/gateway/src/agents/claude_code.js new file mode 100644 index 0000000..0c430cd --- /dev/null +++ b/gateway/src/agents/claude_code.js @@ -0,0 +1,221 @@ +'use strict'; + +const os = require('node:os'); +const path = require('node:path'); + +const { + commandExists, + resolveClaudeCommand, +} = require('../cli'); +const { cachedModels } = require('./model_cache'); +const { commands, markdownCommands, publicCommand } = require('./command_helpers'); +const { runJsonCli } = require('./json_cli'); + +const CLAUDE_COMMANDS = [ + { name: '/mcp', description: 'Show MCP server status' }, + { name: '/model', description: 'Switch model' }, + { name: '/compact', description: 'Compress this thread context' }, + { name: '/review', description: 'Code review' }, + { name: '/memory', description: 'View or edit memory' }, + { name: '/status', description: 'Show session ID, context usage, and rate limits' }, + { name: '/permissions', description: 'Manage permissions' }, + { name: '/agents', description: 'Show available agents' }, + { name: '/bug', description: 'Report a bug' }, + { name: '/clear', description: 'Clear conversation' }, + { name: '/config', description: 'Show or edit config' }, + { name: '/cost', description: 'Show token usage and cost' }, + { name: '/doctor', description: 'Diagnose setup issues' }, + { name: '/help', description: 'Show help' }, + { name: '/init', description: 'Initialize project config' }, + { name: '/login', description: 'Log in' }, + { name: '/logout', description: 'Log out' }, + { name: '/pr_comments', description: 'Load PR comments' }, + { name: '/add-dir', description: 'Add a directory to context' }, + { name: '/terminal-setup', description: 'Setup terminal integration' }, + { name: '/vim', description: 'Toggle vim mode' }, +]; + +class ClaudeCodeAdapter { + constructor({ profileStore } = {}) { + this.id = 'claude-code'; + this.displayName = 'Claude Code'; + this.command = resolveClaudeCommand(); + this.profileStore = profileStore || null; + } + + async metadata(projectDirectory) { + return { + id: this.id, + displayName: this.displayName, + supportsModels: true, + supportsSlashCommands: true, + supportsAttachments: true, + supportsPermissions: true, + sessionKind: 'thread', + commands: await this.commands(projectDirectory), + raw: { + available: commandExists(this.command), + command: publicCommand(this.command), + projectDirectory, + }, + }; + } + + models() { + return cachedModels("claude-code", () => this._fetchModels()); + } + + async _fetchModels() { + const envModels = (process.env.CLAUDE_CODE_MODELS || '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + if (envModels.length > 0) { + return envModels.map((id) => ({ id, displayName: id, raw: { id } })); + } + + const profileKey = this.profileStore?.getKeyForProvider('anthropic'); + const apiKey = profileKey?.key || null; + const baseUrl = profileKey?.baseUrl || null; + if (apiKey) { + try { + const url = (baseUrl || 'https://api.anthropic.com').replace(/\/+$/, ''); + const res = await fetch(`${url}/v1/models`, { + headers: { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + signal: AbortSignal.timeout(8000), + }); + if (res.ok) { + const body = await res.json(); + const models = (body.data || []) + .filter((m) => m.id && /claude/i.test(m.id)) + .sort((a, b) => { + const ca = a.created_at || ''; + const cb = b.created_at || ''; + return cb.localeCompare(ca); + }) + .map((m) => ({ + id: m.id, + displayName: m.display_name || m.id, + raw: m, + })); + if (models.length > 0) return models; + } + } catch (err) { + console.warn(`[claude-code] Failed to fetch models from API: ${err.message}`); + } + } + + const defaults = [ + 'claude-sonnet-4-20250514', + 'claude-opus-4-20250514', + 'claude-3-7-sonnet-20250219', + 'claude-3-5-sonnet-20241022', + 'claude-3-5-haiku-20241022', + ]; + return defaults.map((id) => ({ id, displayName: id, raw: { id } })); + } + + async commands(projectDirectory) { + return commands([ + ...CLAUDE_COMMANDS, + ...(await markdownCommands(path.join(projectDirectory || '', '.claude', 'commands'))), + ...(await markdownCommands(path.join(os.homedir(), '.claude', 'commands'))), + ]); + } + + run({ session, prompt, onEvent, onText, onAgentSessionId, onExit }) { + const isSlashCommand = prompt.trim().startsWith('/'); + const withResume = Boolean(session.agentSessionId); + if (isSlashCommand && !withResume) { + onEvent({ + type: 'command.updated', + data: { source: 'claude-code', eventType: 'slash-command', command: prompt.trim() }, + raw: { command: prompt.trim(), hasSession: false }, + }); + } + return this._runOnce({ + session, + prompt, + withResume, + onEvent, + onText, + onAgentSessionId, + onExit, + }); + } + + _runOnce({ session, prompt, withResume, onEvent, onText, onAgentSessionId, onExit }) { + const args = [ + '-p', + '--output-format', + 'stream-json', + '--verbose', + '--include-partial-messages', + ]; + const permissionMode = (session.raw && session.raw.permissionMode) || + process.env.CLAUDE_CODE_PERMISSION_MODE || + 'acceptEdits'; + args.push('--permission-mode', permissionMode); + if (session.modelId) args.push('--model', session.modelId); + if (withResume && session.agentSessionId) { + args.push('--resume', session.agentSessionId); + } + + const profileKey = this.profileStore?.getKeyForProviderById( + session.raw?.profileId, 'anthropic'); + const extraEnv = {}; + if (profileKey?.key) { + extraEnv.ANTHROPIC_API_KEY = profileKey.key; + if (profileKey.baseUrl) extraEnv.ANTHROPIC_BASE_URL = profileKey.baseUrl; + } + + let retried = false; + const handle = {}; + const wrappedExit = (result) => { + const stale = withResume && + !retried && + typeof result.error === 'string' && + /no conversation found/i.test(result.error); + if (stale) { + retried = true; + console.log(`[claude] stale resume id ${session.agentSessionId} - retrying fresh`); + session.agentSessionId = null; + const retryHandle = this._runOnce({ + session, + prompt, + withResume: false, + onEvent, + onText, + onAgentSessionId, + onExit, + }); + Object.assign(handle, retryHandle); + return; + } + onExit(result); + }; + + const inner = runJsonCli({ + command: this.command, + args, + cwd: session.directory, + env: extraEnv, + stdin: prompt, + agentId: this.id, + onEvent, + onText, + onAgentSessionId, + onExit: wrappedExit, + }); + Object.assign(handle, inner); + return handle; + } +} + +module.exports = { + ClaudeCodeAdapter, + CLAUDE_COMMANDS, +}; diff --git a/gateway/src/agents/codex.js b/gateway/src/agents/codex.js new file mode 100644 index 0000000..6ab9a82 --- /dev/null +++ b/gateway/src/agents/codex.js @@ -0,0 +1,177 @@ +'use strict'; + +const { + commandExists, + resolveCodexCommand, + runCapture, +} = require('../cli'); +const { cachedModels } = require('./model_cache'); +const { commands, publicCommand } = require('./command_helpers'); +const { runJsonCli } = require('./json_cli'); + +const CODEX_COMMANDS = [ + { name: '/mcp', description: 'Show MCP server status' }, + { name: '/personality', description: 'Set personality' }, + { name: '/review', description: 'Code review' }, + { name: '/side', description: 'Start a side conversation in a temporary branch' }, + { name: '/compact', description: 'Compress this thread context' }, + { name: '/feedback', description: 'Submit feedback' }, + { name: '/model', description: 'Switch model' }, + { name: '/fast', description: 'Switch to fast model' }, + { name: '/plan', description: 'Plan a goal' }, + { name: '/goal', description: 'Set a goal for the session' }, + { name: '/fork', description: 'Fork to local branch or new worktree' }, + { name: '/status', description: 'Show session ID, context usage, and rate limits' }, + { name: '/permissions', description: 'Manage sandbox permissions' }, + { name: '/sandbox-add-read-dir', description: 'Add a read-only directory to sandbox' }, + { name: '/ide', description: 'IDE integration settings' }, + { name: '/keymap', description: 'Switch keymap' }, + { name: '/vim', description: 'Toggle vim mode' }, + { name: '/agent', description: 'Manage agents' }, + { name: '/apps', description: 'Manage apps' }, + { name: '/plugins', description: 'Manage plugins' }, + { name: '/hooks', description: 'Manage hooks' }, + { name: '/clear', description: 'Clear screen' }, + { name: '/copy', description: 'Copy last response' }, + { name: '/diff', description: 'Show diff of changes' }, + { name: '/experimental', description: 'Toggle experimental features' }, + { name: '/approve', description: 'Approve pending actions' }, + { name: '/memories', description: 'View or manage memories' }, + { name: '/skills', description: 'View learned skills' }, + { name: '/init', description: 'Initialize project config' }, + { name: '/logout', description: 'Log out' }, + { name: '/mention', description: 'Mention a file or symbol' }, + { name: '/ps', description: 'Show running processes' }, + { name: '/stop', description: 'Stop running process' }, + { name: '/raw', description: 'Send raw prompt' }, + { name: '/debug-config', description: 'Show debug config' }, + { name: '/exit', description: 'Exit session' }, + { name: '/quit', description: 'Quit session' }, + { name: '$', description: 'Run a shell command' }, +]; + +class CodexAdapter { + constructor({ profileStore } = {}) { + this.id = 'codex'; + this.displayName = 'Codex'; + this.command = resolveCodexCommand(); + this.profileStore = profileStore || null; + } + + async metadata(projectDirectory) { + return { + id: this.id, + displayName: this.displayName, + supportsModels: true, + supportsSlashCommands: true, + supportsAttachments: false, + supportsPermissions: true, + sessionKind: 'thread', + commands: commands(CODEX_COMMANDS), + raw: { + available: commandExists(this.command), + command: publicCommand(this.command), + projectDirectory, + }, + }; + } + + models() { + return cachedModels('codex', () => this._fetchModels()); + } + + async _fetchModels() { + const result = await runCapture(this.command, ['debug', 'models', '--bundled']); + if (result.exitCode === 0) { + try { + const parsed = JSON.parse(result.stdout); + const models = Array.isArray(parsed.models) ? parsed.models : []; + return models + .filter((model) => model.visibility !== 'hidden') + .map((model) => ({ + id: model.slug, + displayName: model.display_name || model.slug, + raw: compactCodexModel(model), + })); + } catch (_) { + // Fall through to static list. + } + } + return [ + 'gpt-5.5', + 'gpt-5.4', + 'gpt-5.4-mini', + 'gpt-5.3-codex', + 'gpt-5.2', + ].map((id) => ({ id, displayName: id, raw: { id } })); + } + + async commands() { + return commands(CODEX_COMMANDS); + } + + run({ session, prompt, onEvent, onText, onAgentSessionId, onExit }) { + const args = buildCodexArgs(session); + + const profileKey = this.profileStore?.getKeyForProviderById( + session.raw?.profileId, 'openai'); + const extraEnv = {}; + if (profileKey?.key) { + extraEnv.OPENAI_API_KEY = profileKey.key; + if (profileKey.baseUrl) extraEnv.OPENAI_BASE_URL = profileKey.baseUrl; + } + + // Codex `exec ... -` reads the prompt from stdin until EOF; keeping + // stdin open would block codex from starting work. + return runJsonCli({ + command: this.command, + args, + cwd: session.directory, + env: extraEnv, + stdin: prompt, + agentId: this.id, + onEvent, + onText, + onAgentSessionId, + onExit, + }); + } +} + +function buildCodexArgs(session) { + const sandbox = (session.raw && session.raw.sandbox) || process.env.CODEX_SANDBOX || 'workspace-write'; + const args = session.agentSessionId + ? ['exec', 'resume', '--json', '--skip-git-repo-check'] + : [ + 'exec', + '--json', + '--color', + 'never', + '--cd', + session.directory, + '--sandbox', + sandbox, + '--skip-git-repo-check', + ]; + if (session.modelId) args.push('--model', session.modelId); + if (session.agentSessionId) args.push(session.agentSessionId); + args.push('-'); + return args; +} + +function compactCodexModel(model) { + return { + id: model.slug, + description: model.description, + defaultReasoningLevel: model.default_reasoning_level, + supportedReasoningLevels: model.supported_reasoning_levels, + additionalSpeedTiers: model.additional_speed_tiers, + serviceTiers: model.service_tiers, + }; +} + +module.exports = { + CodexAdapter, + CODEX_COMMANDS, + buildCodexArgs, +}; diff --git a/gateway/src/agents/command_helpers.js b/gateway/src/agents/command_helpers.js new file mode 100644 index 0000000..1bd8b7e --- /dev/null +++ b/gateway/src/agents/command_helpers.js @@ -0,0 +1,71 @@ +'use strict'; + +const fs = require('node:fs/promises'); +const path = require('node:path'); + +function commands(items) { + const seen = new Set(); + const out = []; + for (const item of items) { + const name = typeof item === 'string' ? item : item.name; + if (!name) continue; + const normalized = name.startsWith('/') || name.startsWith('$') ? name : `/${name}`; + if (seen.has(normalized)) continue; + seen.add(normalized); + out.push({ + name: normalized, + description: typeof item === 'object' ? item.description || '' : '', + }); + } + return out; +} + +async function markdownCommands(directory) { + if (!directory) return []; + const entries = await fs.readdir(directory, { withFileTypes: true }).catch(() => []); + const out = []; + for (const entry of entries) { + if (entry.isDirectory()) { + const nested = await markdownCommands(path.join(directory, entry.name)); + out.push(...nested.map((command) => `${entry.name}:${command}`)); + continue; + } + if (!entry.name.endsWith('.md')) continue; + out.push(entry.name.slice(0, -3)); + } + return out; +} + +async function opencodeJsonCommands(projectDirectory) { + if (!projectDirectory) return []; + const file = path.join(projectDirectory, 'opencode.json'); + try { + const parsed = JSON.parse(await fs.readFile(file, 'utf8')); + if (Array.isArray(parsed.commands)) { + return parsed.commands.map((command) => + typeof command === 'string' ? command : command.name || command.id || '', + ); + } + if (parsed.commands && typeof parsed.commands === 'object') { + return Object.keys(parsed.commands); + } + } catch (_) { + // Ignore invalid or missing project config. + } + return []; +} + +function publicCommand(command) { + return { + command: command.command, + prefixArgs: command.prefixArgs || [], + shell: Boolean(command.shell), + }; +} + +module.exports = { + commands, + markdownCommands, + opencodeJsonCommands, + publicCommand, +}; diff --git a/gateway/src/agents/index.js b/gateway/src/agents/index.js new file mode 100644 index 0000000..c2036fd --- /dev/null +++ b/gateway/src/agents/index.js @@ -0,0 +1,17 @@ +'use strict'; + +const { AgentRegistry } = require('./registry'); +const { CodexAdapter, buildCodexArgs } = require('./codex'); +const { ClaudeCodeAdapter } = require('./claude_code'); +const { OpenCodeAdapter, normalizeOpenCodeEvent } = require('./opencode'); +const { runJsonCli } = require('./json_cli'); + +module.exports = { + AgentRegistry, + CodexAdapter, + ClaudeCodeAdapter, + OpenCodeAdapter, + buildCodexArgs, + normalizeOpenCodeEvent, + runJsonCli, +}; diff --git a/gateway/src/agents/json_cli.js b/gateway/src/agents/json_cli.js new file mode 100644 index 0000000..9e88cdd --- /dev/null +++ b/gateway/src/agents/json_cli.js @@ -0,0 +1,343 @@ +'use strict'; + +const { + killProcessTree, + readLines, + spawnCli, +} = require('../cli'); + +function runJsonCli({ + command, + args, + cwd, + env, + stdin, + keepStdinOpen = false, + agentId, + onEvent, + onText, + onToolCall, + onUsage, + onAgentSessionId, + onExit, +}) { + let child; + try { + child = spawnCli(command, args, { cwd, env }); + } catch (error) { + onExit({ + exitCode: -1, + error: error.message, + }); + return { + pid: null, + abort() {}, + }; + } + const state = { + lastFullTextByKey: new Map(), + sawText: false, + stderrLines: [], + }; + readLines(child.stdout, (line) => { + const raw = parseJsonLine(line); + if (!raw) { + onText(line.endsWith('\n') ? line : `${line}\n`); + return; + } + const eventType = raw.type || raw.event || 'cli.event'; + onEvent({ + type: 'command.updated', + data: { stream: 'stdout', eventType }, + raw, + }); + const agentSessionId = extractAgentSessionId(raw); + if (agentSessionId) onAgentSessionId(agentSessionId, raw); + const delta = extractTextDelta(raw, state); + if (delta) { + state.sawText = true; + onText(delta); + } + if (onToolCall) { + const toolCall = extractToolCall(raw); + if (toolCall) onToolCall(toolCall); + } + if (onUsage) { + const usage = extractUsage(raw); + if (usage) onUsage(usage); + } + }); + readLines(child.stderr, (line) => { + state.stderrLines.push(line); + if (state.stderrLines.length > 80) state.stderrLines.shift(); + onEvent({ + type: 'command.updated', + data: { stream: 'stderr', text: line }, + raw: { line }, + }); + }); + if (stdin !== null && stdin !== undefined) { + child.stdin.write(stdin + '\n'); + } + // Close stdin unless the adapter wants to keep it open for later injection + // (e.g. Codex which reads more lines from stdin as the user types). + // Otherwise CLIs like Claude/OpenCode wait for EOF and emit + // 'no stdin data received in 3s' warnings. + if (!keepStdinOpen && child.stdin.writable) { + child.stdin.end(); + } + let settled = false; + const finish = (result) => { + if (settled) return; + settled = true; + onExit(result); + }; + child.on('error', (error) => { + finish({ + exitCode: -1, + error: error.message, + }); + }); + child.on('close', (exitCode) => { + const stderr = state.stderrLines.join('\n').trim(); + finish({ + exitCode, + error: exitCode === 0 ? null : stderr || `agent exited with code ${exitCode}`, + }); + }); + return { + pid: child.pid, + write(text) { + if (!settled && child.stdin.writable) { + child.stdin.write(text + '\n'); + return true; + } + return false; + }, + abort() { + killProcessTree(child); + }, + }; +} + +function extractTextDelta(raw, state) { + if (typeof raw.delta === 'string') { + rememberEmittedText(raw.delta, state); + return raw.delta; + } + if (typeof raw.text_delta === 'string') { + rememberEmittedText(raw.text_delta, state); + return raw.text_delta; + } + if (typeof raw.content_delta === 'string') { + rememberEmittedText(raw.content_delta, state); + return raw.content_delta; + } + + const properties = raw.properties || raw.data || {}; + const part = properties.part || raw.part; + if (part && typeof part.text === 'string') { + return suffixDelta(`part:${part.id || raw.type || 'text'}`, part.text, state); + } + + if (raw.type === 'assistant' && raw.message) { + const text = contentArrayText(raw.message.content); + if (text) return suffixDelta('assistant', text, state); + } + + if (raw.item && raw.item.role === 'assistant') { + const text = contentArrayText(raw.item.content); + if (text) return suffixDelta(`item:${raw.item.id || raw.type || 'assistant'}`, text, state); + } + + if (raw.item && typeof raw.item.text === 'string' && raw.item.text) { + return suffixDelta(`item:${raw.item.id || raw.type || 'agent_message'}`, raw.item.text, state); + } + + if (raw.message && raw.message.role === 'assistant') { + const text = + typeof raw.message.content === 'string' + ? raw.message.content + : contentArrayText(raw.message.content); + if (text) return suffixDelta(`message:${raw.message.id || raw.type || 'assistant'}`, text, state); + } + + if (raw.role === 'assistant') { + const text = + typeof raw.content === 'string' ? raw.content : contentArrayText(raw.content); + if (text) return suffixDelta(`assistant:${raw.id || raw.type || 'content'}`, text, state); + } + + if (!state.sawText && typeof raw.result === 'string') return raw.result; + return ''; +} + +function rememberEmittedText(delta, state) { + const previous = state.lastFullTextByKey.get('assistant') || ''; + state.lastFullTextByKey.set('assistant', previous + delta); +} + +function suffixDelta(key, fullText, state) { + const previous = state.lastFullTextByKey.get(key) || ''; + state.lastFullTextByKey.set(key, fullText); + if (!previous) return fullText; + if (fullText.startsWith(previous)) return fullText.slice(previous.length); + // Non-prefix case: agent sent reformatted/reset text. Skip to avoid + // emitting the entire text as a delta (which would duplicate content). + return ''; +} + +function contentArrayText(content) { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + return content + .map((item) => { + if (typeof item === 'string') return item; + if (!item || typeof item !== 'object') return ''; + if (typeof item.text === 'string') return item.text; + if (typeof item.content === 'string') return item.content; + return ''; + }) + .join(''); +} + +/** + * Extract tool call info from agent JSON events. + * + * Codex: { type: 'function_call', name: '...', arguments: '...' } + * or item.content[].type === 'function_call' + * Claude: { type: 'tool_use', name: '...', input: { ... } } + * or content[].type === 'tool_use' + * OpenCode: handled natively via SSE part events. + */ +function extractToolCall(raw) { + // Codex function_call at top level + if (raw.type === 'function_call' && raw.name) { + return { + name: raw.name, + input: tryParseJson(raw.arguments) || raw.arguments || '', + status: raw.status || 'running', + callId: raw.call_id, + }; + } + // Codex function_call_output + if (raw.type === 'function_call_output') { + return { + name: raw.name || 'function_call', + output: raw.output, + status: 'completed', + callId: raw.call_id, + }; + } + // Claude tool_use in content array + if (raw.type === 'content_block_start' && raw.content_block?.type === 'tool_use') { + return { + name: raw.content_block.name, + input: '', + status: 'running', + toolUseId: raw.content_block.id, + }; + } + if (raw.type === 'tool_use' && raw.name) { + return { + name: raw.name, + input: raw.input || {}, + status: 'running', + toolUseId: raw.id, + }; + } + if (raw.type === 'tool_result') { + return { + name: raw.name || 'tool', + output: raw.content, + status: raw.is_error ? 'error' : 'completed', + toolUseId: raw.tool_use_id, + }; + } + // Codex item-level tool calls + if (raw.item && Array.isArray(raw.item.content)) { + for (const block of raw.item.content) { + if (block.type === 'function_call' && block.name) { + return { + name: block.name, + input: block.arguments || '', + status: block.status || 'completed', + callId: block.call_id, + }; + } + } + } + return null; +} + +/** + * Extract token usage info from agent JSON events. + * Returns { inputTokens, outputTokens, totalTokens } or null. + */ +function extractUsage(raw) { + // OpenAI / Codex: { usage: { input_tokens, output_tokens, total_tokens } } + const usage = raw.usage || raw.token_usage; + if (usage && typeof usage === 'object') { + const input = usage.input_tokens || usage.prompt_tokens || 0; + const output = usage.output_tokens || usage.completion_tokens || 0; + const total = usage.total_tokens || input + output; + if (total > 0) return { inputTokens: input, outputTokens: output, totalTokens: total }; + } + // Claude: { message: { usage: ... } } + if (raw.message?.usage) { + const u = raw.message.usage; + const input = u.input_tokens || 0; + const output = u.output_tokens || 0; + return { inputTokens: input, outputTokens: output, totalTokens: input + output }; + } + // response.completed with usage at top level + if (raw.type === 'response.completed' && raw.response?.usage) { + const u = raw.response.usage; + const input = u.input_tokens || u.prompt_tokens || 0; + const output = u.output_tokens || u.completion_tokens || 0; + return { inputTokens: input, outputTokens: output, totalTokens: input + output }; + } + return null; +} + +function extractAgentSessionId(raw) { + if (raw.thread_id) return raw.thread_id; + if (raw.threadId) return raw.threadId; + if (raw.session_id) return raw.session_id; + if (raw.sessionId) return raw.sessionId; + if (raw.conversation_id) return raw.conversation_id; + if (raw.conversationId) return raw.conversationId; + if (raw.id && /session|thread|conversation/.test(String(raw.type || ''))) { + return raw.id; + } + return null; +} + +function parseJsonLine(line) { + try { + const parsed = JSON.parse(line); + return parsed && typeof parsed === 'object' ? parsed : null; + } catch (_) { + return null; + } +} + +function tryParseJson(value) { + if (typeof value !== 'string') return null; + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === 'object' ? parsed : null; + } catch (_) { + return null; + } +} + +module.exports = { + runJsonCli, + extractTextDelta, + extractToolCall, + extractUsage, + extractAgentSessionId, + parseJsonLine, + tryParseJson, +}; diff --git a/gateway/src/agents/model_cache.js b/gateway/src/agents/model_cache.js new file mode 100644 index 0000000..7b03f7d --- /dev/null +++ b/gateway/src/agents/model_cache.js @@ -0,0 +1,20 @@ +'use strict'; + +const MODEL_CACHE_TTL = 5 * 60 * 1000; +const modelCache = new Map(); + +function cachedModels(key, fetchFn) { + const entry = modelCache.get(key); + if (entry && Date.now() - entry.ts < MODEL_CACHE_TTL) return entry.promise; + const promise = fetchFn().then((models) => { + modelCache.set(key, { ts: Date.now(), promise: Promise.resolve(models) }); + return models; + }).catch((err) => { + modelCache.delete(key); + throw err; + }); + modelCache.set(key, { ts: Date.now(), promise }); + return promise; +} + +module.exports = { cachedModels, modelCache }; diff --git a/gateway/src/agents/opencode.js b/gateway/src/agents/opencode.js new file mode 100644 index 0000000..3751c89 --- /dev/null +++ b/gateway/src/agents/opencode.js @@ -0,0 +1,307 @@ +'use strict'; + +const path = require('node:path'); + +const { + commandExists, + resolveOpenCodeCommand, + runCapture, +} = require('../cli'); +const { OpenCodeServerManager } = require('../opencode_server'); +const { cachedModels } = require('./model_cache'); +const { commands, markdownCommands, opencodeJsonCommands, publicCommand } = require('./command_helpers'); +const { runJsonCli } = require('./json_cli'); +const { + providerModels, + splitOpenCodeModel, + normalizeOpenCodeEvent, + openCodeEventSessionId, + openCodeTerminalResult, +} = require('./opencode_helpers'); + +const OPENCODE_COMMANDS = [ + { name: '/models', description: 'Show or switch models' }, + { name: '/compact', description: 'Compress this thread context' }, + { name: '/summarize', description: 'Summarize conversation' }, + { name: '/help', description: 'Show help' }, + { name: '/new', description: 'Start a new session' }, + { name: '/clear', description: 'Clear conversation' }, + { name: '/sessions', description: 'List sessions' }, + { name: '/resume', description: 'Resume a session' }, + { name: '/continue', description: 'Continue last session' }, + { name: '/share', description: 'Share session' }, + { name: '/unshare', description: 'Unshare session' }, + { name: '/details', description: 'Show session details' }, + { name: '/editor', description: 'Open in editor' }, + { name: '/export', description: 'Export conversation' }, + { name: '/themes', description: 'Change theme' }, + { name: '/init', description: 'Initialize project config' }, + { name: '/undo', description: 'Undo last change' }, + { name: '/redo', description: 'Redo last change' }, + { name: '/exit', description: 'Exit session' }, + { name: '/quit', description: 'Quit session' }, + { name: '/q', description: 'Quit session' }, +]; + +class OpenCodeAdapter { + constructor({ command, server, profileStore } = {}) { + this.id = 'opencode'; + this.displayName = 'OpenCode'; + this.command = command || resolveOpenCodeCommand(); + this.profileStore = profileStore || null; + this._explicitServer = server || null; + this._server = null; + } + + get server() { + if (!this._server) { + this._server = this._explicitServer || new OpenCodeServerManager({ + command: this.command, + extraEnv: this._buildProfileEnv(), + }); + } + return this._server; + } + + async metadata(projectDirectory) { + return { + id: this.id, + displayName: this.displayName, + supportsModels: true, + supportsSlashCommands: true, + supportsAttachments: true, + supportsPermissions: true, + sessionKind: 'session', + commands: await this.commands(projectDirectory), + raw: { + available: commandExists(this.command) || Boolean(this.server.externalBaseUrl), + command: publicCommand(this.command), + serverUrl: this.server.baseUrl || this.server.externalBaseUrl || null, + projectDirectory, + }, + }; + } + + models() { + return cachedModels("opencode", () => this._fetchModels()); + } + + async _fetchModels() { + try { + const providers = await this.server.request('/provider'); + const models = providerModels(providers); + if (models.length > 0) return models; + } catch (_) { + // Fall back to the CLI's static model list when server mode is unavailable. + } + const result = await runCapture(this.command, ['models']); + if (result.exitCode === 0) { + return result.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => /^[^/\s]+\/[^/\s]+$/.test(line)) + .map((id) => ({ id, displayName: id, raw: { id } })); + } + return [{ id: 'opencode/big-pickle', displayName: 'opencode/big-pickle', raw: {} }]; + } + + async commands(projectDirectory) { + return commands([ + ...OPENCODE_COMMANDS, + ...(await markdownCommands(path.join(projectDirectory || '', '.opencode', 'commands'))), + ...(await opencodeJsonCommands(projectDirectory)), + ]); + } + + async createSession({ project, title }) { + const query = project?.directory + ? `?directory=${encodeURIComponent(project.directory)}` + : ''; + const raw = await this.server.request(`/session${query}`, { + method: 'POST', + body: {}, + }); + const agentSessionId = raw && typeof raw.id === 'string' ? raw.id : null; + if (!agentSessionId) throw new Error('OpenCode did not return a session id'); + return { + agentSessionId, + title: + typeof raw.title === 'string' && raw.title.trim() + ? raw.title + : title || 'OpenCode session', + raw, + }; + } + + async listMessages(session) { + if (!session.agentSessionId) return null; + return await this.server.request( + `/session/${encodeURIComponent(session.agentSessionId)}/message`, + ); + } + + async abort(session) { + if (!session.agentSessionId) return false; + await this.server.request( + `/session/${encodeURIComponent(session.agentSessionId)}/abort`, + { method: 'POST' }, + ); + return true; + } + + async deleteSession(session) { + if (!session.agentSessionId) return false; + await this.server.request( + `/session/${encodeURIComponent(session.agentSessionId)}`, + { method: 'DELETE' }, + ); + return true; + } + + async injectMessage(session, text, parts = []) { + if (!session.agentSessionId) return false; + const { providerId, modelId } = splitOpenCodeModel(session.modelId); + const messageParts = [ + ...(text.trim() ? [{ type: 'text', text }] : []), + ...parts.filter((part) => part && typeof part === 'object'), + ]; + await this.server.request( + `/session/${encodeURIComponent(session.agentSessionId)}/message`, + { + method: 'POST', + body: { + providerID: providerId, + modelID: modelId, + directory: session.directory, + mode: (session.raw && session.raw.permissionMode) || process.env.OPENCODE_MODE || 'build', + parts: messageParts, + }, + }, + ); + return true; + } + + async runNative({ session, prompt, parts = [], onEvent, onExit }) { + if (!session.agentSessionId) return null; + + const abortController = new AbortController(); + let settled = false; + let sent = false; + let completionTimer = null; + const finish = (result) => { + if (settled) return; + settled = true; + clearTimeout(completionTimer); + abortController.abort(); + onExit(result); + }; + const markCompletedSoon = (result = { exitCode: 0 }) => { + clearTimeout(completionTimer); + completionTimer = setTimeout(() => finish(result), 250); + }; + + const stream = this.server.openEventStream({ + signal: abortController.signal, + onEvent: (raw, eventName) => { + const event = normalizeOpenCodeEvent(raw, eventName); + if (!event) return; + const remoteSessionId = openCodeEventSessionId(raw); + if (remoteSessionId && remoteSessionId !== session.agentSessionId) return; + onEvent(event); + const terminal = openCodeTerminalResult(raw); + if (terminal) markCompletedSoon(terminal); + }, + }); + await stream.opened; + + const { providerId, modelId } = splitOpenCodeModel(session.modelId); + const messageParts = [ + ...(prompt.trim() ? [{ type: 'text', text: prompt }] : []), + ...parts.filter((part) => part && typeof part === 'object'), + ]; + sent = true; + this.server + .request(`/session/${encodeURIComponent(session.agentSessionId)}/message`, { + method: 'POST', + body: { + providerID: providerId, + modelID: modelId, + directory: session.directory, + mode: (session.raw && session.raw.permissionMode) || process.env.OPENCODE_MODE || 'build', + parts: messageParts, + }, + signal: abortController.signal, + }) + .then(() => {}) + .catch((error) => { + if (!abortController.signal.aborted) { + finish({ exitCode: -1, error: error.message }); + } + }); + stream.done.catch((error) => { + if (!abortController.signal.aborted && sent) { + finish({ exitCode: -1, error: error.message }); + } + }); + + return { + pid: null, + abort: () => { + this.abort(session).catch(() => {}); + finish({ exitCode: -1, error: 'aborted' }); + }, + }; + } + + run({ session, prompt, onEvent, onText, onAgentSessionId, onExit }) { + const args = ['run', '--format', 'json', '--dir', session.directory]; + if (session.modelId) args.push('--model', session.modelId); + if (session.agentSessionId) args.push('--session', session.agentSessionId); + args.push(prompt); + + const extraEnv = this._buildProfileEnv(session.raw?.profileId); + + return runJsonCli({ + command: this.command, + args, + cwd: session.directory, + env: extraEnv, + stdin: null, + agentId: this.id, + onEvent, + onText, + onAgentSessionId, + onExit, + }); + } + + _buildProfileEnv(profileId) { + const extraEnv = {}; + const anthropicKey = this.profileStore?.getKeyForProviderById(profileId, 'anthropic'); + if (anthropicKey?.key) { + extraEnv.ANTHROPIC_API_KEY = anthropicKey.key; + if (anthropicKey.baseUrl) extraEnv.ANTHROPIC_BASE_URL = anthropicKey.baseUrl; + } + const openaiKey = this.profileStore?.getKeyForProviderById(profileId, 'openai'); + if (openaiKey?.key) { + extraEnv.OPENAI_API_KEY = openaiKey.key; + if (openaiKey.baseUrl) extraEnv.OPENAI_BASE_URL = openaiKey.baseUrl; + } + const googleKey = this.profileStore?.getKeyForProviderById(profileId, 'google'); + if (googleKey?.key) { + extraEnv.GOOGLE_API_KEY = googleKey.key; + if (googleKey.baseUrl) extraEnv.GOOGLE_BASE_URL = googleKey.baseUrl; + } + return extraEnv; + } + + close() { + this.server.close?.(); + } +} + +module.exports = { + OpenCodeAdapter, + OPENCODE_COMMANDS, + normalizeOpenCodeEvent, +}; diff --git a/gateway/src/agents/opencode_helpers.js b/gateway/src/agents/opencode_helpers.js new file mode 100644 index 0000000..d5bff8a --- /dev/null +++ b/gateway/src/agents/opencode_helpers.js @@ -0,0 +1,178 @@ +'use strict'; + +function providerModels(payload) { + const all = Array.isArray(payload?.all) ? payload.all : []; + const configured = []; + const unconfigured = []; + for (const provider of all) { + if (!provider || typeof provider !== 'object') continue; + const providerId = provider.id || provider.providerID; + if (!providerId) continue; + const providerName = provider.name || providerId; + const models = provider.models || {}; + // A provider is "configured" if it has an API key or env set + const isConfigured = Boolean( + provider.configured || provider.apiKey || provider.api_key || provider.env, + ); + const target = isConfigured ? configured : unconfigured; + for (const [modelId, model] of Object.entries(models)) { + target.push({ + id: `${providerId}/${modelId}`, + displayName: `${providerName} / ${model?.name || modelId}`, + raw: compactOpenCodeModel(providerId, modelId, model), + }); + } + } + // Configured providers first, then unconfigured + return [...configured, ...unconfigured]; +} + +function compactOpenCodeModel(providerId, modelId, model) { + return { + providerID: providerId, + modelID: modelId, + name: model?.name, + toolCall: model?.tool_call, + attachment: model?.attachment, + reasoning: model?.reasoning, + limit: model?.limit, + }; +} + +function splitOpenCodeModel(value) { + const fallback = process.env.OPENCODE_DEFAULT_MODEL || 'opencode/big-pickle'; + const text = String(value || fallback); + const slash = text.indexOf('/'); + if (slash === -1) { + return { + providerId: process.env.OPENCODE_DEFAULT_PROVIDER || 'opencode', + modelId: text, + }; + } + return { + providerId: text.slice(0, slash), + modelId: text.slice(slash + 1), + }; +} + +function normalizeOpenCodeEvent(raw, eventName = 'message') { + if (!raw || typeof raw !== 'object') return null; + const type = raw.type || eventName; + const properties = raw.properties || raw.data || {}; + switch (type) { + case 'message.updated': { + const info = properties.info || raw.info || raw.message || null; + return { + type, + data: info ? { info } : properties, + raw, + }; + } + case 'message.part.updated': { + const part = properties.part || raw.part || null; + return { + type, + data: part ? { part } : properties, + raw, + }; + } + case 'message.part.delta': + return { + type, + data: { + sessionID: properties.sessionID || raw.sessionID, + messageID: properties.messageID || raw.messageID, + partID: properties.partID || raw.partID, + field: properties.field || raw.field || 'text', + delta: properties.delta ?? raw.delta ?? '', + }, + raw, + }; + case 'session.updated': + return { + type: 'status.updated', + data: { + status: 'running', + source: 'opencode', + eventType: type, + session: properties.info || properties.session || raw.session || properties, + }, + raw, + }; + case 'session.error': + return { + type, + data: { error: openCodeErrorMessage(raw) }, + raw, + }; + case 'session.idle': + return { + type: 'status.updated', + data: { + status: 'idle', + source: 'opencode', + eventType: type, + session: properties.info || properties.session || raw.session || properties, + }, + raw, + }; + default: + return { + type: 'command.updated', + data: { source: 'opencode', eventType: type, properties }, + raw, + }; + } +} + +function openCodeEventSessionId(raw) { + const properties = raw.properties || raw.data || {}; + return ( + properties.sessionID || + raw.sessionID || + properties.sessionId || + raw.sessionId || + properties.session?.id || + raw.session?.id || + properties.info?.sessionID || + raw.info?.sessionID || + raw.message?.sessionID || + properties.part?.sessionID || + raw.part?.sessionID || + (String(raw.type || '').startsWith('session.') ? properties.info?.id : null) || + null + ); +} + +function openCodeTerminalResult(raw) { + const type = raw?.type; + const properties = raw?.properties || raw?.data || {}; + const info = properties.info || raw?.info || raw?.message || {}; + if (type === 'session.error') { + return { exitCode: -1, error: openCodeErrorMessage(raw) }; + } + if (info.status === 'error') { + return { exitCode: -1, error: openCodeErrorMessage(raw) }; + } + if (type === 'session.idle' || type === 'session.completed') { + return { exitCode: 0 }; + } + if (info.role === 'assistant' && info.status === 'completed') { + return { exitCode: 0 }; + } + return null; +} + +function openCodeErrorMessage(raw) { + const properties = raw?.properties || raw?.data || {}; + const error = properties.error || raw?.error || {}; + return error.message || raw?.message || 'OpenCode error'; +} + +module.exports = { + providerModels, + splitOpenCodeModel, + normalizeOpenCodeEvent, + openCodeEventSessionId, + openCodeTerminalResult, +}; diff --git a/gateway/src/agents/registry.js b/gateway/src/agents/registry.js new file mode 100644 index 0000000..282f984 --- /dev/null +++ b/gateway/src/agents/registry.js @@ -0,0 +1,36 @@ +'use strict'; + +const { CodexAdapter } = require('./codex'); +const { ClaudeCodeAdapter } = require('./claude_code'); +const { OpenCodeAdapter } = require('./opencode'); + +class AgentRegistry { + constructor({ openCodeServer, profileStore } = {}) { + this.profileStore = profileStore || null; + this.adapters = new Map( + [ + new CodexAdapter({ profileStore }), + new ClaudeCodeAdapter({ profileStore }), + new OpenCodeAdapter({ server: openCodeServer, profileStore }), + ].map((adapter) => [adapter.id, adapter]), + ); + } + + get(agentId) { + return this.adapters.get(agentId) || null; + } + + async list(projectDirectory) { + return Promise.all( + [...this.adapters.values()].map((adapter) => adapter.metadata(projectDirectory)), + ); + } + + close() { + for (const adapter of this.adapters.values()) { + adapter.close?.(); + } + } +} + +module.exports = { AgentRegistry }; diff --git a/gateway/test/agents_split.test.js b/gateway/test/agents_split.test.js new file mode 100644 index 0000000..a26b081 --- /dev/null +++ b/gateway/test/agents_split.test.js @@ -0,0 +1,58 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +test('agent facade exports registry and adapter utilities', () => { + const agents = require('../src/agents'); + + assert.equal(typeof agents.AgentRegistry, 'function'); + assert.equal(typeof agents.CodexAdapter, 'function'); + assert.equal(typeof agents.ClaudeCodeAdapter, 'function'); + assert.equal(typeof agents.OpenCodeAdapter, 'function'); + assert.equal(typeof agents.buildCodexArgs, 'function'); + assert.equal(typeof agents.normalizeOpenCodeEvent, 'function'); + assert.equal(typeof agents.runJsonCli, 'function'); +}); + +test('each agent adapter is importable from its dedicated file', () => { + const { CodexAdapter, buildCodexArgs } = require('../src/agents/codex'); + const { ClaudeCodeAdapter } = require('../src/agents/claude_code'); + const { OpenCodeAdapter } = require('../src/agents/opencode'); + + assert.equal(new CodexAdapter().id, 'codex'); + assert.equal(new ClaudeCodeAdapter().id, 'claude-code'); + assert.equal(new OpenCodeAdapter({ + server: { + externalBaseUrl: 'http://127.0.0.1:1234', + baseUrl: null, + request() { + throw new Error('not used'); + }, + close() {}, + }, + }).id, 'opencode'); + + assert.deepEqual( + buildCodexArgs({ + directory: 'D:\\Code\\WorkSpace\\remote-multi-agent', + modelId: 'gpt-5.3-codex', + agentSessionId: null, + raw: { sandbox: 'workspace-write' }, + }), + [ + 'exec', + '--json', + '--color', + 'never', + '--cd', + 'D:\\Code\\WorkSpace\\remote-multi-agent', + '--sandbox', + 'workspace-write', + '--skip-git-repo-check', + '--model', + 'gpt-5.3-codex', + '-', + ], + ); +}); diff --git a/lib/api/sse_stream.dart b/lib/api/sse_stream.dart index 99c1a4f..64aec9e 100644 --- a/lib/api/sse_stream.dart +++ b/lib/api/sse_stream.dart @@ -1,8 +1,7 @@ /// Minimal Server-Sent Events client using dart:io HttpClient for true /// chunked streaming on native iOS. /// -/// dart:io does NOT exist on Flutter Web — that's fine, the app uses a -/// polling fallback in chat_store.dart for web builds. +/// This native client relies on dart:io and is used by the mobile app. // ignore_for_file: depend_on_referenced_packages library; diff --git a/lib/state/gateway_client_provider.dart b/lib/state/gateway_client_provider.dart index 2291f98..97439cb 100644 --- a/lib/state/gateway_client_provider.dart +++ b/lib/state/gateway_client_provider.dart @@ -5,12 +5,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../api/gateway_client.dart'; import 'settings_store.dart'; -final gatewayClientProvider = Provider((ref) { - final settings = ref.watch(settingsControllerProvider); - final client = GatewayClient( - baseUrl: Uri.parse(settings.baseUrl), - bearerToken: settings.bearerToken, - ); - ref.onDispose(client.close); - return client; -}); +final gatewayClientProvider = Provider((ref) { + final settings = ref.watch(settingsControllerProvider); + final client = GatewayClient( + baseUrl: Uri.parse(settings.baseUrl), + ); + ref.onDispose(client.close); + return client; +}); diff --git a/lib/state/settings_store.dart b/lib/state/settings_store.dart index 0f76fdd..47008a9 100644 --- a/lib/state/settings_store.dart +++ b/lib/state/settings_store.dart @@ -1,9 +1,7 @@ -/// Persistent connection settings (server URL, bearer token, default model). -/// -/// Stored in SharedPreferences so the app remembers them across launches. -/// We avoid a heavier secure-storage dep on purpose — the server runs on the -/// user's own LAN/Tailscale, and the bearer token is just OPENCODE_SERVER_PASSWORD. -library; +/// Persistent connection settings for the trusted LAN gateway. +/// +/// Stored in SharedPreferences so the app remembers them across launches. +library; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -11,21 +9,19 @@ import 'package:shared_preferences/shared_preferences.dart'; @immutable class AppSettings { - const AppSettings({ - required this.baseUrl, - required this.bearerToken, - required this.providerId, - required this.modelId, + const AppSettings({ + required this.baseUrl, + required this.providerId, + required this.modelId, this.themeMode = ThemeMode.system, this.lastAgentId = '', this.lastModelId = '', this.lastSessionId = '', this.lastProjectId = '', }); - - final String baseUrl; - final String bearerToken; - final String providerId; + + final String baseUrl; + final String providerId; final String modelId; final ThemeMode themeMode; @@ -38,10 +34,9 @@ class AppSettings { bool get isConfigured => baseUrl.isNotEmpty && providerId.isNotEmpty && modelId.isNotEmpty; - AppSettings copyWith({ - String? baseUrl, - String? bearerToken, - String? providerId, + AppSettings copyWith({ + String? baseUrl, + String? providerId, String? modelId, ThemeMode? themeMode, String? lastAgentId, @@ -49,10 +44,9 @@ class AppSettings { String? lastSessionId, String? lastProjectId, }) => - AppSettings( - baseUrl: baseUrl ?? this.baseUrl, - bearerToken: bearerToken ?? this.bearerToken, - providerId: providerId ?? this.providerId, + AppSettings( + baseUrl: baseUrl ?? this.baseUrl, + providerId: providerId ?? this.providerId, modelId: modelId ?? this.modelId, themeMode: themeMode ?? this.themeMode, lastAgentId: lastAgentId ?? this.lastAgentId, @@ -61,25 +55,25 @@ class AppSettings { lastProjectId: lastProjectId ?? this.lastProjectId, ); - static const empty = AppSettings( - baseUrl: 'http://127.0.0.1:4096', - bearerToken: '', - providerId: 'opencode', + static const empty = AppSettings( + baseUrl: 'http://127.0.0.1:4096', + providerId: 'opencode', modelId: 'big-pickle', ); } -class SettingsController extends StateNotifier { - SettingsController(this._prefs) : super(_load(_prefs)); +class SettingsController extends StateNotifier { + SettingsController(this._prefs) : super(_load(_prefs)) { + _prefs.remove(_kLegacyToken); + } final SharedPreferences _prefs; static AppSettings _load(SharedPreferences p) { final themeModeIndex = p.getInt(_kThemeMode); - return AppSettings( - baseUrl: p.getString(_kBaseUrl) ?? AppSettings.empty.baseUrl, - bearerToken: p.getString(_kToken) ?? '', - providerId: p.getString(_kProvider) ?? AppSettings.empty.providerId, + return AppSettings( + baseUrl: p.getString(_kBaseUrl) ?? AppSettings.empty.baseUrl, + providerId: p.getString(_kProvider) ?? AppSettings.empty.providerId, modelId: p.getString(_kModel) ?? AppSettings.empty.modelId, themeMode: themeModeIndex != null && themeModeIndex < ThemeMode.values.length ? ThemeMode.values[themeModeIndex] @@ -93,10 +87,9 @@ class SettingsController extends StateNotifier { Future update(AppSettings next) async { state = next; - await Future.wait([ - _prefs.setString(_kBaseUrl, next.baseUrl), - _prefs.setString(_kToken, next.bearerToken), - _prefs.setString(_kProvider, next.providerId), + await Future.wait([ + _prefs.setString(_kBaseUrl, next.baseUrl), + _prefs.setString(_kProvider, next.providerId), _prefs.setString(_kModel, next.modelId), _prefs.setInt(_kThemeMode, next.themeMode.index), _prefs.setString(_kLastAgent, next.lastAgentId), @@ -130,10 +123,10 @@ class SettingsController extends StateNotifier { } await Future.wait(futures); } - - static const _kBaseUrl = 'oc.baseUrl'; - static const _kToken = 'oc.bearerToken'; - static const _kProvider = 'oc.providerId'; + + static const _kBaseUrl = 'oc.baseUrl'; + static const _kLegacyToken = 'oc.bearerToken'; + static const _kProvider = 'oc.providerId'; static const _kModel = 'oc.modelId'; static const _kThemeMode = 'oc.themeMode'; static const _kLastAgent = 'oc.lastAgentId'; diff --git a/lib/ui/pages/gateway_chat_page.dart b/lib/ui/pages/gateway_chat_page.dart index 63a2147..7f3feb0 100644 --- a/lib/ui/pages/gateway_chat_page.dart +++ b/lib/ui/pages/gateway_chat_page.dart @@ -710,12 +710,11 @@ class _GatewayChatPageState extends ConsumerState Future _handleAddDirCommand() async { final settings = ref.read(settingsControllerProvider); if (!mounted) return; - final path = await showDirectoryPicker( - context, - gatewayBaseUrl: settings.baseUrl, - bearerToken: settings.bearerToken, - initialPath: widget.project.directory, - ); + final path = await showDirectoryPicker( + context, + gatewayBaseUrl: settings.baseUrl, + initialPath: widget.project.directory, + ); if (path == null || !mounted) return; final notifier = diff --git a/lib/ui/pages/git_page.dart b/lib/ui/pages/git_page.dart index 5177408..c116efd 100644 --- a/lib/ui/pages/git_page.dart +++ b/lib/ui/pages/git_page.dart @@ -16,15 +16,12 @@ import '../../state/settings_store.dart'; // Provider for GitClient — uses gateway URL from settings. // --------------------------------------------------------------------------- -final gitClientProvider = Provider((ref) { - final s = ref.watch(settingsControllerProvider); - final client = GitClient( - baseUrl: Uri.parse(s.baseUrl), - bearerToken: s.bearerToken, - ); - ref.onDispose(client.close); - return client; -}); +final gitClientProvider = Provider((ref) { + final s = ref.watch(settingsControllerProvider); + final client = GitClient(baseUrl: Uri.parse(s.baseUrl)); + ref.onDispose(client.close); + return client; +}); // --------------------------------------------------------------------------- // Git Page diff --git a/lib/ui/pages/project_list_page.dart b/lib/ui/pages/project_list_page.dart index f0019a2..2713e67 100644 --- a/lib/ui/pages/project_list_page.dart +++ b/lib/ui/pages/project_list_page.dart @@ -85,7 +85,6 @@ class _ProjectListPageState extends ConsumerState { final directory = await showDirectoryPicker( context, gatewayBaseUrl: settings.baseUrl, - bearerToken: settings.bearerToken, initialPath: 'D:\\', ); if (directory == null || !context.mounted) return; diff --git a/lib/ui/pages/settings_page.dart b/lib/ui/pages/settings_page.dart index 83b4132..92b3860 100644 --- a/lib/ui/pages/settings_page.dart +++ b/lib/ui/pages/settings_page.dart @@ -50,10 +50,9 @@ class SettingsPage extends ConsumerStatefulWidget { ConsumerState createState() => _SettingsPageState(); } -class _SettingsPageState extends ConsumerState { - late final TextEditingController _baseUrlCtrl; - late final TextEditingController _tokenCtrl; - String _providerId = ''; +class _SettingsPageState extends ConsumerState { + late final TextEditingController _baseUrlCtrl; + String _providerId = ''; String _modelId = ''; List _models = const []; Map> _agentModels = const {}; @@ -69,11 +68,10 @@ class _SettingsPageState extends ConsumerState { @override void initState() { - super.initState(); - final s = ref.read(settingsControllerProvider); - _baseUrlCtrl = TextEditingController(text: s.baseUrl); - _tokenCtrl = TextEditingController(text: s.bearerToken); - _providerId = s.providerId; + super.initState(); + final s = ref.read(settingsControllerProvider); + _baseUrlCtrl = TextEditingController(text: s.baseUrl); + _providerId = s.providerId; _modelId = s.modelId; // Auto-test if URL is already configured. if (s.baseUrl.isNotEmpty) { @@ -85,21 +83,19 @@ class _SettingsPageState extends ConsumerState { } @override - void dispose() { - _baseUrlCtrl.dispose(); - _tokenCtrl.dispose(); - super.dispose(); - } + void dispose() { + _baseUrlCtrl.dispose(); + super.dispose(); + } Future _loadProfiles() async { final url = _baseUrlCtrl.text.trim(); if (url.isEmpty) return; setState(() => _profilesLoading = true); try { - final client = GatewayClient( - baseUrl: Uri.parse(url), - bearerToken: _tokenCtrl.text.trim(), - ); + final client = GatewayClient( + baseUrl: Uri.parse(url), + ); final profiles = await client.listProfiles(); final active = await client.getActiveProfile(); client.close(); @@ -119,10 +115,9 @@ class _SettingsPageState extends ConsumerState { final url = _baseUrlCtrl.text.trim(); if (url.isEmpty) return; try { - final client = GatewayClient( - baseUrl: Uri.parse(url), - bearerToken: _tokenCtrl.text.trim(), - ); + final client = GatewayClient( + baseUrl: Uri.parse(url), + ); await client.activateProfile(profileId); client.close(); } catch (_) { @@ -156,10 +151,9 @@ class _SettingsPageState extends ConsumerState { if (confirmed != true) return; final url = _baseUrlCtrl.text.trim(); try { - final client = GatewayClient( - baseUrl: Uri.parse(url), - bearerToken: _tokenCtrl.text.trim(), - ); + final client = GatewayClient( + baseUrl: Uri.parse(url), + ); await client.deleteProfile(profileId); client.close(); } catch (_) { @@ -175,11 +169,10 @@ class _SettingsPageState extends ConsumerState { Future _openProfileEditor({Map? existing}) async { final result = await Navigator.of(context).push( MaterialPageRoute( - builder: (_) => _ProfileEditorPage( - baseUrl: _baseUrlCtrl.text.trim(), - bearerToken: _tokenCtrl.text.trim(), - existing: existing, - ), + builder: (_) => _ProfileEditorPage( + baseUrl: _baseUrlCtrl.text.trim(), + existing: existing, + ), ), ); if (result == true) { @@ -277,10 +270,9 @@ class _SettingsPageState extends ConsumerState { }) async { final url = _baseUrlCtrl.text.trim(); if (url.isEmpty) return; - final client = GatewayClient( - baseUrl: Uri.parse(url), - bearerToken: _tokenCtrl.text.trim(), - ); + final client = GatewayClient( + baseUrl: Uri.parse(url), + ); List> entries; try { entries = await fetch(client); @@ -529,10 +521,9 @@ class _SettingsPageState extends ConsumerState { _testOk = null; }); try { - final client = GatewayClient( - baseUrl: Uri.parse(url), - bearerToken: _tokenCtrl.text.trim(), - ); + final client = GatewayClient( + baseUrl: Uri.parse(url), + ); final ok = await client.health(); if (!ok) { setState(() { @@ -595,10 +586,9 @@ class _SettingsPageState extends ConsumerState { final controller = ref.read(settingsControllerProvider.notifier); final current = ref.read(settingsControllerProvider); await controller.update( - AppSettings( - baseUrl: _baseUrlCtrl.text.trim(), - bearerToken: _tokenCtrl.text.trim(), - providerId: _providerId, + AppSettings( + baseUrl: _baseUrlCtrl.text.trim(), + providerId: _providerId, modelId: _modelId, themeMode: current.themeMode, ), @@ -759,17 +749,7 @@ class _SettingsPageState extends ConsumerState { keyboardType: TextInputType.url, autocorrect: false, ), - const SizedBox(height: 12), - TextField( - controller: _tokenCtrl, - decoration: const InputDecoration( - labelText: 'Bearer token (optional)', - prefixIcon: Icon(Icons.vpn_key_outlined), - ), - autocorrect: false, - obscureText: true, - ), - const SizedBox(height: 14), + const SizedBox(height: 14), Row( children: [ FilledButton.icon( @@ -1207,16 +1187,14 @@ class _ProfileTile extends StatelessWidget { // Profile Editor Page // ═══════════════════════════════════════════════════════════════════════════════ -class _ProfileEditorPage extends StatefulWidget { - const _ProfileEditorPage({ - required this.baseUrl, - required this.bearerToken, - this.existing, - }); - - final String baseUrl; - final String bearerToken; - final Map? existing; +class _ProfileEditorPage extends StatefulWidget { + const _ProfileEditorPage({ + required this.baseUrl, + this.existing, + }); + + final String baseUrl; + final Map? existing; @override State<_ProfileEditorPage> createState() => _ProfileEditorPageState(); @@ -1327,10 +1305,9 @@ class _ProfileEditorPageState extends State<_ProfileEditorPage> { } setState(() => _saving = true); try { - final client = GatewayClient( - baseUrl: Uri.parse(widget.baseUrl), - bearerToken: widget.bearerToken, - ); + final client = GatewayClient( + baseUrl: Uri.parse(widget.baseUrl), + ); final keys = _buildKeys(); if (_isEditing) { final id = widget.existing!['id'] as String; diff --git a/lib/ui/widgets/directory_picker.dart b/lib/ui/widgets/directory_picker.dart index 0b89391..4367dd0 100644 --- a/lib/ui/widgets/directory_picker.dart +++ b/lib/ui/widgets/directory_picker.dart @@ -12,35 +12,31 @@ import 'package:flutter/material.dart'; /// Shows a directory picker bottom sheet and returns the selected path, /// or null if dismissed. -Future showDirectoryPicker( - BuildContext context, { - required String gatewayBaseUrl, - required String bearerToken, - String? initialPath, -}) { +Future showDirectoryPicker( + BuildContext context, { + required String gatewayBaseUrl, + String? initialPath, +}) { return showModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, useSafeArea: true, - builder: (_) => _DirectoryPickerSheet( - gatewayBaseUrl: gatewayBaseUrl, - bearerToken: bearerToken, - initialPath: initialPath ?? 'D:\\', - ), - ); + builder: (_) => _DirectoryPickerSheet( + gatewayBaseUrl: gatewayBaseUrl, + initialPath: initialPath ?? 'D:\\', + ), + ); } class _DirectoryPickerSheet extends StatefulWidget { - const _DirectoryPickerSheet({ - required this.gatewayBaseUrl, - required this.bearerToken, - required this.initialPath, - }); - - final String gatewayBaseUrl; - final String bearerToken; - final String initialPath; + const _DirectoryPickerSheet({ + required this.gatewayBaseUrl, + required this.initialPath, + }); + + final String gatewayBaseUrl; + final String initialPath; @override State<_DirectoryPickerSheet> createState() => _DirectoryPickerSheetState(); @@ -62,15 +58,11 @@ class _DirectoryPickerSheetState extends State<_DirectoryPickerSheet> { _currentPath = widget.initialPath; _dio = Dio( BaseOptions( - baseUrl: widget.gatewayBaseUrl.replaceAll(RegExp(r'/$'), ''), - connectTimeout: const Duration(seconds: 10), - receiveTimeout: const Duration(seconds: 10), - headers: { - if (widget.bearerToken.isNotEmpty) - 'Authorization': 'Bearer ${widget.bearerToken}', - }, - ), - ); + baseUrl: widget.gatewayBaseUrl.replaceAll(RegExp(r'/$'), ''), + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + ), + ); _loadDirs(); } diff --git a/pubspec.yaml b/pubspec.yaml index f45ac6b..6114857 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: remote_multi_agent -description: A mobile client for OpenCode — connect to a remote OpenCode server and tail its event stream. +description: A mobile client for local coding agents through a trusted LAN gateway. publish_to: "none" version: 0.1.1+2 @@ -21,7 +21,7 @@ dependencies: riverpod_annotation: ^2.3.5 # Persistence - shared_preferences: ^2.3.2 # Settings (server URL, bearer token, last selected session) + shared_preferences: ^2.3.2 # Settings (server URL, model, last selected session) # JSON / models freezed_annotation: ^2.4.4 diff --git a/web/favicon.png b/web/favicon.png deleted file mode 100644 index 8aaa46a..0000000 Binary files a/web/favicon.png and /dev/null differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png deleted file mode 100644 index b749bfe..0000000 Binary files a/web/icons/Icon-192.png and /dev/null differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png deleted file mode 100644 index 88cfd48..0000000 Binary files a/web/icons/Icon-512.png and /dev/null differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png deleted file mode 100644 index eb9b4d7..0000000 Binary files a/web/icons/Icon-maskable-192.png and /dev/null differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png deleted file mode 100644 index d69c566..0000000 Binary files a/web/icons/Icon-maskable-512.png and /dev/null differ diff --git a/web/index.html b/web/index.html deleted file mode 100644 index 77d0137..0000000 --- a/web/index.html +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - - - - - - - - - - - - - Remote Multi Agent - - - - - - - diff --git a/web/manifest.json b/web/manifest.json deleted file mode 100644 index 219d519..0000000 --- a/web/manifest.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "Remote Multi Agent", - "short_name": "Remote Agent", - "start_url": ".", - "display": "standalone", - "background_color": "#0175C2", - "theme_color": "#0175C2", - "description": "Mobile client for remote multi-agent workflows", - "orientation": "portrait-primary", - "prefer_related_applications": false, - "icons": [ - { - "src": "icons/Icon-192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "icons/Icon-512.png", - "sizes": "512x512", - "type": "image/png" - }, - { - "src": "icons/Icon-maskable-192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "icons/Icon-maskable-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ] -}