handmux 0.5.0
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/LICENSE +21 -0
- package/README.md +303 -0
- package/README.zh-CN.md +285 -0
- package/bin/handmux.js +417 -0
- package/hooks/handmux-notify.sh +20 -0
- package/hooks/handmux-write.cjs +92 -0
- package/package.json +52 -0
- package/public/assets/index-BN-IwtP6.css +32 -0
- package/public/assets/index-BUQ0R83h.js +157 -0
- package/public/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
- package/public/fonts/TWUnifont.woff2 +0 -0
- package/public/icons/apple-touch-icon.png +0 -0
- package/public/icons/badge-96.png +0 -0
- package/public/icons/icon-192.png +0 -0
- package/public/icons/icon-512.png +0 -0
- package/public/icons/logo.svg +32 -0
- package/public/index.html +105 -0
- package/public/manifest.webmanifest +37 -0
- package/public/offline.html +50 -0
- package/public/sw.js +117 -0
- package/src/.gitkeep +0 -0
- package/src/appName.js +23 -0
- package/src/asr/iflyConfig.js +10 -0
- package/src/asr/iflySign.js +16 -0
- package/src/auth.js +30 -0
- package/src/claudeEvents.js +212 -0
- package/src/cli/cfNamed.js +5 -0
- package/src/cli/claudeHooks.js +116 -0
- package/src/cli/cloudflareUrl.js +9 -0
- package/src/cli/cloudflared.js +53 -0
- package/src/cli/drivers.js +59 -0
- package/src/cli/options.js +169 -0
- package/src/cli/probe.js +16 -0
- package/src/cli/qr.js +34 -0
- package/src/cli/service.js +98 -0
- package/src/cli/setupWizard.js +248 -0
- package/src/cli/sshTunnel.js +12 -0
- package/src/cli/state.js +42 -0
- package/src/cli/supervisor.js +172 -0
- package/src/cli/tmuxConf.js +90 -0
- package/src/cli/tmuxVersion.js +49 -0
- package/src/cli/tunlite.js +22 -0
- package/src/config.js +6 -0
- package/src/docPath.js +46 -0
- package/src/docs.js +222 -0
- package/src/git.js +185 -0
- package/src/httpApi.js +546 -0
- package/src/previewServer.js +182 -0
- package/src/previews.js +118 -0
- package/src/push.js +121 -0
- package/src/server.js +97 -0
- package/src/staticCache.js +8 -0
- package/src/tmux/commands.js +223 -0
- package/src/trimCapture.js +28 -0
- package/src/uploadTypes.js +28 -0
- package/tmux/README.md +77 -0
- package/tmux/claude-tab-seed.py +67 -0
- package/tmux/claude-tab-seen.sh +14 -0
package/bin/handmux.js
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// handmux CLI — install once (`npm i -g handmux`), then drive it with start/stop/restart/status.
|
|
3
|
+
//
|
|
4
|
+
// The whole config story is two doors:
|
|
5
|
+
// handmux start — just run it. No config needed: defaults to `none` (LAN-only), auto-generates a
|
|
6
|
+
// token, prints the (token-free) URL + a QR of it, and the token on its own line. Flags
|
|
7
|
+
// let you try variations for one run (e.g. --tunnel cloudflare).
|
|
8
|
+
// handmux setup — the one place to configure persistently. Interactive; writes ~/.handmux/config.json
|
|
9
|
+
// (name, tunnel, push, voice). Re-run it to change anything.
|
|
10
|
+
// `start` reads that file; with no file it uses defaults. Precedence: flag > file > default — a flag
|
|
11
|
+
// overrides one value for one run and never persists. Advanced: `--config PATH` (a different file, for
|
|
12
|
+
// dev / multiple configs), `handmux config` (show what's in effect and where each value came from).
|
|
13
|
+
//
|
|
14
|
+
// Tunnels: `none` (LAN only, nothing exposed) · `cloudflare` (instant random https URL) ·
|
|
15
|
+
// `cloudflare-named` (stable URL on your own Cloudflare domain) · `ssh` (reverse-forward to your own
|
|
16
|
+
// server via `tunlite run`). `handmux setup` wires any of these up interactively.
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
20
|
+
import { createInterface } from 'node:readline';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { homedir } from 'node:os';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
import { parseArgs, resolveConfig, explainConfig } from '../src/cli/options.js';
|
|
25
|
+
import { renderCompactQr } from '../src/cli/qr.js';
|
|
26
|
+
import { supervise, bareUrl, publicUrlWithToken } from '../src/cli/supervisor.js';
|
|
27
|
+
import { resolveCloudflared } from '../src/cli/cloudflared.js';
|
|
28
|
+
import { resolveTunlite, checkSshAuth } from '../src/cli/tunlite.js';
|
|
29
|
+
import { installService, uninstallService } from '../src/cli/service.js';
|
|
30
|
+
import { checkTmux, MIN_TMUX, tmuxInstallHint } from '../src/cli/tmuxVersion.js';
|
|
31
|
+
import { readState, clearState, isAlive, pocketHome, logPath, configPath, claudeStatePath } from '../src/cli/state.js';
|
|
32
|
+
import { runSetup } from '../src/cli/setupWizard.js';
|
|
33
|
+
import { hooksStatus, installHooks, uninstallHooks } from '../src/cli/claudeHooks.js';
|
|
34
|
+
import { tmuxDotStatus, installTmuxDot, tmuxConfPath } from '../src/cli/tmuxConf.js';
|
|
35
|
+
import { probe } from '../src/cli/probe.js';
|
|
36
|
+
|
|
37
|
+
const HOME = homedir();
|
|
38
|
+
const SELF = fileURLToPath(import.meta.url);
|
|
39
|
+
const HOOKS_SRC = path.resolve(path.dirname(SELF), '../hooks'); // server/hooks (bundled scripts)
|
|
40
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
41
|
+
|
|
42
|
+
const { command, flags } = parseArgs(process.argv.slice(2));
|
|
43
|
+
|
|
44
|
+
// There is ONE config file location: ~/.handmux/config.json (written by `handmux setup`). `--config PATH`
|
|
45
|
+
// points elsewhere — that's the only escape, and it covers dev/multi-config without any cwd magic (a
|
|
46
|
+
// stray ./config.json never gets picked up silently). No file merging or inheritance: at most one file is
|
|
47
|
+
// read. Flags (applied later in resolveConfig) override individual settings from it for that one run and
|
|
48
|
+
// never persist. Returns { path, cfg } — path is the file used (or null), for the startup print so it's
|
|
49
|
+
// never ambiguous what a run loaded.
|
|
50
|
+
function resolveFileConfig() {
|
|
51
|
+
let p = null;
|
|
52
|
+
if (flags.config) { // explicit: must exist
|
|
53
|
+
p = path.resolve(flags.config);
|
|
54
|
+
if (!fs.existsSync(p)) { console.error(`✗ --config ${p}: not found`); process.exit(2); }
|
|
55
|
+
} else {
|
|
56
|
+
const homeP = configPath(HOME);
|
|
57
|
+
if (fs.existsSync(homeP)) p = homeP;
|
|
58
|
+
}
|
|
59
|
+
if (!p) return { path: null, cfg: {} };
|
|
60
|
+
try { return { path: p, cfg: JSON.parse(fs.readFileSync(p, 'utf8')) }; }
|
|
61
|
+
catch (e) { console.error(`✗ bad config ${p}: ${e.message}`); process.exit(2); }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Human-readable summary of which config file a run loaded.
|
|
65
|
+
function describeConfig(p) {
|
|
66
|
+
return p || '(none — flags + defaults)';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 一次性 [Y/n] 提问(默认 Yes)。非 TTY 直接返回 false,绝不卡住。
|
|
70
|
+
async function confirm(question) {
|
|
71
|
+
if (!process.stdin.isTTY) return false;
|
|
72
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
73
|
+
try {
|
|
74
|
+
const a = (await new Promise((res) => rl.question(`${question} [Y/n] `, res))).trim().toLowerCase();
|
|
75
|
+
return a === '' || a === 'y' || a === 'yes';
|
|
76
|
+
} finally { rl.close(); }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ssh 隧道预检:解析 tunlite → 免密就绪?有 TTY 就内嵌 setup-key(输一次密码)再复检,无 TTY 快速失败。
|
|
80
|
+
async function preflightSsh(cfg) {
|
|
81
|
+
cfg.tunliteBin = resolveTunlite(); // 抛出 → 调用方打印并退出
|
|
82
|
+
if (checkSshAuth(cfg.sshHost, { bin: cfg.tunliteBin }) === 0) return;
|
|
83
|
+
if (process.stdin.isTTY && await confirm(`passwordless SSH to ${cfg.sshHost} is not set up. Configure it now?`)) {
|
|
84
|
+
spawnSync(cfg.tunliteBin, ['setup-key', cfg.sshHost], { stdio: 'inherit' });
|
|
85
|
+
if (checkSshAuth(cfg.sshHost, { bin: cfg.tunliteBin }) === 0) return;
|
|
86
|
+
}
|
|
87
|
+
throw new Error(`passwordless SSH not set up — run: ${cfg.tunliteBin} setup-key ${cfg.sshHost}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function main() {
|
|
91
|
+
switch (command) {
|
|
92
|
+
case 'start': return start();
|
|
93
|
+
case 'stop': stop(); return;
|
|
94
|
+
case 'restart': { stop(); await sleep(600); return start(); }
|
|
95
|
+
case 'status': return status();
|
|
96
|
+
case 'logs': return logs();
|
|
97
|
+
case 'config': return configCmd();
|
|
98
|
+
case 'setup': return setupCmd();
|
|
99
|
+
case 'hooks': return hooksCmd();
|
|
100
|
+
case 'service': return serviceCmd();
|
|
101
|
+
case '__supervise': return runSupervise();
|
|
102
|
+
case 'version': case '--version': case '-v': return version();
|
|
103
|
+
default: return help();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// `handmux --version` / `-v` — print the package version (read from this package's package.json, so it
|
|
108
|
+
// stays in lockstep with what npm installed; no hardcoded string to forget to bump).
|
|
109
|
+
function version() {
|
|
110
|
+
console.log(requireOpt('../package.json').version);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function start() {
|
|
114
|
+
const { path: cfgPath, cfg: fileCfg } = resolveFileConfig();
|
|
115
|
+
console.log(`config: ${describeConfig(cfgPath)}`);
|
|
116
|
+
let cfg;
|
|
117
|
+
try { cfg = resolveConfig(flags, fileCfg); }
|
|
118
|
+
catch (e) { console.error(`✗ ${e.message}`); process.exit(2); }
|
|
119
|
+
|
|
120
|
+
// Make a one-run tunnel override visible: it's easy to forget a --tunnel flag is shadowing the file.
|
|
121
|
+
if (flags.tunnel && fileCfg.tunnel && flags.tunnel !== fileCfg.tunnel) {
|
|
122
|
+
console.log(` ↳ --tunnel ${flags.tunnel} overrides config (${fileCfg.tunnel}) for this run only`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// tmux is the whole point — absent is fatal; an untested-old version only warns (rendering may drift).
|
|
126
|
+
const tmux = checkTmux();
|
|
127
|
+
if (!tmux.present) {
|
|
128
|
+
console.error('✗ tmux not found.');
|
|
129
|
+
console.error(' handmux runs on top of tmux (a terminal multiplexer) — it drives your real tmux');
|
|
130
|
+
console.error(' panes from your phone, so you need tmux on this machine first.');
|
|
131
|
+
console.error('');
|
|
132
|
+
console.error(` Install it: ${tmuxInstallHint()}`);
|
|
133
|
+
console.error(' Then run `handmux start` again.');
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
if (!tmux.ok) console.warn(`⚠ tmux ${tmux.raw} is below the tested minimum ${MIN_TMUX}; terminal rendering may be off`);
|
|
137
|
+
|
|
138
|
+
const existing = readState(HOME);
|
|
139
|
+
if (existing && isAlive(existing.supervisorPid)) {
|
|
140
|
+
console.log(`handmux already running (pid ${existing.supervisorPid}) — use 'handmux restart'`);
|
|
141
|
+
await printAccess(existing);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// cloudflare needs a cloudflared binary; resolve (and auto-download) it up front so the failure is a
|
|
146
|
+
// clear message here rather than a silent child that never prints a URL.
|
|
147
|
+
if (cfg.tunnel === 'cloudflare') {
|
|
148
|
+
try { cfg.cloudflaredBin = await resolveCloudflared(HOME); }
|
|
149
|
+
catch (e) { console.error(`✗ ${e.message}`); process.exit(1); }
|
|
150
|
+
}
|
|
151
|
+
if (cfg.tunnel === 'cloudflare-named') {
|
|
152
|
+
try { cfg.cloudflaredBin = await resolveCloudflared(HOME); }
|
|
153
|
+
catch (e) { console.error(`✗ ${e.message}`); process.exit(1); }
|
|
154
|
+
if (!fs.existsSync(path.join(HOME, '.cloudflared', 'config.yml'))) {
|
|
155
|
+
console.error('✗ named tunnel not provisioned — run `handmux setup` first'); process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (cfg.tunnel === 'ssh') {
|
|
159
|
+
try { await preflightSsh(cfg); }
|
|
160
|
+
catch (e) { console.error(`✗ ${e.message}`); process.exit(1); }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (cfg.foreground) {
|
|
164
|
+
supervise(cfg, { home: HOME });
|
|
165
|
+
console.log(`starting handmux (tunnel: ${cfg.tunnel}, port: ${cfg.port}) — Ctrl-C to stop`);
|
|
166
|
+
await waitAndPrint(false);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
fs.mkdirSync(pocketHome(HOME), { recursive: true });
|
|
171
|
+
const out = fs.openSync(logPath(HOME), 'a');
|
|
172
|
+
const payload = Buffer.from(JSON.stringify(cfg)).toString('base64');
|
|
173
|
+
const child = spawn(process.execPath, [SELF, '__supervise', '--payload', payload],
|
|
174
|
+
{ detached: true, stdio: ['ignore', out, out] });
|
|
175
|
+
child.unref();
|
|
176
|
+
console.log(`starting handmux (tunnel: ${cfg.tunnel}, port: ${cfg.port}) …`);
|
|
177
|
+
await waitAndPrint(true);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function stop() {
|
|
181
|
+
const st = readState(HOME);
|
|
182
|
+
if (!st || !isAlive(st.supervisorPid)) { console.log('handmux not running'); clearState(HOME); return; }
|
|
183
|
+
try { process.kill(st.supervisorPid, 'SIGTERM'); } catch { /* race: already gone */ }
|
|
184
|
+
console.log(`stopped handmux (pid ${st.supervisorPid})`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function status() {
|
|
188
|
+
const st = readState(HOME);
|
|
189
|
+
if (!st || !isAlive(st.supervisorPid)) { console.log('● handmux stopped'); return; }
|
|
190
|
+
console.log('● handmux running');
|
|
191
|
+
await printAccess(st);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function runSupervise() {
|
|
195
|
+
const cfg = JSON.parse(Buffer.from(flags.payload, 'base64').toString('utf8'));
|
|
196
|
+
supervise(cfg, { home: HOME });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function logs() {
|
|
200
|
+
const p = logPath(HOME);
|
|
201
|
+
if (!fs.existsSync(p)) { console.log('(no log yet — start handmux first)'); return; }
|
|
202
|
+
const lines = String(flags.lines || 200);
|
|
203
|
+
const args = flags.follow ? ['-n', lines, '-f', p] : ['-n', lines, p];
|
|
204
|
+
spawn('tail', args, { stdio: 'inherit' });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// `handmux service install|uninstall` — the autostart subsystem, mirroring `handmux hooks …`: subsystem
|
|
208
|
+
// name first, then the action. `install` bakes the resolved config into an OS autostart entry (launchd /
|
|
209
|
+
// systemd --user) that runs the supervisor at login; `uninstall` removes it. The action is argv[3].
|
|
210
|
+
async function serviceCmd() {
|
|
211
|
+
const sub = process.argv[3];
|
|
212
|
+
if (sub === 'install') return serviceInstall();
|
|
213
|
+
if (sub === 'uninstall') {
|
|
214
|
+
try { uninstallService({ home: HOME }); }
|
|
215
|
+
catch (e) { console.error(`✗ ${e.message}`); process.exit(1); }
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
console.error('usage: handmux service install [start-flags] | handmux service uninstall');
|
|
219
|
+
process.exit(2);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function serviceInstall() {
|
|
223
|
+
const { path: cfgPath, cfg: fileCfg } = resolveFileConfig();
|
|
224
|
+
console.log(`config: ${describeConfig(cfgPath)}`);
|
|
225
|
+
let cfg;
|
|
226
|
+
try { cfg = resolveConfig(flags, fileCfg); }
|
|
227
|
+
catch (e) { console.error(`✗ ${e.message}`); process.exit(2); }
|
|
228
|
+
if (cfg.tunnel === 'cloudflare' || cfg.tunnel === 'cloudflare-named') {
|
|
229
|
+
try { cfg.cloudflaredBin = await resolveCloudflared(HOME); }
|
|
230
|
+
catch (e) { console.error(`✗ ${e.message}`); process.exit(1); }
|
|
231
|
+
}
|
|
232
|
+
if (cfg.tunnel === 'ssh') {
|
|
233
|
+
// 开机自启无 TTY:要求事先已配好免密,否则快速失败。
|
|
234
|
+
cfg.tunliteBin = resolveTunlite();
|
|
235
|
+
if (checkSshAuth(cfg.sshHost, { bin: cfg.tunliteBin }) !== 0) {
|
|
236
|
+
console.error(`✗ passwordless SSH not set up — run: ${cfg.tunliteBin} setup-key ${cfg.sshHost}`); process.exit(1);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const payload = Buffer.from(JSON.stringify(cfg)).toString('base64');
|
|
240
|
+
const args = [process.execPath, SELF, '__supervise', '--payload', payload];
|
|
241
|
+
try { installService(args, { home: HOME }); }
|
|
242
|
+
catch (e) { console.error(`✗ ${e.message}`); process.exit(1); }
|
|
243
|
+
console.log("handmux will now start at login. 'handmux service uninstall' to remove.");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function setupCmd() {
|
|
247
|
+
const target = flags.config ? path.resolve(flags.config) : configPath(HOME);
|
|
248
|
+
const cfg = await runSetup({ home: HOME, target });
|
|
249
|
+
if (!cfg) { process.exit(2); }
|
|
250
|
+
const hs = hooksStatus(HOME);
|
|
251
|
+
if (hs !== 'no-claude' && hs !== 'installed'
|
|
252
|
+
&& await confirm('Enable Claude Code notifications (inbox)?')) {
|
|
253
|
+
installHooks(HOME, { srcDir: HOOKS_SRC, stateFile: claudeStatePath(HOME) });
|
|
254
|
+
console.log('✓ Claude hooks installed.');
|
|
255
|
+
await maybeOfferTmuxDot();
|
|
256
|
+
}
|
|
257
|
+
if (await confirm('Start handmux now?')) { Object.assign(flags, cfg); return start(); }
|
|
258
|
+
console.log("run 'handmux start' when you're ready.");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// The per-window tmux status dot is the natural companion to the inbox hooks: the hook already writes a
|
|
262
|
+
// colour into each window's `@claude_dot` on every Claude event, but tmux only SHOWS it if
|
|
263
|
+
// `window-status-format` references it — otherwise it's a silent no-op. Offer to add that display block to
|
|
264
|
+
// ~/.tmux.conf (opt-in, idempotent). Skip when it's already wired (ours or hand-rolled). Non-TTY: just hint.
|
|
265
|
+
async function maybeOfferTmuxDot() {
|
|
266
|
+
if (tmuxDotStatus(HOME) !== 'absent') return;
|
|
267
|
+
if (!process.stdin.isTTY) {
|
|
268
|
+
console.log(` Tip: to show a Claude status dot on each tmux window, run \`handmux hooks install\` from an interactive terminal — it wires ${tmuxConfPath(HOME)} for you (see tmux/README.md in the handmux package).`);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (await confirm('Also show a per-window Claude status dot in tmux? (adds a block to ~/.tmux.conf)')) {
|
|
272
|
+
installTmuxDot(HOME);
|
|
273
|
+
console.log(`✓ tmux dot added → ${tmuxConfPath(HOME)}`);
|
|
274
|
+
console.log(' Apply with: tmux source-file ~/.tmux.conf (it changes the shared tmux server — all clients, including your PC).');
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// `handmux hooks install|uninstall` — opt-in wiring of the Claude Code lifecycle hooks that drive the
|
|
279
|
+
// inbox/push. Never creates ~/.claude; if Claude Code isn't present we say so and exit 0 (nothing to do).
|
|
280
|
+
async function hooksCmd() {
|
|
281
|
+
const sub = process.argv[3];
|
|
282
|
+
if (sub === 'install') {
|
|
283
|
+
if (hooksStatus(HOME) === 'no-claude') {
|
|
284
|
+
console.log('Claude Code not detected (~/.claude missing) — nothing to install.');
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
installHooks(HOME, { srcDir: HOOKS_SRC, stateFile: claudeStatePath(HOME) });
|
|
288
|
+
console.log('✓ Claude hooks installed → ~/.claude/settings.json');
|
|
289
|
+
console.log(' Restart or open a new Claude Code session to load them; the inbox lights up as panes report.');
|
|
290
|
+
await maybeOfferTmuxDot();
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (sub === 'uninstall') {
|
|
294
|
+
uninstallHooks(HOME);
|
|
295
|
+
console.log('✓ Claude hooks removed.');
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
console.error('usage: handmux hooks install|uninstall');
|
|
299
|
+
process.exit(2);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// `handmux config` — read-only: print the config that WOULD be used, with each value's origin (flag /
|
|
303
|
+
// the config file path / env / default). This is the answer to "what's actually in effect and where did
|
|
304
|
+
// it come from", so flag-vs-file is never a mystery. Secrets are masked.
|
|
305
|
+
function configCmd() {
|
|
306
|
+
const { path: cfgPath, cfg: fileCfg } = resolveFileConfig();
|
|
307
|
+
let rows;
|
|
308
|
+
try { rows = explainConfig(flags, fileCfg, cfgPath); }
|
|
309
|
+
catch (e) { console.error(`✗ ${e.message}`); process.exit(2); }
|
|
310
|
+
console.log(`config file: ${cfgPath || '(none — using defaults; run `handmux setup` to create one)'}`);
|
|
311
|
+
console.log('');
|
|
312
|
+
const w = Math.max(...rows.map((r) => r.key.length));
|
|
313
|
+
for (const r of rows) {
|
|
314
|
+
console.log(` ${r.key.padEnd(w)} ${r.display} ${r.origin === 'default' ? '' : `· ${r.origin}`}`.trimEnd());
|
|
315
|
+
}
|
|
316
|
+
console.log('');
|
|
317
|
+
console.log(' origin: flag (this run only) · file · env · default');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Poll state.json until the public URL (or an error) shows up, then print access info. cloudflare needs
|
|
321
|
+
// a few seconds to hand back its hostname; none is instant.
|
|
322
|
+
async function waitAndPrint(exitWhenDone) {
|
|
323
|
+
const deadline = Date.now() + 25000;
|
|
324
|
+
let st;
|
|
325
|
+
for (;;) {
|
|
326
|
+
st = readState(HOME);
|
|
327
|
+
if (st && ((st.publicUrl && st.ready) || st.error)) break;
|
|
328
|
+
if (Date.now() > deadline) break;
|
|
329
|
+
await sleep(300);
|
|
330
|
+
}
|
|
331
|
+
await printAccess(st);
|
|
332
|
+
if (st?.error) process.exitCode = 1;
|
|
333
|
+
if (exitWhenDone) process.exit(process.exitCode || 0);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function printAccess(st) {
|
|
337
|
+
if (!st) { console.log(' (no state)'); return; }
|
|
338
|
+
if (st.error) { console.error(` ✗ ${st.error}`); return; }
|
|
339
|
+
const scan = bareUrl(st.publicUrl);
|
|
340
|
+
console.log('');
|
|
341
|
+
console.log(` tunnel ${st.tunnel} · pid ${st.supervisorPid}`);
|
|
342
|
+
console.log(` 🌐 open ${scan || '(pending…)'}`);
|
|
343
|
+
if (st.tunnel === 'none' && st.lanUrl) console.log(` 📶 lan ${bareUrl(st.lanUrl)}`);
|
|
344
|
+
console.log(` 💻 local ${bareUrl(st.localUrl)}`);
|
|
345
|
+
console.log(` 🔑 token ${st.token}`);
|
|
346
|
+
// The QR carries the token so a phone scan signs in one-tap; the PRINTED links above stay token-free
|
|
347
|
+
// (safe to screenshot/share — paste the token shown above to sign in there).
|
|
348
|
+
await maybeQr(st.publicUrl ? publicUrlWithToken(st.publicUrl, st.token) : scan, st);
|
|
349
|
+
if (st.publicUrl && st.tunnel !== 'none') {
|
|
350
|
+
const ok = await probe(st.publicUrl);
|
|
351
|
+
if (ok) console.log(' ✓ reachable');
|
|
352
|
+
else console.log(` ⚠ tunnel up but ${st.publicUrl} did not answer — check the server-side reverse proxy / DNS`);
|
|
353
|
+
}
|
|
354
|
+
console.log('');
|
|
355
|
+
console.log(` handmux status | stop`);
|
|
356
|
+
console.log('');
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Best-effort QR (optional dependency). We borrow qrcode-terminal's QR *model* (vendored, dependency-free)
|
|
360
|
+
// to get the module matrix, then render it ourselves with vertical half-blocks (see qr.js) so it comes out
|
|
361
|
+
// square on a 2:1 terminal cell. If qrcode-terminal isn't installed we just skip it — the URL above is
|
|
362
|
+
// always printed.
|
|
363
|
+
const requireOpt = createRequire(import.meta.url);
|
|
364
|
+
async function maybeQr(url, st) {
|
|
365
|
+
if (!url) return;
|
|
366
|
+
try {
|
|
367
|
+
const QRCode = requireOpt('qrcode-terminal/vendor/QRCode');
|
|
368
|
+
const ECL = requireOpt('qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel');
|
|
369
|
+
const qr = new QRCode(-1, ECL.L);
|
|
370
|
+
qr.addData(url);
|
|
371
|
+
qr.make();
|
|
372
|
+
const n = qr.getModuleCount();
|
|
373
|
+
const matrix = Array.from({ length: n }, (_, r) =>
|
|
374
|
+
Array.from({ length: n }, (_, c) => qr.isDark(r, c)));
|
|
375
|
+
process.stdout.write(renderCompactQr(matrix) + '\n');
|
|
376
|
+
} catch { /* no qrcode-terminal — URL alone is fine */ }
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function help() {
|
|
380
|
+
console.log(`handmux — drive your tmux from your phone
|
|
381
|
+
|
|
382
|
+
handmux start run it (defaults to LAN-only; no config needed)
|
|
383
|
+
handmux setup configure tunnel / name / notifications (writes config; re-run to change)
|
|
384
|
+
handmux stop | restart | status
|
|
385
|
+
handmux logs [--follow] [--lines N]
|
|
386
|
+
|
|
387
|
+
The model: 'start' runs · 'setup' configures (writes ~/.handmux/config.json) · re-run setup to change.
|
|
388
|
+
A flag overrides one value for one run and never persists (flag > file > default).
|
|
389
|
+
|
|
390
|
+
advanced (scripting / multiple configs):
|
|
391
|
+
handmux config show the effective config + where each value came from
|
|
392
|
+
handmux hooks install|uninstall enable/disable Claude Code notifications (inbox)
|
|
393
|
+
handmux service install [start-flags] start at login (launchd/systemd)
|
|
394
|
+
handmux service uninstall remove the autostart entry
|
|
395
|
+
--config PATH use this config file instead of ~/.handmux/config.json (dev / multi-config)
|
|
396
|
+
--version, -v print the handmux version
|
|
397
|
+
|
|
398
|
+
start flags (one-run overrides — for persistence use 'handmux setup'):
|
|
399
|
+
--tunnel none|cloudflare|cloudflare-named|ssh expose method (default: none)
|
|
400
|
+
--ssh-host user@host[:port] ssh tunnel target (tunlite)
|
|
401
|
+
--remote-port N port bound on the ssh host (default: --port)
|
|
402
|
+
--public-url URL public url to advertise (any tunnel, incl. none if you run your own;
|
|
403
|
+
ssh defaults to http://host:remotePort)
|
|
404
|
+
--ssh-jump u@h[,…] optional bastion for ssh
|
|
405
|
+
--cf-hostname H public hostname for cloudflare-named
|
|
406
|
+
--cf-tunnel-name N tunnel name for cloudflare-named (default: handmux)
|
|
407
|
+
--port N server port (default: 19999)
|
|
408
|
+
--host H bind host (default: 0.0.0.0)
|
|
409
|
+
--token S auth token (default: generated)
|
|
410
|
+
--name "My Box" app name in the browser tab + home-screen icon label
|
|
411
|
+
--preview-domain D enable dynamic previews (needs wildcard subdomain)
|
|
412
|
+
--foreground, -f run in the foreground (don't daemonize)
|
|
413
|
+
--no-qr don't render the QR code
|
|
414
|
+
`);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
main();
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# handmux 上报 hook. $1 = stop | notify | prompt | end | resume | permreq. stdin = Claude 原始 payload(JSON).
|
|
3
|
+
# (resume = PostToolUse on AskUserQuestion/ExitPlanMode:答完选项/批准计划 → 状态翻回进行中、带所选项。)
|
|
4
|
+
# (permreq = PermissionRequest:真实弹框一出现就发、带 tool_name → 比 permission_prompt 早亮「需要你」。)
|
|
5
|
+
# 只做一件事:把本次事件写进一个本地 JSON 状态文件(键=tmux pane,值=该 pane 最新事件)。不联网、
|
|
6
|
+
# 不依赖服务进程是否在跑 → 永不阻塞 Claude(始终 exit 0)。服务端按需读这个文件(见 claudeEvents.js)。
|
|
7
|
+
# 真正的读-改-写交给同目录的 handmux-write.js(node:真 JSON 解析 + 文件锁,多 pane 并发 hook 不丢更新)。
|
|
8
|
+
CFG="$(dirname "$0")/handmux-notify.env"
|
|
9
|
+
[ -f "$CFG" ] && . "$CFG"
|
|
10
|
+
PANE="${CLAUDE_PANE:-$TMUX_PANE}"
|
|
11
|
+
[ -z "$PANE" ] && exit 0 # 不在 tmux 里(没有 pane 可定位)→ 暂不记录
|
|
12
|
+
FILE="${HANDMUX_STATE:-$HOME/.claude/handmux-state.json}"
|
|
13
|
+
# 毫秒时间戳:与旧实现的 Date.now() 同单位(客户端已阅水位线兼容)。优先 perl,退化到 秒×1000。
|
|
14
|
+
TS=$(perl -MTime::HiRes -e 'printf "%.0f", Time::HiRes::time()*1000' 2>/dev/null)
|
|
15
|
+
[ -z "$TS" ] && TS=$(( $(date +%s) * 1000 ))
|
|
16
|
+
HOST=$(hostname 2>/dev/null || printf '')
|
|
17
|
+
# payload 经 stdin 原样流给 node(不在 shell 里转义,避免坏数据);pane 含 '%' 直接进 JSON 字段,
|
|
18
|
+
# 不再进 URL → 彻底告别旧的 "%110 被 url-decode 丢弃" 那类坑。
|
|
19
|
+
node "$(dirname "$0")/handmux-write.cjs" "$FILE" "$PANE" "$1" "$TS" "$HOST" 2>/dev/null || true
|
|
20
|
+
exit 0
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// handmux hook writer. Updates ONE JSON state file keyed by tmux pane id with this pane's latest
|
|
3
|
+
// Claude event: { "%pane": { ts, src, host, payload }, ... }. Invoked by handmux-notify.sh:
|
|
4
|
+
// node handmux-write.cjs <file> <pane> <src> <ts> <host> (stdin = Claude's raw hook payload JSON)
|
|
5
|
+
//
|
|
6
|
+
// .cjs (not .js): runs standalone via `node <file>`, including from ~/.claude/hooks (no package.json) —
|
|
7
|
+
// .cjs forces CommonJS everywhere; a bare .js under the server tree ("type":"module") would be ESM and
|
|
8
|
+
// break require().
|
|
9
|
+
//
|
|
10
|
+
// Why node (not pure shell): the file is a single JSON object, so each event is a read-modify-write that
|
|
11
|
+
// must parse JSON and not clobber other panes — shell can't do that safely. The user runs many Claude
|
|
12
|
+
// panes at once, so hooks fire concurrently: we take a short O_EXCL lock (stealing a stale one) around
|
|
13
|
+
// the read-modify-write and replace atomically (tmp + rename), so concurrent writers don't lose updates
|
|
14
|
+
// or corrupt the file. Best-effort throughout and silent — the hook is fire-and-forget and must never
|
|
15
|
+
// fail Claude (the shell wrapper swallows errors and always exits 0).
|
|
16
|
+
const fs = require('node:fs');
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
|
|
19
|
+
const [, , file, pane, src, ts, host = ''] = process.argv;
|
|
20
|
+
if (!file || !pane || !src) process.exit(0);
|
|
21
|
+
|
|
22
|
+
let payload = {};
|
|
23
|
+
try { payload = JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); } catch { /* unreadable stdin → {} */ }
|
|
24
|
+
|
|
25
|
+
// idle_prompt ("been idle ~60s") is decided in update() against the pane's PRIOR state: it either trails
|
|
26
|
+
// a resting state (done/needs → drop, so it can't bump ts and re-surface an already-cleared 已完成) or it
|
|
27
|
+
// terminates an ESC-interrupted working turn (→ clear the stuck 进行中). Flag it here; the read-modify-
|
|
28
|
+
// write under the lock — the only place we can read the prior state safely — makes the call.
|
|
29
|
+
const isIdle = src === 'notify' && payload && payload.notification_type === 'idle_prompt';
|
|
30
|
+
let cleared = false; // set by update() when idle cleared an interrupted 进行中 → also clear @claude_dot below
|
|
31
|
+
|
|
32
|
+
// Synchronous nap without busy-spinning (the hook runs async, so a few ms is free). SharedArrayBuffer
|
|
33
|
+
// may be unavailable in odd runtimes — fall back to a tiny busy loop so the lock retry still paces.
|
|
34
|
+
const nap = (ms) => {
|
|
35
|
+
try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
|
|
36
|
+
catch { const end = Date.now() + ms; while (Date.now() < end) { /* spin */ } }
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
try { fs.mkdirSync(path.dirname(file), { recursive: true }); } catch { /* ignore */ }
|
|
40
|
+
|
|
41
|
+
function update() {
|
|
42
|
+
let obj = {};
|
|
43
|
+
try { const j = JSON.parse(fs.readFileSync(file, 'utf8')); if (j && typeof j === 'object' && !Array.isArray(j)) obj = j; }
|
|
44
|
+
catch { /* fresh / corrupt / half-written → start clean */ }
|
|
45
|
+
const prevSrc = obj[pane] && obj[pane].src;
|
|
46
|
+
if (isIdle) {
|
|
47
|
+
// idle after a resting state (done/needs/nothing) is just the "still waiting" reminder → drop and
|
|
48
|
+
// leave the file as it was (recording it would bump ts and re-surface an already-cleared 已完成).
|
|
49
|
+
// idle after a WORKING turn (prompt/resume) that never got a Stop = an ESC interrupt / walk-away —
|
|
50
|
+
// no Stop hook fires there, so idle is the only signal the turn ended. Without this the 进行中 dot
|
|
51
|
+
// sticks forever; treat it as a soft end and clear the pane (and its @claude_dot, below).
|
|
52
|
+
if (prevSrc === 'prompt' || prevSrc === 'resume') { delete obj[pane]; cleared = true; }
|
|
53
|
+
else return; // resting → drop without writing
|
|
54
|
+
} else if (src === 'end') {
|
|
55
|
+
delete obj[pane]; // SessionEnd (clean exit) → drop the pane
|
|
56
|
+
} else {
|
|
57
|
+
obj[pane] = { ts: Number(ts) || 0, src, host, payload };
|
|
58
|
+
}
|
|
59
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
60
|
+
fs.writeFileSync(tmp, JSON.stringify(obj));
|
|
61
|
+
fs.renameSync(tmp, file); // atomic: a torn write can't corrupt the file
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const lock = `${file}.lock`;
|
|
65
|
+
let held = false;
|
|
66
|
+
for (let i = 0; i < 60 && !held; i++) { // ~0.9s budget, then write lockless (best-effort)
|
|
67
|
+
try { fs.closeSync(fs.openSync(lock, 'wx')); held = true; } // O_EXCL → atomic "I hold it"
|
|
68
|
+
catch {
|
|
69
|
+
try { if (Date.now() - fs.statSync(lock).mtimeMs > 3000) fs.unlinkSync(lock); } catch { /* steal a stale lock */ }
|
|
70
|
+
nap(15);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
try { update(); } catch { /* best effort */ }
|
|
74
|
+
if (held) { try { fs.unlinkSync(lock); } catch { /* ignore */ } }
|
|
75
|
+
|
|
76
|
+
// tmux 页签状态色点(事件驱动):把本次事件的状态色 markup 写进该 pane 所在窗的 @claude_dot 选项。
|
|
77
|
+
// tmux 的 window-status-format 只读 #{@claude_dot}(纯查表、不跑任何 shell),所以稳态零重绘——只有
|
|
78
|
+
// 状态真变化(即本 hook 触发的这一刻)才写一次、才重绘一次,不轮询、不卡。详见 tmux/README.md。
|
|
79
|
+
// best-effort + 1s 超时:不在 tmux / tmux 不可达都静默忽略,永不阻塞或失败 Claude。
|
|
80
|
+
function claudeDot(s, p) {
|
|
81
|
+
if (s === 'stop') return '#[fg=#2e7d46]●#[default] '; // 已完成 绿
|
|
82
|
+
if (s === 'prompt' || s === 'resume') return '#[fg=#2f6fed,blink]●#[default] '; // 进行中 蓝闪
|
|
83
|
+
if (s === 'permreq') return '#[fg=#e0a020]●#[default] '; // 需要你 橙
|
|
84
|
+
if (s === 'notify' && p && p.notification_type === 'permission_prompt') return '#[fg=#e0a020]●#[default] ';
|
|
85
|
+
return null; // 其它无法分类 → 不动该窗的点(end 在下方单独清空)
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const dot = (src === 'end' || cleared) ? '' : claudeDot(src, payload);
|
|
89
|
+
if (dot !== null) {
|
|
90
|
+
require('node:child_process').execFileSync('tmux', ['set-option', '-w', '-t', pane, '@claude_dot', dot], { stdio: 'ignore', timeout: 1000 });
|
|
91
|
+
}
|
|
92
|
+
} catch { /* 不在 tmux / tmux 不可达 → 忽略 */ }
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "handmux",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Mobile vibe coding — drive your real tmux session (and Claude Code) from your phone.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "yuanyuanzijin",
|
|
8
|
+
"homepage": "https://handmux.com",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/yuanyuanzijin/handmux.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/yuanyuanzijin/handmux/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": ["tmux", "mobile", "terminal", "claude-code", "remote", "pwa"],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18"
|
|
19
|
+
},
|
|
20
|
+
"bin": {
|
|
21
|
+
"handmux": "bin/handmux.js"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"bin",
|
|
25
|
+
"src",
|
|
26
|
+
"hooks",
|
|
27
|
+
"tmux",
|
|
28
|
+
"public",
|
|
29
|
+
"README.zh-CN.md"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"start": "node bin/handmux.js start",
|
|
33
|
+
"dev": "node bin/handmux.js start --foreground",
|
|
34
|
+
"handmux": "node bin/handmux.js",
|
|
35
|
+
"bundle": "node scripts/bundle-web.mjs",
|
|
36
|
+
"prepack": "node scripts/bundle-web.mjs",
|
|
37
|
+
"test": "vitest run"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"busboy": "^1.6.0",
|
|
41
|
+
"express": "^4.19.2",
|
|
42
|
+
"tunlite": "^0.10.0",
|
|
43
|
+
"web-push": "^3.6.7"
|
|
44
|
+
},
|
|
45
|
+
"optionalDependencies": {
|
|
46
|
+
"qrcode-terminal": "^0.12.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"supertest": "^7.0.0",
|
|
50
|
+
"vitest": "^2.0.0"
|
|
51
|
+
}
|
|
52
|
+
}
|