merge: v0.3 cockpit — Claude Code status/approve/notify + tmux keepalive + FE polish

High value: H2/H4 hooks→live status + browser notifications; H3 remote
approve/reject (held PermissionRequest); H1 tmux keepalive (survive restart).
Medium: keybar Claude shortcuts, scrollback search, clickable links, QR connect,
PWA, themes, session dashboard, new-tab-in-cwd.
Also fixed: tab-switch flakiness; idle keep-alive sockets hanging close().
212 tests green.
This commit is contained in:
Yaojia Wang
2026-06-18 07:29:08 +02:00
35 changed files with 2151 additions and 81 deletions

View File

@@ -4,13 +4,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Status
**Planning stage — no code yet.** The `docs/` folder holds the authoritative spec; there is no `src/`, `public/`, or `package.json` yet. Read these before implementing:
**Built and working — v0.1 (core) + v0.2 (multi-tab) + v0.3 (Claude Code cockpit) shipped.** `src/` (TypeScript backend), `public/` (esbuild frontend), and tests all exist; 212 tests pass. v0.3 work happens on the `v0.3-cockpit` branch. Key docs:
- `docs/TECH_DOC.md` — design & rationale (the **why**). Authoritative for protocol (§4), session model (§5.2), security (§7), acceptance criteria (§8).
- `docs/ARCHITECTURE.md`development guide (the **how**). Module contracts at **function-signature level**, dependency rules, build order (§6 S1S8), and the invariant checklist (§8).
- `docs/PLAN.md` — the task plan. Dependency-ordered **waves W0W5**, fine-grained **tasks T1T21** for multi-agent parallelism (each task has `Owns:` / `Depends:`; per-task model & isolation in §4).
- `docs/PROGRESS_LOG.md` — the **memory file** (cross-session). Orchestrator-owned; subagents return their entry rather than writing it. See workflow below.
- `.claude/agents/` — project subagents: `module-builder` (TDD-implements one task), `module-reviewer` (read-only review).
- `docs/TECH_DOC.md` — design & rationale (the **why**). Protocol (§4), session model (§5.2), security (§7), acceptance (§8).
- `docs/ARCHITECTURE.md` — function-signature-level contracts, dependency rules, the **invariant checklist (§8)**. On conflict, ARCHITECTURE wins on *how*; the M*/L* anchors are cross-validated fixes.
- `docs/PROGRESS_LOG.md` — the **memory file** (cross-session). Read it first; it's the running record of what shipped (v0.1 T1T21, then v0.2/v0.3 feature entries). Orchestrator-owned.
- `docs/PLAN.md` (v0.1 task plan) and `~/.claude/plans/shimmering-wondering-island.md` (v0.3 cockpit plan).
- `.claude/agents/` — project subagents: `module-builder`, `module-reviewer` (for parallel dev).
**v0.3 additions** (cockpit): Claude Code **hooks → live status** per tab (`POST /hook`, `npm run setup-hooks`); **remote approve/reject** (held `POST /hook/permission`); **tmux keepalive** (`USE_TMUX`); plus FE: themes, search, dashboard, QR, PWA, new-tab-in-cwd. The server gained a hook side-channel but the terminal stream is still a byte-shuttle.
**Language decision: TypeScript (`.ts`), not `.js`** — ARCHITECTURE §0 records this divergence from TECH_DOC's original `.js` filenames. Wherever the two docs conflict, ARCHITECTURE wins on *how* (it was cross-validated and corrected); TECH_DOC wins on *why/scope*.
@@ -61,7 +63,7 @@ npm start # listens on 0.0.0.0:3000
npm test # unit tests (vitest, all modules)
```
Config is via env vars only (no hardcoding): `PORT`, `SHELL_PATH`, `BIND_HOST`, `IDLE_TTL`, `SCROLLBACK_BYTES`, `MAX_PAYLOAD_BYTES`, `ALLOWED_ORIGINS`. Note `allowedOrigins` is derived from the host's network-interface IPs (not from `BIND_HOST``0.0.0.0` is never a valid Origin); see ARCHITECTURE §3.1.
Config is via env vars only (no hardcoding): `PORT`, `SHELL_PATH`, `BIND_HOST`, `IDLE_TTL`, `SCROLLBACK_BYTES`, `MAX_PAYLOAD_BYTES`, `USE_TMUX` (1/0/auto), `ALLOWED_ORIGINS`. Note `allowedOrigins` is derived from the host's network-interface IPs (not from `BIND_HOST``0.0.0.0` is never a valid Origin); see ARCHITECTURE §3.1.
## Architecture (the parts that span files)

View File

@@ -6,6 +6,15 @@ Sessions survive disconnects: the shell (and whatever's running in it) keeps goi
> ⚠️ **This hands a full shell to anyone who can reach the port.** LAN-only, no authentication. **Never** port-forward or tunnel it to the public internet. See [Security](#security).
## Features
- **Multi-tab** — each tab is an independent shell session. Tabs auto-name to the current folder (double-click to rename), show a connection dot (🟢/🟡/🔴) and an unread-output dot, and can be drag-reordered. `+` opens a new tab in the active tab's directory.
- **Claude Code cockpit** (with hooks, see below) — each tab shows Claude's status (⚙ working / ⏳ needs approval / ✓ idle), sends a browser notification when it needs you, and lets you **tap Approve / Reject** on a tool request with no typing.
- **tmux keepalive** (optional) — run the shell inside tmux so sessions survive a server/host restart, not just a disconnect.
- **Mobile + desktop shortcut bar** — one-tap Esc / Esc·Esc / ⇧Tab / arrows / Enter / ^C / ^O / ^T / ^B / Tab / `/` (the Claude Code keys a phone keyboard can't produce).
- **Session keepalive + replay** — the PTY keeps running across disconnects; reconnect replays a ~2 MB scrollback ring buffer.
- **Toolbar** — 🔍 scrollback search · ⚙ themes & font size · ▦ all-sessions dashboard · 📱 QR connect (scan to open on another device). Clickable links. Installable as a PWA.
## Requirements
- Node.js ≥ 18 (developed on v24)
@@ -34,6 +43,23 @@ ipconfig getifaddr en0
For frontend development, run `npm run dev:web` (esbuild --watch) alongside `npm start`.
## Claude Code cockpit (optional)
To see Claude's status per tab and approve/reject tool calls from your phone, install the hooks once:
```bash
npm run setup-hooks # adds http hooks to ~/.claude/settings.json (backs it up)
# npm run setup-hooks -- --remove # to uninstall
```
Then run `claude` inside a tab. The hooks POST to the server (loopback only) when Claude starts/stops/needs permission; the tab badge updates live, and a `PermissionRequest` shows an **Approve / Reject** bar. The hooks are a no-op outside web-terminal (they curl `$WEBTERM_HOOK_URL`, which is only set in spawned shells), so they're safe to leave installed.
**tmux keepalive** — run with `USE_TMUX=1` (or just have `tmux` on PATH; it auto-detects) to keep sessions alive across a server restart:
```bash
USE_TMUX=1 npm start
```
## Configuration
All via environment variables (no hardcoding):
@@ -46,6 +72,7 @@ All via environment variables (no hardcoding):
| `IDLE_TTL` | `86400` (s) | reclaim a detached session after this idle time |
| `SCROLLBACK_BYTES` | `2097152` | per-session replay ring buffer (bytes) |
| `MAX_PAYLOAD_BYTES` | `1048576` | max single WS frame |
| `USE_TMUX` | `auto` | `1`/`0`/`auto` — run the shell inside tmux (keepalive across restart); `auto` = on if `tmux` is on PATH |
| `ALLOWED_ORIGINS` | (derived) | extra allowed WS origins, comma-separated |
`allowedOrigins` is **derived from the host's network-interface IPs** (plus `localhost` and anything in `ALLOWED_ORIGINS`) — never from `BIND_HOST`, since `0.0.0.0` is never a real browser Origin.

View File

@@ -24,7 +24,12 @@
> 新会话读到的第一块。保持准确,只描述"此刻"。
- **当前阶段**: **v0.2 进行中** —— 多标签 + 标题 + 移动端 Claude Code 快捷键栏(✅ 已做,见下方 v0.2 条目)。v0.1 见下
- **当前阶段**: **v0.3 cockpit 完成**(分支 `v0.3-cockpit`,待合并 main)。计划见 `~/.claude/plans/shimmering-wondering-island.md`
- ✅ Step1 前端快赢(键栏/搜索/链接/QR/PWA) · ✅ H2/H4 状态感知+通知 · ✅ H3 远程批准/拒绝 · ✅ H1 tmux 保活 · ✅ M3/M7/M6(主题/仪表盘/同目录新标签)。
- ⬜ 仅剩可选项:**O1 token 认证**(默认关)、**O2 历史会话浏览**(读 ~/.claude/projects JSONL → --resume)。
- **212 测试全绿**;README/CLAUDE.md 已更新;工具栏 🔍⚙▦📱。
- **下一步**: 合并 `v0.3-cockpit` → main;O1/O2 作为可选后续。
- v0.2/v0.1 历史见下方条目。
- **Wave**: **W0W5 基本完成**。v0.1 功能完整、测试与真机浏览器验证通过。仅剩 2 项需用户真设备。
- **进度**: **20/21 任务**(T19 待真手机)· **187 自动化测试全绿** · 真 PTY 端到端验证 · **真浏览器 smoke 通过**
- **验收 F1F9**: ✅ F1/F2(浏览器 smoke:渲染+输入输出+全彩)· ✅ F3(resize 无错)· ⬜ **F4 需局域网真机** · ✅ F5/F6(集成 ⑤⑥ 回放 CJK/ANSI)· ⬜ **F7 需真手机**(软键盘栏)· ✅ F8(浏览器 exit→重连提示)· ✅ F9(集成+curl 401)。
@@ -121,6 +126,63 @@
- **遗留 / 待办**: 无(原前端打包待决项已解决,见下补记)。
- **commit**: `b126cf0`
### 2026-06-18 · v0.3 Step 5 — 中价值 M3/M7/M6(主题/仪表盘/同目录新标签)
- **状态**: `[x]` DONE。真浏览器验证通过。212 测试全绿。
- **M3 主题/字体**(commit `f79f14b`):`settings.ts`(⚙ 面板:dark/light/solarized + 字号 A/A+),localStorage 持久化;`terminal-session.applyTheme`;TabApp.applySettings 应用到所有+新标签。验证:light 主题渲染+持久化。
- **M7 仪表盘**(commit `f79f14b`):`dashboard.ts`(▦ 浮层列出所有标签:连接点+文件夹+Claude 状态;点行切换;打开时每秒刷新);TabApp.snapshot()/focusTab()。验证:列出 2 标签、点击切换。
- **M6 同目录新标签**(commit `87e1173`):`title-util.cwdFromOsc7`(解析 OSC 7 cwd)+4 单测;terminal-session 注册 OSC7 handler→cwd getter,新会话 attach 带 cwd;protocol/types attach.cwd(绝对路径);session/manager/server 透传到 spawn cwd;tabs `+` 在当前 tab 的 cwd 开新标签(无则回 home)。验证:server attach.cwd 生效;`cd /private/tmp``+`→新标签 pwd=/private/tmp(此 zsh 发 OSC7)。
- **工具栏**: 🔍搜索 · ⚙设置 · ▦仪表盘 · 📱QR。
- **commit**: `f79f14b`(M3+M7)、`87e1173`(M6)
### 2026-06-17 · v0.3 Step 4 — H1 tmux 保活(服务重启不丢会话)
- **状态**: `[x]` DONE。**真 tmux 集成测试验证通过**(commit `9099f73`)。**至此 H1H4 全部高价值功能完成。**
- **机制**: useTmux 时把 shell 跑进 `tmux new-session -A -s web_<id>`;node-pty 进程只是 tmux **客户端**。服务重启后,客户端带同 sessionId 重连 → manager 发现 `web_<id>` tmux 会话仍在 → 用同 id 重建客户端(`-A` 附着)→ 接回原 shell。
- **改动**: 新增 `src/session/tmux.ts`(同步 CLI 封装 available/has/kill,best-effort);config `useTmux`(env `USE_TMUX` 1/0/auto→检测 PATH);session createSession 加可选 id(重连用)+ tmux 分支 spawn + `Session.tmuxName` + kill 走 `tmux kill-session`;manager handleAttach 加 Case 3.5(重启后重附)+ shutdown 对 tmux 会话**只杀客户端 pty 不杀 tmux**(保活)。
- **测试**: 共享集成 cfg 设 `USE_TMUX:0`(防 tmux 泄漏);单测 CFG `useTmux:false`;集成 `H1`(真 tmux,gate=tmux+PTY):设 shell 变量 → **重启 server** → 同 id 重连 → 变量仍在(`MARK-tmuxlives`)。208 测试全绿,tsc 通过。
- **注**: 当前后台 dev server(:3000)是 H3 之前启动的、不含 H1;要试 tmux 保活需 `USE_TMUX=1 npm start`
- **commit**: `9099f73`
### 2026-06-17 · v0.3 Step 3 — H3 远程批准/拒绝(不打字)
- **状态**: `[x]` DONE。**端到端真浏览器验证通过**(commit `9a6150c`)。
- **机制**: `PermissionRequest` hook 用 curl POST 到 `/hook/permission` 并**阻塞等响应**;服务端长挂该 HTTP 响应,推 `status{waiting,pending:true}` 给前端 → 前端显示 **[✓Approve][✗Reject]** 横幅 → 点按发 ws `approve`/`reject` → 服务端用 `{hookSpecificOutput.decision.behavior:allow|deny}` 回应被挂的请求 → curl stdout 把决定交给 Claude。**全程不打字**。
- **兜底**: 5 分钟超时 / 断开即释放 → 回空 `{}`,Claude 回退到自己的交互提示;web-terminal 外 curl 无 URL → no-op。
- **改动**: types(ClientMessage approve/reject、status.pending)、protocol(白名单+解析)、manager(pending 透传)、server(`/hook/permission` 长挂 + approve/reject 路由 + resolvePending)、setup-hooks(PermissionRequest 用 held 命令)、FE(terminal-session approve()/reject()+pending、tabs 批准横幅)。
- **验证**: 207 测试全绿(协议 approve/reject 单测 + 集成 ⑧ held→approve→allow);浏览器实测横幅"Claude wants to use Bash"→Approve→held 请求得到 allow→横幅消失(截图 /tmp/v03-approve.png)。
- **commit**: `9a6150c`
### 2026-06-17 · v0.3 Step 2 — H2/H4 Claude 状态感知(headline)
- **状态**: `[x]` DONE(分支 `v0.3-cockpit`)。**端到端真浏览器验证通过**。
- **机制**: Claude Code hooks → 服务端 `/hook` 侧信道 → WS `status` 帧 → 标签角标实时更新。架构演进:服务端获得 hook 侧信道(终端流仍是 byte-shuttle)。
- **H2 服务端**(commit `a411c89`):
- types:`ClaudeStatus` + ServerMessage `status`;Session.claudeStatus;manager.handleHookEvent。
- session:spawn env 注入 `WEBTERM_SESSION`(哪个标签)+ `WEBTERM_HOOK_URL`(本机 /hook)。
- `src/http/hook.ts` 纯映射(PreToolUse→working、PermissionRequest/permission_prompt→waiting、Stop/idle_prompt→idle);6 单测。
- server:`POST /hook`(仅 loopback,express.json 64kb)→ 推 status。**顺带修真 bug**:`closeIdleConnections()` 让 hook keep-alive 空闲连接不再卡住 close()/SIGTERM。
- 集成 ⑦(真 PTY):attach → POST /hook → ws 收到 status(注意测试要在 fetch 前挂监听,推送是同步的)。
- **H4 前端**(commit `c81cebd`):
- terminal-session 处理 status → claudeStatus + onClaudeStatus。
- tabs:每标签 Claude 角标(⚙工作/⏳等批准/✓空闲);**非活动 + waiting 标签加琥珀高亮 + 浏览器通知**(首次切换请求权限)。
- `scripts/setup-hooks.mjs` + `npm run setup-hooks`:幂等装/卸 ~/.claude/settings.json 的 command hook(内联 curl,web-terminal 外是 no-op),自动备份。
- **验证**: 205 测试全绿;浏览器实测 PermissionRequest→⏳、PreToolUse→⚙、Stop→✓;非活动 waiting 标签琥珀+⏳(截图 /tmp/v03-status.png)。0 console error。
- **commit**: `a411c89`(H2 服务端)、`c81cebd`(H4 前端+脚本)
### 2026-06-17 · v0.3 Step 1 — 前端快赢(键栏/搜索/链接/QR/PWA)
- **状态**: `[x]` DONE(分支 `v0.3-cockpit`;纯前端,无服务端改动)
- **计划**: `~/.claude/plans/shimmering-wondering-island.md`(v0.3 cockpit,已批准)。
- **H4-keybar**: 键栏加 Claude 快捷键 Esc·Esc/^O/^T/^B + 每键 tooltip(KEY_MAP 14 键;commit `eaeacf3`)。
- **M1 搜索**: `@xterm/addon-search` 每终端 + 🔍 工具栏按钮 + 搜索框(Enter 下一个/Shift+Enter 上一个/Esc 关)。
- **M2 可点击链接**: `@xterm/addon-web-links`
- **结构**: 标签栏拆成 `#tabs`(可滚)+ `#toolbar`(工具按钮区),为后续 QR/设置/仪表盘复用。
- **M5 QR**: 📱 按钮 → 客户端渲染 location.origin 的二维码(经 LAN IP 打开即可分享)+ localhost 提示(commit `fbc218c`)。
- **M4 PWA**: manifest + icon.svg + sw.js(network-first,不拦 /term//hook)+ 注册;可安装。
- **验证(真浏览器)**: 14 键栏按钮、搜索高亮命中、🔍📱 工具按钮、SW controlled、QR canvas 渲染、0 console error。199 测试全绿、双 tsc、build 通过。截图 /tmp/v03-quickwins.png、/tmp/v03-qr.png。
- **commit**: `eaeacf3``fbc218c`
### 2026-06-17 · v0.2 修复 — 切换标签要点多次
- **状态**: `[x]` FIXED

331
package-lock.json generated
View File

@@ -10,14 +10,18 @@
"hasInstallScript": true,
"dependencies": {
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"express": "^5.2.1",
"node-pty": "^1.1.0",
"qrcode": "^1.5.4",
"ws": "^8.21.0"
},
"devDependencies": {
"@types/express": "^5.0.6",
"@types/node": "^25.9.3",
"@types/qrcode": "^1.5.6",
"@types/ws": "^8.18.1",
"esbuild": "^0.28.1",
"tsx": "^4.22.4",
@@ -910,6 +914,16 @@
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
@@ -1074,6 +1088,18 @@
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/addon-search": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz",
"integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==",
"license": "MIT"
},
"node_modules/@xterm/addon-web-links": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
@@ -1096,6 +1122,30 @@
"node": ">= 0.6"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -1181,6 +1231,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -1191,6 +1250,35 @@
"node": ">=18"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
@@ -1255,6 +1343,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -1274,6 +1371,12 @@
"node": ">=8"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1294,6 +1397,12 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@@ -1499,6 +1608,19 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1541,6 +1663,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -1665,6 +1796,15 @@
"node": ">= 0.10"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -1932,6 +2072,18 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -2094,6 +2246,42 @@
"wrappy": "1"
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -2103,6 +2291,15 @@
"node": ">= 0.8"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
@@ -2140,6 +2337,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
@@ -2182,6 +2388,23 @@
"node": ">= 0.10"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
@@ -2221,6 +2444,21 @@
"node": ">= 0.10"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/rolldown": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
@@ -2322,6 +2560,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -2440,6 +2684,32 @@
"dev": true,
"license": "MIT"
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -2758,6 +3028,12 @@
}
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -2775,6 +3051,20 @@
"node": ">=8"
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -2801,6 +3091,47 @@
"optional": true
}
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
}
}
}

