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:
16
CLAUDE.md
16
CLAUDE.md
@@ -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 S1–S8), and the invariant checklist (§8).
|
||||
- `docs/PLAN.md` — the task plan. Dependency-ordered **waves W0–W5**, fine-grained **tasks T1–T21** 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 T1–T21, 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)
|
||||
|
||||
|
||||
27
README.md
27
README.md
@@ -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.
|
||||
|
||||
@@ -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**: **W0–W5 基本完成**。v0.1 功能完整、测试与真机浏览器验证通过。仅剩 2 项需用户真设备。
|
||||
- **进度**: **20/21 任务**(T19 待真手机)· **187 自动化测试全绿** · 真 PTY 端到端验证 · **真浏览器 smoke 通过**。
|
||||
- **验收 F1–F9**: ✅ 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`)。**至此 H1–H4 全部高价值功能完成。**
|
||||
- **机制**: 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
331
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
94
public/dashboard.ts
Normal 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
7
public/icon.svg
Normal 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 |
@@ -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>
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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.
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
19
public/manifest.webmanifest
Normal file
19
public/manifest.webmanifest
Normal 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
63
public/qr.ts
Normal 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
65
public/search.ts
Normal 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
128
public/settings.ts
Normal 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)
|
||||
}
|
||||
262
public/style.css
262
public/style.css
@@ -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
28
public/sw.js
Normal 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)),
|
||||
)
|
||||
})
|
||||
124
public/tabs.ts
124
public/tabs.ts
@@ -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 = '×'
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
87
scripts/setup-hooks.mjs
Normal 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.')
|
||||
@@ -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
57
src/http/hook.ts
Normal 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 };
|
||||
}
|
||||
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
46
src/session/tmux.ts
Normal 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
|
||||
}
|
||||
}
|
||||
39
src/types.ts
39
src/types.ts
@@ -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
47
test/hook.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -46,6 +46,7 @@ const CFG: Config = {
|
||||
scrollbackBytes: 2 * 1024 * 1024,
|
||||
maxPayloadBytes: 1024 * 1024,
|
||||
wsPath: '/term',
|
||||
useTmux: false,
|
||||
allowedOrigins: [],
|
||||
};
|
||||
|
||||
|
||||
@@ -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 ───────────────────────────────
|
||||
|
||||
@@ -48,6 +48,7 @@ const CFG: Config = {
|
||||
scrollbackBytes: 2 * 1024 * 1024,
|
||||
maxPayloadBytes: 1024 * 1024,
|
||||
wsPath: '/term',
|
||||
useTmux: false,
|
||||
allowedOrigins: [],
|
||||
};
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user