From 62065252d0e7fc893a0a7b9272913bb01b882120 Mon Sep 17 00:00:00 2001 From: botlong Date: Sat, 23 May 2026 20:42:48 +0800 Subject: [PATCH 01/11] docs: add mobile gateway cleanup plan --- .../2026-05-23-mobile-gateway-cleanup.md | 1140 +++++++++++++++++ 1 file changed, 1140 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-23-mobile-gateway-cleanup.md 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..12a0004 --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-mobile-gateway-cleanup.md @@ -0,0 +1,1140 @@ +# 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`. + +```powershell +$matches = rg -n "_tokenCtrl|Bearer token|settings\.bearerToken|bearerToken:" lib/state lib/ui +if ($LASTEXITCODE -eq 0) { + throw "gateway auth UI/settings references still exist`n$matches" +} +``` + +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 `Bearer token (optional)`. +- Remove `bearerToken` from `_ProfileEditorPage` constructor and usages if it is only passed through from the old settings field. + +After editing, this command should print no matches: + +```powershell +rg -n "_tokenCtrl|Bearer token|settings\.bearerToken|bearerToken:" lib/state lib/ui +``` + +- [ ] **Step 8: Run verification for Task 1** + +Run: + +```powershell +if (Test-Path web) { + throw 'web directory still exists' +} +``` + +Expected: PASS with no output. + +Run: + +```powershell +rg -n "_tokenCtrl|Bearer token|settings\.bearerToken|bearerToken:" lib/state lib/ui +``` + +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** + +Run: + +```powershell +$matches = rg -n "鈥|鏍|绔|鎺|锛|鐩|璺|缃|浣|娑|鍚|寮" README.md TODO.md docs gateway/README.md +if ($LASTEXITCODE -eq 0) { + throw "mojibake remains`n$matches" +} +``` + +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 `flutter run -d chrome`. +- 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. The Flutter Web scaffold has 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** + +```powershell +$matches = rg -n "鈥|鏍|绔|鎺|锛|鐩|璺|缃|浣|娑|鍚|寮" README.md TODO.md docs gateway/README.md +if ($LASTEXITCODE -eq 0) { + throw "mojibake remains`n$matches" +} +``` + +Expected: PASS with no output. + +Run: + +```powershell +rg -n "flutter run -d chrome|Flutter Web|web scaffold|Bearer token" README.md gateway/README.md docs TODO.md +``` + +Expected: no matches, except a permitted sentence that says Web is unsupported without naming a Web scaffold. + +- [ ] **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. + +```powershell +rg -n "_tokenCtrl|Bearer token|settings\.bearerToken|bearerToken:" lib/state lib/ui +``` + +Expected: no matches. + +```powershell +if (Test-Path web) { + throw 'web directory still exists' +} +``` + +Expected: PASS with no output. + +```powershell +rg -n "鈥|鏍|绔|鎺|锛|鐩|璺|缃|浣|娑|鍚|寮" README.md TODO.md docs gateway/README.md +``` + +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. From 8b0d9d5441eef85b710a710f85caba361ab4a6f9 Mon Sep 17 00:00:00 2001 From: botlong Date: Sat, 23 May 2026 20:57:32 +0800 Subject: [PATCH 02/11] chore: make v1 mobile-only and remove gateway auth UI --- lib/state/gateway_client_provider.dart | 17 ++-- lib/state/settings_store.dart | 68 +++++++-------- lib/ui/pages/gateway_chat_page.dart | 11 ++- lib/ui/pages/git_page.dart | 15 ++-- lib/ui/pages/project_list_page.dart | 1 - lib/ui/pages/settings_page.dart | 113 ++++++++++--------------- lib/ui/widgets/directory_picker.dart | 52 +++++------- pubspec.yaml | 4 +- web/favicon.png | Bin 917 -> 0 bytes web/icons/Icon-192.png | Bin 5292 -> 0 bytes web/icons/Icon-512.png | Bin 8252 -> 0 bytes web/icons/Icon-maskable-192.png | Bin 5594 -> 0 bytes web/icons/Icon-maskable-512.png | Bin 20998 -> 0 bytes web/index.html | 46 ---------- web/manifest.json | 35 -------- 15 files changed, 117 insertions(+), 245 deletions(-) delete mode 100644 web/favicon.png delete mode 100644 web/icons/Icon-192.png delete mode 100644 web/icons/Icon-512.png delete mode 100644 web/icons/Icon-maskable-192.png delete mode 100644 web/icons/Icon-maskable-512.png delete mode 100644 web/index.html delete mode 100644 web/manifest.json 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..2f8fedf 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,10 +55,9 @@ 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', ); } @@ -76,10 +69,9 @@ class SettingsController extends StateNotifier { 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 +85,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 +121,9 @@ 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 _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 8aaa46ac1ae21512746f852a42ba87e4165dfdd1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 917 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|I14-?iy0X7 zltGxWVyS%@P(fs7NJL45ua8x7ey(0(N`6wRUPW#JP&EUCO@$SZnVVXYs8ErclUHn2 zVXFjIVFhG^g!Ppaz)DK8ZIvQ?0~DO|i&7O#^-S~(l1AfjnEK zjFOT9D}DX)@^Za$W4-*MbbUihOG|wNBYh(yU7!lx;>x^|#0uTKVr7USFmqf|i<65o z3raHc^AtelCMM;Vme?vOfh>Xph&xL%(-1c06+^uR^q@XSM&D4+Kp$>4P^%3{)XKjo zGZknv$b36P8?Z_gF{nK@`XI}Z90TzwSQO}0J1!f2c(B=V`5aP@1P1a|PZ!4!3&Gl8 zTYqUsf!gYFyJnXpu0!n&N*SYAX-%d(5gVjrHJWqXQshj@!Zm{!01WsQrH~9=kTxW#6SvuapgMqt>$=j#%eyGrQzr zP{L-3gsMA^$I1&gsBAEL+vxi1*Igl=8#8`5?A-T5=z-sk46WA1IUT)AIZHx1rdUrf zVJrJn<74DDw`j)Ki#gt}mIT-Q`XRa2-jQXQoI%w`nb|XblvzK${ZzlV)m-XcwC(od z71_OEC5Bt9GEXosOXaPTYOia#R4ID2TiU~`zVMl08TV_C%DnU4^+HE>9(CE4D6?Fz oujB08i7adh9xk7*FX66dWH6F5TM;?E2b5PlUHx3vIVCg!0Dx9vYXATM diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png deleted file mode 100644 index b749bfef07473333cf1dd31e9eed89862a5d52aa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5292 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 diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png deleted file mode 100644 index 88cfd48dff1169879ba46840804b412fe02fefd6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8252 zcmd5=2T+s!lYZ%-(h(2@5fr2dC?F^$C=i-}R6$UX8af(!je;W5yC_|HmujSgN*6?W z3knF*TL1$|?oD*=zPbBVex*RUIKsL<(&Rj9%^UD2IK3W?2j>D?eWQgvS-HLymHo9%~|N2Q{~j za?*X-{b9JRowv_*Mh|;*-kPFn>PI;r<#kFaxFqbn?aq|PduQg=2Q;~Qc}#z)_T%x9 zE|0!a70`58wjREmAH38H1)#gof)U3g9FZ^ zF7&-0^Hy{4XHWLoC*hOG(dg~2g6&?-wqcpf{ z&3=o8vw7lMi22jCG9RQbv8H}`+}9^zSk`nlR8?Z&G2dlDy$4#+WOlg;VHqzuE=fM@ z?OI6HEJH4&tA?FVG}9>jAnq_^tlw8NbjNhfqk2rQr?h(F&WiKy03Sn=-;ZJRh~JrD zbt)zLbnabttEZ>zUiu`N*u4sfQaLE8-WDn@tHp50uD(^r-}UsUUu)`!Rl1PozAc!a z?uj|2QDQ%oV-jxUJmJycySBINSKdX{kDYRS=+`HgR2GO19fg&lZKyBFbbXhQV~v~L za^U944F1_GtuFXtvDdDNDvp<`fqy);>Vw=ncy!NB85Tw{&sT5&Ox%-p%8fTS;OzlRBwErvO+ROe?{%q-Zge=%Up|D4L#>4K@Ke=x%?*^_^P*KD zgXueMiS63!sEw@fNLB-i^F|@Oib+S4bcy{eu&e}Xvb^(mA!=U=Xr3||IpV~3K zQWzEsUeX_qBe6fky#M zzOJm5b+l;~>=sdp%i}}0h zO?B?i*W;Ndn02Y0GUUPxERG`3Bjtj!NroLoYtyVdLtl?SE*CYpf4|_${ku2s`*_)k zN=a}V8_2R5QANlxsq!1BkT6$4>9=-Ix4As@FSS;1q^#TXPrBsw>hJ}$jZ{kUHoP+H zvoYiR39gX}2OHIBYCa~6ERRPJ#V}RIIZakUmuIoLF*{sO8rAUEB9|+A#C|@kw5>u0 zBd=F!4I)Be8ycH*)X1-VPiZ+Ts8_GB;YW&ZFFUo|Sw|x~ZajLsp+_3gv((Q#N>?Jz zFBf`~p_#^${zhPIIJY~yo!7$-xi2LK%3&RkFg}Ax)3+dFCjGgKv^1;lUzQlPo^E{K zmCnrwJ)NuSaJEmueEPO@(_6h3f5mFffhkU9r8A8(JC5eOkux{gPmx_$Uv&|hyj)gN zd>JP8l2U&81@1Hc>#*su2xd{)T`Yw< zN$dSLUN}dfx)Fu`NcY}TuZ)SdviT{JHaiYgP4~@`x{&h*Hd>c3K_To9BnQi@;tuoL z%PYQo&{|IsM)_>BrF1oB~+`2_uZQ48z9!)mtUR zdfKE+b*w8cPu;F6RYJiYyV;PRBbThqHBEu_(U{(gGtjM}Zi$pL8Whx}<JwE3RM0F8x7%!!s)UJVq|TVd#hf1zVLya$;mYp(^oZQ2>=ZXU1c$}f zm|7kfk>=4KoQoQ!2&SOW5|JP1)%#55C$M(u4%SP~tHa&M+=;YsW=v(Old9L3(j)`u z2?#fK&1vtS?G6aOt@E`gZ9*qCmyvc>Ma@Q8^I4y~f3gs7*d=ATlP>1S zyF=k&6p2;7dn^8?+!wZO5r~B+;@KXFEn^&C=6ma1J7Au6y29iMIxd7#iW%=iUzq&C=$aPLa^Q zncia$@TIy6UT@69=nbty5epP>*fVW@5qbUcb2~Gg75dNd{COFLdiz3}kODn^U*=@E z0*$7u7Rl2u)=%fk4m8EK1ctR!6%Ve`e!O20L$0LkM#f+)n9h^dn{n`T*^~d+l*Qlx z$;JC0P9+en2Wlxjwq#z^a6pdnD6fJM!GV7_%8%c)kc5LZs_G^qvw)&J#6WSp< zmsd~1-(GrgjC56Pdf6#!dt^y8Rg}!#UXf)W%~PeU+kU`FeSZHk)%sFv++#Dujk-~m zFHvVJC}UBn2jN& zs!@nZ?e(iyZPNo`p1i#~wsv9l@#Z|ag3JR>0#u1iW9M1RK1iF6-RbJ4KYg?B`dET9 zyR~DjZ>%_vWYm*Z9_+^~hJ_|SNTzBKx=U0l9 z9x(J96b{`R)UVQ$I`wTJ@$_}`)_DyUNOso6=WOmQKI1e`oyYy1C&%AQU<0-`(ow)1 zT}gYdwWdm4wW6|K)LcfMe&psE0XGhMy&xS`@vLi|1#Za{D6l@#D!?nW87wcscUZgELT{Cz**^;Zb~7 z(~WFRO`~!WvyZAW-8v!6n&j*PLm9NlN}BuUN}@E^TX*4Or#dMMF?V9KBeLSiLO4?B zcE3WNIa-H{ThrlCoN=XjOGk1dT=xwwrmt<1a)mrRzg{35`@C!T?&_;Q4Ce=5=>z^*zE_c(0*vWo2_#TD<2)pLXV$FlwP}Ik74IdDQU@yhkCr5h zn5aa>B7PWy5NQ!vf7@p_qtC*{dZ8zLS;JetPkHi>IvPjtJ#ThGQD|Lq#@vE2xdl%`x4A8xOln}BiQ92Po zW;0%A?I5CQ_O`@Ad=`2BLPPbBuPUp@Hb%a_OOI}y{Rwa<#h z5^6M}s7VzE)2&I*33pA>e71d78QpF>sNK;?lj^Kl#wU7G++`N_oL4QPd-iPqBhhs| z(uVM}$ItF-onXuuXO}o$t)emBO3Hjfyil@*+GF;9j?`&67GBM;TGkLHi>@)rkS4Nj zAEk;u)`jc4C$qN6WV2dVd#q}2X6nKt&X*}I@jP%Srs%%DS92lpDY^K*Sx4`l;aql$ zt*-V{U&$DM>pdO?%jt$t=vg5|p+Rw?SPaLW zB6nvZ69$ne4Z(s$3=Rf&RX8L9PWMV*S0@R zuIk&ba#s6sxVZ51^4Kon46X^9`?DC9mEhWB3f+o4#2EXFqy0(UTc>GU| zGCJmI|Dn-dX#7|_6(fT)>&YQ0H&&JX3cTvAq(a@ydM4>5Njnuere{J8p;3?1az60* z$1E7Yyxt^ytULeokgDnRVKQw9vzHg1>X@@jM$n$HBlveIrKP5-GJq%iWH#odVwV6cF^kKX(@#%%uQVb>#T6L^mC@)%SMd4DF? zVky!~ge27>cpUP1Vi}Z32lbLV+CQy+T5Wdmva6Fg^lKb!zrg|HPU=5Qu}k;4GVH+x z%;&pN1LOce0w@9i1Mo-Y|7|z}fbch@BPp2{&R-5{GLoeu8@limQmFF zaJRR|^;kW_nw~0V^ zfTnR!Ni*;-%oSHG1yItARs~uxra|O?YJxBzLjpeE-=~TO3Dn`JL5Gz;F~O1u3|FE- zvK2Vve`ylc`a}G`gpHg58Cqc9fMoy1L}7x7T>%~b&irrNMo?np3`q;d3d;zTK>nrK zOjPS{@&74-fA7j)8uT9~*g23uGnxwIVj9HorzUX#s0pcp2?GH6i}~+kv9fWChtPa_ z@T3m+$0pbjdQw7jcnHn;Pi85hk_u2-1^}c)LNvjdam8K-XJ+KgKQ%!?2n_!#{$H|| zLO=%;hRo6EDmnOBKCL9Cg~ETU##@u^W_5joZ%Et%X_n##%JDOcsO=0VL|Lkk!VdRJ z^|~2pB@PUspT?NOeO?=0Vb+fAGc!j%Ufn-cB`s2A~W{Zj{`wqWq_-w0wr@6VrM zbzni@8c>WS!7c&|ZR$cQ;`niRw{4kG#e z70e!uX8VmP23SuJ*)#(&R=;SxGAvq|&>geL&!5Z7@0Z(No*W561n#u$Uc`f9pD70# z=sKOSK|bF~#khTTn)B28h^a1{;>EaRnHj~>i=Fnr3+Fa4 z`^+O5_itS#7kPd20rq66_wH`%?HNzWk@XFK0n;Z@Cx{kx==2L22zWH$Yg?7 zvDj|u{{+NR3JvUH({;b*$b(U5U z7(lF!1bz2%06+|-v(D?2KgwNw7( zJB#Tz+ZRi&U$i?f34m7>uTzO#+E5cbaiQ&L}UxyOQq~afbNB4EI{E04ZWg53w0A{O%qo=lF8d zf~ktGvIgf-a~zQoWf>loF7pOodrd0a2|BzwwPDV}ShauTK8*fmF6NRbO>Iw9zZU}u zw8Ya}?seBnEGQDmH#XpUUkj}N49tP<2jYwTFp!P+&Fd(%Z#yo80|5@zN(D{_pNow*&4%ql zW~&yp@scb-+Qj-EmErY+Tu=dUmf@*BoXY2&oKT8U?8?s1d}4a`Aq>7SV800m$FE~? zjmz(LY+Xx9sDX$;vU`xgw*jLw7dWOnWWCO8o|;}f>cu0Q&`0I{YudMn;P;L3R-uz# zfns_mZED_IakFBPP2r_S8XM$X)@O-xVKi4`7373Jkd5{2$M#%cRhWer3M(vr{S6>h zj{givZJ3(`yFL@``(afn&~iNx@B1|-qfYiZu?-_&Z8+R~v`d6R-}EX9IVXWO-!hL5 z*k6T#^2zAXdardU3Ao~I)4DGdAv2bx{4nOK`20rJo>rmk3S2ZDu}))8Z1m}CKigf0 z3L`3Y`{huj`xj9@`$xTZzZc3je?n^yG<8sw$`Y%}9mUsjUR%T!?k^(q)6FH6Af^b6 zlPg~IEwg0y;`t9y;#D+uz!oE4VP&Je!<#q*F?m5L5?J3i@!0J6q#eu z!RRU`-)HeqGi_UJZ(n~|PSNsv+Wgl{P-TvaUQ9j?ZCtvb^37U$sFpBrkT{7Jpd?HpIvj2!}RIq zH{9~+gErN2+}J`>Jvng2hwM`=PLNkc7pkjblKW|+Fk9rc)G1R>Ww>RC=r-|!m-u7( zc(a$9NG}w#PjWNMS~)o=i~WA&4L(YIW25@AL9+H9!?3Y}sv#MOdY{bb9j>p`{?O(P zIvb`n?_(gP2w3P#&91JX*md+bBEr%xUHMVqfB;(f?OPtMnAZ#rm5q5mh;a2f_si2_ z3oXWB?{NF(JtkAn6F(O{z@b76OIqMC$&oJ_&S|YbFJ*)3qVX_uNf5b8(!vGX19hsG z(OP>RmZp29KH9Ge2kKjKigUmOe^K_!UXP`von)PR8Qz$%=EmOB9xS(ZxE_tnyzo}7 z=6~$~9k0M~v}`w={AeqF?_)9q{m8K#6M{a&(;u;O41j)I$^T?lx5(zlebpY@NT&#N zR+1bB)-1-xj}R8uwqwf=iP1GbxBjneCC%UrSdSxK1vM^i9;bUkS#iRZw2H>rS<2<$ zNT3|sDH>{tXb=zq7XZi*K?#Zsa1h1{h5!Tq_YbKFm_*=A5-<~j63he;4`77!|LBlo zR^~tR3yxcU=gDFbshyF6>o0bdp$qmHS7D}m3;^QZq9kBBU|9$N-~oU?G5;jyFR7>z hN`IR97YZXIo@y!QgFWddJ3|0`sjFx!m))><{BI=FK%f8s diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png deleted file mode 100644 index eb9b4d76e525556d5d89141648c724331630325d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5594 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 X>c!)3PgKfL&lX^$Z>Cpu&6)6jvi^Z! diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png deleted file mode 100644 index d69c56691fbdb0b7efa65097c7cc1edac12a6d3e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20998 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 MPgg&ebxsLQ025IeI{*Lx 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" - } - ] -} From cb571ffd638860827f02d1f3e45a9f5479660a38 Mon Sep 17 00:00:00 2001 From: botlong Date: Sat, 23 May 2026 21:07:45 +0800 Subject: [PATCH 03/11] fix: clean up legacy mobile-only settings state --- .metadata | 4 ---- lib/api/sse_stream.dart | 3 +-- lib/state/settings_store.dart | 7 +++++-- 3 files changed, 6 insertions(+), 8 deletions(-) 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/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/settings_store.dart b/lib/state/settings_store.dart index 2f8fedf..47008a9 100644 --- a/lib/state/settings_store.dart +++ b/lib/state/settings_store.dart @@ -62,8 +62,10 @@ class AppSettings { ); } -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; @@ -123,6 +125,7 @@ class SettingsController extends StateNotifier { } 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'; From abf65419bf3e520aa628bcd7cd3e276385557aa8 Mon Sep 17 00:00:00 2001 From: botlong Date: Sat, 23 May 2026 21:12:31 +0800 Subject: [PATCH 04/11] test: cover gateway agent module boundaries --- gateway/test/agents_split.test.js | 58 +++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 gateway/test/agents_split.test.js 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', + '-', + ], + ); +}); From b91a7010f98962c298181c95bdca6191891c2199 Mon Sep 17 00:00:00 2001 From: botlong Date: Sat, 23 May 2026 21:21:12 +0800 Subject: [PATCH 05/11] refactor: extract shared gateway agent helpers --- gateway/src/agents.js | 591 +------------------------ gateway/src/agents/command_helpers.js | 71 +++ gateway/src/agents/json_cli.js | 343 ++++++++++++++ gateway/src/agents/model_cache.js | 20 + gateway/src/agents/opencode_helpers.js | 178 ++++++++ 5 files changed, 627 insertions(+), 576 deletions(-) create mode 100644 gateway/src/agents/command_helpers.js create mode 100644 gateway/src/agents/json_cli.js create mode 100644 gateway/src/agents/model_cache.js create mode 100644 gateway/src/agents/opencode_helpers.js diff --git a/gateway/src/agents.js b/gateway/src/agents.js index aa92830..8e2ddd8 100644 --- a/gateway/src/agents.js +++ b/gateway/src/agents.js @@ -1,37 +1,31 @@ '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 { cachedModels } = require('./agents/model_cache'); +const { + commands, + markdownCommands, + opencodeJsonCommands, + publicCommand, +} = require('./agents/command_helpers'); +const { runJsonCli } = require('./agents/json_cli'); +const { + providerModels, + splitOpenCodeModel, + normalizeOpenCodeEvent, + openCodeEventSessionId, + openCodeTerminalResult, +} = require('./agents/opencode_helpers'); const CODEX_COMMANDS = [ { name: '/mcp', description: 'Show MCP server status' }, @@ -709,561 +703,6 @@ class OpenCodeAdapter { } } -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, 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/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_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, +}; From 0e3f29fa55ee98efc8d59132ed513ceb72bd8e36 Mon Sep 17 00:00:00 2001 From: botlong Date: Sat, 23 May 2026 21:31:45 +0800 Subject: [PATCH 06/11] refactor: split gateway agent adapters --- gateway/src/agents.js | 722 +----------------------------- gateway/src/agents/claude_code.js | 221 +++++++++ gateway/src/agents/codex.js | 177 ++++++++ gateway/src/agents/index.js | 17 + gateway/src/agents/opencode.js | 307 +++++++++++++ gateway/src/agents/registry.js | 36 ++ 6 files changed, 759 insertions(+), 721 deletions(-) create mode 100644 gateway/src/agents/claude_code.js create mode 100644 gateway/src/agents/codex.js create mode 100644 gateway/src/agents/index.js create mode 100644 gateway/src/agents/opencode.js create mode 100644 gateway/src/agents/registry.js diff --git a/gateway/src/agents.js b/gateway/src/agents.js index 8e2ddd8..66ba8ea 100644 --- a/gateway/src/agents.js +++ b/gateway/src/agents.js @@ -1,723 +1,3 @@ 'use strict'; -const os = require('node:os'); -const path = require('node:path'); - -const { - commandExists, - resolveClaudeCommand, - resolveCodexCommand, - resolveOpenCodeCommand, - runCapture, -} = require('./cli'); -const { OpenCodeServerManager } = require('./opencode_server'); -const { cachedModels } = require('./agents/model_cache'); -const { - commands, - markdownCommands, - opencodeJsonCommands, - publicCommand, -} = require('./agents/command_helpers'); -const { runJsonCli } = require('./agents/json_cli'); -const { - providerModels, - splitOpenCodeModel, - normalizeOpenCodeEvent, - openCodeEventSessionId, - openCodeTerminalResult, -} = require('./agents/opencode_helpers'); - -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 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/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/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/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 }; From 02d4c216220fbba59faa482cbef07bbd207efea0 Mon Sep 17 00:00:00 2001 From: botlong Date: Sat, 23 May 2026 21:40:49 +0800 Subject: [PATCH 07/11] docs: align v1 mobile and trusted LAN scope --- README.md | 392 ++++++++++++++++++++------------------ TODO.md | 103 +++------- docs/development-spec.md | 13 +- docs/optimization-plan.md | 236 ++--------------------- docs/requirements.md | 7 +- docs/workflow.md | 176 ++++++----------- gateway/README.md | 310 ++++++++++++++++-------------- 7 files changed, 475 insertions(+), 762 deletions(-) diff --git a/README.md b/README.md index 6393b5b..2879965 100644 --- a/README.md +++ b/README.md @@ -1,189 +1,203 @@ -# 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 + claude_code.js # Claude Code adapter + codex.js # Codex adapter + opencode.js # OpenCode adapter + common.js # Shared adapter 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 +``` + +## 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/:id/sessions` | List sessions | +| POST | `/sessions` | Create session | +| POST | `/sessions/:id/message` | Send message | +| GET | `/sessions/:id/events` | SSE event stream | +| 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..9f695f0 100644 --- a/docs/optimization-plan.md +++ b/docs/optimization-plan.md @@ -1,228 +1,16 @@ -# App 优化需求 +# Optimization Plan -日期: 2026-05-22 +## Current Priorities -## 一、多 Profile 配置系统 +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. -类似 CC-Switch,支持多套 API key 配置,可随时切换。 +## Code Health Targets -### 数据模型 - -```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 交互优化** — 记住选择、启动恢复、流程简化 +- 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. 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/workflow.md b/docs/workflow.md index 793f287..d68d508 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -1,146 +1,84 @@ -# 开发工作流程 - -## 项目结构概览 - -``` -lib/ -├── api/ # 网络层:REST 客户端、SSE 流、Git 客户端 -├── models/ # 数据模型:消息、Part、会话、项目 -├── state/ # 状态管理(Riverpod):聊天、会话、项目、设置 -├── ui/ -│ ├── pages/ # 页面:聊天、项目列表、Git、文件浏览、设置 -│ └── widgets/ # 组件:消息气泡、工具卡片、状态栏等 -└── theme.dart # Material 3 主题 - -gateway/src/ # Node.js 网关服务端 -docs/ # 文档 -test/ # 单元测试 +# 开发工作流 + +## 项目结构 + +```text +lib/ Flutter 移动端应用 + api/ Gateway REST、SSE、Git 客户端 + models/ Project、Session、Message、Part、Agent 等模型 + state/ Riverpod 状态管理 + ui/ 页面和组件 +gateway/ Node.js 本地网关 + src/agents/ Codex、Claude Code、OpenCode 适配器 +docs/ 产品、需求和开发文档 +test/ Flutter 单元测试 ``` -## 功能模块对应文件 - -| 功能 | 关键文件 | -|------|----------| -| 聊天流式输出 | `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" -``` +V1 只支持移动端/iOS。Flutter Web 不是支持目标。 -## 打包 IPA(通过 GitHub CI) +## Node Gateway 本地运行 -### 触发构建 +默认只监听本机: -推送到 main 分支即自动触发: - -```bash -git add -git commit -m "feat: ..." -git push +```powershell +cd gateway +npm install +npm start ``` -CI workflow 文件:`.github/workflows/ios.yml` - -### 监控构建 - -```bash -# 查看最近的 workflow 运行 -gh run list --limit 3 +需要让手机访问电脑上的网关时,可以在可信局域网或 Tailscale 网络中暴露: -# 监控指定 run(找 "iOS unsigned IPA" 那个) -gh run watch - -# 或查看状态 -gh run view --json status,conclusion +```powershell +$env:GATEWAY_HOST='0.0.0.0' +$env:GATEWAY_PORT='4096' +npm start ``` -### 下载 IPA - -构建成功后: +V1 gateway 无认证,不校验 bearer token。只应在可信局域网或 Tailscale 中暴露, +不要直接暴露到公网。 -```bash -gh run download --name ios-ipa -``` +## Flutter 测试和分析 -IPA 下载到当前目录: +本机 Flutter 可用时: +```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 -# 下载最新 IPA -gh run download $(gh run list --workflow=ios.yml --limit=1 --json databaseId -q '.[0].databaseId') --name ios-ipa +# 运行 Flutter 测试 +flutter test ``` diff --git a/gateway/README.md b/gateway/README.md index 922602d..21be39d 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -1,148 +1,162 @@ -# 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 + claude_code.js + codex.js + opencode.js + common.js +``` + +Shared helpers live under `gateway/src/agents/`. 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 +``` From 60d3234ac19751558aeeb517947d08fad93cfa2e Mon Sep 17 00:00:00 2001 From: botlong Date: Sat, 23 May 2026 21:44:50 +0800 Subject: [PATCH 08/11] docs: clean plan verification literals --- .../2026-05-23-mobile-gateway-cleanup.md | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/superpowers/plans/2026-05-23-mobile-gateway-cleanup.md b/docs/superpowers/plans/2026-05-23-mobile-gateway-cleanup.md index 12a0004..75b291f 100644 --- a/docs/superpowers/plans/2026-05-23-mobile-gateway-cleanup.md +++ b/docs/superpowers/plans/2026-05-23-mobile-gateway-cleanup.md @@ -93,7 +93,7 @@ if (Test-Path web) { Expected: FAIL with `web directory still exists`. ```powershell -$matches = rg -n "_tokenCtrl|Bearer token|settings\.bearerToken|bearerToken:" lib/state lib/ui +$matches = rg -n "_tokenCtrl|legacy gateway token label|settings\.bearerToken|bearerToken:" lib/state lib/ui if ($LASTEXITCODE -eq 0) { throw "gateway auth UI/settings references still exist`n$matches" } @@ -339,13 +339,13 @@ Make these edits: - Remove `_tokenCtrl = TextEditingController(...)` from `initState`. - Remove `_tokenCtrl.dispose()` from `dispose`. - Remove every `bearerToken: _tokenCtrl.text.trim(),` argument. -- Remove the `TextField` whose label is `Bearer token (optional)`. +- 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, this command should print no matches: ```powershell -rg -n "_tokenCtrl|Bearer token|settings\.bearerToken|bearerToken:" lib/state lib/ui +rg -n "_tokenCtrl|legacy gateway token label|settings\.bearerToken|bearerToken:" lib/state lib/ui ``` - [ ] **Step 8: Run verification for Task 1** @@ -363,7 +363,7 @@ Expected: PASS with no output. Run: ```powershell -rg -n "_tokenCtrl|Bearer token|settings\.bearerToken|bearerToken:" lib/state lib/ui +rg -n "_tokenCtrl|legacy gateway token label|settings\.bearerToken|bearerToken:" lib/state lib/ui ``` Expected: no matches and exit code 1. @@ -840,7 +840,7 @@ git commit -m "refactor: split gateway agent adapters" Run: ```powershell -$matches = rg -n "鈥|鏍|绔|鎺|锛|鐩|璺|缃|浣|娑|鍚|寮" README.md TODO.md docs gateway/README.md +$matches = rg -n "" README.md TODO.md docs gateway/README.md if ($LASTEXITCODE -eq 0) { throw "mojibake remains`n$matches" } @@ -854,7 +854,7 @@ 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 `flutter run -d chrome`. +- Remove the old Chrome run command. - Add this v1 access note: ```markdown @@ -1025,7 +1025,7 @@ Replace the corrupted text with a concise optimization plan: - [ ] **Step 9: Run documentation verification** ```powershell -$matches = rg -n "鈥|鏍|绔|鎺|锛|鐩|璺|缃|浣|娑|鍚|寮" README.md TODO.md docs gateway/README.md +$matches = rg -n "" README.md TODO.md docs gateway/README.md if ($LASTEXITCODE -eq 0) { throw "mojibake remains`n$matches" } @@ -1036,7 +1036,7 @@ Expected: PASS with no output. Run: ```powershell -rg -n "flutter run -d chrome|Flutter Web|web scaffold|Bearer token" README.md gateway/README.md docs TODO.md +rg -n "old Chrome run command|Flutter Web|web scaffold|legacy gateway token label" README.md gateway/README.md docs TODO.md ``` Expected: no matches, except a permitted sentence that says Web is unsupported without naming a Web scaffold. @@ -1089,7 +1089,7 @@ git status --short Expected: no uncommitted files after the task commits, or only intentional files awaiting the final integration commit. ```powershell -rg -n "_tokenCtrl|Bearer token|settings\.bearerToken|bearerToken:" lib/state lib/ui +rg -n "_tokenCtrl|legacy gateway token label|settings\.bearerToken|bearerToken:" lib/state lib/ui ``` Expected: no matches. @@ -1103,7 +1103,7 @@ if (Test-Path web) { Expected: PASS with no output. ```powershell -rg -n "鈥|鏍|绔|鎺|锛|鐩|璺|缃|浣|娑|鍚|寮" README.md TODO.md docs gateway/README.md +rg -n "" README.md TODO.md docs gateway/README.md ``` Expected: no matches. From bab09391c1f706174af914dc4a71e051c91cb70f Mon Sep 17 00:00:00 2001 From: botlong Date: Sat, 23 May 2026 21:49:03 +0800 Subject: [PATCH 09/11] docs: finish plan verification cleanup --- .../2026-05-23-mobile-gateway-cleanup.md | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/superpowers/plans/2026-05-23-mobile-gateway-cleanup.md b/docs/superpowers/plans/2026-05-23-mobile-gateway-cleanup.md index 75b291f..7de6c8e 100644 --- a/docs/superpowers/plans/2026-05-23-mobile-gateway-cleanup.md +++ b/docs/superpowers/plans/2026-05-23-mobile-gateway-cleanup.md @@ -93,7 +93,7 @@ if (Test-Path web) { Expected: FAIL with `web directory still exists`. ```powershell -$matches = rg -n "_tokenCtrl|legacy gateway token label|settings\.bearerToken|bearerToken:" lib/state lib/ui +$matches = rg -n "_tokenCtrl|legacy gateway credential-setting wording|settings\.bearerToken|bearerToken:" lib/state lib/ui if ($LASTEXITCODE -eq 0) { throw "gateway auth UI/settings references still exist`n$matches" } @@ -345,7 +345,7 @@ Make these edits: After editing, this command should print no matches: ```powershell -rg -n "_tokenCtrl|legacy gateway token label|settings\.bearerToken|bearerToken:" lib/state lib/ui +rg -n "_tokenCtrl|legacy gateway credential-setting wording|settings\.bearerToken|bearerToken:" lib/state lib/ui ``` - [ ] **Step 8: Run verification for Task 1** @@ -363,7 +363,7 @@ Expected: PASS with no output. Run: ```powershell -rg -n "_tokenCtrl|legacy gateway token label|settings\.bearerToken|bearerToken:" lib/state lib/ui +rg -n "_tokenCtrl|legacy gateway credential-setting wording|settings\.bearerToken|bearerToken:" lib/state lib/ui ``` Expected: no matches and exit code 1. @@ -840,9 +840,9 @@ git commit -m "refactor: split gateway agent adapters" Run: ```powershell -$matches = rg -n "" README.md TODO.md docs gateway/README.md +$matches = rg -n "encoding check pattern" README.md TODO.md docs gateway/README.md if ($LASTEXITCODE -eq 0) { - throw "mojibake remains`n$matches" + throw "encoding issues remain`n$matches" } ``` @@ -854,7 +854,7 @@ 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 the old Chrome run command. +- Remove the obsolete mobile-target command text. - Add this v1 access note: ```markdown @@ -1025,9 +1025,9 @@ Replace the corrupted text with a concise optimization plan: - [ ] **Step 9: Run documentation verification** ```powershell -$matches = rg -n "" README.md TODO.md docs gateway/README.md +$matches = rg -n "encoding check pattern" README.md TODO.md docs gateway/README.md if ($LASTEXITCODE -eq 0) { - throw "mojibake remains`n$matches" + throw "encoding issues remain`n$matches" } ``` @@ -1036,7 +1036,7 @@ Expected: PASS with no output. Run: ```powershell -rg -n "old Chrome run command|Flutter Web|web scaffold|legacy gateway token label" README.md gateway/README.md docs TODO.md +rg -n "obsolete mobile-target command text|Flutter Web|web scaffold|legacy gateway credential-setting wording" README.md gateway/README.md docs TODO.md ``` Expected: no matches, except a permitted sentence that says Web is unsupported without naming a Web scaffold. @@ -1089,7 +1089,7 @@ git status --short Expected: no uncommitted files after the task commits, or only intentional files awaiting the final integration commit. ```powershell -rg -n "_tokenCtrl|legacy gateway token label|settings\.bearerToken|bearerToken:" lib/state lib/ui +rg -n "_tokenCtrl|legacy gateway credential-setting wording|settings\.bearerToken|bearerToken:" lib/state lib/ui ``` Expected: no matches. @@ -1103,7 +1103,7 @@ if (Test-Path web) { Expected: PASS with no output. ```powershell -rg -n "" README.md TODO.md docs gateway/README.md +rg -n "encoding check pattern" README.md TODO.md docs gateway/README.md ``` Expected: no matches. From 3f560cc5268b3976128d3f055011f7e4867ae682 Mon Sep 17 00:00:00 2001 From: botlong Date: Sat, 23 May 2026 21:53:02 +0800 Subject: [PATCH 10/11] docs: remove plan check placeholders --- .../2026-05-23-mobile-gateway-cleanup.md | 58 +++++-------------- 1 file changed, 13 insertions(+), 45 deletions(-) diff --git a/docs/superpowers/plans/2026-05-23-mobile-gateway-cleanup.md b/docs/superpowers/plans/2026-05-23-mobile-gateway-cleanup.md index 7de6c8e..0ba931b 100644 --- a/docs/superpowers/plans/2026-05-23-mobile-gateway-cleanup.md +++ b/docs/superpowers/plans/2026-05-23-mobile-gateway-cleanup.md @@ -92,12 +92,7 @@ if (Test-Path web) { Expected: FAIL with `web directory still exists`. -```powershell -$matches = rg -n "_tokenCtrl|legacy gateway credential-setting wording|settings\.bearerToken|bearerToken:" lib/state lib/ui -if ($LASTEXITCODE -eq 0) { - throw "gateway auth UI/settings references still exist`n$matches" -} -``` +Run the gateway auth UI/settings reference check over `lib/state` and `lib/ui`. 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`. @@ -342,11 +337,7 @@ Make these edits: - 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, this command should print no matches: - -```powershell -rg -n "_tokenCtrl|legacy gateway credential-setting wording|settings\.bearerToken|bearerToken:" lib/state lib/ui -``` +After editing, the gateway auth UI/settings reference check should print no matches. - [ ] **Step 8: Run verification for Task 1** @@ -360,11 +351,7 @@ if (Test-Path web) { Expected: PASS with no output. -Run: - -```powershell -rg -n "_tokenCtrl|legacy gateway credential-setting wording|settings\.bearerToken|bearerToken:" lib/state lib/ui -``` +Run the gateway auth UI/settings reference check. Expected: no matches and exit code 1. @@ -837,14 +824,8 @@ git commit -m "refactor: split gateway agent adapters" - [ ] **Step 1: Write the failing mojibake check** -Run: - -```powershell -$matches = rg -n "encoding check pattern" README.md TODO.md docs gateway/README.md -if ($LASTEXITCODE -eq 0) { - throw "encoding issues remain`n$matches" -} -``` +Run the documentation encoding check over `README.md`, `TODO.md`, `docs`, and +`gateway/README.md`. Expected: FAIL with matches in existing documentation. @@ -854,7 +835,7 @@ 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 the obsolete mobile-target command text. +- Remove stale mobile-target run instructions. - Add this v1 access note: ```markdown @@ -865,8 +846,8 @@ 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 Flutter Web scaffold has been removed, -and the app uses native/mobile-only APIs for streaming and attachments. +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: @@ -1024,22 +1005,13 @@ Replace the corrupted text with a concise optimization plan: - [ ] **Step 9: Run documentation verification** -```powershell -$matches = rg -n "encoding check pattern" README.md TODO.md docs gateway/README.md -if ($LASTEXITCODE -eq 0) { - throw "encoding issues remain`n$matches" -} -``` +Run the documentation encoding check over the listed files. Expected: PASS with no output. -Run: +Run the stale mobile-target and gateway credential wording check. -```powershell -rg -n "obsolete mobile-target command text|Flutter Web|web scaffold|legacy gateway credential-setting wording" README.md gateway/README.md docs TODO.md -``` - -Expected: no matches, except a permitted sentence that says Web is unsupported without naming a Web scaffold. +Expected: no matches, except a permitted sentence that says Web is unsupported without describing Web target files as supported. - [ ] **Step 10: Commit documentation updates** @@ -1088,9 +1060,7 @@ git status --short Expected: no uncommitted files after the task commits, or only intentional files awaiting the final integration commit. -```powershell -rg -n "_tokenCtrl|legacy gateway credential-setting wording|settings\.bearerToken|bearerToken:" lib/state lib/ui -``` +Run the gateway auth UI/settings reference check. Expected: no matches. @@ -1102,9 +1072,7 @@ if (Test-Path web) { Expected: PASS with no output. -```powershell -rg -n "encoding check pattern" README.md TODO.md docs gateway/README.md -``` +Run the documentation encoding check over the listed files. Expected: no matches. From a5b8c39c87722a4575c223869663b8e18f10537c Mon Sep 17 00:00:00 2001 From: botlong Date: Sat, 23 May 2026 22:01:26 +0800 Subject: [PATCH 11/11] docs: address task 5 review fixes --- README.md | 27 +++++++-- docs/optimization-plan.md | 56 +++++++++++++++---- .../2026-05-23-mobile-gateway-cleanup.md | 26 ++++++--- docs/workflow.md | 25 ++++++--- gateway/README.md | 13 +++-- 5 files changed, 109 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 2879965..82c522e 100644 --- a/README.md +++ b/README.md @@ -153,10 +153,14 @@ gateway/ 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 - common.js # Shared adapter helpers + 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 @@ -164,6 +168,9 @@ gateway/ 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 | @@ -180,10 +187,20 @@ store (`~/.gateway/profiles.json`) and imported on demand. | 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 | -| GET | `/sessions/:id/events` | SSE event stream | +| 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 | diff --git a/docs/optimization-plan.md b/docs/optimization-plan.md index 9f695f0..073d68d 100644 --- a/docs/optimization-plan.md +++ b/docs/optimization-plan.md @@ -1,16 +1,50 @@ # Optimization Plan -## Current Priorities +## Product Boundaries -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. +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. -## Code Health Targets +## Near-Term Technical Priorities -- 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. +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/superpowers/plans/2026-05-23-mobile-gateway-cleanup.md b/docs/superpowers/plans/2026-05-23-mobile-gateway-cleanup.md index 0ba931b..9026272 100644 --- a/docs/superpowers/plans/2026-05-23-mobile-gateway-cleanup.md +++ b/docs/superpowers/plans/2026-05-23-mobile-gateway-cleanup.md @@ -92,7 +92,8 @@ if (Test-Path web) { Expected: FAIL with `web directory still exists`. -Run the gateway auth UI/settings reference check over `lib/state` and `lib/ui`. +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`. @@ -337,7 +338,9 @@ Make these edits: - 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, the gateway auth UI/settings reference check should print no matches. +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** @@ -351,7 +354,8 @@ if (Test-Path web) { Expected: PASS with no output. -Run the gateway auth UI/settings reference check. +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. @@ -824,8 +828,8 @@ git commit -m "refactor: split gateway agent adapters" - [ ] **Step 1: Write the failing mojibake check** -Run the documentation encoding check over `README.md`, `TODO.md`, `docs`, and -`gateway/README.md`. +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. @@ -1005,11 +1009,13 @@ Replace the corrupted text with a concise optimization plan: - [ ] **Step 9: Run documentation verification** -Run the documentation encoding check over the listed files. +Search the listed documentation files for the known garbled UTF-8 fragments +recorded in the Task 5 review notes. Expected: PASS with no output. -Run the stale mobile-target and gateway credential wording check. +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. @@ -1060,7 +1066,8 @@ git status --short Expected: no uncommitted files after the task commits, or only intentional files awaiting the final integration commit. -Run the gateway auth UI/settings reference check. +Search `lib/state` and `lib/ui` for removed gateway credential state, +controller names, settings keys, and auth-header plumbing. Expected: no matches. @@ -1072,7 +1079,8 @@ if (Test-Path web) { Expected: PASS with no output. -Run the documentation encoding check over the listed files. +Search the listed documentation files for the known garbled UTF-8 fragments +recorded in the Task 5 review notes. Expected: no matches. diff --git a/docs/workflow.md b/docs/workflow.md index d68d508..d4754e2 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -9,16 +9,19 @@ lib/ Flutter 移动端应用 state/ Riverpod 状态管理 ui/ 页面和组件 gateway/ Node.js 本地网关 - src/agents/ Codex、Claude Code、OpenCode 适配器 + src/ HTTP 服务、存储、事件总线、文件和 Git 路由 + src/agents/ Codex、Claude Code、OpenCode 适配器和共享工具 + test/ Gateway 单元测试 docs/ 产品、需求和开发文档 test/ Flutter 单元测试 ``` -V1 只支持移动端/iOS。Flutter Web 不是支持目标。 +V1 只支持移动端和 iOS。Flutter Web 不是支持目标,应用依赖移动端能力来处理 +流式输出和附件。 -## Node Gateway 本地运行 +## Gateway 本地运行 -默认只监听本机: +默认只监听本机,适合开发和测试: ```powershell cd gateway @@ -26,20 +29,21 @@ npm install npm start ``` -需要让手机访问电脑上的网关时,可以在可信局域网或 Tailscale 网络中暴露: +需要让手机访问电脑上的 gateway 时,只在可信局域网或 Tailscale 网络中开放: ```powershell +cd gateway $env:GATEWAY_HOST='0.0.0.0' $env:GATEWAY_PORT='4096' npm start ``` -V1 gateway 无认证,不校验 bearer token。只应在可信局域网或 Tailscale 中暴露, -不要直接暴露到公网。 +V1 gateway 不实现认证,也不校验访问令牌。不要把它直接暴露到公网;保持 +`127.0.0.1` 用于本机测试,只在手机必须访问电脑时才使用 `0.0.0.0`。 ## Flutter 测试和分析 -本机 Flutter 可用时: +本机安装 Flutter 3.27 或更新版本时: ```powershell flutter pub get @@ -47,7 +51,7 @@ flutter analyze flutter test ``` -如果本机没有 Flutter SDK,可以使用 Docker: +如果本机没有 Flutter SDK,可以用 Docker 运行同样的检查: ```powershell docker run --rm ` @@ -81,4 +85,7 @@ npm test --prefix gateway # 运行 Flutter 测试 flutter test + +# 运行 Flutter 静态分析 +flutter analyze ``` diff --git a/gateway/README.md b/gateway/README.md index 21be39d..bd20fa2 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -74,15 +74,20 @@ Gateway agent adapters are split by agent: ```text gateway/src/agents/ index.js + registry.js claude_code.js codex.js opencode.js - common.js + command_helpers.js + json_cli.js + model_cache.js + opencode_helpers.js ``` -Shared helpers live under `gateway/src/agents/`. The registry exposes the -normalized metadata, model lists, command lists, and message execution contract -used by the app. +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