View File

@@ -16,18 +16,23 @@
"dev:web": "esbuild public/main.ts --bundle --format=esm --outdir=public/build --sourcemap --watch",
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.web.json",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"setup-hooks": "node scripts/setup-hooks.mjs"
},
"dependencies": {
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"express": "^5.2.1",
"node-pty": "^1.1.0",
"qrcode": "^1.5.4",
"ws": "^8.21.0"
},
"devDependencies": {
"@types/express": "^5.0.6",
"@types/node": "^25.9.3",
"@types/qrcode": "^1.5.6",
"@types/ws": "^8.18.1",
"esbuild": "^0.28.1",
"tsx": "^4.22.4",

94
public/dashboard.ts Normal file
View File

@@ -0,0 +1,94 @@
/**
* public/dashboard.ts — all-sessions overview (M7).
*
* A ▦ toolbar button opens an overlay listing every tab with its connection
* dot, folder name, and Claude status; click a row to focus that tab. While
* open it re-renders every second so statuses stay live.
*/
export interface TabInfo {
idx: number
title: string
conn: string // SessionStatus
claude: string // ClaudeStatus
active: boolean
pending: boolean
}
export interface DashboardHooks {
snapshot: () => TabInfo[]
focus: (idx: number) => void
}
function claudeText(claude: string): string {
if (claude === 'working') return '⚙ working'
if (claude === 'waiting') return '⏳ needs approval'
if (claude === 'idle') return '✓ idle'
return ''
}
export function mountDashboard(toolbar: HTMLElement, hooks: DashboardHooks): void {
const overlay = document.createElement('div')
overlay.id = 'dashboard'
overlay.style.display = 'none'
const card = document.createElement('div')
card.className = 'dash-card'
overlay.appendChild(card)
document.body.appendChild(overlay)
let timer: ReturnType<typeof setInterval> | null = null
const render = (): void => {
card.replaceChildren()
const title = document.createElement('div')
title.className = 'dash-title'
title.textContent = 'Sessions'
card.appendChild(title)
for (const t of hooks.snapshot()) {
const row = document.createElement('div')
row.className = t.active ? 'dash-row active' : 'dash-row'
const dot = document.createElement('span')
dot.className = `tab-dot dot-${t.conn}`
const name = document.createElement('span')
name.className = 'dash-name'
name.textContent = t.title
const claude = document.createElement('span')
claude.className = t.pending ? 'dash-claude pending' : 'dash-claude'
claude.textContent = claudeText(t.claude)
row.append(dot, name, claude)
row.addEventListener('click', () => {
hooks.focus(t.idx)
hide()
})
card.appendChild(row)
}
}
const open = (): void => {
render()
overlay.style.display = 'flex'
timer = setInterval(render, 1000)
}
const hide = (): void => {
overlay.style.display = 'none'
if (timer !== null) {
clearInterval(timer)
timer = null
}
}
overlay.addEventListener('click', (e) => {
if (e.target === overlay) hide()
})
const btn = document.createElement('button')
btn.className = 'toolbtn'
btn.textContent = '▦'
btn.title = 'All sessions'
btn.setAttribute('aria-label', 'All sessions')
btn.addEventListener('click', () => (overlay.style.display === 'none' ? open() : hide()))
toolbar.appendChild(btn)
}

7
public/icon.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<rect width="512" height="512" rx="96" fill="#1a1a1a"/>
<g fill="none" stroke="#3fb950" stroke-width="34" stroke-linecap="round" stroke-linejoin="round">
<polyline points="150,196 226,256 150,316"/>
<line x1="268" y1="320" x2="372" y2="320"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 357 B

View File

@@ -2,13 +2,21 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Web Terminal</title>
<link rel="manifest" href="./manifest.webmanifest">
<meta name="theme-color" content="#1a1a1a">
<link rel="apple-touch-icon" href="./icon.svg">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<link rel="stylesheet" href="./build/main.css">
<link rel="stylesheet" href="./style.css">
</head>
<body>
<div id="tabbar"></div>
<div id="tabbar">
<div id="tabs"></div>
<div id="toolbar"></div>
</div>
<div id="term"></div>
<div id="keybar"></div>
<script type="module" src="./build/main.js"></script>

View File

@@ -1,19 +1,22 @@
/**
* public/keybar.ts — Mobile touch key bar, tuned for Claude Code.
* public/keybar.ts — Mobile/desktop touch key bar, tuned for Claude Code.
*
* Exports pure key-to-byte mappings and a mount function that binds DOM buttons
* to onSend on touchstart (bypassing xterm so the soft keyboard doesn't pop up).
* Buttons send terminal byte sequences a phone soft keyboard can't easily
* produce, most-used first. Order/selection follows the official Claude Code
* keyboard-shortcut docs (interactive-mode / keybindings).
*
* Order/selection follows Claude Code mobile habits — the keys a phone soft
* keyboard can't easily produce, most-used first (leftmost = no scroll needed):
* Esc \x1b interrupt Claude / dismiss (primary — most used)
* Shift+Tab \x1b[Z cycle plan / auto-accept mode
* ↑ ↓ \x1b[A/B select menu option / history
* Enter \r confirm
* Ctrl+C \x03 cancel / quit
* Tab \t complete / toggle
* ← → \x1b[D/C cursor
* / / slash-command launcher
* Esc \x1b interrupt Claude / dismiss (primary)
* Esc·Esc \x1b\x1b rewind / clear draft
* Shift+Tab \x1b[Z cycle plan / auto-accept mode
* ↑ ↓ \x1b[A/B select menu option / history
* Enter \r confirm
* Ctrl+C \x03 cancel / quit
* Ctrl+O \x0f expand transcript / tool detail
* Ctrl+T \x14 toggle task list
* Ctrl+B \x02 background running task
* Tab \t complete / toggle
* ← → \x1b[D/C cursor
* / / slash-command launcher
*/
import type { MountKeybar } from '../src/types.js'
@@ -21,6 +24,7 @@ import type { MountKeybar } from '../src/types.js'
/** Key name → byte string mapping (pure data, for testing). */
export const KEY_MAP = {
esc: '\x1b',
escEsc: '\x1b\x1b',
shiftTab: '\x1b[Z',
arrowUp: '\x1b[A',
arrowDown: '\x1b[B',
@@ -28,24 +32,38 @@ export const KEY_MAP = {
arrowRight: '\x1b[C',
enter: '\r',
ctrlC: '\x03',
ctrlO: '\x0f',
ctrlT: '\x14',
ctrlB: '\x02',
tab: '\t',
slash: '/',
} as const
export type KeyName = keyof typeof KEY_MAP
/** Button layout: most-used Claude Code keys first. `primary` styles a key as prominent. */
export const KEYBAR_BUTTONS: Array<{ label: string; keyName: KeyName; primary?: boolean }> = [
{ label: 'Esc', keyName: 'esc', primary: true },
{ label: '⇧Tab', keyName: 'shiftTab' },
{ label: '↑', keyName: 'arrowUp' },
{ label: '↓', keyName: 'arrowDown' },
{ label: '⏎', keyName: 'enter' },
{ label: '^C', keyName: 'ctrlC' },
{ label: 'Tab', keyName: 'tab' },
{ label: '', keyName: 'arrowLeft' },
{ label: '', keyName: 'arrowRight' },
{ label: '/', keyName: 'slash' },
interface KeybarButton {
label: string
keyName: KeyName
title: string // tooltip / aria-label
primary?: boolean // styled as prominent
}
/** Button layout: most-used Claude Code keys first. */
export const KEYBAR_BUTTONS: KeybarButton[] = [
{ label: 'Esc', keyName: 'esc', title: 'Esc — interrupt Claude / dismiss', primary: true },
{ label: 'Esc²', keyName: 'escEsc', title: 'Esc Esc — rewind / clear draft' },
{ label: '⇧Tab', keyName: 'shiftTab', title: 'Shift+Tab — cycle plan / auto-accept mode' },
{ label: '↑', keyName: 'arrowUp', title: 'Up — previous option / history' },
{ label: '↓', keyName: 'arrowDown', title: 'Down — next option / history' },
{ label: '⏎', keyName: 'enter', title: 'Enter — confirm' },
{ label: '^C', keyName: 'ctrlC', title: 'Ctrl+C — cancel / quit' },
{ label: '^O', keyName: 'ctrlO', title: 'Ctrl+O — expand transcript / tool detail' },
{ label: '^T', keyName: 'ctrlT', title: 'Ctrl+T — toggle task list' },
{ label: '^B', keyName: 'ctrlB', title: 'Ctrl+B — background running task' },
{ label: 'Tab', keyName: 'tab', title: 'Tab — complete / toggle' },
{ label: '←', keyName: 'arrowLeft', title: 'Left' },
{ label: '→', keyName: 'arrowRight', title: 'Right' },
{ label: '/', keyName: 'slash', title: 'Slash — command launcher' },
]
/** Mount the key bar: create buttons in #keybar, bind touchstart → onSend(bytes) + preventDefault. */
@@ -53,13 +71,14 @@ export const mountKeybar: MountKeybar = (onSend) => {
const keybarEl = document.getElementById('keybar')
if (!keybarEl) return
for (const { label, keyName, primary } of KEYBAR_BUTTONS) {
for (const { label, keyName, title, primary } of KEYBAR_BUTTONS) {
const btn = document.createElement('button')
btn.textContent = label
btn.classList.add('keybar-btn')
if (primary) btn.classList.add('keybar-btn-primary')
btn.dataset.key = keyName
btn.setAttribute('aria-label', label)
btn.title = title
btn.setAttribute('aria-label', title)
const bytes = KEY_MAP[keyName]

View File

@@ -1,27 +1,63 @@
/**
* public/main.ts — esbuild entry point for the browser frontend.
*
* Bootstraps the multi-tab app: a tab bar (#tabbar) over a stack of terminal
* panes (#term), each tab an independent TerminalSession (own WS + server
* session). The mobile key bar routes input to the active tab.
* Bootstraps the multi-tab app: a tab bar (#tabs) + a utility toolbar (#toolbar)
* over a stack of terminal panes (#term), each tab an independent TerminalSession.
* The mobile key bar routes input to the active tab.
*
* Per-session terminal/WS/reconnect logic lives in terminal-session.ts;
* tab management + persistence in tabs.ts.
*/
// CSS side-effect import processed by esbuild (→ public/build/main.css).
// tsc cannot resolve CSS paths; @ts-ignore suppresses the single diagnostic.
// @ts-ignore
// @ts-ignore CSS side-effect import processed by esbuild (→ public/build/main.css).
import '@xterm/xterm/css/xterm.css'
import { mountKeybar } from './keybar.js'
import { TabApp } from './tabs.js'
import { mountSearch } from './search.js'
import { mountQrConnect } from './qr.js'
import { mountSettings, loadSettings, type Settings } from './settings.js'
import { mountDashboard } from './dashboard.js'
const paneHost = document.getElementById('term')
const tabBar = document.getElementById('tabbar')
const tabs = document.getElementById('tabs')
const toolbar = document.getElementById('toolbar')
if (!paneHost) throw new Error('#term element not found in DOM')
if (!tabBar) throw new Error('#tabbar element not found in DOM')
if (!tabs) throw new Error('#tabs element not found in DOM')
if (!toolbar) throw new Error('#toolbar element not found in DOM')
const app = new TabApp(paneHost, tabBar)
const app = new TabApp(paneHost, tabs)
// Key bar (mobile) sends to whichever tab is active.
// Apply saved theme/font (M3) to the restored tabs.
let settings: Settings = loadSettings()
app.applySettings(settings)
// Key bar (mobile + desktop) sends to whichever tab is active.
mountKeybar((data) => app.sendToActive(data))
// Toolbar utilities.
mountSearch(toolbar, {
find: (query, dir) => app.findInActive(query, dir),
clear: () => app.clearActiveSearch(),
})
mountSettings(
toolbar,
() => settings,
(s) => {
settings = s
app.applySettings(s)
},
)
mountDashboard(toolbar, {
snapshot: () => app.snapshot(),
focus: (idx) => app.focusTab(idx),
})
mountQrConnect(toolbar)
// PWA: register the service worker (installable + offline shell, M4).
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
void navigator.serviceWorker.register('./sw.js').catch(() => {
// SW registration is best-effort; the app works without it.
})
})
}

View File

@@ -0,0 +1,19 @@
{
"name": "Web Terminal",
"short_name": "WebTerm",
"description": "Drive your shell / Claude Code from any device on your LAN.",
"start_url": "./",
"scope": "./",
"display": "standalone",
"orientation": "any",
"background_color": "#1a1a1a",
"theme_color": "#1a1a1a",
"icons": [
{
"src": "./icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}

63
public/qr.ts Normal file
View File

@@ -0,0 +1,63 @@
/**
* public/qr.ts — "open on another device" QR modal (M5).
*
* Renders a QR of the current origin (client-side, no server call). When the
* page is opened via the host's LAN IP, the QR is directly shareable to a
* phone/tablet on the same network.
*/
import QRCode from 'qrcode'
export function mountQrConnect(toolbar: HTMLElement): void {
const modal = document.createElement('div')
modal.id = 'qrmodal'
modal.style.display = 'none'
const card = document.createElement('div')
card.className = 'qr-card'
const title = document.createElement('div')
title.className = 'qr-title'
title.textContent = 'Open on another device'
const canvas = document.createElement('canvas')
const urlEl = document.createElement('div')
urlEl.className = 'qr-url'
const note = document.createElement('div')
note.className = 'qr-note'
const close = document.createElement('button')
close.className = 'qr-close'
close.textContent = 'Close'
card.append(title, canvas, urlEl, note, close)
modal.appendChild(card)
document.body.appendChild(modal)
const open = (): void => {
const origin = location.origin
urlEl.textContent = origin
void QRCode.toCanvas(canvas, origin, { width: 220, margin: 1 })
const host = location.hostname
note.textContent =
host === 'localhost' || host === '127.0.0.1'
? 'Tip: open this page via your LAN IP (not localhost) for a shareable code.'
: 'Scan on a device on the same network.'
modal.style.display = 'flex'
}
const hide = (): void => {
modal.style.display = 'none'
}
modal.addEventListener('click', (e) => {
if (e.target === modal) hide()
})
close.addEventListener('click', hide)
const toggle = document.createElement('button')
toggle.className = 'toolbtn'
toggle.textContent = '📱'
toggle.title = 'Connect another device (QR)'
toggle.setAttribute('aria-label', 'Connect another device')
toggle.addEventListener('click', open)
toolbar.appendChild(toggle)
}

65
public/search.ts Normal file
View File

@@ -0,0 +1,65 @@
/**
* public/search.ts — scrollback search box for the active terminal (M1).
*
* Adds a 🔍 button to the toolbar that toggles a small search overlay.
* Enter = next, Shift+Enter = previous, Esc = close.
*/
export interface SearchHooks {
find: (query: string, dir: 'next' | 'prev') => void
clear: () => void
}
function makeBtn(label: string, title: string): HTMLButtonElement {
const b = document.createElement('button')
b.textContent = label
b.title = title
b.setAttribute('aria-label', title)
return b
}
export function mountSearch(toolbar: HTMLElement, hooks: SearchHooks): void {
const box = document.createElement('div')
box.id = 'searchbox'
box.style.display = 'none'
const input = document.createElement('input')
input.type = 'text'
input.className = 'search-input'
input.placeholder = 'Search scrollback…'
const prev = makeBtn('↑', 'Previous match (Shift+Enter)')
const next = makeBtn('↓', 'Next match (Enter)')
const close = makeBtn('×', 'Close (Esc)')
box.append(input, prev, next, close)
document.body.appendChild(box)
const open = (): void => {
box.style.display = 'flex'
input.focus()
input.select()
}
const hide = (): void => {
box.style.display = 'none'
hooks.clear()
}
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault()
hooks.find(input.value, e.shiftKey ? 'prev' : 'next')
} else if (e.key === 'Escape') {
e.preventDefault()
hide()
}
e.stopPropagation()
})
prev.addEventListener('click', () => hooks.find(input.value, 'prev'))
next.addEventListener('click', () => hooks.find(input.value, 'next'))
close.addEventListener('click', hide)
const toggle = makeBtn('🔍', 'Search scrollback')
toggle.className = 'toolbtn'
toggle.addEventListener('click', () => (box.style.display === 'none' ? open() : hide()))
toolbar.appendChild(toggle)
}

128
public/settings.ts Normal file
View File

@@ -0,0 +1,128 @@
/**
* public/settings.ts — theme + font-size settings (M3).
*
* A ⚙ toolbar button opens a small panel; choices persist to localStorage and
* apply to every terminal via the onChange callback.
*/
import type { ITheme } from '@xterm/xterm'
export interface Settings {
theme: string
fontSize: number
}
export const THEMES: Record<string, ITheme> = {
dark: { background: '#1a1a1a', foreground: '#e8e8e8', cursor: '#e8e8e8' },
light: {
background: '#f5f5f5',
foreground: '#1a1a1a',
cursor: '#1a1a1a',
selectionBackground: '#bcd3f5',
},
solarized: { background: '#002b36', foreground: '#93a1a1', cursor: '#93a1a1' },
}
const KEY = 'web-terminal:settings'
export const DEFAULT_SETTINGS: Settings = { theme: 'dark', fontSize: 14 }
export function loadSettings(): Settings {
try {
const raw = localStorage.getItem(KEY)
if (raw) {
const s = JSON.parse(raw) as Partial<Settings>
return {
theme: typeof s.theme === 'string' && THEMES[s.theme] ? s.theme : DEFAULT_SETTINGS.theme,
fontSize:
typeof s.fontSize === 'number' && s.fontSize >= 10 && s.fontSize <= 24
? s.fontSize
: DEFAULT_SETTINGS.fontSize,
}
}
} catch {
// ignore
}
return { ...DEFAULT_SETTINGS }
}
function saveSettings(s: Settings): void {
try {
localStorage.setItem(KEY, JSON.stringify(s))
} catch {
// ignore
}
}
export function mountSettings(
toolbar: HTMLElement,
current: () => Settings,
onChange: (s: Settings) => void,
): void {
const panel = document.createElement('div')
panel.id = 'settingspanel'
panel.style.display = 'none'
const apply = (next: Settings): void => {
saveSettings(next)
onChange(next)
render()
}
function render(): void {
const s = current()
panel.replaceChildren()
const themeRow = document.createElement('div')
themeRow.className = 'settings-row'
themeRow.append(label('Theme'))
for (const key of Object.keys(THEMES)) {
const b = document.createElement('button')
b.textContent = key
b.className = key === s.theme ? 'settings-opt active' : 'settings-opt'
b.addEventListener('click', () => apply({ ...s, theme: key }))
themeRow.appendChild(b)
}
const fontRow = document.createElement('div')
fontRow.className = 'settings-row'
fontRow.append(label('Font'))
const minus = document.createElement('button')
minus.className = 'settings-opt'
minus.textContent = 'A'
minus.addEventListener('click', () => apply({ ...s, fontSize: Math.max(10, s.fontSize - 1) }))
const size = document.createElement('span')
size.className = 'settings-size'
size.textContent = String(s.fontSize)
const plus = document.createElement('button')
plus.className = 'settings-opt'
plus.textContent = 'A+'
plus.addEventListener('click', () => apply({ ...s, fontSize: Math.min(24, s.fontSize + 1) }))
fontRow.append(minus, size, plus)
panel.append(themeRow, fontRow)
}
function label(text: string): HTMLElement {
const el = document.createElement('span')
el.className = 'settings-label'
el.textContent = text
return el
}
document.body.appendChild(panel)
const toggle = document.createElement('button')
toggle.className = 'toolbtn'
toggle.textContent = '⚙'
toggle.title = 'Settings (theme, font)'
toggle.setAttribute('aria-label', 'Settings')
toggle.addEventListener('click', () => {
if (panel.style.display === 'none') {
render()
panel.style.display = 'block'
} else {
panel.style.display = 'none'
}
})
toolbar.appendChild(toggle)
}

View File

@@ -36,12 +36,223 @@ html, body {
border-bottom: 1px solid #3a3a3a;
display: flex;
align-items: stretch;
z-index: 1001;
}
/* tabs: scrollable; toolbar: fixed cluster on the right */
#tabs {
flex: 1 1 auto;
display: flex;
align-items: stretch;
overflow-x: auto;
overflow-y: hidden;
z-index: 1001;
scrollbar-width: thin;
}
#toolbar {
flex: 0 0 auto;
display: flex;
align-items: center;
border-left: 1px solid #3a3a3a;
}
.toolbtn {
background: none;
border: none;
color: #aaa;
cursor: pointer;
font-size: 15px;
padding: 0 12px;
height: 100%;
}
.toolbtn:hover {
color: #fff;
background-color: #2a2a2a;
}
/* Search box overlay (M1) */
#searchbox {
position: fixed;
top: calc(var(--tabbar-h) + 6px);
right: 8px;
z-index: 1100;
display: flex;
gap: 4px;
padding: 6px;
background-color: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.search-input {
width: 180px;
padding: 4px 6px;
font: inherit;
font-size: 13px;
color: #fff;
background: #111;
border: 1px solid #444;
border-radius: 3px;
outline: none;
}
#searchbox button {
background: #333;
border: 1px solid #444;
color: #ddd;
cursor: pointer;
border-radius: 3px;
padding: 0 8px;
}
#searchbox button:hover {
background: #555;
}
/* QR connect modal (M5) */
#qrmodal {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
}
.qr-card {
background: #fff;
color: #111;
border-radius: 12px;
padding: 20px;
text-align: center;
max-width: 90vw;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.qr-title {
font-weight: 600;
margin-bottom: 12px;
}
.qr-url {
margin-top: 10px;
font-size: 13px;
color: #333;
word-break: break-all;
}
.qr-note {
margin-top: 8px;
font-size: 12px;
color: #666;
max-width: 240px;
}
.qr-close {
margin-top: 14px;
padding: 6px 18px;
border: none;
border-radius: 6px;
background: #1a1a1a;
color: #fff;
cursor: pointer;
font: inherit;
}
/* Settings panel (M3) */
#settingspanel {
position: fixed;
top: calc(var(--tabbar-h) + 6px);
right: 8px;
z-index: 1100;
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 6px;
padding: 10px;
min-width: 210px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
color: #ddd;
}
.settings-row {
display: flex;
align-items: center;
gap: 6px;
margin: 6px 0;
}
.settings-label {
width: 48px;
color: #aaa;
font-size: 13px;
}
.settings-opt {
background: #333;
border: 1px solid #444;
color: #ddd;
border-radius: 4px;
padding: 4px 9px;
cursor: pointer;
font: inherit;
font-size: 12px;
}
.settings-opt.active {
background: #4a9eff;
color: #fff;
border-color: #4a9eff;
}
.settings-size {
min-width: 22px;
text-align: center;
}
/* Dashboard (M7) */
#dashboard {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 56px;
background: rgba(0, 0, 0, 0.6);
}
.dash-card {
background: #222;
border: 1px solid #3a3a3a;
border-radius: 10px;
min-width: 320px;
max-width: 90vw;
max-height: 70vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.dash-title {
padding: 12px 16px;
font-weight: 600;
color: #fff;
border-bottom: 1px solid #3a3a3a;
}
.dash-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
cursor: pointer;
color: #ddd;
border-bottom: 1px solid #2a2a2a;
}
.dash-row:hover {
background: #2a2a2a;
}
.dash-row.active {
background: #1a1a1a;
box-shadow: inset 3px 0 0 #4a9eff;
}
.dash-name {
flex: 1;
}
.dash-claude {
color: #aaa;
font-size: 13px;
}
.dash-claude.pending {
color: #ffcf8f;
font-weight: 600;
}
.tab {
display: flex;
align-items: center;
@@ -91,6 +302,55 @@ html, body {
font-weight: 600;
}
/* Claude Code activity glyph (H2/H4) */
.tab-claude {
margin-right: 6px;
font-size: 12px;
flex: none;
}
/* A background tab that needs approval gets an amber outline. */
.tab.claude-waiting {
background-color: #3a2a10;
box-shadow: inset 0 -2px 0 #d29922;
}
/* Approve/reject banner (H3) — sits just above the key bar. */
#approvalbar {
position: fixed;
left: 0;
right: 0;
bottom: var(--keybar-h);
z-index: 1050;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: #3a2a10;
border-top: 1px solid #d29922;
color: #ffcf8f;
font-size: 14px;
}
.approval-label {
flex: 1 1 auto;
}
#approvalbar button {
flex: none;
border: none;
border-radius: 5px;
padding: 7px 16px;
cursor: pointer;
font: inherit;
font-weight: 600;
}
.approval-yes {
background: #2ea043;
color: #fff;
}
.approval-no {
background: #c93c37;
color: #fff;
}
/* Drag-to-reorder feedback. */
.tab.dragging {
opacity: 0.4;

28
public/sw.js Normal file
View File

@@ -0,0 +1,28 @@
/**
* public/sw.js — minimal service worker (M4: PWA installability + offline shell).
*
* Network-first so we never serve a stale build while online; falls back to the
* cache when offline. Never intercepts the WebSocket (/term) or hook endpoints.
*/
const CACHE = 'webterm-v1'
self.addEventListener('install', () => self.skipWaiting())
self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()))
self.addEventListener('fetch', (e) => {
const req = e.request
if (req.method !== 'GET') return
const url = new URL(req.url)
if (url.pathname === '/term' || url.pathname.startsWith('/hook')) return // WS / hooks
e.respondWith(
fetch(req)
.then((res) => {
const copy = res.clone()
caches.open(CACHE).then((c) => c.put(req, copy))
return res
})
.catch(() => caches.match(req)),
)
})

