@@ -617,15 +669,15 @@ export function ConversationView(props: {
agentOptions={agentOptions}
modelOptions={modelOptions}
onAgentTypeChange={changeAgentType}
- onModelChange={setModel}
+ onModelChange={handleModelChange}
effort={effort}
- onEffortChange={setEffort}
+ onEffortChange={handleEffortChange}
permissionMode={permissionMode}
- onPermissionModeChange={setPermissionMode}
+ onPermissionModeChange={handlePermissionModeChange}
fastMode={fastMode}
- onFastModeChange={setFastMode}
+ onFastModeChange={handleFastModeChange}
planMode={planMode}
- onPlanModeChange={setPlanMode}
+ onPlanModeChange={handlePlanModeChange}
conversationSummary={conversationSummary}
contextSummary={contextSummary}
usageSummary={usageSummary}
diff --git a/evcod/webui/src/components/DirectoryPicker.tsx b/evcod/webui/src/components/DirectoryPicker.tsx
index 3122862..3de20a5 100644
--- a/evcod/webui/src/components/DirectoryPicker.tsx
+++ b/evcod/webui/src/components/DirectoryPicker.tsx
@@ -102,7 +102,7 @@ export function DirectoryPicker(props: {
{crumbs.length === 0 ? Default locations : null}
{crumbs.map((crumb, index) => (
-
+
load(crumb.path)}>{crumb.label}
{index < crumbs.length - 1 ? / : null}
@@ -149,8 +149,8 @@ export function DirectoryPicker(props: {
No sub-folders here.
) : null}
- {listing?.entries.map((entry) => (
-
load(entry.path)}>
+ {listing?.entries.map((entry, index) => (
+ load(entry.path)}>
{entry.name}
{entry.path}
diff --git a/evcod/webui/src/components/HostWorkspace.tsx b/evcod/webui/src/components/HostWorkspace.tsx
index 17fb198..791f49b 100644
--- a/evcod/webui/src/components/HostWorkspace.tsx
+++ b/evcod/webui/src/components/HostWorkspace.tsx
@@ -426,8 +426,8 @@ function HostFilesTab(props: { api: CoreApi; homeDir?: string }) {
{listing.parent}
) : null}
- {(listing?.entries ?? []).map((entry) => (
- entry.isDir && void load(entry.path)} />
+ {(listing?.entries ?? []).map((entry, index) => (
+ entry.isDir && void load(entry.path)} />
))}
diff --git a/evcod/webui/src/types.ts b/evcod/webui/src/types.ts
index 23ccaaa..5fc2841 100644
--- a/evcod/webui/src/types.ts
+++ b/evcod/webui/src/types.ts
@@ -163,6 +163,11 @@ export type AgentSession = {
conversationId: string;
paneId?: string;
agentType: string;
+ model?: string;
+ permissionMode?: string;
+ effort?: string;
+ fastMode?: boolean;
+ planMode?: boolean;
status: string;
statusReason?: string;
providerSessionId?: string;
diff --git a/evcod_warp/src/native-agent.js b/evcod_warp/src/native-agent.js
index 749db74..5e93532 100644
--- a/evcod_warp/src/native-agent.js
+++ b/evcod_warp/src/native-agent.js
@@ -1,6 +1,6 @@
import { spawn } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs';
-import { basename, extname, join, normalize, resolve } from 'node:path';
+import { basename, extname, join, normalize, resolve, sep } from 'node:path';
import { homedir, platform } from 'node:os';
import {
normalizeClaudeTranscriptLine,
@@ -41,7 +41,11 @@ export class NativeAgentController {
this.projectPath = projectPath;
this.projectId = projectId;
this.model = model;
+ this.lastSyncedModel = String(model ?? '').trim() || undefined;
this.permissionMode = normalizePermissionMode(permissionMode);
+ this.effort = undefined;
+ this.fastMode = false;
+ this.planMode = false;
this.passthroughArgs = Array.isArray(passthroughArgs) ? passthroughArgs : [];
this.homeDir = homeDir;
this.env = env;
@@ -55,6 +59,7 @@ export class NativeAgentController {
this.turnCompletionWaiters = new Map();
this.implicitCompletionTimers = new Map();
this.emitted = new Map();
+ this.ownershipCache = new Map();
this.fallbackMessageSeq = 0;
this.transcriptEpoch = 0;
this.lastKnownSize = 0;
@@ -208,6 +213,14 @@ export class NativeAgentController {
} else if (event.event === 'agent.cancel.requested') {
if (this.paneId) await this.core.inputPane(this.paneId, '\x03').catch(() => undefined);
await this.#completeActiveTurn('canceled').catch(() => undefined);
+ } else if (event.event === 'agent.config.changed') {
+ await this.#applySessionConfigEvent(event.payload);
+ } else if (event.event === 'agent.model.changed') {
+ // The web UI changed the model on a running session: type the provider's
+ // model-switch command into the native TUI so both sides stay in sync.
+ // Only act on web-originated changes (source "launch"/"terminal" are
+ // already reflected by the CLI and must not be re-injected).
+ await this.#applySessionConfigEvent(event.payload, { modelOnly: true });
}
}
@@ -269,6 +282,22 @@ export class NativeAgentController {
await this.#rediscoverTranscript();
return;
}
+ let grew = true;
+ try {
+ grew = statSync(this.transcriptPath).size !== this.lastKnownSize;
+ } catch {
+ grew = true;
+ }
+ await this.#syncCurrentTranscript();
+ // When the current transcript is idle, the agent may have rotated to a new
+ // file (claude `/clear`, a fresh codex rollout). Follow that file so messages
+ // produced after the rotation are not lost.
+ if (!grew && this.#followRotation()) {
+ await this.#syncCurrentTranscript();
+ }
+ }
+
+ async #syncCurrentTranscript() {
if (this.agentConfig.transcriptFormat === 'json') {
await this.#syncJsonTranscript();
} else {
@@ -276,6 +305,51 @@ export class NativeAgentController {
}
}
+ // Switch to a newer transcript that belongs to this session. Only files we can
+ // attribute to this agent are eligible: those under a project-scoped search
+ // root, or (codex, whose sessions dir is global) whose session_meta cwd matches
+ // this project. Returns true when the active transcript was switched.
+ #followRotation() {
+ if (this.stopped || !this.agentConfig) return false;
+ let currentMtime = 0;
+ try {
+ currentMtime = statSync(this.transcriptPath).mtimeMs;
+ } catch {
+ currentMtime = 0;
+ }
+ let best;
+ for (const file of snapshotTranscriptFiles(this.agentConfig).values()) {
+ if (file.path === this.transcriptPath || file.mtimeMs <= currentMtime) continue;
+ if (!this.#isOwnedTranscript(file.path)) continue;
+ if (!best || file.mtimeMs > best.mtimeMs) best = file;
+ }
+ if (!best) return false;
+ this.transcriptPath = best.path;
+ this.lastKnownSize = 0;
+ this.lastMessageCount = 0;
+ this.partialLine = '';
+ this.fallbackMessageSeq = 0;
+ this.transcriptEpoch++;
+ return true;
+ }
+
+ #isOwnedTranscript(path) {
+ const cached = this.ownershipCache.get(path);
+ if (cached !== undefined) return cached;
+ let owned = false;
+ for (const root of this.agentConfig.searchRoots ?? []) {
+ if (root.scoped && isPathInside(path, root.dir)) {
+ owned = true;
+ break;
+ }
+ }
+ if (!owned && this.agentType === 'codex') {
+ owned = codexTranscriptCwd(path) === resolve(this.projectPath);
+ }
+ this.ownershipCache.set(path, owned);
+ return owned;
+ }
+
async #rediscoverTranscript() {
if (!this.agentConfig || this.stopped) return;
const transcript = await this.#discoverTranscript(this.discoveryBaseline ?? new Map(), 1);
@@ -351,6 +425,10 @@ export class NativeAgentController {
const partKey = messageKey(message, fallbackKey);
if (this.emitted.has(partKey)) continue;
this.emitted.set(partKey, true);
+ if (message.kind === 'session_config') {
+ await this.#applyCliSessionConfig(message.payload);
+ continue;
+ }
if (message.role === 'user') {
await this.#emitUserMessage(message);
continue;
@@ -361,6 +439,53 @@ export class NativeAgentController {
}
}
+ // Mirror a CLI-side config change (e.g. codex `/model` typed in the TUI) back
+ // to the core so the web UI's selector follows it. Tagged source "terminal"
+ // so the core does not echo it back as a model-switch keystroke.
+ async #applyCliSessionConfig(config) {
+ const model = String(config?.model ?? '').trim();
+ const permissionMode = config?.permissionMode == null || config?.permissionMode === '' ? '' : normalizePermissionMode(config.permissionMode);
+ const effort = String(config?.effort ?? '').trim();
+ const patch = { configSource: 'terminal' };
+ if (model && model !== this.lastSyncedModel) {
+ this.lastSyncedModel = model;
+ patch.model = model;
+ }
+ if (permissionMode && permissionMode !== this.permissionMode) {
+ this.permissionMode = permissionMode;
+ patch.permissionMode = permissionMode;
+ }
+ if (effort && effort !== this.effort) {
+ this.effort = effort;
+ patch.effort = effort;
+ }
+ if (Object.keys(patch).length === 1 || !this.session?.id) return;
+ if (typeof this.core.updateSession !== 'function') return;
+ this.session = await this.core.updateSession(this.session.id, patch).catch(() => this.session);
+ }
+
+ async #applySessionConfigEvent(config, { modelOnly = false } = {}) {
+ const source = String(config?.source ?? 'web');
+ const model = String(config?.model ?? '').trim();
+ const permissionMode = config?.permissionMode == null || config?.permissionMode === '' ? '' : normalizePermissionMode(config.permissionMode);
+ const effort = String(config?.effort ?? '').trim();
+ const shouldSwitchModel = source === 'web' && model && model !== this.lastSyncedModel;
+ if (model) {
+ this.model = model;
+ this.lastSyncedModel = model;
+ }
+ if (!modelOnly) {
+ if (permissionMode) this.permissionMode = permissionMode;
+ if (effort) this.effort = effort;
+ if (typeof config?.fastMode === 'boolean') this.fastMode = config.fastMode;
+ if (typeof config?.planMode === 'boolean') this.planMode = config.planMode;
+ }
+ if (shouldSwitchModel && this.paneId) {
+ const input = nativeModelSwitchInput(this.agentType, model);
+ if (input) await this.core.inputPane(this.paneId, input).catch(() => undefined);
+ }
+ }
+
async #emitUserMessage(message) {
const content = String(message.content ?? '');
const normalized = normalizePrompt(content);
@@ -538,7 +663,7 @@ export function getAgentConfig(agentType, projectPath, { homeDir = homedir(), en
command: env.EVCOD_CLAUDE_BIN ?? env.CLAUDE_BIN ?? 'claude',
args: [...permissionArgs, ...modelArgs, '--add-dir', projectPath, ...extraArgs],
ensureDirs: [transcriptDir],
- searchRoots: [{ dir: transcriptDir, recursive: false }],
+ searchRoots: [{ dir: transcriptDir, recursive: false, scoped: true }],
extensions: ['.jsonl'],
transcriptFormat: 'jsonl',
parseTranscriptLine: normalizeClaudeTranscriptLine,
@@ -554,7 +679,7 @@ export function getAgentConfig(agentType, projectPath, { homeDir = homedir(), en
command: env.EVCOD_CODEX_BIN ?? env.CODEX_BIN ?? 'codex',
args: [...modelArgs, ...extraArgs],
ensureDirs: [transcriptDir],
- searchRoots: [{ dir: transcriptDir, recursive: true }],
+ searchRoots: [{ dir: transcriptDir, recursive: true, scoped: false }],
extensions: ['.jsonl'],
transcriptFormat: 'jsonl',
parseTranscriptLine: normalizeCodexTranscriptLine,
@@ -572,8 +697,8 @@ export function getAgentConfig(agentType, projectPath, { homeDir = homedir(), en
args: [...modelArgs, ...extraArgs],
ensureDirs: [projectChatDir],
searchRoots: [
- { dir: projectChatDir, recursive: false },
- { dir: join(geminiRoot, 'tmp'), recursive: true },
+ { dir: projectChatDir, recursive: false, scoped: true },
+ { dir: join(geminiRoot, 'tmp'), recursive: true, scoped: false },
],
extensions: ['.json'],
transcriptFormat: 'json',
@@ -589,7 +714,7 @@ export function getAgentConfig(agentType, projectPath, { homeDir = homedir(), en
command: env.EVCOD_QWEN_BIN ?? env.QWEN_BIN ?? 'qwen',
args: [...modelArgs, ...extraArgs],
ensureDirs: [transcriptDir],
- searchRoots: [{ dir: transcriptDir, recursive: false }],
+ searchRoots: [{ dir: transcriptDir, recursive: false, scoped: true }],
extensions: ['.jsonl'],
transcriptFormat: 'jsonl',
parseTranscriptLine: normalizeQwenTranscriptLine,
@@ -603,6 +728,19 @@ export function getAgentConfig(agentType, projectPath, { homeDir = homedir(), en
}
}
+// Keystrokes that switch the active model inside a native agent TUI. Best-effort
+// and consistent with how prompts/permissions are injected: claude/codex/gemini/
+// qwen all expose a `/model
` slash command in their interactive UI.
+export function nativeModelSwitchInput(agentType, model) {
+ const trimmed = String(model ?? '').trim();
+ if (!trimmed) return '';
+ const normalized = String(agentType ?? '').toLowerCase();
+ if (['claude', 'codex', 'gemini', 'qwen'].includes(normalized)) {
+ return terminalSubmit(`/model ${trimmed}`);
+ }
+ return '';
+}
+
function nativeModelArgs(agentType, model) {
const trimmed = String(model ?? '').trim();
if (!trimmed) return [];
@@ -618,7 +756,38 @@ function nativeModelArgs(agentType, model) {
}
export function encodeProjectPath(path) {
- return resolve(path).replace(/[^a-zA-Z0-9]/g, '-');
+ const raw = String(path ?? '');
+ const absolute = /^[a-zA-Z]:[\\/]/.test(raw) ? raw : resolve(raw);
+ return absolute.replace(/[^a-zA-Z0-9]/g, '-');
+}
+
+function isPathInside(path, dir) {
+ const base = resolve(dir);
+ const target = resolve(path);
+ return target === base || target.startsWith(base + sep);
+}
+
+// Read the cwd a codex rollout was started in (recorded in its session_meta
+// record) so a globally-stored rollout can be attributed to a project.
+function codexTranscriptCwd(path) {
+ try {
+ for (const raw of readFileSync(path, 'utf8').split(/\r?\n/)) {
+ if (!raw.trim()) continue;
+ let record;
+ try {
+ record = JSON.parse(raw);
+ } catch {
+ continue;
+ }
+ const cwd = record?.payload?.cwd ?? record?.cwd;
+ if (record?.type === 'session_meta' || cwd) {
+ return cwd ? resolve(String(cwd)) : '';
+ }
+ }
+ } catch {
+ // Unreadable file: treat as unattributable.
+ }
+ return '';
}
function snapshotTranscriptFiles(config) {
@@ -706,8 +875,19 @@ function agentPromptFromMessage(message, payload) {
return String(payload?.agentContent ?? message?.payload?.agentContent ?? message?.content ?? '');
}
+// Inject a chat prompt into a native agent's TUI and submit it. A bare CR
+// submits the current input, so a multi-line prompt sent as "a\rb\r" would be
+// submitted line-by-line (splitting one prompt into several and breaking the
+// web-echo dedup). Modern agent TUIs (claude/codex/gemini/qwen) enable
+// bracketed paste, so wrap multi-line text in paste markers — newlines inside a
+// paste are treated as literal newlines — then send a single trailing CR to
+// submit the whole prompt at once. Single-line prompts keep the simple path.
function terminalSubmit(value) {
- return `${String(value ?? '').replace(/\r?\n/g, '\r')}\r`;
+ const text = String(value ?? '');
+ if (/[\r\n]/.test(text)) {
+ return `\x1b[200~${text.replace(/\r\n/g, '\n')}\x1b[201~\r`;
+ }
+ return `${text}\r`;
}
function nativePermissionResponseInput(agentType, { response, allow, permission = {} } = {}) {
diff --git a/evcod_warp/src/normalizer.js b/evcod_warp/src/normalizer.js
index d226383..8d6d869 100644
--- a/evcod_warp/src/normalizer.js
+++ b/evcod_warp/src/normalizer.js
@@ -157,6 +157,20 @@ export function normalizeCodexTranscriptLine(line) {
const type = payload.type;
const special = normalizeSpecialMessage(payload, `codex:${type ?? line.type ?? 'event'}:${payload.id ?? line.timestamp ?? ''}`);
if (special) return [special];
+ // Codex records the active model (and approval/sandbox) per turn in a
+ // turn_context record. Surface it as a session_config signal so the controller
+ // can mirror a CLI-side model switch back to the web UI (CLI -> UI sync).
+ if (line.type === 'turn_context') {
+ const model = String(payload.model ?? payload.collaboration_mode?.settings?.model ?? '').trim();
+ const permissionMode = codexPermissionMode(payload.approval_policy ?? payload.approvalPolicy ?? payload.collaboration_mode?.settings?.approval_policy);
+ const effort = String(payload.reasoning_effort ?? payload.reasoningEffort ?? payload.collaboration_mode?.settings?.reasoning_effort ?? '').trim();
+ const config = {};
+ if (model) config.model = model;
+ if (permissionMode) config.permissionMode = permissionMode;
+ if (effort) config.effort = effort;
+ if (Object.keys(config).length === 0) return [];
+ return [{ kind: 'session_config', status: 'completed', payload: config, key: `codex:turn_context:${payload.turn_id ?? line.timestamp ?? model ?? ''}` }];
+ }
if (line.type === 'response_item') {
if (type === 'message') {
const text = extractCodexText(payload.content);
@@ -234,31 +248,15 @@ export function normalizeCodexTranscriptLine(line) {
const usage = payload.info?.last_token_usage ?? payload.info?.total_token_usage ?? payload.info ?? payload;
return [{ kind: 'usage', status: 'completed', payload: usage, key: `codex:usage:${line.timestamp ?? JSON.stringify(usage).slice(0, 80)}` }];
}
- if (line.type === 'event_msg' && type === 'user_message') {
- const content = codexUserMessageText(payload);
- return content.trim() && !shouldIgnoreSyntheticCodexUserMessage(content)
- ? [{
- role: 'user',
- kind: 'text',
- content,
- status: 'completed',
- payload,
- key: `codex:user:${payload.id ?? line.timestamp ?? content.slice(0, 40)}`,
- }]
- : [];
- }
- if (line.type === 'event_msg' && isCodexAssistantMessageType(type)) {
- const content = extractCodexText(payload.message ?? payload.text ?? payload.content ?? payload.delta);
- return content
- ? [{
- role: 'assistant',
- kind: 'text',
- content,
- status: 'completed',
- payload,
- key: `codex:${type}:${payload.id ?? line.timestamp ?? content.slice(0, 40)}`,
- }]
- : [];
+ // Codex writes every user/assistant message to the rollout twice: once as a
+ // durable `response_item` (handled above) and once as an `event_msg`
+ // (`user_message` / `agent_message`) for live streaming. Mirroring both would
+ // duplicate every message in the web chat, so the durable `response_item` is
+ // the single source of truth for message text and these `event_msg` variants
+ // are ignored. Reasoning is the exception: `response_item` reasoning summaries
+ // are often empty/encrypted, so readable reasoning only arrives via `event_msg`.
+ if (line.type === 'event_msg' && (type === 'user_message' || isCodexAssistantMessageType(type))) {
+ return [];
}
if (line.type === 'event_msg' && isCodexReasoningType(type)) {
const content = extractCodexText(payload.delta ?? payload.text ?? payload.message ?? payload.reasoning ?? payload.content);
@@ -285,24 +283,21 @@ export function normalizeCodexTranscriptLine(line) {
return [];
}
-function codexUserMessageText(payload) {
- if (payload.message != null) return String(payload.message);
- if (payload.text != null) return String(payload.text);
- if (payload.content != null) return extractCodexText(payload.content);
- if (Array.isArray(payload.text_elements)) {
- return payload.text_elements
- .map((part) => (typeof part === 'string' ? part : part.text ?? part.content ?? part.message ?? ''))
- .filter(Boolean)
- .join('\n');
- }
- return '';
-}
-
function shouldIgnoreSyntheticCodexUserMessage(content) {
const normalized = String(content ?? '').trim();
return normalized.startsWith('# AGENTS.md instructions') || normalized.startsWith('');
}
+function codexPermissionMode(value) {
+ const normalized = String(value ?? '').trim().toLowerCase();
+ if (!normalized) return '';
+ if (['never', 'on-request', 'on_request', 'ask', 'ask-first', 'ask_first'].includes(normalized)) return 'ask-first';
+ if (['read-only', 'read_only', 'readonly'].includes(normalized)) return 'read-only';
+ if (['on-failure', 'on_failure', 'untrusted'].includes(normalized)) return 'ask-first';
+ if (['always', 'full-access', 'full_access', 'danger-full-access'].includes(normalized)) return 'full-access';
+ return '';
+}
+
function isCodexAssistantMessageType(type) {
return [
'agent_message',
diff --git a/evcod_warp/src/opencode-tui.js b/evcod_warp/src/opencode-tui.js
index 2339ae5..2ce15f2 100644
--- a/evcod_warp/src/opencode-tui.js
+++ b/evcod_warp/src/opencode-tui.js
@@ -22,7 +22,12 @@ export class OpencodeTuiController {
this.paneId = paneId;
this.projectId = projectId;
this.cwd = cwd;
- this.model = model;
+ this.model = parseOpencodeModel(model) ?? model;
+ this.modelKey = opencodeModelKey(this.model);
+ this.permissionMode = undefined;
+ this.effort = undefined;
+ this.fastMode = false;
+ this.planMode = false;
this.attach = attach;
this.server = undefined;
this.serverProc = undefined;
@@ -118,6 +123,8 @@ export class OpencodeTuiController {
if (message?.source === 'agent' || source === 'agent' || source === 'terminal') return;
const turnId = String(event.payload.turnId ?? message?.turnId ?? '');
await this.enqueuePrompt(agentPromptFromMessage(message, event.payload), turnId, source);
+ } else if (event.event === 'agent.config.changed' || event.event === 'agent.model.changed') {
+ await this.#applySessionConfigEvent(event.payload, { modelOnly: event.event === 'agent.model.changed' });
} else if (event.event === 'agent.cancel.requested') {
if (this.currentTurnId) this.cancelledTurns.add(this.currentTurnId);
if (this.sessionId) await this.server.abort(this.sessionId);
@@ -378,6 +385,28 @@ export class OpencodeTuiController {
});
}
+ async #applySessionConfigEvent(config, { modelOnly = false } = {}) {
+ const source = String(config?.source ?? 'web');
+ const model = parseOpencodeModel(config?.model);
+ const nextModelKey = opencodeModelKey(model);
+ const shouldSwitchModel = source === 'web' && model && nextModelKey !== this.modelKey;
+ if (model) {
+ this.model = model;
+ this.modelKey = nextModelKey;
+ }
+ if (!modelOnly) {
+ const permissionMode = String(config?.permissionMode ?? '').trim();
+ const effort = String(config?.effort ?? '').trim();
+ if (permissionMode) this.permissionMode = permissionMode;
+ if (effort) this.effort = effort;
+ if (typeof config?.fastMode === 'boolean') this.fastMode = config.fastMode;
+ if (typeof config?.planMode === 'boolean') this.planMode = config.planMode;
+ }
+ if (shouldSwitchModel && this.paneId && typeof this.core.inputPane === 'function') {
+ await this.core.inputPane(this.paneId, terminalSubmit(`/model ${model.providerID}/${model.modelID}`)).catch(() => undefined);
+ }
+ }
+
async #completeActiveTurn(status) {
if (!this.currentTurnId || this.completedTurns.has(this.currentTurnId)) return;
const turnId = this.currentTurnId;
@@ -426,6 +455,24 @@ function quote(value) {
return `"${value.replace(/(["\\])/g, '\\$1')}"`;
}
+function parseOpencodeModel(value) {
+ if (value && typeof value === 'object' && value.providerID && value.modelID) {
+ return { providerID: String(value.providerID), modelID: String(value.modelID) };
+ }
+ const text = String(value ?? '').trim();
+ const slash = text.indexOf('/');
+ if (slash <= 0 || slash === text.length - 1) return undefined;
+ return { providerID: text.slice(0, slash), modelID: text.slice(slash + 1) };
+}
+
+function opencodeModelKey(model) {
+ return model?.providerID && model?.modelID ? `${model.providerID}/${model.modelID}` : '';
+}
+
+function terminalSubmit(value) {
+ return `${value}\r`;
+}
+
function normalizePrompt(value) {
return String(value ?? '').replace(/\r\n/g, '\n').trim();
}
diff --git a/evcod_warp/test/native-agent.test.js b/evcod_warp/test/native-agent.test.js
index 8efd9e7..6817e90 100644
--- a/evcod_warp/test/native-agent.test.js
+++ b/evcod_warp/test/native-agent.test.js
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
-import { existsSync } from 'node:fs';
+import { existsSync, utimesSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { NativeAgentController, encodeProjectPath, getAgentConfig } from '../src/native-agent.js';
@@ -427,6 +427,35 @@ for (const agentType of ['claude', 'codex', 'qwen']) {
});
}
+test('submits multi-line web prompts as a single bracketed paste, not line-by-line', async () => {
+ const projectPath = await mkdtemp(join(tmpdir(), 'evcod-native-multiline-'));
+ const calls = [];
+ const core = {
+ async inputPane(paneId, data) {
+ calls.push(['inputPane', paneId, data]);
+ },
+ };
+ const controller = new NativeAgentController({
+ core,
+ conversationId: 'conversation-1',
+ paneId: 'pane-1',
+ agentType: 'claude',
+ projectPath,
+ spawnImpl() {
+ throw new Error('spawn should not run in this test');
+ },
+ });
+ controller.inputReadyAt = 0;
+
+ const single = await controller.promptFromChat('just one line', 'turn-1', 'web');
+ assert.equal(single, true);
+ assert.deepEqual(calls.at(-1), ['inputPane', 'pane-1', 'just one line\r']);
+
+ await controller.promptFromChat('line one\nline two\nline three', 'turn-2', 'web');
+ // Wrapped in bracketed paste so the TUI keeps the newlines literal, then one CR.
+ assert.deepEqual(calls.at(-1), ['inputPane', 'pane-1', '\x1b[200~line one\nline two\nline three\x1b[201~\r']);
+});
+
test('mirrors Codex response_item user and event_msg assistant records into CORE', async () => {
const homeDir = await mkdtemp(join(tmpdir(), 'evcod-native-home-'));
const projectPath = await mkdtemp(join(tmpdir(), 'evcod-native-project-'));
@@ -477,7 +506,7 @@ test('mirrors Codex response_item user and event_msg assistant records into CORE
JSON.stringify({ type: 'response_item', timestamp: 'u1', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'from response item' }] } }),
JSON.stringify({ type: 'event_msg', timestamp: 'r1', payload: { type: 'agent_reasoning_delta', delta: 'inspect ' } }),
JSON.stringify({ type: 'event_msg', timestamp: 'r2', payload: { type: 'agent_reasoning_delta', delta: 'state' } }),
- JSON.stringify({ type: 'event_msg', timestamp: 'a1', payload: { type: 'agent_message', message: 'answer from event' } }),
+ JSON.stringify({ type: 'response_item', timestamp: 'a1', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'answer from event' }] } }),
JSON.stringify({ type: 'event_msg', timestamp: 'done1', payload: { type: 'task_complete', turn_id: 'codex-turn' } }),
'',
].join('\n'),
@@ -500,6 +529,72 @@ test('mirrors Codex response_item user and event_msg assistant records into CORE
await controller.stop();
});
+test('mirrors a CLI-side codex model switch back to the core as a terminal config change', async () => {
+ const homeDir = await mkdtemp(join(tmpdir(), 'evcod-native-home-'));
+ const projectPath = await mkdtemp(join(tmpdir(), 'evcod-native-project-'));
+ const transcriptPath = join(projectPath, 'codex-model.jsonl');
+ const calls = [];
+
+ const core = {
+ async updateSession(id, patch) {
+ calls.push(['updateSession', id, patch]);
+ return { id, ...patch };
+ },
+ async createAgentMessage(conversationId, message) {
+ calls.push(['createAgentMessage', conversationId, message]);
+ return { id: `message-${calls.length}`, ...message };
+ },
+ async sendUserMessage(conversationId, content, source) {
+ return { messages: [{ id: 'u', role: 'user', turnId: 't', content, source }] };
+ },
+ async acquireLock(id, owner, turnId) {
+ return { id, lockOwner: owner, lockTurnId: turnId };
+ },
+ async releaseLock(id, owner, status) {
+ return { id, status };
+ },
+ async completeTurn() {},
+ };
+
+ const controller = new NativeAgentController({
+ core,
+ conversationId: 'conversation-1',
+ paneId: 'pane-1',
+ agentType: 'codex',
+ projectPath,
+ homeDir,
+ model: 'gpt-5',
+ spawnImpl() {
+ throw new Error('spawn should not run in this test');
+ },
+ });
+ controller.agentConfig = getAgentConfig('codex', projectPath, { homeDir });
+ controller.parseTranscriptLine = controller.agentConfig.parseTranscriptLine;
+ controller.transcriptPath = transcriptPath;
+ controller.session = { id: 'session-1' };
+
+ await writeFile(
+ transcriptPath,
+ [
+ // Same model as launch -> no redundant push.
+ JSON.stringify({ type: 'turn_context', timestamp: 'tc1', payload: { turn_id: 't1', cwd: projectPath, model: 'gpt-5' } }),
+ // User switched the model in the TUI -> push back to core (source terminal).
+ JSON.stringify({ type: 'turn_context', timestamp: 'tc2', payload: { turn_id: 't2', cwd: projectPath, model: 'gpt-5.5' } }),
+ '',
+ ].join('\n'),
+ 'utf8',
+ );
+ await controller.syncOnce();
+
+ const updates = calls.filter((call) => call[0] === 'updateSession');
+ assert.equal(updates.length, 1);
+ assert.deepEqual(updates[0], ['updateSession', 'session-1', { model: 'gpt-5.5', configSource: 'terminal' }]);
+ // session_config signals must not leak into the chat as messages.
+ assert.equal(calls.filter((call) => call[0] === 'createAgentMessage').length, 0);
+
+ await controller.stop();
+});
+
test('native agent cancel sends Ctrl-C to the native terminal and releases the turn', async () => {
const homeDir = await mkdtemp(join(tmpdir(), 'evcod-native-home-'));
const projectPath = await mkdtemp(join(tmpdir(), 'evcod-native-project-'));
@@ -554,6 +649,117 @@ test('native agent cancel sends Ctrl-C to the native terminal and releases the t
assert.deepEqual(calls.find((call) => call[0] === 'releaseLock'), ['releaseLock', 'session-1', 'web', 'idle']);
});
+test('web model changes are injected into the native TUI, terminal-origin ones are not', async () => {
+ const projectPath = await mkdtemp(join(tmpdir(), 'evcod-native-model-'));
+ const calls = [];
+ let relayHandler;
+ const core = {
+ async inputPane(paneId, data) {
+ calls.push(['inputPane', paneId, data]);
+ },
+ subscribe(handler) {
+ relayHandler = handler;
+ return { readyState: 1, addEventListener() {}, close() {} };
+ },
+ close() {},
+ };
+ const controller = new NativeAgentController({
+ core,
+ conversationId: 'conversation-1',
+ paneId: 'pane-1',
+ agentType: 'codex',
+ projectPath,
+ spawnImpl() {
+ throw new Error('spawn should not run in this test');
+ },
+ });
+ controller.session = { id: 'session-1' };
+ controller.subscribe();
+
+ relayHandler({
+ type: 'event',
+ event: 'agent.model.changed',
+ payload: { conversationId: 'conversation-1', model: 'gpt-5-codex', source: 'web' },
+ });
+ // Core also emits the broader config event for the same model change; the
+ // native TUI should receive one slash command, not two.
+ relayHandler({
+ type: 'event',
+ event: 'agent.config.changed',
+ payload: { conversationId: 'conversation-1', model: 'gpt-5-codex', source: 'web' },
+ });
+ // A change the CLI already applied (launch/terminal) must not be re-typed.
+ relayHandler({
+ type: 'event',
+ event: 'agent.model.changed',
+ payload: { conversationId: 'conversation-1', model: 'gpt-5', source: 'launch' },
+ });
+ // A change for a different conversation must be ignored.
+ relayHandler({
+ type: 'event',
+ event: 'agent.model.changed',
+ payload: { conversationId: 'other', model: 'gpt-4', source: 'web' },
+ });
+ await new Promise((resolveWait) => setTimeout(resolveWait, 0));
+
+ assert.deepEqual(calls, [['inputPane', 'pane-1', '/model gpt-5-codex\r']]);
+});
+
+test('native agent consumes full session config changes without echo loops', async () => {
+ const projectPath = await mkdtemp(join(tmpdir(), 'evcod-native-config-'));
+ const calls = [];
+ let relayHandler;
+ const core = {
+ async inputPane(paneId, data) {
+ calls.push(['inputPane', paneId, data]);
+ },
+ subscribe(handler) {
+ relayHandler = handler;
+ return { readyState: 1, addEventListener() {}, close() {} };
+ },
+ close() {},
+ };
+ const controller = new NativeAgentController({
+ core,
+ conversationId: 'conversation-1',
+ paneId: 'pane-1',
+ agentType: 'claude',
+ projectPath,
+ spawnImpl() {
+ throw new Error('spawn should not run in this test');
+ },
+ });
+ controller.session = { id: 'session-1' };
+ controller.subscribe();
+
+ relayHandler({
+ type: 'event',
+ event: 'agent.config.changed',
+ payload: {
+ conversationId: 'conversation-1',
+ model: 'claude-sonnet-4-6',
+ permissionMode: 'ask-first',
+ effort: 'high',
+ fastMode: true,
+ planMode: true,
+ source: 'web',
+ },
+ });
+ relayHandler({
+ type: 'event',
+ event: 'agent.config.changed',
+ payload: { conversationId: 'conversation-1', model: 'claude-opus-4-8', source: 'terminal' },
+ });
+ await new Promise((resolveWait) => setTimeout(resolveWait, 0));
+
+ assert.equal(controller.model, 'claude-opus-4-8');
+ assert.equal(controller.permissionMode, 'ask-first');
+ assert.equal(controller.effort, 'high');
+ assert.equal(controller.fastMode, true);
+ assert.equal(controller.planMode, true);
+ assert.deepEqual(calls, [['inputPane', 'pane-1', '/model claude-sonnet-4-6\r']]);
+});
+
test('native permission responses are mapped to provider TUI input', async () => {
const cases = [
{ agentType: 'claude', allow: '1\r', deny: '2\r' },
@@ -968,6 +1174,117 @@ test('starts a new terminal-origin turn without inheriting the previous active t
await controller.stop();
});
+test('follows a transcript rotation within a scoped project dir without losing messages', async () => {
+ const homeDir = await mkdtemp(join(tmpdir(), 'evcod-native-home-'));
+ const projectPath = await mkdtemp(join(tmpdir(), 'evcod-native-project-'));
+ const transcriptDir = join(homeDir, '.claude', 'projects', encodeProjectPath(projectPath));
+ await mkdir(transcriptDir, { recursive: true });
+ const fileA = join(transcriptDir, 'a.jsonl');
+ const fileB = join(transcriptDir, 'b.jsonl');
+ const calls = [];
+ const core = {
+ async createAgentMessage(conversationId, message) {
+ calls.push(['createAgentMessage', conversationId, message]);
+ return { id: `m${calls.length}`, ...message };
+ },
+ async completeTurn() {},
+ close() {},
+ };
+ const controller = new NativeAgentController({
+ core,
+ conversationId: 'c1',
+ paneId: 'p1',
+ agentType: 'claude',
+ projectPath,
+ homeDir,
+ env: { EVCOD_NATIVE_IMPLICIT_TURN_COMPLETE_MS: '-1' },
+ spawnImpl() {
+ throw new Error('spawn should not run in this test');
+ },
+ });
+ controller.agentConfig = getAgentConfig('claude', projectPath, { homeDir });
+ controller.parseTranscriptLine = controller.agentConfig.parseTranscriptLine;
+ controller.transcriptPath = fileA;
+
+ await writeFile(fileA, JSON.stringify({ type: 'assistant', uuid: 'a1', message: { content: [{ type: 'text', text: 'answer A' }] } }) + '\n', 'utf8');
+ utimesSync(fileA, new Date(Date.now() - 10000), new Date(Date.now() - 10000));
+ await controller.syncOnce();
+ assert(calls.some((call) => call[0] === 'createAgentMessage' && call[2].content === 'answer A'));
+
+ // A fresh session file appears (rotation) while file A goes idle.
+ await writeFile(fileB, JSON.stringify({ type: 'assistant', uuid: 'b1', message: { content: [{ type: 'text', text: 'answer B' }] } }) + '\n', 'utf8');
+ utimesSync(fileB, new Date(Date.now() + 10000), new Date(Date.now() + 10000));
+ await controller.syncOnce();
+
+ assert.equal(controller.transcriptPath, fileB);
+ assert(calls.some((call) => call[0] === 'createAgentMessage' && call[2].content === 'answer B'));
+
+ await controller.stop();
+});
+
+test('does not follow a newer codex rollout belonging to a different project', async () => {
+ const homeDir = await mkdtemp(join(tmpdir(), 'evcod-native-home-'));
+ const projectPath = await mkdtemp(join(tmpdir(), 'evcod-native-project-'));
+ const sessionsDir = join(homeDir, '.codex', 'sessions');
+ await mkdir(sessionsDir, { recursive: true });
+ const ours = join(sessionsDir, 'ours.jsonl');
+ const foreign = join(sessionsDir, 'foreign.jsonl');
+ const calls = [];
+ const core = {
+ async createAgentMessage(conversationId, message) {
+ calls.push(['createAgentMessage', conversationId, message]);
+ return { id: `m${calls.length}`, ...message };
+ },
+ async completeTurn() {},
+ close() {},
+ };
+ const controller = new NativeAgentController({
+ core,
+ conversationId: 'c1',
+ paneId: 'p1',
+ agentType: 'codex',
+ projectPath,
+ homeDir,
+ env: { EVCOD_NATIVE_IMPLICIT_TURN_COMPLETE_MS: '-1' },
+ spawnImpl() {
+ throw new Error('spawn should not run in this test');
+ },
+ });
+ controller.agentConfig = getAgentConfig('codex', projectPath, { homeDir });
+ controller.parseTranscriptLine = controller.agentConfig.parseTranscriptLine;
+ controller.transcriptPath = ours;
+
+ await writeFile(
+ ours,
+ [
+ JSON.stringify({ type: 'session_meta', payload: { cwd: projectPath } }),
+ JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'answer ours' }] } }),
+ '',
+ ].join('\n'),
+ 'utf8',
+ );
+ utimesSync(ours, new Date(Date.now() - 10000), new Date(Date.now() - 10000));
+ await controller.syncOnce();
+ assert(calls.some((call) => call[0] === 'createAgentMessage' && call[2].content === 'answer ours'));
+
+ await writeFile(
+ foreign,
+ [
+ JSON.stringify({ type: 'session_meta', payload: { cwd: '/some/other/project' } }),
+ JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'answer foreign' }] } }),
+ '',
+ ].join('\n'),
+ 'utf8',
+ );
+ utimesSync(foreign, new Date(Date.now() + 10000), new Date(Date.now() + 10000));
+ await controller.syncOnce();
+
+ assert.equal(controller.transcriptPath, ours);
+ assert(!calls.some((call) => call[0] === 'createAgentMessage' && call[2].content === 'answer foreign'));
+
+ await controller.stop();
+});
+
function transcriptRecords(agentType, userContent, assistantContent, suffix) {
if (agentType === 'claude') {
return [
diff --git a/evcod_warp/test/normalizer.test.js b/evcod_warp/test/normalizer.test.js
index b5f7485..16e06af 100644
--- a/evcod_warp/test/normalizer.test.js
+++ b/evcod_warp/test/normalizer.test.js
@@ -259,14 +259,14 @@ test('normalizes Codex TodoWrite function calls as todo lists', () => {
});
test('normalizes Codex rollout transcript records', () => {
+ // Codex mirrors each user prompt as both an event_msg/user_message and a
+ // durable response_item; only the response_item is mirrored to avoid duplicates.
const user = normalizeCodexTranscriptLine({
type: 'event_msg',
timestamp: 'u1',
payload: { type: 'user_message', message: 'typed in codex tui', images: [], local_images: [], text_elements: [] },
});
- assert.equal(user[0].role, 'user');
- assert.equal(user[0].kind, 'text');
- assert.equal(user[0].content, 'typed in codex tui');
+ assert.deepEqual(user, []);
const responseUser = normalizeCodexTranscriptLine({
type: 'response_item',
@@ -306,15 +306,15 @@ test('normalizes Codex rollout transcript records', () => {
assert.equal(complete[0].finalizesTurn, true);
});
-test('normalizes Codex event_msg assistant records from transcripts', () => {
+test('ignores duplicate Codex event_msg messages but keeps reasoning and errors', () => {
+ // agent_message duplicates the durable response_item assistant message, so it
+ // is ignored to avoid double-mirroring every reply into the web chat.
const text = normalizeCodexTranscriptLine({
type: 'event_msg',
timestamp: 'a1',
payload: { type: 'agent_message', message: 'assistant event text' },
});
- assert.equal(text[0].role, 'assistant');
- assert.equal(text[0].kind, 'text');
- assert.equal(text[0].content, 'assistant event text');
+ assert.deepEqual(text, []);
const reasoning = normalizeCodexTranscriptLine({
type: 'event_msg',
@@ -330,8 +330,7 @@ test('normalizes Codex event_msg assistant records from transcripts', () => {
timestamp: 'a2',
payload: { type: 'assistant_message', message: 'assistant alias text' },
});
- assert.equal(assistantAlias[0].kind, 'text');
- assert.equal(assistantAlias[0].content, 'assistant alias text');
+ assert.deepEqual(assistantAlias, []);
const failed = normalizeCodexTranscriptLine({
type: 'event_msg',
@@ -343,6 +342,30 @@ test('normalizes Codex event_msg assistant records from transcripts', () => {
assert.equal(failed[0].finalizesTurn, true);
});
+test('surfaces the Codex active model from turn_context as a session_config signal', () => {
+ const cfg = normalizeCodexTranscriptLine({
+ type: 'turn_context',
+ timestamp: 'tc1',
+ payload: { turn_id: 'turn-1', cwd: '/tmp/project', model: 'gpt-5.5', approval_policy: 'never' },
+ });
+ assert.equal(cfg.length, 1);
+ assert.equal(cfg[0].kind, 'session_config');
+ assert.deepEqual(cfg[0].payload, { model: 'gpt-5.5', permissionMode: 'ask-first' });
+
+ const none = normalizeCodexTranscriptLine({ type: 'turn_context', payload: { turn_id: 'turn-2' } });
+ assert.deepEqual(none, []);
+});
+
+test('surfaces Codex approval and effort from turn_context as session config', () => {
+ const cfg = normalizeCodexTranscriptLine({
+ type: 'turn_context',
+ timestamp: 't1',
+ payload: { turn_id: 'turn-1', approval_policy: 'never', reasoning_effort: 'medium' },
+ });
+ assert.equal(cfg[0].kind, 'session_config');
+ assert.deepEqual(cfg[0].payload, { permissionMode: 'ask-first', effort: 'medium' });
+});
+
test('ignores synthetic Codex user context records', () => {
const messages = normalizeCodexTranscriptLine({
type: 'response_item',
diff --git a/evcod_warp/test/opencode-tui.test.js b/evcod_warp/test/opencode-tui.test.js
index 21b87fd..f5dcba3 100644
--- a/evcod_warp/test/opencode-tui.test.js
+++ b/evcod_warp/test/opencode-tui.test.js
@@ -291,6 +291,88 @@ test('opencode cancel aborts the server and releases the active turn as canceled
assert.deepEqual(calls.find((call) => call[0] === 'releaseLock'), ['releaseLock', 'session-1', 'web', 'idle']);
});
+test('opencode consumes session config changes and uses the new model', async () => {
+ const calls = [];
+ let relayHandler;
+ const core = {
+ subscribe(handler) {
+ relayHandler = handler;
+ return { readyState: 1, addEventListener() {}, close() {} };
+ },
+ async inputPane(paneId, data) {
+ calls.push(['inputPane', paneId, data]);
+ },
+ async acquireLock(sessionId, owner, turnId, expectedVersion, providerSessionId) {
+ calls.push(['acquireLock', sessionId, owner, turnId, expectedVersion, providerSessionId]);
+ return { id: sessionId, lockOwner: owner, turnId };
+ },
+ async releaseLock(sessionId, owner, status) {
+ calls.push(['releaseLock', sessionId, owner, status]);
+ return { id: sessionId, status };
+ },
+ async completeTurn(conversationId, turnId, status) {
+ calls.push(['completeTurn', conversationId, turnId, status]);
+ },
+ async createAgentMessage(conversationId, message) {
+ calls.push(['createAgentMessage', conversationId, message]);
+ return { id: `message-${calls.length}`, ...message };
+ },
+ close() {},
+ };
+ const controller = new OpencodeTuiController({
+ core,
+ conversationId: 'conversation-1',
+ paneId: 'pane-1',
+ projectId: 'project-1',
+ cwd: process.cwd(),
+ model: 'openai/gpt-5.4',
+ });
+ controller.session = { id: 'session-1' };
+ controller.sessionId = 'opencode-session';
+ controller.server = {
+ async sendMessage(sessionId, prompt, model) {
+ calls.push(['sendMessage', sessionId, prompt, model]);
+ },
+ async listMessages() {
+ return [{ info: { id: 'assistant-1', role: 'assistant', time: { completed: 1 } }, parts: [{ id: 'text', type: 'text', text: 'ok' }] }];
+ },
+ };
+
+ controller.subscribe();
+ relayHandler({
+ type: 'event',
+ event: 'agent.config.changed',
+ payload: {
+ conversationId: 'conversation-1',
+ model: 'anthropic/claude-sonnet-4-6',
+ permissionMode: 'ask-first',
+ effort: 'high',
+ fastMode: true,
+ planMode: true,
+ source: 'web',
+ },
+ });
+ relayHandler({
+ type: 'event',
+ event: 'agent.model.changed',
+ payload: { conversationId: 'conversation-1', model: 'anthropic/claude-sonnet-4-6', source: 'web' },
+ });
+ await controller.promptFromChat('hello', 'turn-1', 'web');
+
+ assert.equal(controller.permissionMode, 'ask-first');
+ assert.equal(controller.effort, 'high');
+ assert.equal(controller.fastMode, true);
+ assert.equal(controller.planMode, true);
+ assert.deepEqual(calls.find((call) => call[0] === 'inputPane'), ['inputPane', 'pane-1', '/model anthropic/claude-sonnet-4-6\r']);
+ assert.equal(calls.filter((call) => call[0] === 'inputPane').length, 1);
+ assert.deepEqual(calls.find((call) => call[0] === 'sendMessage'), [
+ 'sendMessage',
+ 'opencode-session',
+ 'hello',
+ { providerID: 'anthropic', modelID: 'claude-sonnet-4-6' },
+ ]);
+});
+
test('opencode catchUpPending replays every pending web prompt after the last assistant message', async () => {
const prompts = [];
const core = {
From 58a2db0d1d6fb56569593d34d9d5ef1341d4d298 Mon Sep 17 00:00:00 2001
From: wt <123@qq.com>
Date: Sat, 20 Jun 2026 12:07:02 +0800
Subject: [PATCH 2/2] chore: remove local guide documents
---
AGENTS.md | 32 ----
CLAUDE.md | 81 ----------
PROJECT_OVERVIEW.md | 361 --------------------------------------------
3 files changed, 474 deletions(-)
delete mode 100644 AGENTS.md
delete mode 100644 CLAUDE.md
delete mode 100644 PROJECT_OVERVIEW.md
diff --git a/AGENTS.md b/AGENTS.md
deleted file mode 100644
index 785dcab..0000000
--- a/AGENTS.md
+++ /dev/null
@@ -1,32 +0,0 @@
-# Repository Guidelines
-
-## Project Structure & Module Organization
-
-This monorepo contains three active code areas. `evcod/core/` is the Go backend service, with the entry point in `cmd/evcod-core/` and internal packages under `internal/` for API routing, services, storage, platform integration, events, and config. `evcod/webui/` is the React 19 + Vite + TypeScript client; source lives in `src/`, reusable UI in `src/components/`, chat-specific code in `src/chat/`, browser scripts in `tests/`, and static files in `public/`. `evcod_warp/` is a Node.js ESM CLI bridge; runtime code is in `src/`, command entry points are in `bin/`, and Node tests are in `test/`. Documentation is mainly under `evcod/docs/`, `chat_diff/`, and Chinese review/design documents at the repo root.
-
-## Build, Test, and Development Commands
-
-- `evcod/dev.command`: start the local development environment, including core and WebUI.
-- `evcod/test_scripts/run_full_tests.sh`: run the full pipeline: Go formatting/tests/build, core smoke tests, warp tests, WebUI build, and browser flows.
-- `evcod/test_scripts/run_full_tests.sh --no-browser`: run the pipeline without Playwright browser flows.
-- `go -C evcod/core test ./...`: run all Go tests.
-- `go -C evcod/core build -o ../../.tmp/evcod-core ./cmd/evcod-core`: build the core binary.
-- `npm --prefix evcod/webui run build`: type-check and build the WebUI.
-- `npm --prefix evcod/webui run dev`: start Vite on `127.0.0.1`.
-- `npm --prefix evcod_warp test`: run Node tests for the warp CLI.
-
-## Coding Style & Naming Conventions
-
-Format Go with `gofmt`; use package-local tests named `*_test.go`. TypeScript and React files use ESM imports, functional components, and existing component naming such as `ConversationView.tsx`. Keep Node code in `evcod_warp` as ESM and require Node `>=22`. Match the surrounding documentation language; many docs and comments are Chinese.
-
-## Testing Guidelines
-
-Place Go tests beside the package they cover. WebUI browser flows are standalone Node/Playwright scripts in `evcod/webui/tests/` and may require `EVCOD_CORE_URL`, `EVCOD_WEB_URL`, and `EVCOD_API_KEY`. Warp tests use the built-in `node --test` runner and follow `*.test.js` naming.
-
-## Commit & Pull Request Guidelines
-
-Recent commits mostly use Conventional Commit style, for example `feat(core,webui,warp): ...`, `fix: ...`, `perf: ...`, and `test(webui): ...`. Prefer scoped, imperative subjects. Pull requests should summarize behavior changes, list validation commands run, link related issues or design docs, and include screenshots or recordings for visible WebUI changes.
-
-## Security & Configuration Tips
-
-Core requests require API keys. Use `go -C evcod/core run ./cmd/evcod-core key print` to create or print the default key and `key rotate` when credentials may be exposed. Avoid committing local state files, generated archives, or secrets.
diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 100644
index 238bc2a..0000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1,81 +0,0 @@
-# CLAUDE.md
-
-This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
-
-## Repository layout
-
-This is a multi-module monorepo for **evcod**, a local-first remote terminal/coding-assistant tool. Three code modules plus Chinese design docs:
-
-- `evcod/core/` — Go 1.26 backend service (`evcod-core` binary). Auth, projects, files, Git, workspace/worktrees, terminals (PTY), conversations, agent sessions, host metrics, and local state persistence. Serves both REST + WebSocket and (optionally) the built WebUI.
-- `evcod/webui/` — React 19 + Vite 6 + TypeScript browser client (`evcod-webui`). Connects to one or more cores; provides terminals, file/Git panels, host dashboard, and chat. State via zustand; terminals via xterm; code editing via Monaco.
-- `evcod_warp/` — Node.js (>=22, ESM) CLI `evcod-warp`. Bridges external AI agents (`claude`, `codex`, `gemini`, `qwen`, `opencode`, plus `fake` test fixture) to a running core, mirroring their conversation into the web chat.
-- `evcod/docs/`, `chat_diff/`, `审查报告/`, `其他/`, `Chat 对齐文档.md` — Chinese design/architecture/review documents. API contract lives in `evcod/docs/api/` (see `rpc-protocol.md`, `overview.md`, `openapi.json`).
-
-## Common commands
-
-All commands below assume the repo root unless noted. The Go module path is `evcod/core`; use `go -C ` rather than `cd`.
-
-### Run the full dev environment
-```bash
-evcod/dev.command # builds core, starts core (:10065) + webui dev (:10066), prints API key
-```
-
-### Full test/build pipeline (the CI equivalent)
-```bash
-evcod/test_scripts/run_full_tests.sh # gofmt, go test, build, core API smoke, warp tests, webui build + browser E2E
-evcod/test_scripts/run_full_tests.sh --no-browser # skip the Playwright browser flows
-```
-
-### Core (Go)
-```bash
-go -C evcod/core build -o ../../.tmp/evcod-core ./cmd/evcod-core
-go -C evcod/core test ./... # all tests
-go -C evcod/core test ./internal/services -run TestAgentCatalog # single test
-gofmt -w evcod/core # format (run before committing Go)
-go -C evcod/core run ./cmd/evcod-core key print # print/create default API key
-go -C evcod/core run ./cmd/evcod-core key rotate # revoke all keys, create a new one
-go -C evcod/core run ./cmd/evcod-core serve # run server (default bind 127.0.0.1:4865)
-```
-
-### WebUI (Node)
-```bash
-npm --prefix evcod/webui ci # install
-npm --prefix evcod/webui run dev # vite dev server
-npm --prefix evcod/webui run build # tsc -b && vite build
-# Browser E2E (Playwright-driven plain node scripts; require a running core + webui):
-EVCOD_CORE_URL=... EVCOD_WEB_URL=... npm --prefix evcod/webui run test:smoke
-# also: test:full-flow, test:feature-flow, test:agent-pane, test:agent-all
-```
-
-### Warp (Node)
-```bash
-npm --prefix evcod_warp test # node --test, all test/*.test.js
-node --test evcod_warp/test/normalizer.test.js # single test file
-node evcod_warp/bin/evcod-warp.js --list # list available agent backends
-```
-
-## Architecture
-
-### Core service (Go)
-Layered, dependency flows inward: `cmd/evcod-core/main.go` → `internal/app` → `internal/api` (HTTP/WS router) → `internal/services` → `internal/store` + `internal/platform` + `internal/events`.
-
-- **`app.New` wiring**: opens the store, creates the events hub, constructs `services.Services` (a struct aggregating `Keys`, `Projects`, `Files`, `Git`, `Terminal`, `Chat`, `Agent`, `Host`, workspace). `Serve` ensures an API key, exports `EVCOD_CORE_URL`/`EVCOD_API_KEY` into the process env (so a colocated warp/agent can find the core), starts the host metrics loop and the scheduled chat queue, then serves HTTP.
-- **Store (`internal/store/store.go`)**: persistence is a **single JSON file** loaded fully into memory behind a `sync.RWMutex` — there is no SQL database despite the `--db` flag naming. The `state` struct is the entire schema; every entity list (projects, worktrees, conversations, messages, agent sessions/turns, timeline events, terminal panes, host metrics, audit logs, etc.) lives there and is rewritten on change. Keep this in mind for performance and concurrency.
-- **API router (`internal/api/router.go`)**: a single `http.ServeMux`. `ServeHTTP` applies CORS, optionally serves the built WebUI for non-`/api`/`/ws` GETs (`serveWebUI`, path-traversal guarded), then authenticates every `/api`/`/ws` request by bearer token (or `?token=` for browser WebSockets) and checks scope before dispatching. Two WebSocket endpoints: `/ws/rpc` (client RPC) and `/ws/relay` (agent/relay).
-- **Platform abstraction (`internal/platform`)**: `NewRuntime()` factory selects an implementation; `common/` holds cross-platform defaults, `mac/` and `win/` hold OS specifics (PTY, default state path, host metrics). Add OS-specific behavior here, not in services.
-- **Events hub (`internal/events/hub.go`)**: in-process pub/sub. Services publish events (terminal output, chat/timeline updates, host metrics) that the WS layer fans out to subscribers.
-
-### WS RPC protocol
-JSON envelopes (`type: request|response|event`) inspired by muxy — see `evcod/docs/api/rpc-protocol.md` for the method table (`terminal.*`, `conversation.*`, etc.). REST endpoints and the RPC protocol are two parallel surfaces over the same services; keep both in sync when adding capabilities, and update `evcod/docs/api/` + `openapi.json`.
-
-### Agent bridge (warp)
-`evcod_warp` connects to a core via `CoreBridge` (`src/core-bridge.js`: REST for conversations/messages, WS for events). `src/cli.js` routes by agent type: **native agents** (`claude`, `codex`, `gemini`, `qwen`, `opencode`) run their real TUI inside a core terminal pane and mirror output to web chat (`native-agent.js`, `opencode-tui.js`); only `fake` uses the legacy readline `SessionController` scheme. Backends live in `src/backends/`, transports (jsonl/jsonrpc) in `src/transports/`, output normalization in `normalizer.js`. The agent catalog the WebUI shows is computed server-side in `core/internal/services/agent_catalog.go` by probing for agent binaries and config (env-var overrides like `EVCOD_CLAUDE_BIN`/`EVCOD_CLAUDE_MODEL`).
-
-## Configuration & auth
-- Core config (`internal/config`): `EVCOD_BIND` (default `127.0.0.1:4865`), `EVCOD_DB` (state JSON path), `EVCOD_WEBUI_DIR` (serve built WebUI). Flags `--bind`, `--db`, `--webui-dir` override.
-- Auth: every request needs an API key. REST/non-browser WS use `Authorization: Bearer evcod_xxx`; browser WebSockets pass `?token=evcod_xxx`. The first server start auto-creates a default key; `key rotate` revokes all keys.
-
-## Conventions
-- `gofmt` is enforced by the test pipeline — format Go before committing.
-- Most documentation and many code comments are in Chinese; match the surrounding language when editing docs.
-- Browser E2E "tests" in `webui/tests/` and `test_scripts/*.mjs` are standalone node scripts (Playwright-driven), not a unit-test framework — they need a live core + webui and the `EVCOD_CORE_URL`/`EVCOD_WEB_URL`/`EVCOD_API_KEY` env vars.
diff --git a/PROJECT_OVERVIEW.md b/PROJECT_OVERVIEW.md
deleted file mode 100644
index 40f5183..0000000
--- a/PROJECT_OVERVIEW.md
+++ /dev/null
@@ -1,361 +0,0 @@
-# evcod 项目完整介绍
-
-> 本文档面向第一次接触本仓库的工程师 / 协作者,完整介绍 **evcod** 的定位、架构、模块职责、数据模型、接口协议、运行与测试方式。
->
-> 更新时间:2026-06-20
-
----
-
-## 1. 项目定位
-
-**evcod** 是一个**本地优先(local-first)的远程终端 / 编码助手工具**。核心思路:在你的本机(或局域网内某台机器)常驻一个内核服务,浏览器作为客户端连接它,从而获得一套"随处可访问"的远程开发工作台——远程终端、项目 / 文件 / Git 管理、主机监控仪表盘,以及把外部 AI 编码 Agent(Claude、Codex、Gemini 等)的对话镜像进网页聊天。
-
-设计上把能力拆成三个独立产品面 / 模块:
-
-| 模块 | 目录 | 语言 / 运行时 | 角色 |
-|------|------|---------------|------|
-| **core** | `evcod/core/` | Go 1.26(`evcod-core` 二进制) | 常驻内核服务:鉴权、项目、文件、Git、工作区 / worktree、终端(PTY)、会话、Agent 会话、主机指标、本地状态持久化。同时提供 REST + WebSocket,并可托管已构建的 WebUI。 |
-| **webui** | `evcod/webui/` | React 19 + Vite 6 + TypeScript(`evcod-webui`) | 浏览器客户端:连接一个或多个 core,提供终端、文件 / Git 面板、主机仪表盘、聊天。 |
-| **warp** | `evcod_warp/` | Node.js ≥22(ESM,CLI `evcod-warp`) | Agent 桥:把外部 AI Agent(`claude` / `codex` / `gemini` / `qwen` / `opencode`,以及测试用 `fake`)接入运行中的 core,把它们的对话镜像到网页聊天。 |
-
-此外还有大量中文设计 / 评审文档(见第 9 节)。当前架构参考了 MUXY 的远程终端 / 工作区思路,并在 Web UI 上实现了类似桌面工具的多栏工作台布局。
-
----
-
-## 2. 仓库总览
-
-```text
-evcod_all/
-├── CLAUDE.md # 给 Claude Code 的项目指引(也适合人快速上手)
-├── Chat 对齐文档.md # 聊天 / 会话对齐设计文档
-├── chat_diff/ # 聊天相关 diff / 对齐记录
-├── 审查报告/ # 评审报告(中文)
-├── 其他/ # 杂项文档
-├── start-evcod.bat # Windows 启动入口
-└── evcod/
- ├── README.md
- ├── dev.command # macOS 一键开发环境(构建 core + 起 core/webui)
- ├── start.bat / start-dev.bat
- ├── core/ # Go 内核服务
- │ ├── cmd/evcod-core/ # main 入口(serve / key print / key rotate)
- │ └── internal/
- │ ├── app/ # App.New 装配、Serve 生命周期
- │ ├── config/ # 配置(环境变量 + flag)
- │ ├── api/ # HTTP/WS 路由(单一 ServeMux)
- │ ├── services/ # 业务服务层
- │ ├── store/ # 单 JSON 文件持久化
- │ ├── domain/ # 领域模型(纯数据结构)
- │ ├── events/ # 进程内事件 hub(pub/sub)
- │ └── platform/ # 平台抽象(common / mac / win)
- ├── webui/ # React 浏览器客户端
- │ ├── src/ # 应用源码
- │ └── tests/ # Playwright 驱动的浏览器 E2E(独立 node 脚本)
- ├── docs/ # API 契约 + 中文项目文档
- │ └── api/ # rpc-protocol.md / overview.md / openapi.json 等
- ├── scripts/
- ├── test_scripts/ # run_full_tests.sh(CI 等价物)+ 各类 .mjs
- └── gomoku-game/ # 示例 / 演示项目
-└── evcod_warp/
- ├── bin/evcod-warp.js # CLI 入口
- ├── src/
- │ ├── cli.js # 按 agent 类型路由
- │ ├── core-bridge.js # 连接 core(REST + WS)
- │ ├── native-agent.js # 原生 Agent 在终端 pane 内运行并镜像
- │ ├── opencode-*.js # opencode 专用 server / tui
- │ ├── session-controller.js # 旧式 readline 方案(仅 fake)
- │ ├── normalizer.js # 输出归一化
- │ ├── registry.js / types.js / tool-detail.js
- │ ├── backends/ # claude.js / codex.js / opencode.js / fake.js
- │ └── transports/ # jsonl.js / jsonrpc.js / factory.js
- └── test/ # node --test 单元测试
-```
-
----
-
-## 3. Core 服务(Go)
-
-### 3.1 分层架构
-
-依赖**自外向内单向流动**:
-
-```
-cmd/evcod-core/main.go
- → internal/app (装配 + 生命周期)
- → internal/api (HTTP/WS 路由)
- → internal/services (业务逻辑)
- → internal/store (持久化)
- + internal/platform (平台抽象)
- + internal/events (事件 hub)
-```
-
-### 3.2 启动与装配(`app.New` / `Serve`)
-
-- `app.New`:创建平台 `Runtime` → 打开 store(默认状态路径来自 runtime)→ 创建 events hub → 构造 `services.Services`(聚合 `Keys`、`Projects`、`Files`、`Git`、`Terminal`、`Chat`、`Agent`、`Host`、`Workspace`)。
-- `Serve`:
- 1. 确保存在 API key(首次启动自动创建默认 key);
- 2. 把 `EVCOD_CORE_URL` / `EVCOD_API_KEY` 写入进程环境变量——**这样同机运行的 warp / agent 能自动发现 core**;
- 3. 启动主机指标采集循环(`Host.Start`);
- 4. 启动定时聊天队列(`Chat.StartScheduledQueue`);
- 5. 起 HTTP server(带 10s ReadHeaderTimeout),监听 ctx 取消优雅关闭。
-
-入口命令(`main.go`):
-- `serve`(默认)——启动服务;
-- `key print`——打印 / 创建默认 API key;
-- `key rotate`——吊销所有 key 并新建一个。
-
-### 3.3 Store —— 单 JSON 文件持久化(重点)
-
-`internal/store/store.go`:**整个持久化就是一个 JSON 文件,全量加载进内存,由 `sync.RWMutex` 保护**。尽管 config 里有 `--db` flag,但**并没有 SQL 数据库**。
-
-- `state` 结构体即完整 schema;每一类实体(projects、worktrees、conversations、messages、agent sessions/turns、timeline events、terminal panes、host metrics、audit logs、device tokens……)都是其中的列表。
-- 任何变更都会**重写整个文件**。
-- ⚠️ 由此带来的工程约束:注意性能(全量序列化)与并发(全局锁);大量小写入要谨慎。
-
-### 3.4 API 路由(`internal/api/router.go`)
-
-单一 `http.ServeMux`,`ServeHTTP` 处理顺序:
-
-1. 施加 CORS;
-2. 对非 `/api`、非 `/ws` 的 GET,可选地托管已构建的 WebUI(`serveWebUI`,带路径穿越防护);
-3. 对每个 `/api`、`/ws` 请求做 **bearer token 鉴权**(浏览器 WebSocket 用 `?token=`),并检查 scope,再分发。
-
-两个 WebSocket 端点:
-- `/ws/rpc` —— 客户端 RPC(WebUI ↔ core);
-- `/ws/relay` —— Agent / relay(warp ↔ core)。
-
-**主要 REST 端点**(按域分组):
-
-| 域 | 端点 |
-|----|------|
-| 系统 / 设置 | `/api/system`、`/api/settings/runtime` |
-| 主机监控 | `/api/host/overview`、`/host/performance`、`/host/history`、`/host/interfaces`、`/host/processes`、`/host/ports`、`/host/files/list` |
-| 审计 | `/api/audit/logs` |
-| 设备令牌 | `/api/tokens`、`/api/tokens/{id}` |
-| 项目 | `/api/projects`、`/api/projects/{id}` |
-| Worktree | `/api/worktrees`、`/worktrees/refresh`、`/worktrees/{id}` |
-| 工作区 Tab | `/api/workspace/tabs`、`/workspace/tabs/{id}` |
-| 文件 | `/api/files/list`、`read`、`write`、`search`、`browse-directories`、`create`、`mkdir`、`rename`、`delete` |
-| Git | `/api/git/status`、`diff`、`diff-content`、`log`、`branches`、`checkout`、`branch`、`stage`、`unstage`、`discard`、`commit`、`pull`、`push`、`stash`、`stash/apply` |
-| 终端 | `/api/terminal/panes`、`/terminal/panes/{id}` |
-| Agent | `/api/agent/catalog`、`history`、`history/import`、`launch`、`sessions`、`sessions/{id}` |
-| 会话 | `/api/conversations`、`/conversations/{id}` |
-
-> REST 与 WS RPC 是同一套服务的**两个并行表面**;新增能力时两边都要保持同步,并更新 `evcod/docs/api/` 与 `openapi.json`。
-
-### 3.5 平台抽象(`internal/platform`)
-
-`NewRuntime()` 工厂选择实现:
-- `common/` —— 跨平台默认;
-- `mac/` —— macOS 特定(PTY、默认状态路径、主机指标);
-- `win/` —— Windows 特定。
-
-OS 相关行为应放进这里,**不要写进 services**。
-
-### 3.6 事件 hub(`internal/events/hub.go`)
-
-进程内 pub/sub。services 发布事件(终端输出、聊天 / timeline 更新、主机指标),WS 层把事件扇出给订阅者。
-
-### 3.7 服务层(`internal/services`)
-
-`Services` 聚合结构(`services.go`)持有所有子服务:
-
-- **KeyService** —— API key 生命周期、设备令牌(创建 / 吊销 / scope 归一化)、token 鉴权。
-- **ProjectService** —— 项目增删查。
-- **FileService** —— 文件列举 / 读写 / 搜索 / 重命名 / 删除、目录浏览。
-- **GitService** —— status / diff / log / branch / stage / commit / pull / push / stash 等。
-- **TerminalService** —— PTY 终端 pane(用 `github.com/aymanbagabas/go-pty`),快照 / 输入 / resize / 关闭。
-- **ChatService** —— 会话、消息、timeline 事件、定时消息队列。
-- **AgentService** —— Agent 会话、turn、锁(lockOwner/lockVersion)、provider session 关联。
-- **WorkspaceService** —— worktree、工作区 tab。
-- **HostService** —— 主机概览、性能采样、历史指标(分桶保留)、进程 / 端口 / 接口、主机文件浏览。
-- **agent_catalog.go** —— **服务端探测** agent 二进制与配置(支持 `EVCOD_CLAUDE_BIN` / `EVCOD_CLAUDE_MODEL` 等 env 覆盖),算出 WebUI 展示的 Agent 目录。
-
----
-
-## 4. WS RPC 协议
-
-JSON 信封,`type: request | response | event`,灵感来自 muxy。方法表见 `evcod/docs/api/rpc-protocol.md`,代表性方法:
-
-- 终端:`terminal.create` / `terminal.list` / `terminal.input` / `terminal.resize` / `terminal.snapshot` / `terminal.close`
-- 会话:`conversation.create` / `conversation.list` / `conversation.send`
-- 项目:`project.list`
-- 消息 / turn 流:`message.stream`、`turn.completed`
-- 工具调用:`tool.call` / `tool.result`、`confirmation.requested`
-
----
-
-## 5. 领域模型(`internal/domain/models.go`)
-
-纯数据结构(JSON 标签),核心实体:
-
-- **Project / Worktree / WorkspaceTab** —— 项目、Git worktree、工作区标签页(kind / title / pinned / color)。
-- **TerminalPane** —— 终端 pane(cwd / rows / cols / status)。
-- **Conversation / ChatMessage** —— 会话与消息(role / kind / content / seq / turnId / source / status / payload)。
-- **AgentTurn** —— Agent 一次回合的完整状态机时间戳(requested / consumed / accepted / invoked / completed / failed / cancelled)+ attemptId / consumerId / providerSessionId。
-- **TimelineEvent** —— 带 seq + epoch 的时间线事件。
-- **PermissionRequest** —— 工具调用授权请求(toolName / action / resources / risk / choices / status)。
-- **QueuedMessage** —— 定时 / 排队消息。
-- **AuditLogEntry** —— 审计日志(action / category / actor / remoteAddr / outcome / metadata)。
-- **DeviceToken / TokenAuth** —— 设备令牌与鉴权结果(scopes / revoked / tokenHash)。
-- **AgentSession / AgentModel / AgentCatalogEntry / AgentHistoryItem** —— Agent 会话、模型、目录条目、历史导入项。
-- **Host\*** —— 主机概览、资源用量、磁盘分区 / IO、网络 IO、性能样本、历史分桶、网卡、进程、端口、文件。
-- **Git\*** —— 文件状态、status、branch、commit、stash。
-- **FileEntry / DirectoryListing / Event** 等。
-
-> 这些模型直接定义了 REST/WS 的 JSON 形状,是前后端契约的事实来源。
-
----
-
-## 6. WebUI(React 19 + Vite 6 + TypeScript)
-
-`evcod/webui/src/`:
-
-- **入口 / 框架**:`main.tsx`、`App.tsx`、`styles.css`。
-- **状态**:`store.ts`(zustand)、`types.ts`、`api.ts`(REST/WS 客户端)、`lib.ts`。
-- **终端**:`TerminalView.tsx` + `@xterm/xterm`(含 fit / serialize addon)。
-- **代码编辑**:`MonacoEditors.tsx`、`FileEditorWorkspace.tsx` + `monaco-editor`。
-- **面板组件**(`components/`):
- - `Sidebar.tsx`、`RightPanel.tsx`、`WorkspaceStrip.tsx`、`PaneTabs.tsx` —— 多栏工作台布局;
- - `FilesPanel.tsx`、`DirectoryPicker.tsx` —— 文件管理;
- - `GitPanel.tsx` —— Git 面板;
- - `HostWorkspace.tsx` —— 主机仪表盘;
- - `ConversationView.tsx` + `components/chat/` —— 聊天 / 对话;
- - `SettingsPage.tsx` —— 设置。
-- **聊天流处理**(`src/chat/`):`chatStreamAdapter.ts`、`streamTypes.ts`、`timelineRenderState.ts`、`toolDetail.ts` —— 把 core 的 timeline/message 事件渲染成聊天 UI(含工具调用详情)。
-- **辅助**:`markdown.tsx`、`messageParts.ts`、`fileLinks.ts`、`feedback.tsx`。
-
-依赖关键:`react@19`、`zustand@5`、`@xterm/xterm@5.5`、`monaco-editor@0.55`、`lucide-react`、`vite@6`。
-
----
-
-## 7. Warp —— Agent 桥(Node.js)
-
-把外部 AI Agent 接入 core,并把它们的对话镜像进网页聊天。
-
-- **`CoreBridge`(`src/core-bridge.js`)**:REST 处理 conversations/messages,WS 接收事件。
-- **`src/cli.js`**:按 agent 类型路由:
- - **原生 agents**(`claude` / `codex` / `gemini` / `qwen` / `opencode`):在 core 的终端 pane 内运行其**真实 TUI**,并把输出镜像到网页聊天(`native-agent.js`、`opencode-tui.js`);
- - 只有 **`fake`** 走旧式 readline `SessionController` 方案(测试夹具)。
-- **`src/backends/`**:各 agent 后端(`claude.js`、`codex.js`、`opencode.js`、`fake.js`,由 `index.js` 注册)。
-- **`src/transports/`**:传输层 —— `jsonl.js`(行分隔 JSON)、`jsonrpc.js`,由 `factory.js` 选择。
-- **`normalizer.js`**:输出归一化;**`tool-detail.js`**:工具调用详情。
-- CLI:`node evcod_warp/bin/evcod-warp.js --list` 列出可用后端。
-
-> WebUI 展示的 Agent 目录由 core 端 `agent_catalog.go` 探测计算(不是 warp),warp 负责实际拉起并桥接。
-
----
-
-## 8. 配置与鉴权
-
-### 配置(`internal/config`)
-
-| 项 | 环境变量 | flag | 默认 |
-|----|----------|------|------|
-| 绑定地址 | `EVCOD_BIND` | `--bind` | `127.0.0.1:4865` |
-| 状态文件 | `EVCOD_DB` | `--db` | runtime 默认路径 |
-| WebUI 目录 | `EVCOD_WEBUI_DIR` | `--webui-dir` | (不托管) |
-
-Agent 相关 env 覆盖:`EVCOD_CLAUDE_BIN` / `EVCOD_CLAUDE_MODEL`(其余 agent 同理)。
-
-> 注意:`dev.command` 中 core 跑 `:10065`、webui dev 跑 `:10066`,与上面的默认 `4865` 不同(开发脚本显式覆盖)。
-
-### 鉴权
-
-- 每个请求都需要 API key。
-- REST / 非浏览器 WS:`Authorization: Bearer evcod_xxx`。
-- 浏览器 WebSocket:`?token=evcod_xxx`。
-- 首次启动自动创建默认 key;`key rotate` 吊销所有 key。
-- 设备令牌支持 scope;审计日志记录访问。
-
----
-
-## 9. 文档
-
-- **`evcod/docs/api/`** —— API 契约(事实来源):`overview.md`、`rpc-protocol.md`、`auth.md`、`conversation.md`、`files.md`、`terminal.md`、`workspace.md`、`errors.md`、`openapi.json`。
-- **`evcod/docs/project-overview.zh-CN.md`** —— 中文项目状态文档。
-- 仓库根:`Chat 对齐文档.md`、`chat_diff/`(聊天对齐)、`审查报告/`(评审报告)、`其他/`。
-- 多数文档与不少代码注释是**中文**——编辑文档时请匹配周围语言。
-
----
-
-## 10. 构建、运行与测试
-
-### 一键开发环境(macOS)
-
-```bash
-evcod/dev.command # 构建 core,起 core(:10065) + webui dev(:10066),并打印 API key
-```
-
-### 完整测试 / 构建流水线(CI 等价物)
-
-```bash
-evcod/test_scripts/run_full_tests.sh # gofmt + go test + 构建 + core API 烟测 + warp 测试 + webui 构建 + 浏览器 E2E
-evcod/test_scripts/run_full_tests.sh --no-browser # 跳过 Playwright 浏览器流程
-```
-
-### Core(Go,模块路径 `evcod/core`,用 `go -C ` 而非 cd)
-
-```bash
-go -C evcod/core build -o ../../.tmp/evcod-core ./cmd/evcod-core
-go -C evcod/core test ./...
-go -C evcod/core test ./internal/services -run TestAgentCatalog # 单测
-gofmt -w evcod/core # 提交前格式化(流水线强制)
-go -C evcod/core run ./cmd/evcod-core key print # 打印/创建默认 key
-go -C evcod/core run ./cmd/evcod-core key rotate
-go -C evcod/core run ./cmd/evcod-core serve # 默认 127.0.0.1:4865
-```
-
-### WebUI(Node)
-
-```bash
-npm --prefix evcod/webui ci
-npm --prefix evcod/webui run dev # vite dev server
-npm --prefix evcod/webui run build # tsc -b && vite build
-# 浏览器 E2E(Playwright 驱动的独立 node 脚本,需要在跑的 core + webui):
-EVCOD_CORE_URL=... EVCOD_WEB_URL=... npm --prefix evcod/webui run test:smoke
-# 另有 test:full-flow / test:feature-flow / test:agent-pane / test:agent-all
-```
-
-### Warp(Node ≥22)
-
-```bash
-npm --prefix evcod_warp test # node --test,全部 test/*.test.js
-node --test evcod_warp/test/normalizer.test.js # 单测文件
-node evcod_warp/bin/evcod-warp.js --list # 列出可用 agent 后端
-```
-
----
-
-## 11. 关键约定与注意事项
-
-1. **gofmt 强制**:提交 Go 前必须格式化(流水线会检查)。
-2. **REST 与 WS RPC 双表面同步**:新增能力两边都要改,并更新 `docs/api/` + `openapi.json`。
-3. **持久化是单 JSON 文件 + 全局锁**:警惕性能与并发,避免高频小写入。
-4. **OS 特定逻辑放 `platform/`**,不要写进 services。
-5. **浏览器 "tests" 不是单测框架**:`webui/tests/`、`test_scripts/*.mjs` 是 Playwright 驱动的独立 node 脚本,需要 live core + webui,以及 `EVCOD_CORE_URL` / `EVCOD_WEB_URL` / `EVCOD_API_KEY` 环境变量。
-6. **Agent 目录由 core 探测**(`agent_catalog.go`),warp 负责拉起与桥接;二者职责不同。
-7. **文档语言**:中文为主,编辑时匹配周围语言。
-
----
-
-## 12. 一次完整的数据流(示意)
-
-以"在网页里和 Claude 对话写代码"为例:
-
-```
-浏览器(WebUI)
- │ REST: 创建项目 / 选目录 / 打开 worktree
- │ WS /ws/rpc: conversation.create, conversation.send
- ▼
-core (services.Chat / Agent) ──写入──► store(JSON) ──发布──► events hub
- ▲ │
- │ WS /ws/relay │ 扇出事件
- │ ▼
-evcod-warp (CoreBridge) 浏览器实时收到
- │ 在 core 的终端 pane 内运行真实 claude TUI message.stream / timeline
- │ normalizer 归一化输出 → 镜像回 core → 网页聊天
- ▼
-claude / codex / gemini ...(外部 AI Agent 二进制)
-```
-
-core 在 `Serve` 时把 `EVCOD_CORE_URL` / `EVCOD_API_KEY` 注入环境,所以同机的 warp 无需额外配置即可发现并连上 core。