auvezy-terminal-remote 0.4.2 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,107 +1,297 @@
1
1
  # auvezy-terminal-remote
2
2
 
3
- > 局域网内通过手机 / 平板浏览器远程控制 PC 上的任意终端程序(zsh / bash / claude / 任何 CLI)。
3
+ **English** | [简体中文](https://github.com/jjj201200/auvezy-terminal-remote/blob/main/README.zh-CN.md)
4
+
5
+ > Remote-control any terminal program on your PC (zsh / bash / claude / any CLI)
6
+ > from a phone or tablet browser over LAN.
4
7
  >
5
- > 一行命令 `atr <program>`,多终端多实例自动出现在浏览器顶栏 tab 切换。
8
+ > One command — `atr <program>` — and every instance shows up as a tab in your
9
+ > browser's top bar.
10
+
11
+ > **License: [PolyForm Noncommercial 1.0.0](./LICENSE)** —
12
+ > free for personal, educational, and nonprofit use, including modification and
13
+ > redistribution. Commercial use requires a separate license.
14
+
15
+ ## What is this
6
16
 
7
- ## 这是什么
17
+ You're on the couch with your phone. A long-running CLI on your PC
18
+ (Claude Code / a deploy script / a debug session…) is doing its thing and
19
+ you want to:
8
20
 
9
- 你坐在沙发上拿着手机,PC 上某个 CLI(Claude Code / 部署脚本 / 调试会话…)正在跑一个长任务。你希望:
21
+ - See its live output (ANSI colors included)
22
+ - Type the next command, hit arrow keys
23
+ - Get a phone notification when Claude triggers an approval hook
24
+ - Not open any port to the public internet, not depend on a cloud service
10
25
 
11
- - 实时看到终端输出(包括 ANSI 颜色)
12
- - 输入下一条指令、按方向键
13
- - Claude 触发审批 hook 时,手机锁屏弹通知
14
- - 不开公网、不依赖云
26
+ That's exactly what this project does. PTY output is bridged over WebSocket
27
+ to a webapp; webapp input is bridged back to the PTY. Listens on LAN IPs only,
28
+ authed by token + local cookie.
15
29
 
16
- 这正是这个项目要做的。把 PTY 输出经 WebSocket 桥到 webapp,
17
- 把 webapp 输入桥回 PTY。仅绑 LAN IP,用 token + 本地 cookie 鉴权。
30
+ ## Quick start
18
31
 
19
- ## 安装
32
+ ### Global install (npm users)
20
33
 
21
34
  ```bash
22
- npm install -g auvezy-terminal-remote
35
+ npm install -g auvezy-terminal-remote # -g is required
23
36
  ```
24
37
 
25
- ## 使用
38
+ > ⚠️ The default `npm i auvezy-terminal-remote` shown at the top right of the
39
+ > npm package page is **missing `-g`**. This is a CLI tool — without `-g` the
40
+ > `atr` binary won't be on your PATH. Use the command above.
41
+
42
+ Then in any terminal:
26
43
 
27
44
  ```bash
28
- atr # 跑当前 $SHELLzsh / bash 自动检测)
29
- atr claude # claude
30
- atr zsh # zsh
31
- atr claude --resume foo # 透传任意参数给子进程
45
+ atr # runs your $SHELL (auto-detects zsh / bash)
46
+ atr claude # runs claude
47
+ atr zsh # runs zsh
48
+ atr claude --resume foo # passes any args through to the child process
32
49
  ```
33
50
 
34
- 启动后扫终端打印的二维码 webapp 自动登录(token `~/.auvezy/terminal-remote/config.json`)。
51
+ After it starts, scan the QR code printed in the terminal — the webapp logs in
52
+ automatically (token lives in `~/.auvezy/terminal-remote/config.json`).
35
53
 
36
- **多实例**:在不同终端多次 `atr <prog>`,每次会自动占一个新端口(3000、3001、3002…),
37
- 浏览器顶栏会自动出现新 tab,点击即可切换。
54
+ **Multiple instances**: Run `atr <prog>` in different terminals; each grabs the
55
+ next available port (3000, 3001, 3002…). Tabs for new instances appear in the
56
+ browser's top bar automatically — click to switch.
38
57
 
39
58
  ```bash
40
- atr list # 列出本机所有实例
41
- atr stop # 停止本机所有实例
42
- atr attach <url> # 命令行接管已有实例
59
+ atr list # list all running instances on this machine
60
+ atr stop # stop all instances on this machine
61
+ atr attach <url> # take over a running instance from the command line
43
62
  ```
44
63
 
45
- ## 启动选项
64
+ ### From source (development or self-build)
65
+
66
+ ```bash
67
+ # GitHub (primary)
68
+ git clone https://github.com/jjj201200/auvezy-terminal-remote.git
69
+ # or Gitee mirror (faster from mainland China)
70
+ git clone https://gitee.com/drowsyflesh/auvezy-terminal-remote.git
46
71
 
72
+ cd auvezy-terminal-remote
73
+ bash install.sh # checks Node 20+ / pnpm 9+ / build deps → installs → builds
74
+ node backend/dist/cli.js # equivalent to `atr`
47
75
  ```
48
- atr [子命令] [选项]
49
-
50
- 子命令:
51
- start 启动 backend(默认)
52
- attach attach 到运行中的实例(命令行接管)
53
- list 列出本机所有运行中实例
54
- stop 停止本机所有实例
55
-
56
- 选项:
57
- -p, --port <n> 端口(默认 3000,多实例自动递增;除非 -S)
58
- -S, --strict-port 严格端口模式:被占即报错退出,不自适应
59
- --spawn-timeout <s> PTY spawn 兜底秒数(默认 30;0=不超时;
60
- 首个浏览器连入 / Enter / 超时三选一触发)
61
- --wait-confirm 强制必须按 Enter spawn(覆盖浏览器/超时触发)
62
- --name <s> 实例名(用于 webapp 显示)
63
- --no-terminal 不打印二维码(CI / 守护进程友好)
64
- --command <cmd> PTY 启动命令(默认当前 $SHELL)
65
- --args <json> 命令参数(JSON 数组字符串)
66
- -h, --help 显示帮助
67
- -v, --version 显示版本号
76
+
77
+
78
+ ## Feature matrix
79
+
80
+ | Feature | How it's implemented |
81
+ |---|---|
82
+ | PTY bridge | node-pty + xterm.js 5 |
83
+ | Auth | timingSafeEqual token + Session Cookie (port-bound) |
84
+ | Multi-instance | port-finder auto-increment + cookie-name suffix isolation |
85
+ | Reconnect / replay | OutputBuffer + history_sync (alt-screen filtered by default) |
86
+ | Approval push | Web Push (VAPID, 3 priorities) + iOS Safari LocalNotification fallback |
87
+ | IP drift detection | 30s polling + stability threshold + ip_changed broadcast |
88
+ | Config rewrite | Webapp Settings dialog → /api/config |
89
+ | `attach` subcommand | Master arbitration (webapp > attach > PC) |
90
+
91
+ ## Configuration
92
+
93
+ On startup the backend reads `~/.auvezy/terminal-remote/config.json`:
94
+
95
+ ```json
96
+ {
97
+ "token": "<64-char hex, auto-generated>",
98
+ "shortcuts": [
99
+ { "label": "ESC", "data": "" },
100
+ { "label": "↑", "data": "[A" }
101
+ ],
102
+ "command": null,
103
+ "args": null,
104
+ "rateLimitPerMinute": 10,
105
+ "sessionTtlMs": 86400000
106
+ }
107
+ ```
108
+
109
+ VAPID keys live in the same directory: `vapid.json` (mode 0o600, auto-generated
110
+ or read from env vars `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY`).
111
+
112
+ Subscriptions are in `push-subscriptions.json`; the multi-instance registry is in
113
+ `instances/<port>.json`.
114
+
115
+ ## Startup options
116
+
68
117
  ```
118
+ atr [subcommand] [options]
69
119
 
70
- 环境变量:
120
+ Subcommands:
121
+ start start the backend (default)
122
+ attach attach to a running instance from the command line
123
+ list list all running instances on this machine
124
+ stop stop all instances on this machine
71
125
 
72
- | 变量 | 用途 |
126
+ Options:
127
+ -p, --port <n> port (default 3000, auto-increments unless -S)
128
+ -S, --strict-port strict port mode: fail if port is taken, no fallback
129
+ --spawn-timeout <s> PTY spawn fallback seconds (default 30; 0 = no timeout;
130
+ first browser connect / Enter / timeout — whichever first)
131
+ --wait-confirm require Enter to spawn (overrides browser/timeout triggers)
132
+ --name <s> instance name (shown in webapp)
133
+ --no-terminal don't print QR code (CI / daemon-friendly)
134
+ --command <cmd> PTY command (default: 'claude')
135
+ --args <json> command args (JSON array string)
136
+ -h, --help show help
137
+ -v, --version show version
138
+ ```
139
+
140
+ Environment variables:
141
+
142
+ | Variable | Purpose |
73
143
  |---|---|
74
- | `OCR_COMMAND` | 子进程命令(默认 `$SHELL`,没有则 `/bin/sh`;显式设为 `claude` Claude)|
75
- | `OCR_ARGS` | 命令参数(JSON 数组字符串,如 `'["-c","tail -f /dev/null"]'`)|
76
- | `OCR_CWD` | 子进程工作目录(默认 `process.cwd()`)|
77
- | `OCR_ANSI_FILTER` | 是否过滤 alt-screen 输出(默认 `false`)。设 `true` vim/htop 退出后重连回放更干净;但全程 alt-screen TUI(claude/tmux/...)仍受内置黑名单保护 |
78
- | `OCR_ANSI_FILTER_TUI_NAMES` | 追加自家 alt-screen TUI 黑名单(逗号分隔),例如 `"lazygit,k9s,gh-dash"` |
79
- | `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` | 注入 VAPID(高优先级,跳过文件)|
80
- | `PORT` | `--port` |
81
- | `STRICT_PORT` | `--strict-port`(设 `true` 启用严格模式)|
82
- | `OCR_SPAWN_TIMEOUT` | `--spawn-timeout`(秒;0 = 无超时)|
83
- | `AUTH_TOKEN` | 指定 token(默认自动生成)|
84
- | `LOG_LEVEL` | pino 级别(默认 info)|
144
+ | `OCR_COMMAND` | Child command (default `$SHELL`, or `/bin/sh`; set to `claude` to run Claude) |
145
+ | `OCR_ARGS` | Command args (JSON array string, e.g. `'["-c","tail -f /dev/null"]'`) |
146
+ | `OCR_CWD` | Child process working directory (default: `process.cwd()`) |
147
+ | `OCR_ANSI_FILTER` | Filter alt-screen output (default `false`). Set `true` for cleaner reconnect replay after vim/htop exits; full-time alt-screen TUIs (claude/tmux/...) are still protected by built-in blocklist |
148
+ | `OCR_ANSI_FILTER_TUI_NAMES` | Append to your own alt-screen TUI blocklist (comma-separated), e.g. `"lazygit,k9s,gh-dash"` |
149
+ | `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` | Inject VAPID keys (highest priority, skips file) |
150
+ | `PORT` | Same as `--port` |
151
+ | `STRICT_PORT` | Same as `--strict-port` (set `true` to enable strict mode) |
152
+ | `OCR_SPAWN_TIMEOUT` | Same as `--spawn-timeout` (seconds; 0 = no timeout) |
153
+ | `AUTH_TOKEN` | Specify token (default: auto-generated) |
154
+ | `LOG_LEVEL` | pino level (default `info`) |
155
+
156
+ > Legacy names `CLAUDE_COMMAND` / `CLAUDE_ARGS` / `CLAUDE_CWD` still work
157
+ > (warned once at startup). Renamed to make it clear: this project is not tied
158
+ > to Claude — it can run any PTY program.
159
+
160
+ ## Install as a PWA (recommended on mobile)
161
+
162
+ The webapp ships with a manifest. "Add to Home Screen" gives you a near-native
163
+ app experience:
164
+
165
+ - **Android Chrome**: top-right ⋮ → "Install app" (or the address bar shows an
166
+ "Install" prompt)
167
+ - **iOS Safari**: share button → "Add to Home Screen"
168
+
169
+ After install: no browser UI (no address bar, no bottom nav), independent task
170
+ card, status bar matches the app color.
171
+
172
+ > **Web Push limitations**: browsers require Push to be in a secure context
173
+ > (HTTPS / localhost). LAN HTTP (http://192.168.x.x) cannot subscribe to push.
174
+ > The settings panel will display "HTTPS required". Workarounds: use Tailscale /
175
+ > Cloudflare Tunnel to put HTTPS in front of the backend, or deploy with a
176
+ > self-signed certificate.
177
+
178
+ ## Running in WSL, accessing from Windows browser
179
+
180
+ WSL2 has two network modes that behave differently:
181
+
182
+ - **Mirrored mode** (Win11 22H2+ default): WSL gets the Windows LAN IP directly
183
+ (e.g. `192.168.x.x`). Windows browsers can use the IP printed on the banner,
184
+ no extra config.
185
+ - **NAT mode** (default): WSL is on `172.x.x.x` private network — Windows
186
+ browsers can't connect directly. The backend detects this on startup and
187
+ prints PowerShell config commands at the end of the banner.
188
+
189
+ **One-shot auto config** (admin PowerShell):
190
+
191
+ ```powershell
192
+ # Forward common port range (default 3000–3010)
193
+ .\scripts\wsl-port-forward.ps1
194
+
195
+ # Forward specific ports only
196
+ .\scripts\wsl-port-forward.ps1 -Ports 3000,3001
197
+
198
+ # Register to re-forward on login (no manual re-run when WSL IP changes)
199
+ .\scripts\wsl-port-forward.ps1 -Persist
200
+
201
+ # Cleanup
202
+ .\scripts\wsl-port-forward.ps1 -Reset
203
+ ```
204
+
205
+ ## Architecture / decisions
206
+
207
+ - Design doc: [`docs/plans/open-claude-remote-clone/design.md`](./docs/plans/open-claude-remote-clone/design.md)
208
+ - Module diagram and data flow: [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md)
209
+ - Architecture decision records (ADRs):
210
+ [`docs/plans/open-claude-remote-clone/adrs/`](./docs/plans/open-claude-remote-clone/adrs/)
211
+
212
+ ## Development
213
+
214
+ ```bash
215
+ pnpm install
216
+ pnpm dev # backend (tsx watch) + frontend (vite) in parallel
217
+ pnpm test # shared + backend + frontend unit tests
218
+ pnpm typecheck
219
+ pnpm build # full build artifacts (frontend copied into backend/frontend-dist)
220
+ ```
221
+
222
+
223
+ ## Roadmap
224
+
225
+ ### Tier 1 (must-have for mobile, low effort, big UX win)
226
+
227
+ 1. **Local Echo** (Mosh / Blink / code-server)
228
+ Input lag killer on mobile 4G/weak networks. xterm prediction plugin shows
229
+ keystrokes immediately, PTY response replaces.
230
+ 2. **Multi-line paste warning + bracketed paste** (VS Code, Tabby)
231
+ Mobile users paste 5-line commands from WeChat/email — currently goes
232
+ straight to PTY, dangerous. Detect multi-line → confirm dialog.
233
+ 3. **Shell Integration subset (OSC 633/133)**
234
+ - Command decorations (green/red dot)
235
+ - Run Recent Command — fuzzy cross-session history quick pick
236
+ - Both extremely friendly on mobile (slow typing → cross-session history
237
+ search is core)
238
+ 4. **Auto Reply** (VS Code)
239
+ Match prompt → auto-respond y/N. Mobile users hate typing `[y/N]`.
240
+ 5. **Process Revive** (VS Code terminal revive)
241
+ You already have instances.json; serialize scrollback into it. After restart
242
+ webapp can see the previous content. The only hard part on the LAN-only
243
+ route is serialization size — bumping to 5MB is fine.
85
244
 
86
- ## 配置
245
+ ### Tier 2 (mobile UX bonus)
87
246
 
88
- 启动时自动读 `~/.auvezy/terminal-remote/config.json`(首次启动自动生成)。
89
- VAPID `~/.auvezy/terminal-remote/vapid.json`,多实例注册表在
90
- `~/.auvezy/terminal-remote/instances/<port>.json`。
247
+ 6. **SmartKeys long-press menu** (Blink)
248
+ On-screen keyboard expansion row: long-press Tab → Shift+Tab; long-press Esc
249
+ → `^[`; long-press Ctrl → sticky until next key. We already have a Toolbar
250
+ shortcut panel, missing "long-press menu" + "modifier sticky".
251
+ 7. **Thumb-drag cursor strip** (Termius: long-press space as trackpad)
252
+ Bottom 8px transparent strip on the terminal area; drag = arrow key
253
+ sequence. Best solution for precise cursor movement on mobile.
254
+ 8. **OSC 8 hyperlinks + word-link / file-link** (VS Code)
255
+ xterm.js native LinkProvider — a few lines makes `src/foo.ts:42` clickable.
256
+ 9. **Multi-chord shortcuts / modifier sticky** (Tabby, Blink)
257
+ Mobile virtual modifier + Cmd-K Cmd-S two-step combos save more screen than
258
+ a wall of buttons.
259
+ 10. **Quick Fixes** (VS Code)
260
+ Scan output, suggest fixes. `fatal: ... --set-upstream` one-click apply.
261
+ High effort but very flashy.
91
262
 
92
- ## WSL 中跑、Windows 浏览器访问
263
+ ### Tier 3 (write permission / security / collaboration)
93
264
 
94
- WSL2 的两种网络模式行为不同:
265
+ 11. **Writable / Read-only split** (ttyd -W, gotty -w)
266
+ When multiple devices connect to one instance, others can be set to
267
+ read-only. Very low effort (distinguish at WS handshake).
268
+ 12. **Broadcast Input** (Termius: simultaneous input on multiple terminals)
269
+ When multiple webapps connect to one instance, broadcast the same input to
270
+ all PTYs. Easy to add to our multi-instance arch.
271
+ 13. **TLS self-signed cert** (ttyd -S, gotty -t)
272
+ HTTPS on LAN lets Web Push API work in more browsers (currently restricted
273
+ on LAN HTTP).
274
+ 14. **OAuth / client cert auth** (ttyd client cert)
275
+ On top of our token, add client cert for hardware auth. Low priority —
276
+ token is already enough.
95
277
 
96
- - **mirrored 模式**(Win11 22H2+ 默认):WSL 直接拿 Windows LAN IP(如 `192.168.x.x`),
97
- Windows 浏览器可以直接用 banner 上的 IP 访问,无需任何额外配置
98
- - **NAT 模式**(默认):WSL 在 `172.x.x.x` 私网,Windows 浏览器无法直连。
99
- backend 启动时会自动检测并在 banner 末尾打印 PowerShell 配置命令
278
+ ### Tier 4 (explicitly NOT copying)
100
279
 
101
- ## 系统要求
280
+ - ❌ Plugin system (Tabby): unnecessary for a LAN-only single binary
281
+ - ❌ Cloud Settings Sync (VS Code): conflicts with the LAN-only red line
282
+ - ❌ Sixel/iTerm image protocols: low value on mobile, xterm.js doesn't
283
+ natively support them
284
+ - ❌ asciinema public sharing: conflicts with LAN-only; if anything we'd only
285
+ do local `.cast` export
286
+ - ❌ SFTP/SCP file management (Termius/Wetty): outside the "remote PTY control"
287
+ scope
288
+ - ❌ End-to-end encrypted Vault: home LAN users don't need this
102
289
 
103
- - Node.js ≥ 20
290
+ ---
104
291
 
105
- ## 许可
292
+ ## Pain points unique to us (others haven't done these)
106
293
 
107
- 专有软件,保留所有权利。详见 LICENSE。
294
+ - **Tailscale / VPN QR code labeling**: we already do dual LAN+Tailscale codes —
295
+ a thoughtful detail on the LAN-only route
296
+ - **Webapp toast notification + iOS LocalNotification fallback**: under iOS PWA
297
+ push restrictions, this fallback strategy isn't considered by anyone else
package/dist/cli.js CHANGED
@@ -37,7 +37,7 @@ function isServerMessage(value) {
37
37
  if (!value || typeof value !== "object")
38
38
  return false;
39
39
  const type = value.type;
40
- return type === "terminal_output" || type === "status_update" || type === "history_sync" || type === "heartbeat" || type === "error" || type === "session_ended" || type === "terminal_resize" || type === "ip_changed";
40
+ return type === "terminal_output" || type === "status_update" || type === "history_sync" || type === "heartbeat" || type === "error" || type === "session_ended" || type === "terminal_resize" || type === "ip_changed" || type === "alt_screen_change";
41
41
  }
42
42
  var init_ws_protocol = __esm({
43
43
  "shared/dist/ws-protocol.js"() {
@@ -1204,6 +1204,19 @@ var init_instance_events = __esm({
1204
1204
 
1205
1205
  // backend/dist/api/instance-routes.js
1206
1206
  import { Router as Router5 } from "express";
1207
+ async function tryHttpSelfShutdown(target, token) {
1208
+ const url = `http://127.0.0.1:${target.port}/api/instances/self/shutdown?token=${encodeURIComponent(token)}`;
1209
+ const ac = new AbortController();
1210
+ const timer = setTimeout(() => ac.abort(), 3e3);
1211
+ try {
1212
+ const r = await fetch(url, { method: "POST", signal: ac.signal });
1213
+ return r.ok;
1214
+ } catch {
1215
+ return false;
1216
+ } finally {
1217
+ clearTimeout(timer);
1218
+ }
1219
+ }
1207
1220
  function createInstanceRoutes(opts) {
1208
1221
  const router = Router5();
1209
1222
  const { authModule, registry, currentInstanceId } = opts;
@@ -1310,6 +1323,18 @@ data: ${JSON.stringify({ instances: items })}
1310
1323
  res.status(e.httpStatus).json({ error: e.toPayload() });
1311
1324
  return;
1312
1325
  }
1326
+ if (opts.sharedToken) {
1327
+ const httpOk = await tryHttpSelfShutdown(target, opts.sharedToken);
1328
+ if (httpOk) {
1329
+ try {
1330
+ await registry.unregister(id);
1331
+ } catch {
1332
+ }
1333
+ res.json({ ok: true, outcome: "sigterm" });
1334
+ return;
1335
+ }
1336
+ logger.warn({ id, host: target.host, port: target.port }, "HTTP self-shutdown \u5931\u8D25\uFF0Cfallback \u5230 process.kill");
1337
+ }
1313
1338
  const pattern = `${target.host}:${target.port}`;
1314
1339
  const results = await stopInstances(pattern, { registry });
1315
1340
  const r = results.find((x) => x.instance.instanceId === id);
@@ -1325,6 +1350,19 @@ data: ${JSON.stringify({ instances: items })}
1325
1350
  res.status(e.httpStatus).json({ error: e.toPayload() });
1326
1351
  }
1327
1352
  });
1353
+ if (opts.selfShutdown) {
1354
+ const triggerSelfShutdown = opts.selfShutdown;
1355
+ router.post("/instances/self/shutdown", (req, res) => {
1356
+ const token = typeof req.query["token"] === "string" ? req.query["token"] : "";
1357
+ if (!token || !authModule.verifyToken(token)) {
1358
+ res.status(401).json({ error: { code: ErrorCode.AUTH_INVALID_TOKEN, message: "invalid token" } });
1359
+ return;
1360
+ }
1361
+ logger.info({ ip: req.ip }, "POST /instances/self/shutdown");
1362
+ res.json({ ok: true });
1363
+ setImmediate(() => triggerSelfShutdown());
1364
+ });
1365
+ }
1328
1366
  return router;
1329
1367
  }
1330
1368
  var init_instance_routes = __esm({
@@ -1470,7 +1508,7 @@ function detectDisplayIp(hostHint) {
1470
1508
  }
1471
1509
  }
1472
1510
  const picked = tailscale[0] ?? lanReal[0] ?? lanVirtual[0] ?? linkLocals[0] ?? "127.0.0.1";
1473
- if (process.env["ATR_DEBUG_NETWORK"] !== "0") {
1511
+ if (process.env["ATR_DEBUG_NETWORK"] === "1") {
1474
1512
  process.stderr.write(`[detectDisplayIp] tailscale=[${tailscale.join(",")}] lanReal=[${lanReal.join(",")}] lanVirtual=[${lanVirtual.join(",")}] linkLocal=[${linkLocals.join(",")}] \u2192 picked=${picked}
1475
1513
  `);
1476
1514
  }
@@ -1602,7 +1640,9 @@ function createApiRouter(opts = {}) {
1602
1640
  authModule: opts.authModule,
1603
1641
  registry: opts.registry,
1604
1642
  currentInstanceId: opts.currentInstanceId,
1605
- spawner: opts.spawner
1643
+ spawner: opts.spawner,
1644
+ selfShutdown: opts.selfShutdown,
1645
+ sharedToken: opts.sharedToken
1606
1646
  }));
1607
1647
  }
1608
1648
  if (opts.authModule && opts.pushService) {
@@ -1715,7 +1755,7 @@ var init_pty_manager = __esm({
1715
1755
  */
1716
1756
  write(data) {
1717
1757
  if (!this.process) {
1718
- logger.warn({ dataLength: data.length }, "\u5C1D\u8BD5\u5199\u5165 PTY \u4F46\u8FDB\u7A0B\u672A\u8FD0\u884C");
1758
+ logger.debug({ dataLength: data.length }, "\u5C1D\u8BD5\u5199\u5165 PTY \u4F46\u8FDB\u7A0B\u672A\u8FD0\u884C");
1719
1759
  return;
1720
1760
  }
1721
1761
  this.process.write(data);
@@ -1824,6 +1864,7 @@ var init_pty_manager = __esm({
1824
1864
  if (this._inAltScreen !== isEnter) {
1825
1865
  this._inAltScreen = isEnter;
1826
1866
  logger.debug({ inAltScreen: isEnter }, "PTY alt-screen \u72B6\u6001\u5207\u6362");
1867
+ this.emit("altScreenChange", isEnter);
1827
1868
  }
1828
1869
  }
1829
1870
  }
@@ -1934,7 +1975,7 @@ var init_ws_server = __esm({
1934
1975
  }
1935
1976
  const clientType = this.opts.authenticate ? this.opts.authenticate(req) : "webapp";
1936
1977
  if (clientType === null) {
1937
- logger.warn({ url: req.url }, "WS upgrade \u88AB\u9274\u6743\u62D2\u7EDD");
1978
+ logger.debug({ url: req.url }, "WS upgrade \u88AB\u9274\u6743\u62D2\u7EDD");
1938
1979
  socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
1939
1980
  socket.destroy();
1940
1981
  return;
@@ -2393,6 +2434,9 @@ var init_session_controller = __esm({
2393
2434
  this.pty.on("resize", (cols, rows) => {
2394
2435
  this.ws.broadcast({ type: "terminal_resize", cols, rows });
2395
2436
  });
2437
+ this.pty.on("altScreenChange", (inAltScreen) => {
2438
+ this.ws.broadcast({ type: "alt_screen_change", inAltScreen });
2439
+ });
2396
2440
  }
2397
2441
  /**
2398
2442
  * 入队一段 PTY 输出,按三阈值决定是否立即 flush
@@ -2891,7 +2935,7 @@ function createWsAuthenticate(authModule) {
2891
2935
  logger.info({ remoteAddress: req.socket.remoteAddress }, "WS \u901A\u8FC7 URL token \u8BA4\u8BC1\uFF08attach\uFF09");
2892
2936
  return "attach";
2893
2937
  }
2894
- logger.warn({ remoteAddress: req.socket.remoteAddress }, "WS URL token \u65E0\u6548");
2938
+ logger.debug({ remoteAddress: req.socket.remoteAddress }, "WS URL token \u65E0\u6548");
2895
2939
  return null;
2896
2940
  }
2897
2941
  const cookieHeader = req.headers.cookie ?? "";
@@ -2899,7 +2943,7 @@ function createWsAuthenticate(authModule) {
2899
2943
  if (sid && authModule.validateSession(sid)) {
2900
2944
  return "webapp";
2901
2945
  }
2902
- logger.warn({
2946
+ logger.debug({
2903
2947
  remoteAddress: req.socket.remoteAddress,
2904
2948
  cookieNames: cookieHeader.split(";").map((c) => c.trim().split("=")[0]).filter(Boolean),
2905
2949
  expectedCookie: authModule.getCookieName()
@@ -2958,6 +3002,7 @@ var init_hook_receiver = __esm({
2958
3002
  import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4, copyFileSync } from "node:fs";
2959
3003
  import { resolve as resolve4, basename as basename2 } from "node:path";
2960
3004
  import { homedir as homedir2 } from "node:os";
3005
+ import { statSync as statSync2 } from "node:fs";
2961
3006
  function createClaudeSettings(port, existing) {
2962
3007
  const hookUrl = `http://127.0.0.1:${port}/api/hook`;
2963
3008
  const hookCommand = `curl -s -X POST ${hookUrl} -H 'Content-Type: application/json' -d @-`;
@@ -3194,7 +3239,26 @@ function resolveDefaultShell(env) {
3194
3239
  const shell = env["SHELL"];
3195
3240
  if (shell && shell.length > 0)
3196
3241
  return shell;
3197
- return process.platform === "win32" ? "cmd.exe" : "/bin/sh";
3242
+ if (process.platform === "win32") {
3243
+ return resolveWindowsShell();
3244
+ }
3245
+ return "/bin/sh";
3246
+ }
3247
+ function resolveWindowsShell() {
3248
+ const candidates = ["pwsh.exe", "powershell.exe", "cmd.exe"];
3249
+ const pathDirs = (process.env["PATH"] ?? "").split(";").filter(Boolean);
3250
+ for (const cmd of candidates) {
3251
+ for (const dir of pathDirs) {
3252
+ try {
3253
+ const full = `${dir}\\${cmd}`;
3254
+ if (statSync2(full).isFile()) {
3255
+ return cmd;
3256
+ }
3257
+ } catch {
3258
+ }
3259
+ }
3260
+ }
3261
+ return "cmd.exe";
3198
3262
  }
3199
3263
  function defaultShellArgs(command) {
3200
3264
  const base = command.split("/").pop()?.toLowerCase() ?? "";
@@ -3389,7 +3453,7 @@ var init_port_finder = __esm({
3389
3453
 
3390
3454
  // backend/dist/registry/instance-spawner.js
3391
3455
  import { spawn as spawn2 } from "node:child_process";
3392
- import { existsSync as existsSync5, statSync as statSync2, openSync } from "node:fs";
3456
+ import { existsSync as existsSync5, statSync as statSync3, openSync } from "node:fs";
3393
3457
  import { resolve as resolve6, isAbsolute, dirname as dirname3 } from "node:path";
3394
3458
  function waitForEarlyExit(child, timeoutMs) {
3395
3459
  return new Promise((res) => {
@@ -3461,7 +3525,7 @@ var init_instance_spawner = __esm({
3461
3525
  if (!existsSync5(cwd)) {
3462
3526
  throw new InstanceError(ErrorCode.CWD_NOT_EXIST, `\u5DE5\u4F5C\u76EE\u5F55\u4E0D\u5B58\u5728\uFF1A${cwd}`, 400);
3463
3527
  }
3464
- if (!statSync2(cwd).isDirectory()) {
3528
+ if (!statSync3(cwd).isDirectory()) {
3465
3529
  throw new InstanceError(ErrorCode.CWD_NOT_EXIST, `cwd \u4E0D\u662F\u76EE\u5F55\uFF1A${cwd}`, 400);
3466
3530
  }
3467
3531
  const name = input.name && input.name.trim() ? input.name.trim() : basename3(cwd);
@@ -4096,6 +4160,9 @@ ${hint}
4096
4160
  },
4097
4161
  credentials: true
4098
4162
  }));
4163
+ let triggerShutdown = () => {
4164
+ logger.warn("shutdown \u8FD8\u672A\u5C31\u7EEA\u5374\u88AB\u8C03\u7528\uFF0C\u5FFD\u7565");
4165
+ };
4099
4166
  app.use("/api", createApiRouter({
4100
4167
  authModule,
4101
4168
  hookReceiver,
@@ -4105,7 +4172,9 @@ ${hint}
4105
4172
  spawner,
4106
4173
  pushService,
4107
4174
  port: cfg.port,
4108
- displayIp
4175
+ displayIp,
4176
+ selfShutdown: () => triggerShutdown(),
4177
+ sharedToken: cfg.token
4109
4178
  }));
4110
4179
  let devProxy = null;
4111
4180
  if (cfg.devProxyPort !== void 0) {
@@ -4167,10 +4236,12 @@ ${hint}
4167
4236
  relay.stop();
4168
4237
  pty2.destroy();
4169
4238
  if (process.stdout.isTTY) {
4170
- process.stdout.write("\x1B[?1049l\x1B[?1000l\x1B[?1002l\x1B[?1003l\x1B[?1005l\x1B[?1006l\x1B[?1015l\x1B[?2004l\x1B[?1004l\x1B[?25h\x1B[!p\x1B[0m\x1Bc");
4171
- try {
4172
- execSync("stty sane 2>/dev/null", { stdio: "ignore" });
4173
- } catch {
4239
+ process.stdout.write("\x1B[?1004l\x1B[?25h\x1B[!p\x1B[0m");
4240
+ if (process.platform !== "win32") {
4241
+ try {
4242
+ execSync("stty sane 2>/dev/null", { stdio: "ignore" });
4243
+ } catch {
4244
+ }
4174
4245
  }
4175
4246
  }
4176
4247
  ipMonitor.stop();
@@ -4197,6 +4268,7 @@ ${hint}
4197
4268
  relay.stop();
4198
4269
  ctrl.setStatus("idle", err.message);
4199
4270
  });
4271
+ triggerShutdown = () => shutdown(0);
4200
4272
  process.on("SIGINT", () => shutdown(0));
4201
4273
  process.on("SIGTERM", () => shutdown(0));
4202
4274
  httpServer.on("error", (err) => {
@@ -4679,11 +4751,11 @@ var init_attach_client = __esm({
4679
4751
  try {
4680
4752
  parsed = JSON.parse(raw.toString());
4681
4753
  } catch {
4682
- logger.warn("\u6536\u5230\u975E JSON WS \u6D88\u606F\uFF0C\u5FFD\u7565");
4754
+ logger.debug("\u6536\u5230\u975E JSON WS \u6D88\u606F\uFF0C\u5FFD\u7565");
4683
4755
  return;
4684
4756
  }
4685
4757
  if (!isServerMessage(parsed)) {
4686
- logger.warn("\u6536\u5230\u4E0D\u8BC6\u522B\u7684 server message\uFF0C\u5FFD\u7565");
4758
+ logger.debug("\u6536\u5230\u4E0D\u8BC6\u522B\u7684 server message\uFF0C\u5FFD\u7565");
4687
4759
  return;
4688
4760
  }
4689
4761
  this.handleServerMessage(parsed);