View File

@@ -17,6 +17,7 @@
*/
import { TerminalSession } from './terminal-session.js'
import { THEMES, DEFAULT_SETTINGS, type Settings } from './settings.js'
const TABS_KEY = 'web-terminal:tabs'
const ACTIVE_KEY = 'web-terminal:active'
@@ -40,15 +41,51 @@ export class TabApp {
private activeIndex = -1
private editingIndex = -1
private dragIndex = -1
private notifyAsked = false
private settings: Settings = DEFAULT_SETTINGS
private readonly paneHost: HTMLElement
private readonly tabBar: HTMLElement
private readonly approvalBar: HTMLDivElement
constructor(paneHost: HTMLElement, tabBar: HTMLElement) {
this.paneHost = paneHost
this.tabBar = tabBar
this.approvalBar = document.createElement('div')
this.approvalBar.id = 'approvalbar'
this.approvalBar.style.display = 'none'
document.body.appendChild(this.approvalBar)
this.restore()
}
/** Show/hide the approve/reject banner for the active tab's held request (H3). */
private updateApprovalBar(): void {
const session = this.tabs[this.activeIndex]?.session
if (!session || !session.pendingApproval) {
this.approvalBar.style.display = 'none'
return
}
this.approvalBar.replaceChildren()
const label = document.createElement('span')
label.className = 'approval-label'
label.textContent = `Claude wants to use ${session.pendingTool ?? 'a tool'}`
const approve = document.createElement('button')
approve.className = 'approval-yes'
approve.textContent = '✓ Approve'
approve.addEventListener('click', () => {
session.approve()
this.updateApprovalBar()
})
const reject = document.createElement('button')
reject.className = 'approval-no'
reject.textContent = '✗ Reject'
reject.addEventListener('click', () => {
session.reject()
this.updateApprovalBar()
})
this.approvalBar.append(label, approve, reject)
this.approvalBar.style.display = 'flex'
}
private displayTitle(entry: TabEntry, idx: number): string {
return entry.customTitle ?? entry.autoTitle ?? `Term ${idx + 1}`
}
@@ -114,7 +151,7 @@ export class TabApp {
/* ── tab lifecycle ───────────────────────────────────────────── */
private addEntry(sessionId: string | null, customTitle: string | null): TabEntry {
private addEntry(sessionId: string | null, customTitle: string | null, cwd?: string): TabEntry {
const entry: TabEntry = {
session: null as unknown as TerminalSession,
customTitle,
@@ -124,6 +161,7 @@ export class TabApp {
}
entry.session = new TerminalSession({
sessionId,
...(cwd !== undefined ? { cwd } : {}),
onSessionId: () => this.persist(),
// onActivity only fires for hidden (inactive) panes (see TerminalSession).
onActivity: () => {
@@ -135,15 +173,26 @@ export class TabApp {
this.refreshTab(entry)
},
onStatus: () => this.refreshTab(entry),
onClaudeStatus: (status) => {
this.refreshTab(entry)
this.updateApprovalBar()
// Notify when a background tab needs approval (H2/H4).
if (status === 'waiting' && this.tabs.indexOf(entry) !== this.activeIndex) {
this.notify(entry)
}
},
})
this.paneHost.appendChild(entry.session.el)
this.tabs.push(entry)
entry.session.applyTheme(THEMES[this.settings.theme] ?? THEMES['dark']!, this.settings.fontSize)
entry.session.connect()
return entry
}
newTab(): void {
this.addEntry(null, null)
// M6: open the new tab in the active tab's current directory, if known.
const cwd = this.tabs[this.activeIndex]?.session.cwd ?? undefined
this.addEntry(null, null, cwd)
this.persist()
this.rebuild()
this.activate(this.tabs.length - 1)
@@ -151,11 +200,13 @@ export class TabApp {
activate(i: number): void {
if (i < 0 || i >= this.tabs.length) return
this.maybeAskNotify() // first switch is a user gesture — request notif permission
this.activeIndex = i
const entry = this.tabs[i]
if (entry) entry.hasActivity = false // viewing clears the unread dot
this.tabs.forEach((t, idx) => (idx === i ? t.session.show() : t.session.hide()))
this.tabs.forEach((t) => this.refreshTab(t)) // in-place class/text update, no rebuild
this.updateApprovalBar()
this.persist()
}
@@ -206,6 +257,64 @@ export class TabApp {
this.tabs[this.activeIndex]?.session.send(data)
}
/** Apply theme + font settings to every terminal (M3). */
applySettings(s: Settings): void {
this.settings = s
const theme = THEMES[s.theme] ?? THEMES['dark']!
for (const t of this.tabs) t.session.applyTheme(theme, s.fontSize)
}
/** A read-only view of all tabs for the dashboard (M7). */
snapshot(): Array<{
idx: number
title: string
conn: string
claude: string
active: boolean
pending: boolean
}> {
return this.tabs.map((t, idx) => ({
idx,
title: this.displayTitle(t, idx),
conn: t.session.status,
claude: t.session.claudeStatus,
active: idx === this.activeIndex,
pending: t.session.pendingApproval,
}))
}
focusTab(idx: number): void {
this.activate(idx)
}
/** Scrollback search in the active tab (M1). */
findInActive(query: string, dir: 'next' | 'prev'): void {
const s = this.tabs[this.activeIndex]?.session
if (!s) return
if (dir === 'next') s.findNext(query)
else s.findPrevious(query)
}
clearActiveSearch(): void {
this.tabs[this.activeIndex]?.session.clearSearch()
}
/** Ask for notification permission once, on a user gesture (H2/H4). */
private maybeAskNotify(): void {
if (this.notifyAsked) return
this.notifyAsked = true
if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
void Notification.requestPermission()
}
}
/** Browser notification for a background tab needing approval. */
private notify(entry: TabEntry): void {
if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return
const title = this.displayTitle(entry, this.tabs.indexOf(entry))
new Notification(`Claude needs you — ${title}`, { body: 'Waiting for approval' })
}
/* ── rendering ───────────────────────────────────────────────── */
/** In-place update of one tab's classes/label/dot — never destroys the DOM. */
@@ -215,12 +324,19 @@ export class TabApp {
const idx = this.tabs.indexOf(entry)
el.classList.toggle('active', idx === this.activeIndex)
el.classList.toggle('unread', entry.hasActivity && idx !== this.activeIndex)
const cs = entry.session.claudeStatus
el.classList.toggle('claude-waiting', cs === 'waiting' && idx !== this.activeIndex)
const title = this.displayTitle(entry, idx)
el.title = title
const dot = el.querySelector('.tab-dot')
if (dot) dot.className = `tab-dot dot-${entry.session.status}`
const label = el.querySelector('.tab-label')
if (label) label.textContent = title
const claude = el.querySelector('.tab-claude')
if (claude) {
claude.textContent =
cs === 'working' ? '⚙' : cs === 'waiting' ? '⏳' : cs === 'idle' ? '✓' : ''
}
}
/** Full rebuild — ONLY for structural changes (add/close/reorder/rename). */
@@ -299,6 +415,10 @@ export class TabApp {
})
tabEl.appendChild(label)
const claude = document.createElement('span')
claude.className = 'tab-claude'
tabEl.appendChild(claude)
const close = document.createElement('button')
close.className = 'tab-close'
close.textContent = '×'

View File

@@ -9,9 +9,12 @@
*/
import { Terminal } from '@xterm/xterm'
import type { ITheme } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import type { ClientMessage, ServerMessage } from '../src/types.js'
import { folderFromTitle } from './title-util.js'
import { SearchAddon } from '@xterm/addon-search'
import { WebLinksAddon } from '@xterm/addon-web-links'
import type { ClaudeStatus, ClientMessage, ServerMessage } from '../src/types.js'
import { folderFromTitle, cwdFromOsc7 } from './title-util.js'
const RESET = '\x1b[0m'
const BOLD = '\x1b[1m'
@@ -49,6 +52,10 @@ export interface TerminalSessionOpts {
onTitle?: (title: string) => void
/** Optional: fired when connection status changes (for the tab status dot). */
onStatus?: (status: SessionStatus) => void
/** Optional: fired when Claude Code activity changes (from a hook, H2). */
onClaudeStatus?: (status: ClaudeStatus, detail?: string) => void
/** Optional: spawn a NEW session in this directory ("new tab here", M6). */
cwd?: string
}
export class TerminalSession {
@@ -56,12 +63,19 @@ export class TerminalSession {
private readonly term: Terminal
private readonly fitAddon: FitAddon
private readonly searchAddon: SearchAddon
private readonly resizeObserver: ResizeObserver
private readonly onSessionId: (id: string) => void
private readonly onActivity: (() => void) | undefined
private readonly onTitle: ((title: string) => void) | undefined
private readonly onStatus: ((status: SessionStatus) => void) | undefined
private readonly onClaudeStatus: ((status: ClaudeStatus, detail?: string) => void) | undefined
private readonly spawnCwd: string | undefined
private statusValue: SessionStatus = 'connecting'
private claudeStatusValue: ClaudeStatus = 'unknown'
private cwdValue: string | null = null
private pendingApprovalValue = false
private pendingToolValue: string | undefined = undefined
private ws: WebSocket | null = null
private sessionId: string | null
@@ -80,6 +94,8 @@ export class TerminalSession {
this.onActivity = opts.onActivity
this.onTitle = opts.onTitle
this.onStatus = opts.onStatus
this.onClaudeStatus = opts.onClaudeStatus
this.spawnCwd = opts.cwd
this.el = document.createElement('div')
this.el.className = 'term-pane'
@@ -92,11 +108,20 @@ export class TerminalSession {
})
this.fitAddon = new FitAddon()
this.term.loadAddon(this.fitAddon)
this.searchAddon = new SearchAddon()
this.term.loadAddon(this.searchAddon)
this.term.loadAddon(new WebLinksAddon()) // M2: tap a URL Claude prints to open it
this.term.open(this.el)
this.term.onData((data) => this.send(data))
// Tab title = current folder, derived from the shell's OSC title.
this.term.onTitleChange((title) => this.onTitle?.(folderFromTitle(title) ?? title))
// OSC 7 reports the full cwd (M6) — used for "new tab here".
this.term.parser.registerOscHandler(7, (payload) => {
const c = cwdFromOsc7(payload)
if (c !== null) this.cwdValue = c
return true
})
this.resizeObserver = new ResizeObserver(() => this.scheduleResize())
this.resizeObserver.observe(this.el)
@@ -112,6 +137,24 @@ export class TerminalSession {
return this.statusValue
}
/** Current Claude Code activity (from hooks, H2). */
get claudeStatus(): ClaudeStatus {
return this.claudeStatusValue
}
/** Current working directory reported by the shell via OSC 7 (M6), or null. */
get cwd(): string | null {
return this.cwdValue
}
/** Whether a tool approval is held server-side and can be resolved (H3). */
get pendingApproval(): boolean {
return this.pendingApprovalValue
}
get pendingTool(): string | undefined {
return this.pendingToolValue
}
private setStatus(s: SessionStatus): void {
this.statusValue = s
this.onStatus?.(s)
@@ -162,7 +205,13 @@ export class TerminalSession {
this.reconnectDelay = 1000
this.setStatus('connected')
this.term.write(statusLine(`${GREEN}${BOLD}Connected${RESET}`))
socket.send(buildMessage({ type: 'attach', sessionId: this.sessionId }))
// Send cwd only on a fresh session (M6 "new tab here"); on reconnect the
// sessionId is already set and the server ignores cwd.
const attachMsg: ClientMessage =
this.sessionId === null && this.spawnCwd !== undefined
? { type: 'attach', sessionId: null, cwd: this.spawnCwd }
: { type: 'attach', sessionId: this.sessionId }
socket.send(buildMessage(attachMsg))
})
socket.addEventListener('message', (event: MessageEvent<string>) => {
@@ -200,6 +249,13 @@ export class TerminalSession {
if (this.el.style.display === 'none') this.onActivity?.()
break
}
case 'status': {
this.claudeStatusValue = msg.status
this.pendingApprovalValue = msg.pending === true
this.pendingToolValue = msg.pending === true ? msg.detail : undefined
this.onClaudeStatus?.(msg.status, msg.detail)
break
}
case 'exit': {
this.setStatus('exited')
const reason = msg.reason ? ` (${msg.reason})` : ''
@@ -238,10 +294,45 @@ export class TerminalSession {
}, delay)
}
private sendMsg(msg: ClientMessage): void {
if (this.ws === null || this.ws.readyState !== WebSocket.OPEN) return
this.ws.send(buildMessage(msg))
}
/** Send raw bytes as input (used by term.onData and the key bar). */
send(data: string): void {
if (this.ws === null || this.ws.readyState !== WebSocket.OPEN) return
this.ws.send(buildMessage({ type: 'input', data }))
this.sendMsg({ type: 'input', data })
}
/** Resolve a held PermissionRequest (H3). */
approve(): void {
this.pendingApprovalValue = false
this.sendMsg({ type: 'approve' })
}
reject(): void {
this.pendingApprovalValue = false
this.sendMsg({ type: 'reject' })
}
/** Scrollback search (M1). */
findNext(query: string): void {
this.searchAddon.findNext(query)
}
findPrevious(query: string): void {
this.searchAddon.findPrevious(query)
}
clearSearch(): void {
this.searchAddon.clearDecorations()
}
/** Apply a theme + font size (M3) and re-fit if visible. */
applyTheme(theme: ITheme, fontSize: number): void {
this.term.options.theme = theme
this.term.options.fontSize = fontSize
if (this.el.style.display !== 'none') {
const dims = this.safefit()
if (dims !== null) this.sendResize(dims.cols, dims.rows)
}
}
/** Make this pane visible, fit it, and focus it. */

View File

@@ -23,3 +23,19 @@ export function folderFromTitle(title: string): string | null {
if (slash !== -1) s = s.slice(slash + 1)
return s || null
}
/**
* Parse the full cwd path from an OSC 7 sequence payload (M6).
* data = "file://host/Users/me/dir" → "/Users/me/dir"
* Returns null if it isn't a file:// URL.
*/
export function cwdFromOsc7(data: string): string | null {
const m = /^file:\/\/[^/]*(\/.*)$/.exec(data.trim())
if (m === null) return null
const path = m[1] as string
try {
return decodeURIComponent(path)
} catch {
return path
}
}

87
scripts/setup-hooks.mjs Normal file
View File

@@ -0,0 +1,87 @@
#!/usr/bin/env node
/**
* scripts/setup-hooks.mjs — install/remove web-terminal's Claude Code hooks (H2).
*
* Adds `command` hooks to ~/.claude/settings.json that POST each hook event to
* the web-terminal server so it can show live Claude status per tab. The command
* is an inline curl that uses two env vars the server injects into each spawned
* shell: $WEBTERM_HOOK_URL (the server's /hook URL, with the right port) and
* $WEBTERM_SESSION (which tab). Outside web-terminal those vars are unset, so
* curl no-ops (|| true) — the hooks are harmless in normal terminals.
*
* Usage:
* node scripts/setup-hooks.mjs # install (idempotent)
* node scripts/setup-hooks.mjs --remove # uninstall
*
* Backs up settings.json to settings.json.webterm-bak before writing.
*/
import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync } from 'node:fs'
import { homedir } from 'node:os'
import path from 'node:path'
const SETTINGS = path.join(homedir(), '.claude', 'settings.json')
const MARKER = 'WEBTERM_HOOK_URL' // identifies our hook entries
// Fire-and-forget status events: POST and discard the response.
const FF_COMMAND =
'curl -s -X POST "$WEBTERM_HOOK_URL" -H "X-Webterm-Session: $WEBTERM_SESSION"' +
' -H "Content-Type: application/json" --data-binary @- >/dev/null 2>&1 || true'
const FF_EVENTS = ['UserPromptSubmit', 'PreToolUse', 'Notification', 'Stop', 'SessionEnd']
// PermissionRequest is HELD: curl waits for the server's response (the decision
// JSON, produced when you tap Approve/Reject) and writes it to stdout for Claude.
// Empty/failed (outside web-terminal, or timeout) → Claude shows its own prompt.
const PERM_COMMAND =
'curl -s --max-time 350 -X POST "${WEBTERM_HOOK_URL}/permission"' +
' -H "X-Webterm-Session: $WEBTERM_SESSION" -H "Content-Type: application/json" --data-binary @-'
const remove = process.argv.includes('--remove')
let settings = {}
if (existsSync(SETTINGS)) {
try {
settings = JSON.parse(readFileSync(SETTINGS, 'utf8'))
} catch {
console.error(`${SETTINGS} is not valid JSON — aborting (fix it first).`)
process.exit(1)
}
copyFileSync(SETTINGS, `${SETTINGS}.webterm-bak`)
}
if (typeof settings.hooks !== 'object' || settings.hooks === null) settings.hooks = {}
// Strip any existing web-terminal hook groups (makes install idempotent + powers --remove).
for (const ev of Object.keys(settings.hooks)) {
const groups = settings.hooks[ev]
if (!Array.isArray(groups)) continue
settings.hooks[ev] = groups.filter(
(g) =>
!(
g &&
Array.isArray(g.hooks) &&
g.hooks.some((h) => typeof h?.command === 'string' && h.command.includes(MARKER))
),
)
if (settings.hooks[ev].length === 0) delete settings.hooks[ev]
}
if (!remove) {
for (const ev of FF_EVENTS) {
if (!Array.isArray(settings.hooks[ev])) settings.hooks[ev] = []
settings.hooks[ev].push({ hooks: [{ type: 'command', command: FF_COMMAND }] })
}
if (!Array.isArray(settings.hooks['PermissionRequest'])) settings.hooks['PermissionRequest'] = []
settings.hooks['PermissionRequest'].push({ hooks: [{ type: 'command', command: PERM_COMMAND }] })
}
mkdirSync(path.dirname(SETTINGS), { recursive: true })
writeFileSync(SETTINGS, `${JSON.stringify(settings, null, 2)}\n`)
console.log(
remove
? `Removed web-terminal hooks from ${SETTINGS}`
: `Installed web-terminal hooks into ${SETTINGS} (events: ${[...FF_EVENTS, 'PermissionRequest'].join(', ')})`,
)
if (existsSync(`${SETTINGS}.webterm-bak`)) console.log(`Backup: ${SETTINGS}.webterm-bak`)
console.log('Restart any running `claude` sessions for the hooks to take effect.')

View File

@@ -8,6 +8,15 @@
import os from 'node:os'
import type { Config, EnvLike } from './types.js'
import { tmuxAvailable } from './session/tmux.js'
/** USE_TMUX: '1'/'true'/'on' → on, '0'/'false'/'off' → off, else auto-detect tmux. */
function resolveUseTmux(raw: string | undefined): boolean {
const v = raw?.trim().toLowerCase()
if (v === '1' || v === 'true' || v === 'on') return true
if (v === '0' || v === 'false' || v === 'off') return false
return tmuxAvailable() // unset / 'auto'
}
// ── constants ─────────────────────────────────────────────────────────────────
@@ -141,6 +150,8 @@ export function loadConfig(env: EnvLike): Config {
const wsPath = env['WS_PATH'] ?? DEFAULT_WS_PATH
const useTmux = resolveUseTmux(env['USE_TMUX'])
const allowedOrigins = deriveAllowedOrigins(port, env['ALLOWED_ORIGINS'])
return Object.freeze({
@@ -152,6 +163,7 @@ export function loadConfig(env: EnvLike): Config {
scrollbackBytes,
maxPayloadBytes,
wsPath,
useTmux,
allowedOrigins,
} satisfies Config)
}

57
src/http/hook.ts Normal file
View File

@@ -0,0 +1,57 @@
/**
* src/http/hook.ts (H2) — map a Claude Code hook POST to a session status update.
*
* Pure + never throws. The web-terminal sessionId arrives in the
* `X-Webterm-Session` header (injected as $WEBTERM_SESSION on spawn); the hook
* payload arrives as JSON in the body. We translate the event into a coarse
* ClaudeStatus the UI can show. The server is still a byte-shuttle for the
* terminal stream — this is an out-of-band side-channel.
*/
import type { ClaudeStatus } from '../types.js';
export interface HookEvent {
sessionId: string;
status: ClaudeStatus;
detail?: string;
}
/**
* @param sessionId value of the `X-Webterm-Session` header
* @param body parsed JSON body of the hook POST (untrusted)
* @returns a status update, or null if the payload is unusable
*/
export function parseHookEvent(sessionId: string | undefined, body: unknown): HookEvent | null {
if (typeof sessionId !== 'string' || sessionId.length === 0) return null;
if (body === null || typeof body !== 'object') return null;
const b = body as Record<string, unknown>;
const event = typeof b['hook_event_name'] === 'string' ? b['hook_event_name'] : '';
const notif = typeof b['notification_type'] === 'string' ? b['notification_type'] : '';
const tool = typeof b['tool_name'] === 'string' ? b['tool_name'] : undefined;
let status: ClaudeStatus;
switch (event) {
case 'PreToolUse':
case 'PostToolUse':
case 'UserPromptSubmit':
status = 'working';
break;
case 'PermissionRequest':
status = 'waiting';
break;
case 'Stop':
case 'SessionEnd':
status = 'idle';
break;
case 'Notification':
status = notif === 'permission_prompt' ? 'waiting' : 'idle';
break;
default:
return null;
}
return status === 'waiting' && tool !== undefined
? { sessionId, status, detail: tool }
: { sessionId, status };
}

View File

@@ -24,7 +24,7 @@ export const SESSION_ID_RE =
// ─── Type whitelist ───────────────────────────────────────────────────────────
const ALLOWED_TYPES = new Set<string>(['attach', 'input', 'resize'])
const ALLOWED_TYPES = new Set<string>(['attach', 'input', 'resize', 'approve', 'reject'])
// ─── parseClientMessage ───────────────────────────────────────────────────────
@@ -73,6 +73,14 @@ export function parseClientMessage(raw: string): ParseResult {
return validateInput(obj)
}
// approve/reject (H3) carry no payload.
if (type === 'approve') {
return { ok: true, message: { type: 'approve' } }
}
if (type === 'reject') {
return { ok: true, message: { type: 'reject' } }
}
// type === 'attach'
return validateAttach(obj)
}
@@ -125,8 +133,21 @@ function validateAttach(obj: Record<string, unknown>): ParseResult {
return { ok: false, error: 'attach.sessionId field is required' }
}
// Optional cwd (M6): must be an absolute path string if present.
const rawCwd = obj['cwd']
let cwd: string | undefined
if (rawCwd !== undefined) {
if (typeof rawCwd !== 'string' || !rawCwd.startsWith('/')) {
return { ok: false, error: 'attach.cwd must be an absolute path string' }
}
cwd = rawCwd
}
if (sessionId === null) {
return { ok: true, message: { type: 'attach', sessionId: null } }
return {
ok: true,
message: cwd !== undefined ? { type: 'attach', sessionId: null, cwd } : { type: 'attach', sessionId: null },
}
}
if (typeof sessionId !== 'string') {
@@ -143,7 +164,10 @@ function validateAttach(obj: Record<string, unknown>): ParseResult {
}
}
return { ok: true, message: { type: 'attach', sessionId } }
return {
ok: true,
message: cwd !== undefined ? { type: 'attach', sessionId, cwd } : { type: 'attach', sessionId },
}
}
// ─── serialize ────────────────────────────────────────────────────────────────

View File

@@ -28,6 +28,7 @@ import path from 'node:path'
import { fileURLToPath } from 'node:url'
import express from 'express'
import type { Response } from 'express'
import { WebSocketServer } from 'ws'
import type { WebSocket as WsWebSocket } from 'ws'
import type { IncomingMessage } from 'node:http'
@@ -35,6 +36,7 @@ import type { IncomingMessage } from 'node:http'
import { loadConfig } from './config.js'
import { parseClientMessage, serialize } from './protocol.js'
import { isOriginAllowed } from './http/origin.js'
import { parseHookEvent } from './http/hook.js'
import { createSessionManager } from './session/manager.js'
import { detachWs, writeInput, resize } from './session/session.js'
import type { Config } from './types.js'
@@ -45,6 +47,20 @@ import { WS_OPEN } from './types.js'
const DEFAULT_COLS = 80
const DEFAULT_ROWS = 24
// H3: how long the server holds a PermissionRequest before falling back to
// Claude's own interactive prompt (so it never hangs if nobody responds).
const PERM_TIMEOUT_MS = 5 * 60_000
/** The decision JSON a PermissionRequest command hook writes to stdout. */
function permDecision(behavior: 'allow' | 'deny'): unknown {
return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior } } }
}
/** True for loopback peers (hooks always run on the host). */
function isLoopback(ip: string): boolean {
return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'
}
// ── helpers ───────────────────────────────────────────────────────────────────
/** Derive __dirname equivalent in ESM. */
@@ -69,6 +85,18 @@ function safeSend(ws: WsWebSocket, data: string): void {
export function startServer(cfg: Config): { close(): Promise<void> } {
const manager = createSessionManager(cfg)
// H3: held PermissionRequest responses, keyed by sessionId. The express `res`
// is parked here until the client approves/rejects (or it times out).
const pendingApprovals = new Map<string, { res: Response; timer: ReturnType<typeof setTimeout> }>()
function resolvePending(sessionId: string, decision: unknown): void {
const p = pendingApprovals.get(sessionId)
if (p === undefined) return
clearTimeout(p.timer)
pendingApprovals.delete(sessionId)
p.res.json(decision)
}
// ── Express static hosting ────────────────────────────────────────────────
const app = express()
@@ -77,6 +105,53 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
const publicDir = path.join(__dirname, '..', 'public')
app.use(express.static(publicDir))
// ── Claude Code hook side-channel (H2) ────────────────────────────────────
// Hooks running inside a spawned shell POST status here (loopback only — the
// shell runs on the host). sessionId arrives in the X-Webterm-Session header.
app.post('/hook', express.json({ limit: '64kb' }), (req, res) => {
if (!isLoopback(req.socket.remoteAddress ?? '')) {
res.status(403).end()
return
}
const ev = parseHookEvent(req.header('x-webterm-session'), req.body)
if (ev === null) {
res.status(400).end()
return
}
manager.handleHookEvent(ev.sessionId, ev.status, ev.detail)
res.status(204).end()
})
// H3: PermissionRequest hook — held until the client approves/rejects. The
// hook's curl writes our response (the decision JSON) to stdout for Claude.
app.post('/hook/permission', express.json({ limit: '64kb' }), (req, res) => {
if (!isLoopback(req.socket.remoteAddress ?? '')) {
res.status(403).end()
return
}
const sessionId = req.header('x-webterm-session')
const body = (req.body ?? {}) as Record<string, unknown>
const tool = typeof body['tool_name'] === 'string' ? body['tool_name'] : undefined
const session = typeof sessionId === 'string' ? manager.get(sessionId) : undefined
// Nobody watching this tab (no session / not attached) → let Claude prompt itself.
if (sessionId === undefined || session === undefined || session.attachedWs === null) {
res.json({})
return
}
resolvePending(sessionId, {}) // clear any stale hold for this session
const timer = setTimeout(() => {
pendingApprovals.delete(sessionId)
res.json({}) // timeout → fall back to Claude's interactive prompt
manager.handleHookEvent(sessionId, 'idle')
}, PERM_TIMEOUT_MS)
pendingApprovals.set(sessionId, { res, timer })
// Show the approve/reject affordance on the client.
manager.handleHookEvent(sessionId, 'waiting', tool, true)
})
// ── HTTP server ───────────────────────────────────────────────────────────
const httpServer = createServer(app)
@@ -154,7 +229,7 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
let session
try {
session = manager.handleAttach(ws, msg.sessionId, dims, Date.now())
session = manager.handleAttach(ws, msg.sessionId, dims, Date.now(), msg.cwd)
} catch (err: unknown) {
// M4: spawn failure — send exit(-1) and close only this connection.
const reason =
@@ -182,6 +257,13 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
writeInput(session, msg.data)
} else if (msg.type === 'resize') {
resize(session, msg.cols, msg.rows)
} else if (msg.type === 'approve') {
// H3: resolve the held PermissionRequest with allow.
resolvePending(boundSessionId, permDecision('allow'))
manager.handleHookEvent(boundSessionId, 'working', undefined, false)
} else if (msg.type === 'reject') {
resolvePending(boundSessionId, permDecision('deny'))
manager.handleHookEvent(boundSessionId, 'working', undefined, false)
} else if (msg.type === 'attach') {
// Re-attach after first bind is not expected; log and ignore.
console.error('[server] unexpected re-attach on established connection')
@@ -194,6 +276,9 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
ws.on('close', () => {
if (boundSessionId === null) return
// H3: release any held approval so the hook isn't stuck for the full timeout.
resolvePending(boundSessionId, {})
const session = manager.get(boundSessionId)
if (session === undefined) return
@@ -219,6 +304,7 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
console.error('[server] shutdown signal received — cleaning up')
doShutdown()
httpServer.close(() => process.exit(0))
httpServer.closeIdleConnections() // drop idle keep-alive (hook) sockets so close() drains
}
process.on('SIGINT', onSignal)
@@ -246,6 +332,9 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
return new Promise<void>((resolve) => {
doShutdown()
httpServer.close(() => resolve())
// Drop idle keep-alive sockets (e.g. an undici hook connection) so the
// close callback can fire instead of waiting for the keep-alive timeout.
httpServer.closeIdleConnections()
})
},
}

View File

@@ -22,10 +22,18 @@
* Coding style: immutable-update preference, no console.log, errors explicit.
*/
import type { Config, Dims, Session, SessionManager, WebSocketLike } from '../types.js';
import type {
ClaudeStatus,
Config,
Dims,
Session,
SessionManager,
WebSocketLike,
} from '../types.js';
import { WS_OPEN } from '../types.js';
import { serialize } from '../protocol.js';
import { createSession, attachWs, kill } from './session.js';
import { hasSession, tmuxName } from './tmux.js';
/**
* Send a serialised ServerMessage to `ws` only when it is OPEN (M5).
@@ -74,11 +82,13 @@ export function createSessionManager(cfg: Config): SessionManager {
sessionId: string | null,
dims: Dims,
now: number,
cwd?: string,
): Session {
// ── Case 1: null → always create a new session ──────────────────────────
if (sessionId === null) {
// M4: createSession may throw (spawn failure). Do NOT catch here.
const session = createSession(cfg, dims, now, onSessionExit);
// M6: cwd (if given) is the spawn directory for "new tab here".
const session = createSession(cfg, dims, now, onSessionExit, undefined, cwd);
const kicked = attachWs(session, ws);
if (kicked !== null) kicked.close();
sessions = new Map(sessions).set(session.meta.id, session);
@@ -106,6 +116,17 @@ export function createSessionManager(cfg: Config): SessionManager {
return existing;
}
// ── Case 3.5 (H1): not in our table, but a tmux session survived a restart ─
// Re-attach with the SAME id so `tmux new-session -A -s web_<id>` reconnects
// to the still-running shell instead of spawning a fresh one.
if (cfg.useTmux && hasSession(tmuxName(sessionId))) {
const revived = createSession(cfg, dims, now, onSessionExit, sessionId);
const kickedRevived = attachWs(revived, ws);
if (kickedRevived !== null) kickedRevived.close();
sessions = new Map(sessions).set(revived.meta.id, revived);
return revived;
}
// ── Case 4: session not found → create a new one ─────────────────────────
// M4: createSession may throw. Do NOT catch here.
const session = createSession(cfg, dims, now, onSessionExit);
@@ -119,6 +140,28 @@ export function createSessionManager(cfg: Config): SessionManager {
return sessions.get(id);
}
/**
* H2: a Claude Code hook reported activity for `sessionId`. Record it and push
* a `status` frame to the attached ws (no-op if the session is gone/detached).
*/
function handleHookEvent(
sessionId: string,
status: ClaudeStatus,
detail?: string,
pending?: boolean,
): void {
const session = sessions.get(sessionId);
if (session === undefined) return;
session.claudeStatus = status;
const msg: { type: 'status'; status: ClaudeStatus; detail?: string; pending?: boolean } = {
type: 'status',
status,
};
if (detail !== undefined) msg.detail = detail;
if (pending) msg.pending = true;
sendIfOpen(session.attachedWs, msg);
}
/**
* Reclaim detached sessions whose liveness window has expired (M3).
*
@@ -151,13 +194,21 @@ export function createSessionManager(cfg: Config): SessionManager {
return count;
}
/** Kill every live session and clear the table (called on SIGINT/SIGTERM). */
/**
* Called on SIGINT/SIGTERM/close. For non-tmux sessions, kill the PTY. For
* tmux sessions (H1), kill only the client pty — the tmux server keeps the
* shell alive so it survives the restart and can be re-attached.
*/
function shutdown(): void {
for (const session of sessions.values()) {
kill(session);
if (session.tmuxName !== null) {
session.pty.kill(); // detach client; tmux keeps the shell
} else {
kill(session);
}
}
sessions = new Map();
}
return { handleAttach, get, reapIdle, shutdown };
return { handleAttach, get, handleHookEvent, reapIdle, shutdown };
}

View File

@@ -37,6 +37,7 @@ import type {
import { WS_OPEN } from '../types.js';
import { serialize } from '../protocol.js';
import { createRingBuffer } from './ring-buffer.js';
import { tmuxName, killSession } from './tmux.js';
/** Send a server message to `ws` only if it is OPEN (M5). Never throws on a
* closed socket; forwarding to a dead ws is simply a no-op. */
@@ -57,18 +58,36 @@ export function createSession(
dims: Dims,
now: number,
onExit: (session: Session) => void,
// H1: pass an existing id to RE-ATTACH to a surviving tmux session (e.g. after
// a server restart). Default = a fresh id for a brand-new session.
id: string = randomUUID(),
// M6: spawn directory for a new session ("new tab here"); defaults to homeDir.
cwd?: string,
): Session {
// M4: let a spawn failure (e.g. missing shell) propagate synchronously.
const pty: IPty = spawn(cfg.shellPath, [], {
// The id is injected into the shell env so Claude Code hooks know which tab an
// event belongs to ($WEBTERM_SESSION) and where to POST ($WEBTERM_HOOK_URL, H2).
const tName = cfg.useTmux ? tmuxName(id) : null;
// H1: under tmux, the node-pty process is a tmux CLIENT; `new-session -A`
// attaches to web_<id> if it exists (restart survival) or creates it.
const file = tName !== null ? 'tmux' : cfg.shellPath;
const args = tName !== null ? ['new-session', '-A', '-s', tName, cfg.shellPath] : [];
// M4: let a spawn failure (e.g. missing shell / tmux) propagate synchronously.
const pty: IPty = spawn(file, args, {
name: 'xterm-256color',
cols: dims.cols,
rows: dims.rows,
cwd: cfg.homeDir,
env: process.env,
cwd: cwd ?? cfg.homeDir,
env: {
...process.env,
WEBTERM_SESSION: id,
WEBTERM_HOOK_URL: `http://127.0.0.1:${cfg.port}/hook`,
},
});
const meta: SessionMeta = {
id: randomUUID(),
id,
createdAt: now,
shellPath: cfg.shellPath,
};
@@ -81,6 +100,8 @@ export function createSession(
lastOutputAt: now,
exitedAt: null,
exitCode: null,
claudeStatus: 'unknown',
tmuxName: tName,
pty,
};
@@ -139,7 +160,12 @@ export function resize(session: Session, cols: number, rows: number): void {
session.pty.resize(cols, rows);
}
/** Kill the underlying PTY (process exit / idle reclaim). */
/**
* Kill the session (idle reclaim). Under tmux this ends the actual shell via
* `tmux kill-session` (H1); killing only the client pty would leave the tmux
* session running. Always also kills the client pty.
*/
export function kill(session: Session): void {
if (session.tmuxName !== null) killSession(session.tmuxName);
session.pty.kill();
}

46
src/session/tmux.ts Normal file
View File

@@ -0,0 +1,46 @@
/**
* src/session/tmux.ts (H1) — thin synchronous wrappers around the tmux CLI.
*
* Used only when cfg.useTmux is on. Running the shell inside a tmux session
* lets it survive a server/host restart: the node-pty process is just a tmux
* *client*; killing it detaches, and the tmux server keeps the shell alive.
*
* Every call is best-effort and never throws (tmux missing / session gone are
* treated as "false"/no-op).
*/
import { execFileSync } from 'node:child_process'
/** Is the tmux binary available on PATH? */
export function tmuxAvailable(): boolean {
try {
execFileSync('tmux', ['-V'], { stdio: 'ignore' })
return true
} catch {
return false
}
}
/** tmux session name for a web-terminal session id (UUIDs are tmux-safe). */
export function tmuxName(sessionId: string): string {
return `web_${sessionId}`
}
/** Does a tmux session with this name already exist (e.g. after a restart)? */
export function hasSession(name: string): boolean {
try {
execFileSync('tmux', ['has-session', '-t', name], { stdio: 'ignore' })
return true
} catch {
return false
}
}
/** Kill a tmux session (ends the shell). No-op if it's already gone. */
export function killSession(name: string): void {
try {
execFileSync('tmux', ['kill-session', '-t', name], { stdio: 'ignore' })
} catch {
// already gone — fine
}
}

View File

@@ -27,6 +27,7 @@ export interface Config {
readonly scrollbackBytes: number; // ring buffer capacity, default 2MB
readonly maxPayloadBytes: number; // max WS frame bytes, default 1MB (L5)
readonly wsPath: string; // WS upgrade path, default '/term' (L3; invariant 8)
readonly useTmux: boolean; // H1: spawn the shell inside tmux so it survives a server restart
readonly allowedOrigins: readonly string[]; // derived from NIC IPs, NOT bindHost (M1)
}
@@ -38,18 +39,27 @@ export type EnvLike = Readonly<Record<string, string | undefined>>;
/* ─────────────────────── protocol (§3.2) ─────────────────────── */
/** client → server */
/** client → server. approve/reject (H3) resolve a held PermissionRequest.
* attach.cwd (M6) = spawn a new session in this directory ("new tab here"). */
export type ClientMessage =
| { type: 'attach'; sessionId: string | null }
| { type: 'attach'; sessionId: string | null; cwd?: string }
| { type: 'input'; data: string }
| { type: 'resize'; cols: number; rows: number };
| { type: 'resize'; cols: number; rows: number }
| { type: 'approve' }
| { type: 'reject' };
/** Claude Code activity, derived from hooks (H2). 'unknown' = no hook signal yet. */
export type ClaudeStatus = 'working' | 'waiting' | 'idle' | 'unknown';
/** server → client. exit.code = shell code; -1 when spawn never succeeded (M4).
* exit.reason optional normally, REQUIRED on spawn failure / abnormal exit. */
* exit.reason optional normally, REQUIRED on spawn failure / abnormal exit.
* status (H2/H3) = Claude Code activity; `pending` true when a tool approval
* is held server-side and the client can approve/reject it. */
export type ServerMessage =
| { type: 'attached'; sessionId: string }
| { type: 'output'; data: string }
| { type: 'exit'; code: number; reason?: string };
| { type: 'exit'; code: number; reason?: string }
| { type: 'status'; status: ClaudeStatus; detail?: string; pending?: boolean };
/** parseClientMessage result — never throws; errors flow here (§5.3). */
export type ParseResult =
@@ -139,6 +149,10 @@ export interface Session {
* and attach replays the buffer then re-sends `exit` (L1). */
exitedAt: number | null;
exitCode: number | null;
/** Claude Code activity from hooks (H2); updated by manager.handleHookEvent. */
claudeStatus: ClaudeStatus;
/** tmux session name (H1) when running under tmux, else null. */
readonly tmuxName: string | null;
readonly pty: IPty;
}
@@ -154,8 +168,21 @@ export interface Session {
/* ──────────────────────── manager (§3.5) ─────────────────────── */
export interface SessionManager {
handleAttach(ws: WebSocketLike, sessionId: string | null, dims: Dims, now: number): Session;
handleAttach(
ws: WebSocketLike,
sessionId: string | null,
dims: Dims,
now: number,
cwd?: string,
): Session;
get(id: string): Session | undefined;
/** Set a session's Claude status (from a hook) and push it to the attached ws (H2/H3). */
handleHookEvent(
sessionId: string,
status: ClaudeStatus,
detail?: string,
pending?: boolean,
): void;
/** reclaim when now - max(detachedAt, lastOutputAt) > idleTtlMs (M3). Returns count. */
reapIdle(now: number): number;
shutdown(): void;

47
test/hook.test.ts Normal file
View File

@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest'
import { parseHookEvent } from '../src/http/hook.js'
const SID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
describe('parseHookEvent', () => {
it('maps tool events to "working"', () => {
for (const e of ['PreToolUse', 'PostToolUse', 'UserPromptSubmit']) {
expect(parseHookEvent(SID, { hook_event_name: e })).toEqual({
sessionId: SID,
status: 'working',
})
}
})
it('maps PermissionRequest to "waiting" with the tool name as detail', () => {
expect(parseHookEvent(SID, { hook_event_name: 'PermissionRequest', tool_name: 'Bash' })).toEqual(
{ sessionId: SID, status: 'waiting', detail: 'Bash' },
)
})
it('maps Notification permission_prompt to "waiting", idle_prompt to "idle"', () => {
expect(
parseHookEvent(SID, { hook_event_name: 'Notification', notification_type: 'permission_prompt' }),
).toEqual({ sessionId: SID, status: 'waiting' })
expect(
parseHookEvent(SID, { hook_event_name: 'Notification', notification_type: 'idle_prompt' }),
).toEqual({ sessionId: SID, status: 'idle' })
})
it('maps Stop / SessionEnd to "idle"', () => {
expect(parseHookEvent(SID, { hook_event_name: 'Stop' })).toEqual({ sessionId: SID, status: 'idle' })
expect(parseHookEvent(SID, { hook_event_name: 'SessionEnd' })).toEqual({
sessionId: SID,
status: 'idle',
})
})
it('returns null for missing sessionId, bad body, or unknown event', () => {
expect(parseHookEvent(undefined, { hook_event_name: 'Stop' })).toBeNull()
expect(parseHookEvent('', { hook_event_name: 'Stop' })).toBeNull()
expect(parseHookEvent(SID, null)).toBeNull()
expect(parseHookEvent(SID, 'nope')).toBeNull()
expect(parseHookEvent(SID, { hook_event_name: 'SomethingElse' })).toBeNull()
expect(parseHookEvent(SID, {})).toBeNull()
})
})

View File

@@ -25,6 +25,7 @@
*/
import net from 'node:net'
import { execFileSync } from 'node:child_process'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import WebSocket from 'ws'
@@ -49,6 +50,19 @@ const PTY_AVAILABLE = (() => {
})()
const itPty = PTY_AVAILABLE ? it : it.skip
/** H1 tmux tests need real PTY + the tmux binary. */
const TMUX_OK =
PTY_AVAILABLE &&
(() => {
try {
execFileSync('tmux', ['-V'], { stdio: 'ignore' })
return true
} catch {
return false
}
})()
const itTmux = TMUX_OK ? it : it.skip
// ── Helpers ──────────────────────────────────────────────────────────────────
/** Pick a free port on 127.0.0.1 by briefly binding to port 0. */
@@ -88,6 +102,7 @@ function makeTestConfig(port: number, shellPath: string, maxPayloadBytes = 512 *
// Keep idle TTL tiny so stray sessions don't linger.
IDLE_TTL: '86400',
MAX_PAYLOAD_BYTES: String(maxPayloadBytes),
USE_TMUX: '0', // default off so most cases don't spawn/leak tmux sessions
})
}
@@ -545,4 +560,174 @@ describe('startServer — integration', () => {
await waitForClose(ws2, 3_000).catch(() => undefined)
},
)
// ── ⑦ Hook side-channel → status push (H2, needs real PTY — sandbox-off) ───
itPty(
'⑦ [needs real PTY (sandbox-off)] POST /hook pushes a status frame to the attached ws (H2)',
async () => {
const ws = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
headers: { Origin: `http://127.0.0.1:${port}` },
})
await waitForOpen(ws, 3_000)
ws.send(JSON.stringify({ type: 'attach', sessionId: null }))
const attached = (await waitForMessage(
ws,
(m) =>
typeof m === 'object' && m !== null && (m as Record<string, unknown>)['type'] === 'attached',
5_000,
)) as Record<string, unknown>
const sessionId = attached['sessionId'] as string
// Arm the status listener BEFORE firing the hook — the server pushes the
// status frame synchronously (before the POST returns 204), so attaching
// the listener after the fetch would race past it.
const statusPromise = waitForMessage(
ws,
(m) =>
typeof m === 'object' && m !== null && (m as Record<string, unknown>)['type'] === 'status',
5_000,
)
// Simulate a Claude Code PermissionRequest hook firing for this session.
const res = await fetch(`http://127.0.0.1:${port}/hook`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId },
body: JSON.stringify({ hook_event_name: 'PermissionRequest', tool_name: 'Bash' }),
})
expect(res.status).toBe(204)
const status = (await statusPromise) as Record<string, unknown>
expect(status['status']).toBe('waiting')
expect(status['detail']).toBe('Bash')
ws.close()
await waitForClose(ws, 3_000).catch(() => undefined)
},
)
// ── ⑧ Held PermissionRequest resolves on approve (H3, real PTY — sandbox-off) ─
itPty(
'⑧ [needs real PTY (sandbox-off)] POST /hook/permission is held until approve → allow decision',
async () => {
const ws = new WebSocket(`ws://127.0.0.1:${port}${cfg.wsPath}`, {
headers: { Origin: `http://127.0.0.1:${port}` },
})
await waitForOpen(ws, 3_000)
ws.send(JSON.stringify({ type: 'attach', sessionId: null }))
const attached = (await waitForMessage(
ws,
(m) =>
typeof m === 'object' && m !== null && (m as Record<string, unknown>)['type'] === 'attached',
5_000,
)) as Record<string, unknown>
const sessionId = attached['sessionId'] as string
// Arm the pending-status listener before firing (push is synchronous).
const pendingPromise = waitForMessage(
ws,
(m) =>
typeof m === 'object' && m !== null && (m as Record<string, unknown>)['pending'] === true,
5_000,
)
// Fire the PermissionRequest hook — this POST is HELD by the server until
// we approve. Do NOT await it yet.
const permPromise = fetch(`http://127.0.0.1:${port}/hook/permission`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId },
body: JSON.stringify({ tool_name: 'Bash' }),
})
const pending = (await pendingPromise) as Record<string, unknown>
expect(pending['status']).toBe('waiting')
expect(pending['pending']).toBe(true)
// Approve over the ws → the held POST should resolve with an allow decision.
ws.send(JSON.stringify({ type: 'approve' }))
const decision = (await (await permPromise).json()) as {
hookSpecificOutput?: { decision?: { behavior?: string } }
}
expect(decision.hookSpecificOutput?.decision?.behavior).toBe('allow')
ws.close()
await waitForClose(ws, 3_000).catch(() => undefined)
},
)
// ── H1: tmux keepalive — a shell survives a full server restart ────────────
itTmux(
'H1 [needs tmux + real PTY] shell state survives a server restart',
async () => {
const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
const isAttached = (m: unknown): boolean =>
typeof m === 'object' && m !== null && (m as Record<string, unknown>)['type'] === 'attached'
const tport = await getFreePort()
const tcfg = loadConfig({
PORT: String(tport),
BIND_HOST: '127.0.0.1',
SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
ALLOWED_ORIGINS: `http://127.0.0.1:${tport}`,
IDLE_TTL: '86400',
USE_TMUX: '1',
})
let sid = ''
let srv = startServer(tcfg)
await delay(150)
try {
// Attach and set a shell variable inside the tmux shell.
const ws1 = new WebSocket(`ws://127.0.0.1:${tport}/term`, {
headers: { Origin: `http://127.0.0.1:${tport}` },
})
await waitForOpen(ws1, 3_000)
ws1.send(JSON.stringify({ type: 'attach', sessionId: null }))
const att = (await waitForMessage(ws1, isAttached, 5_000)) as Record<string, unknown>
sid = att['sessionId'] as string
await delay(500)
ws1.send(JSON.stringify({ type: 'input', data: 'WEBTERM_MARK=tmuxlives\r' }))
await delay(500)
ws1.close()
await waitForClose(ws1, 3_000).catch(() => undefined)
// Restart the server — shutdown must keep the tmux session alive (H1).
await srv.close()
srv = startServer(tcfg)
await delay(150)
// Reconnect with the SAME sessionId → re-attach to the surviving shell.
const ws2 = new WebSocket(`ws://127.0.0.1:${tport}/term`, {
headers: { Origin: `http://127.0.0.1:${tport}` },
})
await waitForOpen(ws2, 3_000)
const out: string[] = []
ws2.on('message', (raw) => {
try {
const m = JSON.parse(raw.toString('utf8')) as Record<string, unknown>
if (m['type'] === 'output' && typeof m['data'] === 'string') out.push(m['data'])
} catch {
/* ignore */
}
})
ws2.send(JSON.stringify({ type: 'attach', sessionId: sid }))
await delay(400)
ws2.send(JSON.stringify({ type: 'input', data: 'echo MARK-$WEBTERM_MARK\r' }))
await delay(900)
// The variable set before the restart is still set → same shell survived.
expect(out.join('')).toContain('MARK-tmuxlives')
ws2.close()
await waitForClose(ws2, 3_000).catch(() => undefined)
} finally {
await srv.close()
if (sid !== '') {
try {
execFileSync('tmux', ['kill-session', '-t', `web_${sid}`], { stdio: 'ignore' })
} catch {
/* already gone */
}
}
}
},
20_000,
)
})

