Metadata-Version: 2.4
Name: tmux-ai-coder-panel
Version: 1.0.9
Summary: tmux + 多 CLI 会话本地面板（Claude Code / Qwen Code / Codex / Aider / OpenCode / Qoder / dsh 等），SBP4AI 统一适配器识别。左侧 tmux 会话树，右侧懒加载会话流 + 历史。
Author: slamKun
License: MIT
Project-URL: Homepage, https://github.com/sbp4ai/sbp-agent
Project-URL: Repository, https://github.com/sbp4ai/sbp-agent
Project-URL: Specification, https://github.com/sbp4ai/sbp-spec
Keywords: tmux,claude-code,codex,aider,opencode,qwen-code,qoder,dsh,agent,panel,sbp4ai
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: MacOS
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Software Development :: Embedded Systems
Classifier: Topic :: Terminals
Requires-Python: >=3.9
Description-Content-Type: text/markdown
Requires-Dist: fastapi>=0.110
Requires-Dist: uvicorn[standard]>=0.27
Requires-Dist: python-dotenv>=1.0
Provides-Extra: bridge
Requires-Dist: aiohttp>=3.9; extra == "bridge"

# tmux-claude-panel

基于 `example/tmux_ai_coder_panel_src` 改造的本地 Web 面板：
**左边是 tmux 会话里跑着的 Claude Code，右边是那段会话的 jsonl 历史 + 实时屏幕 + 键盘输入。**

一个进程、一个端口、零前端构建。

```
┌────────────────┬──────────────────────────────────────────┐
│ tmux · claude  │  e389d12e  [tmux: main] [837 行]     ⟳   │
│ 6 会话         │  ────────────────────────────────────────│
│ ▾ main ●       │   14:22  · claude-sonnet                │
│   ● e389d12e   │   ⚙ Bash · npm test                     │
│     修复播放     │   测试全过，271 个用例                    │
│   ○ bc0dca7a   │                                          │
│ ▸ tmp          │   ↑ 上滑加载更早                          │
│ ▾ 其他会话     │  ── 实时屏幕 ────────────────────────────  │
│   ○ 9c81df58   │  $ claude ...                            │
│                │  [⏎ Esc ↑ ↓ 1 2 y n Ctrl-C]              │
│                │  [输入指令…………………]  [发送]             │
└────────────────┴──────────────────────────────────────────┘
```

## 快速开始

```bash
cd panel
pip install -r requirements.txt
python -m tmux_panel                 # 127.0.0.1:20016，被占用自动 +1
# python -m tmux_panel --host 0.0.0.0   # 手机/局域网访问
```

### 两种传输，同一套帧语义

| 模式 | 打开方式 | 适合 |
| --- | --- | --- |
| REST 轮询（默认） | `http://host:20016/?transport=rest&interval=5000` | 无长连接、最省服务端；代理/网关/小程序都友好 |
| WebSocket 推送（可选） | `http://host:20016/?transport=ws` | 局域网内最跟手，变化即推；连不上自动降级回轮询 |

右上角小徽章显示当前是 `REST 5s` 还是 `WS`。**默认就是 REST 轮询**（无长连接，最省
服务端），`?transport=ws` 才显式开长连接；**WS 连不上时前端自动降级为轮询**，不需要手动切。
轮询走 `GET /api/index` + `GET /api/state`，都带 `ETag`：
内容没变回 304 不带 body，实测 1s × 3 客户端从 2.29 MB/min 降到 193 KB/min。

服务端省资源的机制（与协议无关，详见 `../spec/SBP-transport.md`）：
共享抓屏缓存、索引只在 tick 里重建一次、tick 周期自适应（5s 轮询就按 2.5s 抓屏）、
扇出只序列化一次。诊断数据看 `GET /api/stats`。

首次启动会在当前目录生成 `.env`（随机账号密码并打印），登录后即可用。
`--no-auth` 可关闭登录，只在纯本机调试时用。

左侧出现会话的前提：
1. 有 tmux 会话：`tmux new -s work -c ~/codes/foo`
2. 该会话的工作目录里跑过 `claude`（`~/.claude/projects/<slug>/<uuid>.jsonl` 存在）

### 侧栏的深色 / 灰色（活跃判定）

