一个轻量级 agent 工作台:自动发现你本机装的所有 AI 编程 CLI,把它们的散落会话、工作区、终端和远程服务器收进一个界面。 Go 1.23+ / Wails v2 / React 19 / Bubbletea,Windows · macOS · Linux,MIT 协议。
一、先说说我为什么想造这个工具
不知道你有没有这种感觉——过去一年我装了太多 AI 编程工具。
Claude Code、Codex CLI、Gemini CLI、Cursor、OpenCode,还有些公司内部封装的。它们每一个都有自己的脾气:
- 会话记录散落在完全不同的目录,用完全不同的格式存着;
- 有的存 JSONL,有的干脆存进 SQLite;
- 想恢复一个三天前的会话?先猜猜它写到哪个文件里去了;
- 我有 12 个项目在跑不同的工具,切一次要开三个终端窗口。
某天我算了一下:我花在"找会话"上的时间,比花在"写代码"上的还多。
市面上的工具要么是"再实现一个 AI 对话协议",要么是绑定死一个生态。而我想要的其实特别朴素:
别再让我记路径了。扫我的磁盘,把所有工具的会话列出来,让我一键回去。
于是有了 kshell。
二、核心设计:发现 → 选择 → 交付
kshell 的定位只有三个动词:
发现(Discovery) → 扫磁盘,找出已装工具 + 工作区 + 历史会话
选择(Selection) → 统一的列表 / 网格视图,跨工具横向比较
交付(Delivery) → 把会话交还给原生 CLI 继续跑
第三步是刻意的设计选择,值得展开讲。
为什么不自己实现对话协议?
Claude Code 有自己的 ACP、Codex 有自己的 resume 参数、Cursor 又是一套。每家都在长出自己的方言。
kshell 的立场是:不重复实现对话能力,把会话交还给工具自己的 CLI。
- 终端路径:点击会话 → 拼出
claude --resume <id>或codex resume <id>,扔进 ConPTY/PTY 跑原生交互界面,退出后回到 kshell; - 聊天路径(ACP) :对已接入 ACP 的工具(Claude Code、Cursor、CodeBuddy)走 Agent Client Protocol,在窗口内直接对话;工具不支持 ACP 时自动回退到终端。
这样做的收益是显而易见的:kshell 不追着各家协议跑,工具升级了它大概率还活着。而且工具本身的新特性(新的 diff 算法、新的工具调用能力)你第一时间就能用上。
三、架构:一条单向的数据流
providers → discovery → ui / desktop
(识别工具、解析会话) (扫描与缓存) (呈现)
整个后端只有 217 个 Go 文件、约 3.4 万行代码,模块划分很克制:
| 模块 | 职责 | 代码量 |
|---|---|---|
internal/desktop | Wails 绑定层(58 个文件,桌面端最大头) | ~10000 行 |
internal/providers | 各工具适配 + Provider 接口 | ~5300 行 |
internal/discovery | 扫描与缓存 | ~2400 行 |
internal/terminal | ConPTY/PTY 终端后端 | ~2000 行 |
internal/remote | SSH 连接存储与候选扫描 | ~1900 行 |
internal/ui | Bubbletea TUI 三视图 | ~2500 行 |
internal/acp / internal/chat | Agent Client Protocol 客户端 | ~2600 行 |
3.1 Provider 接口:加新工具不用改代码
这是整个项目最想拿出来讲的设计。
type Provider interface {
ID() string
DisplayName() string
DetectSpec(home string) DetectSpec
SessionRoots(home string) []string
SessionFilePattern() string
ParseSession(path string, head []byte) (*Session, error)
NewSessionCmd(ws string, bin string) Launch
ResumeCmd(s Session, bin string) Launch
}
实现这个接口,你的工具就被 kshell 纳管了。但内置四工具是硬编码的——因为每家的检测与 resume 都有历史包袱,配置表达不了。
真正有意思的是那些可选接口,它们解决了"文件遍历这套抽象不够用"的现实:
// 工具的 TUI 要跟随 kshell 的浅/深主题
type Themer interface { ThemeArgs(...) ([]string, map[string]string, string) }
// 「会话不在文件里」——比如 opencode 把会话存进了 SQLite
type SessionEnumerator interface {
EnumerateSessions(home string, bin string) ([]Session, error)
}
// glob 盖不住的深层文件,比如 CodeBuddy 的 subagents/*.jsonl
type PathMatcher interface {
MatchSessionRel(rel string) bool
}
SessionEnumerator 是我最满意的一个。 opencode 新版把 JSON 会话迁进了 ~/.local/share/opencode/opencode.db,文件遍历这套假设直接失效。kshell 的处理不是加特例,而是让 provider 自己声明"我能枚举":
opencodeSessionsSQL = `select id, directory as cwd, title, time_created as created, time_updated as updated from session where parent_id is null and time_archived is null order by time_updated desc`
⚠️ 这里有个坑,我自己也踩了:这条 SQL 必须写成单行。 Windows 上 opencode 是 npm 的 .cmd 包装脚本,命令行最终交给 cmd.exe 重解析,多行 SQL 会在换行处被截断——症状是"一条会话都查不出来",而且日志里看不出任何异常。代码注释里已经写死了这条规矩:
// 必须写成**单行**:... 换行只影响可读性,绝不要为了排版拆行。
3.2 YAML 声明新工具:零代码扩展
不想写 Go?直接在 ~/.kshell/providers.yaml 里声明:
providers:
- id: mytool
name: MyTool
detect:
command: mytool
dirs: ["~/.mytool"]
sessions:
glob: ~/.mytool/projects/*/*.jsonl
format: jsonl
fields:
cwd: cwd
id: sessionId
timestamp: timestamp
title: message.content
resume:
args: ["--resume", "{id}"]
verified: false # 声明式配置一律标未实测
MergeProviders 合并时内置同 ID 优先——用户配置可以扩展,但不能篡改内置工具的行为。verified: false 这个字段是刻意的:声明式配置没经过实测,UI 上会明确标出,避免用户误以为和内置工具一样可靠。
3.3 只读文件头部:性能上的关键决策
Claude Code / Codex 的 JSONL 会话动辄好几 MB。kshell 全程只读文件头部字节,各家上限不同:
- codex:256 KB
- gemini:2 MB
解析出的元数据(ID、cwd、时间、标题)对这个量级完全够用。整读多 MB 的 JSONL 是纯粹的浪费。
3.4 缓存:mtime + size 双重门控
每次刷新都全盘解析 JSONL 是不可接受的。kshell 的两级缓存都用 mtime + size 双因子判定是否复用:
// internal/discovery/index.go:79
return ok && entry.MTime == mtime && entry.Size == size
为什么两个因子都要?只比 mtime 会漏掉"同一时间戳内被等量改写"的情况;只比 size 会漏掉"内容变了但长度没变"。两者都相同才认为文件未变。
再往上还有一层 snapshot.json 支撑秒开 + 后台刷新——用户看到的是上次的完整结果,同时后台在重扫,扫完再原子替换。感知延迟基本为零。
3.5 终端退出:drain 机制与那 40% 的输出
这是踩过坑才有的代码。
桌面端关闭终端时,直接 Close() ConPTY 会丢掉约 40% 的尾部输出——agent 刚吐完一整段回复,你一关窗口,最后几句就没了。
正确做法是 drain:
延迟 80–500ms → 持续收取剩余输出 → 输出排空后再关闭
延迟时长的逻辑是:既要给子进程留出写完缓冲区的时间,又不能让"关终端"这个操作感觉卡顿。
顺便区分两个容易混淆的东西:
- drain 是"关闭终端时排空缓冲区";
~/.kshell/exit.signal是"桌面版轮询该文件优雅退出"。
后者是给构建脚本用的,而且构建过程不会退出正在运行的实例——你可以一边用着旧版本一边重新构建。
四、SSH:安全上的几条硬规矩
远程管理这块我定了很明确的底线:
- 始终走系统
ssh二进制,加-o BatchMode=yes,复用~/.ssh/config、ssh-agent、ProxyJump、known_hosts; - 不传密码,不绕过主机密钥校验;
- 私钥只存路径,绝不把密钥内容落盘到
connections.yaml。
候选扫描还做了置信度分级:
| 来源 | 置信度 |
|---|---|
~/.ssh/config | 高 |
.env* / Spring application*.yml | 中 |
docker-compose / Makefile / deploy*.sh / ansible | 低 |
| README 等文档 | 低 |
低置信度候选默认不勾选,必须人工确认后才写入 connections.yaml。自动猜出来的服务器直接给你连上,等于把"连错机器执行 rm -rf"的风险前置到扫描阶段了。
五、两个入口,无模式开关
| 入口 | 技术 | 产物 |
|---|---|---|
| 桌面端 | Wails v2 + React 19 + xterm | kshell-desktop |
| TUI | Bubbletea | kshell |
TUI 的定位不是"降级方案",而是键盘流的老手会真的喜欢的形态:
┌ kshell ●claude ●codex ○gemini ws: ~/projects/demo ─┐
│ [Sessions] Files Remote (Tab 切换) │
├──────────────────────┬──────────────────────────────────────┤
│ WORKSPACES │ PREVIEW │
│ ▸ demo 12 │ 会话摘要 / 文件内容 / ssh 输出 │
├──────────────────────┴──────────────────────────────────────┤
│ ↑↓ 移动 ⏎ 进入 / 搜索 n 新建 r 重扫 ? 帮助 q 退出 │
└─────────────────────────────────────────────────────────────┘
n 新建会话、r 重新扫描、/ 搜索过滤、? 帮助——全键盘可达。
六、目前支持的工具
| 工具 | 会话存储 | 终端 resume | ACP 聊天 |
|---|---|---|---|
| Claude Code | ~/.claude/projects/<slug>/*.jsonl | 已验证 | claude-agent-acp |
| Codex CLI | ~/.codex/sessions/**/*.jsonl | 已验证 | 走终端 |
| Cursor | ~/.cursor/projects/*/agent-transcripts/ | 内置 | cursor-acp |
| CodeBuddy | ~/.codebuddy/projects/*/*.jsonl | 已验证 | CLI --acp |
| Gemini CLI | ~/.gemini/tmp/ | 推断,未实测 | 走终端 |
| OpenCode | SQLite(opencode db … --format json) | 内置(--session) | 走终端 |
⚠️ 表格里的"已验证"和"推断,未实测"是有意区分的。 工具升级会改会话格式,我不敢替厂商打包票。凡是我本机没真实跑过的,都老实标出来。
检测上的一个坑:Cursor 的 shim 会被删光
Cursor 官方更新器会周期性清空安装目录里的入口 shim,只留下 node.exe + index.js 本体。这时候在 PATH 上 Detect 必然落空。
kshell 的兜底策略是:在各个 InstallDirs 里找 node.exe 与主脚本都存活的组合:
// shim 全部落空时,Detect 会在 InstallDirs 各目录里找
// node.exe 与主脚本存活的组合作为最后兜底(Source=node-entry)。
NodeEntryScript string
于是 Detection 里多了一个 BinArgs 来承载入口前缀参数:
// BinPath 指向 node.exe 时为 [主脚本名],普通可执行文件为 nil。
// 启动与版本探测都须先拼上这组参数。
BinArgs []string
这里又踩过一次坑:最初版本没拼 BinArgs,子进程继承了别的 cwd,找不到主脚本。所以启动和版本探测都必须先拼上这组参数——现在有回归测试盯着(TestDetectAllCarriesBinArgs,还特意隔离了 PATH 以免依赖本机环境)。
七、前端:一个 Store + 常驻 xterm
前端架构刻意做得很薄:
- 单一 Zustand store(
state/store.ts),页签状态持久化到 localStorage; - 所有后端调用经
lib/api.ts→ 生成的window.go.desktop.App; - 后端推送经
EventsOn:terminal:data/scan:done/chat:update。
有一个性能细节值得单独说:xterm 实例常驻挂载(terminalRegistry),切页签只隐藏不销毁。
早期版本切走终端页签就销毁 xterm,切回来时缓冲区、滚动位置、ANSI 状态全丢,重建还有闪烁。改成常驻挂载后,切页签是零成本操作。
前端 106 个 TS/TSX 文件,106 个测试配套——npm test 走 vitest jsdom 环境。
八、工程实践
测试:206 个 _test.go
Go 侧 206 个测试文件,覆盖重点在容易回归的地方:会话解析、路径匹配、缓存门控、忽略规则继承。
go build ./...
go vet ./...
go test ./... -count=1
前端另跑 npm test / npm run build。
顺手修掉的两个真 bug
继承忽略(inherited ignore) :gitignore 命中的是目录时,其下的子项也要继承忽略状态。第一版只判断直接命中,导致"文件树里显示了本该隐藏的文件"。
嵌套 git 的中间层徽章:仓库套仓库时,中间那层 .git 目录应该显式标出来——只标最外层会让人误以为整棵子树归属同一个仓库。
这两个 bug 都不是逻辑复杂,而是测试没覆盖到组合场景。修完之后补了回归用例。
构建
# TUI → dist\kshell.exe
.\build.ps1
# 桌面端 → dist\kshell-desktop.exe(自动构建 frontend)
.\build.ps1 -Desktop
build.ps1 -Desktop 不会请求正在运行的旧实例退出,构建期间你可以继续用旧版本。dist 拷贝若因 kshell-desktop.exe 被占用失败,脚本会提示你从托盘退出后重试。
版本
目前 v0.1.3。232 次提交,MIT 协议。
九、明确不做的事
先说清楚不做什么,比列功能更有诚意:
- ❌ SFTP / 文件传输——系统
scp就够 - ❌ 端口转发——
ssh -L是成熟方案 - ❌ 云同步——会话文件本机就有,同步等于制造第二份真相
- ❌ 再实现一遍各家对话协议——这是最大的技术债来源,绕开
十、怎么用
下载:GitHub Releases 或 GitCode Releases,桌面端还可以在「设置 → 通用 → 关于」里检查更新,升级优先走 GitCode 国内源。
从源码构建:
git clone https://github.com/kaiys202212/kshell.git
cd kshell
go install github.com/wailsapp/wails/v2/cmd/wails@latest # 桌面端需要
.\build.ps1 # TUI
.\build.ps1 -Desktop # 桌面端
写在最后
这个项目从一个具体的痛点长出来:AI 编程工具越多,"找回自己的会话"就越难。
我没有试图造一个更强的 AI 编程工具,而是造了一个中立的入口。谁家工具好用就用谁,kshell 只负责让你能看见它们、方便地回到它们。
如果你也在同时用好几个 AI CLI,欢迎来试,也欢迎提 issue——尤其是新的工具适配,那是最容易一起把事情做好的地方。