View File

@@ -78,10 +78,18 @@ describe('keybar', () => {
expect(KEY_MAP.slash).toHaveLength(1)
})
it('all 10 keys are defined', () => {
it('Claude Code control keys map to the right bytes', () => {
expect(KEY_MAP.escEsc).toBe('\x1b\x1b') // rewind / clear draft
expect(KEY_MAP.ctrlO).toBe('\x0f') // expand transcript
expect(KEY_MAP.ctrlT).toBe('\x14') // task list
expect(KEY_MAP.ctrlB).toBe('\x02') // background
})
it('all 14 keys are defined', () => {
const keys = Object.keys(KEY_MAP)
expect(keys).toEqual([
'esc',
'escEsc',
'shiftTab',
'arrowUp',
'arrowDown',
@@ -89,6 +97,9 @@ describe('keybar', () => {
'arrowRight',
'enter',
'ctrlC',
'ctrlO',
'ctrlT',
'ctrlB',
'tab',
'slash',
])
@@ -96,8 +107,8 @@ describe('keybar', () => {
it('all mapped bytes are unique strings', () => {
const bytes = Object.values(KEY_MAP)
expect(bytes).toHaveLength(10)
expect(new Set(bytes).size).toBe(10) // all unique
expect(bytes).toHaveLength(14)
expect(new Set(bytes).size).toBe(14) // all unique
})
it('ANSI arrow sequences use correct format', () => {

View File

@@ -46,6 +46,7 @@ const CFG: Config = {
scrollbackBytes: 2 * 1024 * 1024,
maxPayloadBytes: 1024 * 1024,
wsPath: '/term',
useTmux: false,
allowedOrigins: [],
};

View File

@@ -107,6 +107,15 @@ describe('parseClientMessage — valid messages', () => {
expect(result.message).toEqual({ type: 'resize', cols: 120, rows: 40 })
}
})
it('parses approve / reject (H3, no payload)', () => {
const a = parseClientMessage(JSON.stringify({ type: 'approve' }))
expect(a.ok).toBe(true)
if (a.ok) expect(a.message).toEqual({ type: 'approve' })
const r = parseClientMessage(JSON.stringify({ type: 'reject' }))
expect(r.ok).toBe(true)
if (r.ok) expect(r.message).toEqual({ type: 'reject' })
})
})
// ─── parseClientMessage — invalid / error paths ───────────────────────────────

View File

@@ -48,6 +48,7 @@ const CFG: Config = {
scrollbackBytes: 2 * 1024 * 1024,
maxPayloadBytes: 1024 * 1024,
wsPath: '/term',
useTmux: false,
allowedOrigins: [],
};

View File

@@ -1,5 +1,21 @@
import { describe, it, expect } from 'vitest'
import { folderFromTitle } from '../public/title-util.js'
import { folderFromTitle, cwdFromOsc7 } from '../public/title-util.js'
describe('cwdFromOsc7', () => {
it('extracts the path from a file:// URL with a host', () => {
expect(cwdFromOsc7('file://mac.local/Users/me/web-terminal')).toBe('/Users/me/web-terminal')
})
it('extracts the path with an empty host (file:///)', () => {
expect(cwdFromOsc7('file:///Users/me/dir')).toBe('/Users/me/dir')
})
it('decodes percent-encoded spaces', () => {
expect(cwdFromOsc7('file://h/Users/me/my%20dir')).toBe('/Users/me/my dir')
})
it('returns null for non-file URLs', () => {
expect(cwdFromOsc7('http://x/y')).toBeNull()
expect(cwdFromOsc7('garbage')).toBeNull()
})
})
describe('folderFromTitle', () => {
it('extracts the folder from user@host:~/path', () => {