一个 tmux 组里只有「当前正被 agent 用着」的那个会话是深黑 + 绿点，其余一律灰掉并挂
`历史` 角标；这个 tmux 里没跑 agent 时整组全灰。判定在 `tmux_panel/live.py`，三级证据：

| 级别 | 证据 | 含义 |
| --- | --- | --- |
| `held` | pane 进程树里有进程打开着 `<sid>.jsonl`（`lsof` / `/proc`） | 最硬：claude 正挂着这个会话 |
| `recent` | pane 里跑着 agent，且最新 jsonl 在 180s 内被写过 | agent 在跑、刚有动静 |
| `guess` | agent 在跑但文件已冷 | 仍认最新的那个（claude 默认续最近会话） |
| `none` | 没有 agent | 全灰 |

dsh / codex 这类没有会话文件的 pty agent 同一套规则，只是证据换成进程：
按 **pane** 维护一条 `term:<tmux>`（多 agent pane 时第二个起是 `term:<tmux>#<pane_id>`），
进程树里此刻还有 agent 进程 → 深黑 + 绿点，副标题取自屏幕上的 `task:` 行；
agent 已退出（pane 只剩 shell）→ 灰色 + `未激活` 角标，但仍可点开看屏幕。
纯 shell 的 pane 不进列表。判定都在 `tmux_panel/live.py` 的 `running_agent()`。

索引每 6s 重算一次，指纹（活跃 sid / 数量 / 标题）变了才向前端广播；
前端正在编辑注释时暂存推送，编辑结束再补渲染。见 `panel/docs/ui_active_grey.png`。

## 它到底做了什么

| 能力 | 实现 |
|---|---|
| 列出 tmux 会话 | 扫 `~/.tmux/tmux-<uid>/` 与 `/tmp/tmux-<uid>/` 下**所有** socket，逐个 `tmux -L <name> list-sessions`（systemd 不 source shell rc 也能找到） |
| 会话 ↔ claude 映射 | tmux pane 的 `pane_current_path` → `~/.claude/projects/<slug>/`；slug = 路径非字母数字全替换成 `-` |
| 侧栏标题 | claude session id（前 8 位），副标题默认取 jsonl 里的 `aiTitle` / `lastPrompt` |
| 自定义注释 | 点条目上的 `✎` 改副标题，存在 `~/.tmux-claude-panel/annotations.json` |
| 懒加载 | 打开会话才请求 `session.history{limit:20}`；上滑按 `before=<行号>` 继续翻 |
| 倒序分页 | 首次访问给 jsonl 建**字节偏移索引**（837 行 / 2ms，单页 0.3ms），之后按行号 O(1) 定位，不整文件重读 |
| 实时增量 | 1.2s 轮询被 attach 的 jsonl，`size/mtime` 变化就追加推送 `session.message` |
| 实时屏幕 | `tmux capture-pane -p -S -40`，变化才推 `output.delta` |
| 输入 | `tmux send-keys -l -- "<text>"` + `Enter`，特殊键走键名映射（Esc/↑/Ctrl-C…） |

## 协议（SBP v0.1 子集）

