Termexo 是我在维护的一个 Windows 本地多 Agent 工作台(MIT 开源),把 Claude Code、Codex 和 OpenCode 收进一个可观察、可恢复的工作空间。V0.7 里做了一件看起来很小、实际牵扯整条启动链路的事:让终端显示并切换它正在使用的登录账号。
这篇记录一下为什么它不能做成「改个下拉框就好」。

一、登录态在目录里,不在 Key 里
先说清楚约束。Claude Code 和 Codex 的账号身份不是一个可以随时替换的 token,而是一整个配置目录:
- Claude Code 看
CLAUDE_CONFIG_DIR - Codex 看
CODEX_HOME
凭据(.credentials.json)、身份(.claude.json 里的 userID / oauthAccount / machineID)、会话记录、缓存全在这个目录下。指向哪个目录,CLI 就是哪个人。
Termexo 给每个托管账号分配一个独立目录:
text
%APPDATA%\dev.agentdock.desktop\accounts\<agent>\<profileId>\
「切换账号」在实现层面就是「换一个环境变量的值」。问题是:已经启动的进程改不了自己的环境变量。
二、两阶段启动:环境从来不经过前端
Termexo 的 Agent 启动本来就是两阶段的,这次直接复用:
text
1. prepare_claude_launch(terminalId, accountProfileId, profileId, ...)
→ 解析账号 Profile、模型 Profile、网络 Profile
→ 从 Windows Credential Manager 读 API Key
→ 写这个终端专属的 hook 配置
→ 把完整环境 Map 按 terminalId 存进 LaunchEnvironmentStore
→ 只返回一个 AgentLaunchSpec(命令行 + 可执行文件路径)
2. create_terminal(terminalId, ...)
→ PtyManager::start 从 store 里 take(取走,不是读取)这份环境
→ 注入 PTY
前端全程拿不到密钥,也不自己拼命令行。terminalId 由前端用 crypto.randomUUID() 先生成,两步共用同一个 id。
所以切换账号的完整流程是:
ts
// 1. 先用新账号构建启动命令
const launch = await prepareLaunch({
terminalId: original.id,
accountProfileId, // ← 唯一变的东西
profileId: original.profileId,
mcpProfileId: original.mcpProfileId,
autoConfirm: original.autoConfirm,
});
// 2. 命令建好了,才动正在跑的 PTY
await terminalGateway.close(original.id);
// 3. 用新命令重启,bump runtimeRevision 让面板重建
state.restartTerminalWithProfile(original.id, launch.command, {
model: original.model,
profileId: original.profileId,
mcpProfileId: original.mcpProfileId,
accountProfileId,
});
顺序是有意的:先构建、再关闭 。如果准备阶段失败(账号目录建不出来、凭据读不到),异常在 close() 之前抛出,正在跑的会话完全没被动到。这和之前做模型切换时踩过的坑是同一个------先拆后建,失败就只剩一个关掉的终端。
三、重启一定是新会话,这不是偷懒
对话框里明确写着「新会话」。原因不是实现不了续接,而是续接在语义上就是错的:
会话记录(~/.claude/projects/**/*.jsonl)物理上存在原账号的配置目录里。换到新账号的目录之后,那个 sessionId 在新目录里根本不存在,claude --resume <id> 会直接失败。
所以 restartTerminalWithProfile 里把 nativeSessionId 清成 undefined,界面上也直说这一点,而不是让用户发第一条消息才发现终端退出了。

四、重连时环境要能重建
LaunchEnvironmentStore 是 take-once 的内存存储。应用重启后终端要重连,这份环境早就没了------如果不管,CLI 会回落到默认目录,安静地变成另一个账号。
解决办法是让终端自己记住它的身份,重连时按记录重建:
rust
pub(crate) fn relaunch_environment(
database: &WorkspaceDatabase,
credentials: &CredentialStore,
agent_type: &str,
account_profile_id: Option<&str>,
model_profile_id: Option<&str>,
workspace_id: Option<&str>,
) -> Result<HashMap<String, String>, String>
create_terminal 发现 store 里没有环境时就走这条路,重建账号目录、代理设置和供应商密钥。切换过账号的终端,因为 accountProfileId 已经写回终端记录,重启之后仍然在新账号上。
五、一个细节:显示哪个账号
终端记录的是启动时解析出来的 accountProfileId。但从旧版本恢复出来的终端可能根本没有这个字段------后端此时会回落到「该 Agent 的默认账号,没有默认就取第一个」。
如果前端只在字段存在时显示,这些终端就会是空白的,而它们其实确实跑在某个账号上。所以前端用同一套规则解析:
ts
export function terminalAccountName(
profiles: readonly AccountProfile[],
agentType: AgentType,
accountProfileId?: string,
): string {
if (agentType !== 'claude' && agentType !== 'codex') {
return '';
}
const resolved = resolveAccountProfileId(profiles, agentType, accountProfileId);
return profiles.find((profile) => profile.id === resolved)?.name ?? '';
}
前后端用同一个回落顺序,标签才不会骗人。
六、附带修掉的一个「已登录还让登录」
做这个功能时顺带定位到一个老问题:托管账号明明登录过,第一次开终端还是被要求登录。
Claude Code 只凭配置目录里的 hasCompletedOnboarding 决定跑不跑首次向导,而向导里固定带一步登录,不看本地有没有有效凭据。早期版本的 claude auth login 成功后不写这个标记,于是用早期版本登录过的账号每次都撞上。
修法是在账号目录里已存在 .credentials.json 时补上这个标记,写入走同目录临时文件 + rename,避免 CLI 同时读到半个文件:
rust
fn ensure_claude_onboarding_complete(directory: &Path) -> Result<(), String> {
if !directory.join(CLAUDE_CREDENTIALS_FILE).is_file() {
return Ok(()); // 没登录过的账号,仍然正常走向导
}
let config_path = directory.join(CLAUDE_GLOBAL_CONFIG_FILE);
let mut config = read_claude_global_config(&config_path)?;
if config.get(CLAUDE_ONBOARDING_FLAG).and_then(Value::as_bool) == Some(true) {
return Ok(());
}
config.insert(CLAUDE_ONBOARDING_FLAG.into(), Value::Bool(true));
write_json_atomically(&config_path, &Value::Object(config))
}
配置文件损坏时跳过并记日志,不覆盖------Claude 会用自己的备份修复,重写会把它修复的依据毁掉。
七、V0.7 其他改动
- 窗口去掉系统标题栏改由应用自绘,顶栏横跨整个窗口,左右侧栏在其下。
- 终端改用 GPU 渲染,长回滚不再卡顿,无可用 GPU 时自动回退。
- 修复桌面版终端标签无法拖拽排序(webview 自身的拖放处理吞掉了事件)。
- 账号之间可以复制配置:设置、全局指令、插件、技能会走,凭据、身份和会话历史不走。
环境与链接
Windows 10/11 x64,WebView2,Node.js 18.18+:
powershell
npx termexo@latest
- GitHub:github.com/gemron/Term...
- Release:github.com/gemron/Term...
我是项目维护者。这套两阶段启动 + 环境重建的做法如果你有更好的方案,欢迎在 Issue 里聊。第三方 Endpoint 的兼容性问题(尤其是 Codex 的 Responses API 差异)也很想收集真实案例。
AI 生成内容声明:本文正文由 AI 生成,封面图为 AI 辅助生成。文中的代码、命令、文件路径与界面截图均取自 Termexo V0.7.0 的真实源码和运行界面,已由项目维护者逐条核校。
项目采用 MIT 许可证。