完整协议规范（角色 / 帧 / 消息表 / 懒加载 / 安全 / 演进 + 多 agent 适配器映射）见
**[sbp-spec 仓库](https://github.com/sbp4ai/sbp-spec)**。

信封与 `spec/SBP-0.1.md` 一致，`WS /ws`：

```jsonc
→ {"v":"sbp/0.1","id":"c1","type":"req","name":"sessions.index","payload":{"refresh":true}}
← {"v":"sbp/0.1","type":"res","ref":"c1","name":"sessions.index","ok":true,
   "payload":{"groups":[{"name":"main","cwd":"/Users/mac","live":true,"items":[...]}]}}

→ {"name":"session.history","sid":"e389…","payload":{"before":818,"limit":20}}
→ {"name":"session.attach","sid":"e389…"}          // 订阅增量 + 屏幕
→ {"name":"session.input","sid":"e389…","payload":{"kind":"text","text":"继续","submit":true}}
← evt session.message / output.delta / session.state
```

| req | 说明 |
|---|---|
| `sessions.index` | 只要索引：sid / 标题 / 副标题 / 条数 / 时间 / 是否 live |
| `session.history` | `before` 为行号（不含），返回 `items / has_more / next_before / total` |
| `session.attach` | 返回 `last_seq` 与 `tmux` 归属，决定前端能否输入 |
| `session.input` | `kind=text` 或 `kind=key` |
| `session.subtitle` | 写注释后广播新索引 |
| `pane.snapshot` | 主动抓屏 |

## 从原项目改了什么

保留：
- `TmuxManager` 的多 socket 解析、`path_to_session_name`、鉴权（HMAC cookie + `?token=` 兜底）、
  `find_free_port` / `.env` 自动生成 / 启动横幅 —— 这些是踩过坑的地方，直接沿用。

替换：
- **Vue3 打包产物**（`frontend_dist`，改不动）→ 换成 `web/` 下无构建的三件套（html/css/js）。
- **WebSocket 语义**：原来是「键盘 LED 面板」事件（`keypress`/`set_mode`），
  现在是「会话流」协议（索引/分页/订阅/输入），并按 SBP 的信封规范命名。
- **新增数据层**：`claude.py` 负责 jsonl 发现、索引、分页、增量；`hub.py` 负责分组与推送。

没有改 `example/` 下的任何文件，两边可以并存对比。

## 已知边界

- 只做了 Claude Code 的 jsonl（`~/.claude/projects`）。换别的 agent 只需换 `claude.py` 的解析层。
- 子代理消息（`isSidechain:true`）默认不展示，避免刷屏；需要时改 `normalize()`。
- 一个 tmux 会话里如果切过多个 claude 会话，侧栏会把该目录下的会话全列出来（按 mtime 倒序），
  真正"当前在跑"的那个排最前并带绿点。
- jsonl 超过 8MB 时侧栏不显示条数（避免每次列表都全量扫行）；打开后仍可正常翻页。

## 目录

```
panel/
├── tmux_panel/
│   ├── tmux.py      # tmux 访问（多 socket + send-keys/capture-pane）
│   ├── claude.py    # jsonl 发现/索引/倒序分页/增量/注释
│   ├── hub.py       # 分组索引 + 1.2s 推送循环
│   ├── server.py    # FastAPI + WS 协议
│   └── __main__.py  # CLI：端口探测 / .env / 横幅
└── web/
    ├── index.html
    ├── style.css
    └── app.js
```

## 更新日志

- **1.0.8** — 非 claude agent 也能翻历史了（qwen code 实测可用）。以前面板只认
  `~/.claude/projects/<slug>/<uuid>.jsonl`，qwen code 这类「其实也把对话落盘成
  jsonl」的 CLI 被一律当成纯终端，页面上只有「没有历史文件，下方是实时屏幕」。
  现在历史源由适配器声明驱动：`adapters/*.yaml` 加一段 `history:`，面板据此把
  正在跑的进程绑到它的会话文件（qwen：`--resume <uuid>` / `chats/*.runtime.json`
  里的 `{pid, session_id}` / cwd 目录下最新一个）。
  同时修了两处：qwen 的正文在 `message.parts`（不是 `content`），已归一；
  分页改为**按消息条数**（qwen 的 jsonl 里 system 快照占了绝大多数，按行翻会翻出半页空）。
- **1.0.7** — 修复挂在 nginx 子路径下白屏：页面里的静态资源/API/WebSocket 以前是写死的
  根绝对路径（`/static/...`、`/api/...`、`/ws`），挂在 `https://host/xxx-panel/` 这类
  反代子路径下时，浏览器会跑到站点根去找资源，结果就是 CSS/JS 全 404、页面全白。
  现在面板自查 `PANEL_BASE` 环境变量或反代下发的 `X-Forwarded-Prefix` 请求头，
  把前缀注入页面（`window.__PANEL_BASE__`），`app.js` / `sidebar.html` / `qwapp.html`
  一律按前缀拼 URL。直接跑在站点根时行为不变。
- **1.0.9** — 新增 `--bridge` / `SBP_BRIDGE`：面板作为 device 上报到本机
  sbp-server，配合 [sbp-console](https://github.com/sbp4ai/sbp-console) 实现
  「一个客户端 + 下拉切 N 台机器」。历史由面板现场回答，屏幕/选项卡/输入全部经
  SBP 转发。依赖 aiohttp 收在 `bridge` extra 里。
- **1.0.6** — 修复 Linux 下 `/api/index` 返回 500：进程树遍历里
  `stack.extend(kids.get(cur))` 在节点没有子进程时拿到 `None` 会抛
  `TypeError: 'NoneType' object is not iterable`（`live.py::_tree`）。
  已改为 `extend(... or [])`。
- **1.0.5** — PyPI 描述与仓库互链（[sbp-spec](https://github.com/sbp4ai/sbp-spec)）。
- **1.0.4** — 统一适配器声明（aider / opencode / qodercli），多 CLI 识别以
  `sbp-spec/spec/adapters/*.yaml` 为唯一真相源；新增 CORS 与 `SBP4AI` 品牌。

## 多 CLI 的历史：适配器里声明 `history`

面板默认只认 claude 的 jsonl。要让别的 CLI 也能翻历史，在它的适配器声明里加一段：

```yaml
history:
  root: "~/.qwen/projects"                 # 历史文件根
  glob: "*/chats/*.jsonl"                  # 相对 root 的匹配
  cwd_dir: "{slug}/chats"                  # 兜底：cwd 对应的目录（{slug} 与 claude 同规则）
  id_from_cmdline: '--resume\s+([0-9a-fA-F-]{8,})'   # 从进程命令行抠 session id
  runtime: {glob: "*/chats/*.runtime.json", pid: pid, sid: session_id}  # pid -> sid 映射
  format: parts                            # 正文在 message.parts（claude 用 content）
```

没写这段的 agent 保持原样：只用实时屏幕（`kind: term`），不会误伤。

## 一个客户端看 N 台机器：`--bridge`

面板原本只服务本机浏览器：每台机器一个网址，切一次换一次登录态。
加上 `--bridge` 后，面板把自己注册成 sbp-server 上的一台 **device**，把本机
tmux / Claude / Qwen Code 会话报上去：

```bash
python -m tmux_panel --bridge ws://127.0.0.1:8787/ws   # 或环境变量 SBP_BRIDGE
# systemd:
#   Environment=SBP_BRIDGE=ws://127.0.0.1:8787/ws
```

于是客户端（[sbp-console](https://github.com/sbp4ai/sbp-console) 网页、微信小程序）
只要配「服务器地址 + HMAC 签名」，就能在下拉里切机器。面板**只往外连**，
本机不需要对外开放新端口；要不要把 sbp-server 暴露到公网由 nginx 决定。

bridge 这条路上报/响应的帧：

| 方向 | 帧 |
|---|---|
| 出 | `session.index`（索引变了才推）、`output.delta`（屏幕）、`session.prompt`（SBP-0.2 选项卡）、`session.message`、`session.state` |
| 入 | `session.attach` / `detach` / `history` / `input` / `answer`、`pane.snapshot`、`session.subtitle` |

历史是**面板现场读 CLI 的 jsonl** 回答的（`session.history` 转发到设备），
所以客户端翻到的内容与在本机面板里看到的完全一致；sbp-server 自己那份 jsonl
只在设备离线时兜底。

依赖：`--bridge` 需要 aiohttp（`pip install "tmux-ai-coder-panel[bridge]"`）。
没配 `--bridge` 时面板行为与之前完全一样，一行 bridge 代码都不跑。

## 挂在反代子路径下（nginx）

面板本身始终提供根路径的服务；反代剥掉前缀后请求照样落在 `/`、`/api/*`、`/ws`、
`/static/*`。唯一要做的是**告诉面板它对外叫什么前缀**，它会据此生成页面里的 URL：

| 方式 | 用法 |
|---|---|
| 请求头（推荐，一个实例可同时服务根和多个前缀） | `proxy_set_header X-Forwarded-Prefix /ecs-coder-panel;` |
| 环境变量（固定前缀） | `PANEL_BASE=/ecs-coder-panel/` |

参考 nginx 配置见 `sbp-deploy/nginx/sbp-panel-subpath.conf`。**不再需要**早先那套
`sub_filter` 字符串替换（`/src/`、`/node_modules/`、`'/api` …）——那是给旧 vite 前端用的，
留着反而会改写坏新面板的输出。
