handmux 0.5.2 → 0.6.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/README.md +18 -0
- package/README.zh-CN.md +16 -0
- package/bin/handmux.js +158 -106
- package/hooks/handmux-notify.sh +4 -2
- package/hooks/handmux-write.cjs +16 -3
- package/package.json +1 -1
- package/public/assets/index-DlO8ul0J.js +157 -0
- package/public/assets/{index-CSX2d9C7.css → index-_nvN0yNe.css} +1 -1
- package/public/index.html +2 -2
- package/src/agents/claude.js +95 -0
- package/src/agents/codex.js +110 -0
- package/src/agents/index.js +20 -0
- package/src/agents/scanUtils.js +209 -0
- package/src/claudeEvents.js +25 -60
- package/src/cli/cloudflared.js +49 -3
- package/src/cli/codexHooks.js +125 -0
- package/src/cli/i18n/en.js +186 -0
- package/src/cli/i18n/index.js +53 -0
- package/src/cli/i18n/zh.js +185 -0
- package/src/cli/options.js +3 -0
- package/src/cli/setupWizard.js +57 -46
- package/src/cli/updateCheck.js +90 -0
- package/src/httpApi.js +47 -5
- package/src/orphans.js +138 -0
- package/tmux/claude-tab-seed.py +6 -1
- package/public/assets/index-CNEiBm9y.js +0 -157
package/README.md
CHANGED
|
@@ -72,6 +72,24 @@ handmux setup # or configure tunnel + name + notifications
|
|
|
72
72
|
The printed links are token-free — safe to screenshot or share. Only the **QR** carries the token, and
|
|
73
73
|
the `🔑 token` line is your password: paste it to sign in when you open a plain link instead of scanning.
|
|
74
74
|
|
|
75
|
+
### On Windows? Use WSL2
|
|
76
|
+
|
|
77
|
+
handmux drives **tmux**, which is Unix-only — there's no native Windows build. Run it inside **WSL2**
|
|
78
|
+
(a real Linux kernel with real tmux) and everything above works unchanged:
|
|
79
|
+
|
|
80
|
+
```powershell
|
|
81
|
+
wsl --install # one-time, in PowerShell (admin): installs WSL2 + Ubuntu, then reboot
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Then open the Ubuntu terminal and follow the steps above (`apt install tmux`, install Node,
|
|
85
|
+
`npm i -g handmux`). Two WSL-specific notes:
|
|
86
|
+
|
|
87
|
+
- **Use a tunnel.** WSL2 is a NAT'd VM with its own IP, so the same-wifi LAN URL won't reach your
|
|
88
|
+
phone. Start with `handmux start --tunnel cloudflare` — the public link works regardless.
|
|
89
|
+
- **Autostart needs systemd.** `handmux service` uses systemd; enable it once by adding
|
|
90
|
+
`[boot]` / `systemd=true` to `/etc/wsl.conf`, then `wsl --shutdown`. Without it, just run
|
|
91
|
+
`handmux start` in a terminal you keep open.
|
|
92
|
+
|
|
75
93
|
## Features
|
|
76
94
|
|
|
77
95
|
Not just a remote shell — a full **mobile cockpit** for your terminal and your coding agents.
|
package/README.zh-CN.md
CHANGED
|
@@ -65,6 +65,22 @@ handmux setup # 或一次性配好隧道 + 名称 + 通知
|
|
|
65
65
|
打印出来的明文链接不带 token,可安全截图/分享。只有**二维码**带 token;`🔑 token` 那行就是你的密码——
|
|
66
66
|
开明文链接时把它粘进去就能登录。
|
|
67
67
|
|
|
68
|
+
### Windows 用户?装进 WSL2
|
|
69
|
+
|
|
70
|
+
handmux 靠 **tmux** 驱动,而 tmux 只有 Unix 版、没有原生 Windows 版。装进 **WSL2**(真正的 Linux 内核
|
|
71
|
+
+ 真 tmux),上面的一切原样适用:
|
|
72
|
+
|
|
73
|
+
```powershell
|
|
74
|
+
wsl --install # 一次性,在 PowerShell(管理员)里:装好 WSL2 + Ubuntu,然后重启
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
然后打开 Ubuntu 终端,按上面的步骤来(`apt install tmux`、装 Node、`npm i -g handmux`)。两点 WSL 专属提醒:
|
|
78
|
+
|
|
79
|
+
- **务必走隧道。** WSL2 是带独立 IP 的 NAT 虚拟机,同 wifi 的局域网地址手机连不到。直接用
|
|
80
|
+
`handmux start --tunnel cloudflare`,公网链接不受影响。
|
|
81
|
+
- **自启需要 systemd。** `handmux service` 依赖 systemd;在 `/etc/wsl.conf` 里加 `[boot]` /
|
|
82
|
+
`systemd=true` 再 `wsl --shutdown` 启用一次即可。不启用的话,开个终端跑 `handmux start` 并保持窗口即可。
|
|
83
|
+
|
|
68
84
|
## 功能
|
|
69
85
|
|
|
70
86
|
不止是个远程 shell——是给终端和你的编码 agent 配的一整个**手机驾驶舱**。
|
package/bin/handmux.js
CHANGED
|
@@ -31,8 +31,11 @@ import { checkTmux, MIN_TMUX, tmuxInstallHint } from '../src/cli/tmuxVersion.js'
|
|
|
31
31
|
import { readState, clearState, isAlive, pocketHome, logPath, configPath, claudeStatePath } from '../src/cli/state.js';
|
|
32
32
|
import { runSetup } from '../src/cli/setupWizard.js';
|
|
33
33
|
import { hooksStatus, installHooks, uninstallHooks } from '../src/cli/claudeHooks.js';
|
|
34
|
+
import { codexHooksStatus, installCodexHooks, uninstallCodexHooks } from '../src/cli/codexHooks.js';
|
|
34
35
|
import { tmuxDotStatus, installTmuxDot, tmuxConfPath } from '../src/cli/tmuxConf.js';
|
|
35
36
|
import { probe } from '../src/cli/probe.js';
|
|
37
|
+
import { notifyUpdate, runUpdateCheck, PKG_NAME } from '../src/cli/updateCheck.js';
|
|
38
|
+
import { t, initLocale, setLocale } from '../src/cli/i18n/index.js';
|
|
36
39
|
|
|
37
40
|
const HOME = homedir();
|
|
38
41
|
const SELF = fileURLToPath(import.meta.url);
|
|
@@ -41,6 +44,17 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
41
44
|
|
|
42
45
|
const { command, flags } = parseArgs(process.argv.slice(2));
|
|
43
46
|
|
|
47
|
+
// Resolve the CLI language ONCE, up front, so every command (help, errors, access block) prints in it.
|
|
48
|
+
// Priority: --lang > config `lang` > shell locale (LANG/LC_*) > English. The config peek is lenient — a
|
|
49
|
+
// missing/broken file just means "no language hint here"; the real validation happens per-command later.
|
|
50
|
+
function peekConfigLang() {
|
|
51
|
+
try {
|
|
52
|
+
const p = flags.config ? path.resolve(flags.config) : configPath(HOME);
|
|
53
|
+
return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : {};
|
|
54
|
+
} catch { return {}; }
|
|
55
|
+
}
|
|
56
|
+
initLocale(flags, peekConfigLang(), process.env);
|
|
57
|
+
|
|
44
58
|
// There is ONE config file location: ~/.handmux/config.json (written by `handmux setup`). `--config PATH`
|
|
45
59
|
// points elsewhere — that's the only escape, and it covers dev/multi-config without any cwd magic (a
|
|
46
60
|
// stray ./config.json never gets picked up silently). No file merging or inheritance: at most one file is
|
|
@@ -51,19 +65,32 @@ function resolveFileConfig() {
|
|
|
51
65
|
let p = null;
|
|
52
66
|
if (flags.config) { // explicit: must exist
|
|
53
67
|
p = path.resolve(flags.config);
|
|
54
|
-
if (!fs.existsSync(p)) { console.error(
|
|
68
|
+
if (!fs.existsSync(p)) { console.error(t('err.configNotFound', { path: p })); process.exit(2); }
|
|
55
69
|
} else {
|
|
56
70
|
const homeP = configPath(HOME);
|
|
57
71
|
if (fs.existsSync(homeP)) p = homeP;
|
|
58
72
|
}
|
|
59
73
|
if (!p) return { path: null, cfg: {} };
|
|
60
74
|
try { return { path: p, cfg: JSON.parse(fs.readFileSync(p, 'utf8')) }; }
|
|
61
|
-
catch (e) { console.error(
|
|
75
|
+
catch (e) { console.error(t('err.badConfig', { path: p, msg: e.message })); process.exit(2); }
|
|
62
76
|
}
|
|
63
77
|
|
|
64
78
|
// Human-readable summary of which config file a run loaded.
|
|
65
79
|
function describeConfig(p) {
|
|
66
|
-
return p || '
|
|
80
|
+
return p || t('config.none');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Which user-visible settings THIS run would use differ from what's already running (from state.json)?
|
|
84
|
+
// Kept to the two people actually re-run `start` to change — the tunnel and the port; each row is ready to
|
|
85
|
+
// drop straight into the `start.running.changedRow` message ({key, from, to}). Only compares fields the
|
|
86
|
+
// running state actually recorded, so an older state.json can't manufacture phantom diffs.
|
|
87
|
+
function configChanges(cfg, st) {
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const key of ['tunnel', 'port']) {
|
|
90
|
+
const running = st[key];
|
|
91
|
+
if (running != null && String(cfg[key]) !== String(running)) out.push({ key, from: running, to: cfg[key] });
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
67
94
|
}
|
|
68
95
|
|
|
69
96
|
// 一次性 [Y/n] 提问(默认 Yes)。非 TTY 直接返回 false,绝不卡住。
|
|
@@ -80,11 +107,11 @@ async function confirm(question) {
|
|
|
80
107
|
async function preflightSsh(cfg) {
|
|
81
108
|
cfg.tunliteBin = resolveTunlite(); // 抛出 → 调用方打印并退出
|
|
82
109
|
if (checkSshAuth(cfg.sshHost, { bin: cfg.tunliteBin }) === 0) return;
|
|
83
|
-
if (process.stdin.isTTY && await confirm(
|
|
110
|
+
if (process.stdin.isTTY && await confirm(t('ssh.confirmSetup', { host: cfg.sshHost }))) {
|
|
84
111
|
spawnSync(cfg.tunliteBin, ['setup-key', cfg.sshHost], { stdio: 'inherit' });
|
|
85
112
|
if (checkSshAuth(cfg.sshHost, { bin: cfg.tunliteBin }) === 0) return;
|
|
86
113
|
}
|
|
87
|
-
throw new Error(
|
|
114
|
+
throw new Error(t('ssh.notSetup', { bin: cfg.tunliteBin, host: cfg.sshHost }));
|
|
88
115
|
}
|
|
89
116
|
|
|
90
117
|
async function main() {
|
|
@@ -98,7 +125,9 @@ async function main() {
|
|
|
98
125
|
case 'setup': return setupCmd();
|
|
99
126
|
case 'hooks': return hooksCmd();
|
|
100
127
|
case 'service': return serviceCmd();
|
|
128
|
+
case 'update': case 'upgrade': return updateCmd();
|
|
101
129
|
case '__supervise': return runSupervise();
|
|
130
|
+
case '__update-check': return runUpdateCheck(HOME);
|
|
102
131
|
case 'version': case '--version': case '-v': return version();
|
|
103
132
|
default: return help();
|
|
104
133
|
}
|
|
@@ -110,59 +139,97 @@ function version() {
|
|
|
110
139
|
console.log(requireOpt('../package.json').version);
|
|
111
140
|
}
|
|
112
141
|
|
|
142
|
+
// `handmux update` (alias `upgrade`) — run the plain global install for the user. We don't self-patch or
|
|
143
|
+
// restart a running instance; on success we refresh the update cache so the "upgrade available" notice
|
|
144
|
+
// clears, and remind them to `handmux restart` to actually run the new code.
|
|
145
|
+
function updateCmd() {
|
|
146
|
+
console.log(t('update.running'));
|
|
147
|
+
const r = spawnSync('npm', ['install', '-g', `${PKG_NAME}@latest`], { stdio: 'inherit' });
|
|
148
|
+
if (r.status === 0) {
|
|
149
|
+
runUpdateCheck(HOME);
|
|
150
|
+
console.log(t('update.done'));
|
|
151
|
+
console.log(t('update.restartHint'));
|
|
152
|
+
} else {
|
|
153
|
+
console.log(t('update.failed', { pkg: PKG_NAME }));
|
|
154
|
+
process.exitCode = 1;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Best-effort upgrade notice from the cached "latest version" (never blocks; refreshes in the background).
|
|
159
|
+
function maybeNotifyUpdate() {
|
|
160
|
+
notifyUpdate(HOME, { version: requireOpt('../package.json').version, selfPath: SELF });
|
|
161
|
+
}
|
|
162
|
+
|
|
113
163
|
async function start() {
|
|
114
164
|
const { path: cfgPath, cfg: fileCfg } = resolveFileConfig();
|
|
115
|
-
console.log(
|
|
165
|
+
console.log(t('config.loaded', { path: describeConfig(cfgPath) }));
|
|
116
166
|
let cfg;
|
|
117
167
|
try { cfg = resolveConfig(flags, fileCfg); }
|
|
118
|
-
catch (e) { console.error(
|
|
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
|
-
}
|
|
168
|
+
catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(2); }
|
|
124
169
|
|
|
125
170
|
// tmux is the whole point — absent is fatal; an untested-old version only warns (rendering may drift).
|
|
126
171
|
const tmux = checkTmux();
|
|
127
172
|
if (!tmux.present) {
|
|
128
|
-
console.error('
|
|
129
|
-
console.error('
|
|
130
|
-
console.error('
|
|
173
|
+
console.error(t('tmux.notFound'));
|
|
174
|
+
console.error(t('tmux.explain1'));
|
|
175
|
+
console.error(t('tmux.explain2'));
|
|
131
176
|
console.error('');
|
|
132
|
-
console.error(
|
|
133
|
-
console.error('
|
|
177
|
+
console.error(t('tmux.install', { hint: tmuxInstallHint() }));
|
|
178
|
+
console.error(t('tmux.thenStart'));
|
|
134
179
|
process.exit(1);
|
|
135
180
|
}
|
|
136
|
-
if (!tmux.ok) console.warn(
|
|
181
|
+
if (!tmux.ok) console.warn(t('tmux.tooOld', { raw: tmux.raw, min: MIN_TMUX }));
|
|
137
182
|
|
|
183
|
+
// Already running? `start` never disrupts a live instance on its own. If this run's config matches
|
|
184
|
+
// what's running, just reassure + reprint the address. If it DIFFERS (e.g. you changed the tunnel and
|
|
185
|
+
// re-ran `start` expecting it to apply), spell out the difference and — interactively — offer to restart
|
|
186
|
+
// into it; otherwise point at `handmux restart`. The principle stays intact: we only restart on an
|
|
187
|
+
// explicit yes.
|
|
138
188
|
const existing = readState(HOME);
|
|
139
189
|
if (existing && isAlive(existing.supervisorPid)) {
|
|
140
|
-
|
|
190
|
+
const changed = configChanges(cfg, existing);
|
|
191
|
+
if (!changed.length) {
|
|
192
|
+
console.log(t('start.running.same'));
|
|
193
|
+
await printAccess(existing);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
console.log(t('start.running.changedHead', { tunnel: existing.tunnel }));
|
|
197
|
+
for (const c of changed) console.log(t('start.running.changedRow', c));
|
|
198
|
+
if (process.stdin.isTTY && await confirm(t('start.running.switchQ'))) {
|
|
199
|
+
stop(); await sleep(600); return start();
|
|
200
|
+
}
|
|
201
|
+
console.log(t('start.running.hint'));
|
|
141
202
|
await printAccess(existing);
|
|
142
203
|
return;
|
|
143
204
|
}
|
|
144
205
|
|
|
206
|
+
// Make a one-run tunnel override visible (printed only now that we're actually starting, so it can't be
|
|
207
|
+
// mistaken for a switch when the instance was already running): a --tunnel flag shadowing the file.
|
|
208
|
+
if (flags.tunnel && fileCfg.tunnel && flags.tunnel !== fileCfg.tunnel) {
|
|
209
|
+
console.log(t('start.overrides', { flag: flags.tunnel, file: fileCfg.tunnel }));
|
|
210
|
+
}
|
|
211
|
+
|
|
145
212
|
// cloudflare needs a cloudflared binary; resolve (and auto-download) it up front so the failure is a
|
|
146
213
|
// clear message here rather than a silent child that never prints a URL.
|
|
147
214
|
if (cfg.tunnel === 'cloudflare') {
|
|
148
215
|
try { cfg.cloudflaredBin = await resolveCloudflared(HOME); }
|
|
149
|
-
catch (e) { console.error(
|
|
216
|
+
catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(1); }
|
|
150
217
|
}
|
|
151
218
|
if (cfg.tunnel === 'cloudflare-named') {
|
|
152
219
|
try { cfg.cloudflaredBin = await resolveCloudflared(HOME); }
|
|
153
|
-
catch (e) { console.error(
|
|
220
|
+
catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(1); }
|
|
154
221
|
if (!fs.existsSync(path.join(HOME, '.cloudflared', 'config.yml'))) {
|
|
155
|
-
console.error('
|
|
222
|
+
console.error(t('err.namedNotProvisioned')); process.exit(1);
|
|
156
223
|
}
|
|
157
224
|
}
|
|
158
225
|
if (cfg.tunnel === 'ssh') {
|
|
159
226
|
try { await preflightSsh(cfg); }
|
|
160
|
-
catch (e) { console.error(
|
|
227
|
+
catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(1); }
|
|
161
228
|
}
|
|
162
229
|
|
|
163
230
|
if (cfg.foreground) {
|
|
164
231
|
supervise(cfg, { home: HOME });
|
|
165
|
-
console.log(
|
|
232
|
+
console.log(t('start.foreground', { tunnel: cfg.tunnel, port: cfg.port }));
|
|
166
233
|
await waitAndPrint(false);
|
|
167
234
|
return;
|
|
168
235
|
}
|
|
@@ -173,21 +240,21 @@ async function start() {
|
|
|
173
240
|
const child = spawn(process.execPath, [SELF, '__supervise', '--payload', payload],
|
|
174
241
|
{ detached: true, stdio: ['ignore', out, out] });
|
|
175
242
|
child.unref();
|
|
176
|
-
console.log(
|
|
243
|
+
console.log(t('start.starting', { tunnel: cfg.tunnel, port: cfg.port }));
|
|
177
244
|
await waitAndPrint(true);
|
|
178
245
|
}
|
|
179
246
|
|
|
180
247
|
function stop() {
|
|
181
248
|
const st = readState(HOME);
|
|
182
|
-
if (!st || !isAlive(st.supervisorPid)) { console.log('
|
|
249
|
+
if (!st || !isAlive(st.supervisorPid)) { console.log(t('stop.notRunning')); clearState(HOME); return; }
|
|
183
250
|
try { process.kill(st.supervisorPid, 'SIGTERM'); } catch { /* race: already gone */ }
|
|
184
|
-
console.log(
|
|
251
|
+
console.log(t('stop.stopped', { pid: st.supervisorPid }));
|
|
185
252
|
}
|
|
186
253
|
|
|
187
254
|
async function status() {
|
|
188
255
|
const st = readState(HOME);
|
|
189
|
-
if (!st || !isAlive(st.supervisorPid)) { console.log('
|
|
190
|
-
console.log('
|
|
256
|
+
if (!st || !isAlive(st.supervisorPid)) { console.log(t('status.stopped')); return; }
|
|
257
|
+
console.log(t('status.running'));
|
|
191
258
|
await printAccess(st);
|
|
192
259
|
}
|
|
193
260
|
|
|
@@ -198,7 +265,7 @@ function runSupervise() {
|
|
|
198
265
|
|
|
199
266
|
function logs() {
|
|
200
267
|
const p = logPath(HOME);
|
|
201
|
-
if (!fs.existsSync(p)) { console.log(
|
|
268
|
+
if (!fs.existsSync(p)) { console.log(t('logs.none')); return; }
|
|
202
269
|
const lines = String(flags.lines || 200);
|
|
203
270
|
const args = flags.follow ? ['-n', lines, '-f', p] : ['-n', lines, p];
|
|
204
271
|
spawn('tail', args, { stdio: 'inherit' });
|
|
@@ -212,50 +279,50 @@ async function serviceCmd() {
|
|
|
212
279
|
if (sub === 'install') return serviceInstall();
|
|
213
280
|
if (sub === 'uninstall') {
|
|
214
281
|
try { uninstallService({ home: HOME }); }
|
|
215
|
-
catch (e) { console.error(
|
|
282
|
+
catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(1); }
|
|
216
283
|
return;
|
|
217
284
|
}
|
|
218
|
-
console.error('usage
|
|
285
|
+
console.error(t('service.usage'));
|
|
219
286
|
process.exit(2);
|
|
220
287
|
}
|
|
221
288
|
|
|
222
289
|
async function serviceInstall() {
|
|
223
290
|
const { path: cfgPath, cfg: fileCfg } = resolveFileConfig();
|
|
224
|
-
console.log(
|
|
291
|
+
console.log(t('config.loaded', { path: describeConfig(cfgPath) }));
|
|
225
292
|
let cfg;
|
|
226
293
|
try { cfg = resolveConfig(flags, fileCfg); }
|
|
227
|
-
catch (e) { console.error(
|
|
294
|
+
catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(2); }
|
|
228
295
|
if (cfg.tunnel === 'cloudflare' || cfg.tunnel === 'cloudflare-named') {
|
|
229
296
|
try { cfg.cloudflaredBin = await resolveCloudflared(HOME); }
|
|
230
|
-
catch (e) { console.error(
|
|
297
|
+
catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(1); }
|
|
231
298
|
}
|
|
232
299
|
if (cfg.tunnel === 'ssh') {
|
|
233
300
|
// 开机自启无 TTY:要求事先已配好免密,否则快速失败。
|
|
234
301
|
cfg.tunliteBin = resolveTunlite();
|
|
235
302
|
if (checkSshAuth(cfg.sshHost, { bin: cfg.tunliteBin }) !== 0) {
|
|
236
|
-
console.error(
|
|
303
|
+
console.error(t('err.generic', { msg: t('ssh.notSetup', { bin: cfg.tunliteBin, host: cfg.sshHost }) })); process.exit(1);
|
|
237
304
|
}
|
|
238
305
|
}
|
|
239
306
|
const payload = Buffer.from(JSON.stringify(cfg)).toString('base64');
|
|
240
307
|
const args = [process.execPath, SELF, '__supervise', '--payload', payload];
|
|
241
308
|
try { installService(args, { home: HOME }); }
|
|
242
|
-
catch (e) { console.error(
|
|
243
|
-
console.log(
|
|
309
|
+
catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(1); }
|
|
310
|
+
console.log(t('service.installed'));
|
|
244
311
|
}
|
|
245
312
|
|
|
246
313
|
async function setupCmd() {
|
|
247
314
|
const target = flags.config ? path.resolve(flags.config) : configPath(HOME);
|
|
248
315
|
const cfg = await runSetup({ home: HOME, target });
|
|
249
316
|
if (!cfg) { process.exit(2); }
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
317
|
+
// Offer to enable the inbox hooks when an agent is present but not yet wired (Claude 'absent', or Codex
|
|
318
|
+
// 'absent'). installAgentHooks() then wires every present agent (idempotent for any already installed).
|
|
319
|
+
const offerHooks = hooksStatus(HOME) === 'absent' || codexHooksStatus(HOME) === 'absent';
|
|
320
|
+
if (offerHooks && await confirm(t('hooks.confirmEnable'))) {
|
|
321
|
+
installAgentHooks();
|
|
255
322
|
await maybeOfferTmuxDot();
|
|
256
323
|
}
|
|
257
|
-
if (await confirm('
|
|
258
|
-
console.log(
|
|
324
|
+
if (await confirm(t('setup.confirmStart'))) { Object.assign(flags, cfg); return start(); }
|
|
325
|
+
console.log(t('setup.later'));
|
|
259
326
|
}
|
|
260
327
|
|
|
261
328
|
// The per-window tmux status dot is the natural companion to the inbox hooks: the hook already writes a
|
|
@@ -265,37 +332,55 @@ async function setupCmd() {
|
|
|
265
332
|
async function maybeOfferTmuxDot() {
|
|
266
333
|
if (tmuxDotStatus(HOME) !== 'absent') return;
|
|
267
334
|
if (!process.stdin.isTTY) {
|
|
268
|
-
console.log(
|
|
335
|
+
console.log(t('tmuxdot.tip', { conf: tmuxConfPath(HOME) }));
|
|
269
336
|
return;
|
|
270
337
|
}
|
|
271
|
-
if (await confirm('
|
|
338
|
+
if (await confirm(t('tmuxdot.confirm'))) {
|
|
272
339
|
installTmuxDot(HOME);
|
|
273
|
-
console.log(
|
|
274
|
-
console.log('
|
|
340
|
+
console.log(t('tmuxdot.added', { path: tmuxConfPath(HOME) }));
|
|
341
|
+
console.log(t('tmuxdot.apply'));
|
|
275
342
|
}
|
|
276
343
|
}
|
|
277
344
|
|
|
278
|
-
//
|
|
279
|
-
//
|
|
345
|
+
// Install the inbox hooks for every coding agent present on this host (Claude Code, Codex — the state file
|
|
346
|
+
// is shared, entries are agent-tagged). Each is opt-in by the mere presence of its config dir. Prints a
|
|
347
|
+
// per-agent line and returns how many were wired, so callers can gate the tmux-dot offer / the "reload"
|
|
348
|
+
// hint. Codex's single `notify` slot may already hold the user's OWN program — we never clobber it, we warn.
|
|
349
|
+
function installAgentHooks() {
|
|
350
|
+
let installed = 0;
|
|
351
|
+
if (hooksStatus(HOME) !== 'no-claude') {
|
|
352
|
+
installHooks(HOME, { srcDir: HOOKS_SRC, stateFile: claudeStatePath(HOME) });
|
|
353
|
+
console.log(t('hooks.installedClaude'));
|
|
354
|
+
installed++;
|
|
355
|
+
}
|
|
356
|
+
if (codexHooksStatus(HOME) !== 'no-codex') {
|
|
357
|
+
installCodexHooks(HOME, { srcDir: HOOKS_SRC, stateFile: claudeStatePath(HOME) });
|
|
358
|
+
console.log(t('hooks.installedCodex'));
|
|
359
|
+
installed++;
|
|
360
|
+
}
|
|
361
|
+
return installed;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// `handmux hooks install|uninstall` — opt-in wiring of the coding-agent lifecycle hooks that drive the
|
|
365
|
+
// inbox/push. Never creates ~/.claude or ~/.codex; if neither agent is present we say so and exit 0.
|
|
280
366
|
async function hooksCmd() {
|
|
281
367
|
const sub = process.argv[3];
|
|
282
368
|
if (sub === 'install') {
|
|
283
|
-
if (hooksStatus(HOME) === 'no-claude') {
|
|
284
|
-
console.log('
|
|
369
|
+
if (hooksStatus(HOME) === 'no-claude' && codexHooksStatus(HOME) === 'no-codex') {
|
|
370
|
+
console.log(t('hooks.noAgents'));
|
|
285
371
|
return;
|
|
286
372
|
}
|
|
287
|
-
|
|
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.');
|
|
373
|
+
if (installAgentHooks() > 0) console.log(t('hooks.installedHint'));
|
|
290
374
|
await maybeOfferTmuxDot();
|
|
291
375
|
return;
|
|
292
376
|
}
|
|
293
377
|
if (sub === 'uninstall') {
|
|
294
378
|
uninstallHooks(HOME);
|
|
295
|
-
|
|
379
|
+
uninstallCodexHooks(HOME);
|
|
380
|
+
console.log(t('hooks.removed'));
|
|
296
381
|
return;
|
|
297
382
|
}
|
|
298
|
-
console.error('usage
|
|
383
|
+
console.error(t('hooks.usage'));
|
|
299
384
|
process.exit(2);
|
|
300
385
|
}
|
|
301
386
|
|
|
@@ -306,15 +391,15 @@ function configCmd() {
|
|
|
306
391
|
const { path: cfgPath, cfg: fileCfg } = resolveFileConfig();
|
|
307
392
|
let rows;
|
|
308
393
|
try { rows = explainConfig(flags, fileCfg, cfgPath); }
|
|
309
|
-
catch (e) { console.error(
|
|
310
|
-
console.log(
|
|
394
|
+
catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(2); }
|
|
395
|
+
console.log(t('configcmd.file', { path: cfgPath || t('configcmd.fileNone') }));
|
|
311
396
|
console.log('');
|
|
312
397
|
const w = Math.max(...rows.map((r) => r.key.length));
|
|
313
398
|
for (const r of rows) {
|
|
314
399
|
console.log(` ${r.key.padEnd(w)} ${r.display} ${r.origin === 'default' ? '' : `· ${r.origin}`}`.trimEnd());
|
|
315
400
|
}
|
|
316
401
|
console.log('');
|
|
317
|
-
console.log(
|
|
402
|
+
console.log(t('configcmd.legend'));
|
|
318
403
|
}
|
|
319
404
|
|
|
320
405
|
// Poll state.json until the public URL (or an error) shows up, then print access info. cloudflare needs
|
|
@@ -334,26 +419,27 @@ async function waitAndPrint(exitWhenDone) {
|
|
|
334
419
|
}
|
|
335
420
|
|
|
336
421
|
async function printAccess(st) {
|
|
337
|
-
if (!st) { console.log(
|
|
338
|
-
if (st.error) { console.error(
|
|
422
|
+
if (!st) { console.log(t('access.noState')); return; }
|
|
423
|
+
if (st.error) { console.error(t('access.error', { msg: st.error })); return; }
|
|
339
424
|
const scan = bareUrl(st.publicUrl);
|
|
340
425
|
console.log('');
|
|
341
|
-
console.log(
|
|
342
|
-
console.log(
|
|
343
|
-
if (st.tunnel === 'none' && st.lanUrl) console.log(
|
|
344
|
-
console.log(
|
|
345
|
-
console.log(
|
|
426
|
+
console.log(t('access.tunnel', { tunnel: st.tunnel, pid: st.supervisorPid }));
|
|
427
|
+
console.log(t('access.open', { url: scan || t('access.pending') }));
|
|
428
|
+
if (st.tunnel === 'none' && st.lanUrl) console.log(t('access.lan', { url: bareUrl(st.lanUrl) }));
|
|
429
|
+
console.log(t('access.local', { url: bareUrl(st.localUrl) }));
|
|
430
|
+
console.log(t('access.token', { token: st.token }));
|
|
346
431
|
// The QR carries the token so a phone scan signs in one-tap; the PRINTED links above stay token-free
|
|
347
432
|
// (safe to screenshot/share — paste the token shown above to sign in there).
|
|
348
433
|
await maybeQr(st.publicUrl ? publicUrlWithToken(st.publicUrl, st.token) : scan, st);
|
|
349
434
|
if (st.publicUrl && st.tunnel !== 'none') {
|
|
350
435
|
const ok = await probe(st.publicUrl);
|
|
351
|
-
if (ok) console.log('
|
|
352
|
-
else console.log(
|
|
436
|
+
if (ok) console.log(t('access.reachable'));
|
|
437
|
+
else console.log(t('access.unreachable', { url: st.publicUrl }));
|
|
353
438
|
}
|
|
354
439
|
console.log('');
|
|
355
|
-
console.log(
|
|
440
|
+
console.log(t('access.hint'));
|
|
356
441
|
console.log('');
|
|
442
|
+
maybeNotifyUpdate();
|
|
357
443
|
}
|
|
358
444
|
|
|
359
445
|
// Best-effort QR (optional dependency). We borrow qrcode-terminal's QR *model* (vendored, dependency-free)
|
|
@@ -377,41 +463,7 @@ async function maybeQr(url, st) {
|
|
|
377
463
|
}
|
|
378
464
|
|
|
379
465
|
function help() {
|
|
380
|
-
console.log(
|
|
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
|
-
`);
|
|
466
|
+
console.log(t('help.body'));
|
|
415
467
|
}
|
|
416
468
|
|
|
417
469
|
main();
|
package/hooks/handmux-notify.sh
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
|
-
# handmux 上报 hook. $1 = stop | notify | prompt | end | resume | permreq.
|
|
2
|
+
# handmux 上报 hook. $1 = stop | notify | prompt | end | resume | permreq. $2 = agent id(留空=claude;
|
|
3
|
+
# Codex 传 'codex')—— Claude 与 Codex 的 hook payload 字段一致(stdin JSON),所以共用这一个脚本。
|
|
4
|
+
# stdin = agent 原始 payload(JSON).
|
|
3
5
|
# (resume = PostToolUse on AskUserQuestion/ExitPlanMode:答完选项/批准计划 → 状态翻回进行中、带所选项。)
|
|
4
6
|
# (permreq = PermissionRequest:真实弹框一出现就发、带 tool_name → 比 permission_prompt 早亮「需要你」。)
|
|
5
7
|
# 只做一件事:把本次事件写进一个本地 JSON 状态文件(键=tmux pane,值=该 pane 最新事件)。不联网、
|
|
@@ -16,5 +18,5 @@ TS=$(perl -MTime::HiRes -e 'printf "%.0f", Time::HiRes::time()*1000' 2>/dev/null
|
|
|
16
18
|
HOST=$(hostname 2>/dev/null || printf '')
|
|
17
19
|
# payload 经 stdin 原样流给 node(不在 shell 里转义,避免坏数据);pane 含 '%' 直接进 JSON 字段,
|
|
18
20
|
# 不再进 URL → 彻底告别旧的 "%110 被 url-decode 丢弃" 那类坑。
|
|
19
|
-
node "$(dirname "$0")/handmux-write.cjs" "$FILE" "$PANE" "$1" "$TS" "$HOST" 2>/dev/null || true
|
|
21
|
+
node "$(dirname "$0")/handmux-write.cjs" "$FILE" "$PANE" "$1" "$TS" "$HOST" "$2" 2>/dev/null || true
|
|
20
22
|
exit 0
|
package/hooks/handmux-write.cjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
const fs = require('node:fs');
|
|
17
17
|
const path = require('node:path');
|
|
18
18
|
|
|
19
|
-
const [, , file, pane, src, ts, host = ''] = process.argv;
|
|
19
|
+
const [, , file, pane, src, ts, host = '', agent = ''] = process.argv;
|
|
20
20
|
if (!file || !pane || !src) process.exit(0);
|
|
21
21
|
|
|
22
22
|
let payload = {};
|
|
@@ -28,6 +28,7 @@ try { payload = JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); } catch { /* unr
|
|
|
28
28
|
// write under the lock — the only place we can read the prior state safely — makes the call.
|
|
29
29
|
const isIdle = src === 'notify' && payload && payload.notification_type === 'idle_prompt';
|
|
30
30
|
let cleared = false; // set by update() when idle cleared an interrupted 进行中 → also clear @claude_dot below
|
|
31
|
+
let noop = false; // set when a codex PostToolUse resume had nothing to un-stick → skip the state + dot write
|
|
31
32
|
|
|
32
33
|
// Synchronous nap without busy-spinning (the hook runs async, so a few ms is free). SharedArrayBuffer
|
|
33
34
|
// may be unavailable in odd runtimes — fall back to a tiny busy loop so the lock retry still paces.
|
|
@@ -53,8 +54,20 @@ function update() {
|
|
|
53
54
|
else return; // resting → drop without writing
|
|
54
55
|
} else if (src === 'end') {
|
|
55
56
|
delete obj[pane]; // SessionEnd (clean exit) → drop the pane
|
|
57
|
+
} else if (src === 'resume' && agent === 'codex') {
|
|
58
|
+
// Codex fires PostToolUse on EVERY tool call, so its resume exists purely to un-stick a pane from 需要你
|
|
59
|
+
// back to 进行中 after the user approved a PermissionRequest. Apply it ONLY as that transition — a mid-
|
|
60
|
+
// turn tool call (pane already 进行中 / 已完成) is a no-op, so we don't rewrite the entry or repaint the
|
|
61
|
+
// dot on every command (the load Claude's matcher avoids). Claude's resume — no agent arg — is unaffected.
|
|
62
|
+
const prev = obj[pane];
|
|
63
|
+
const prevPerm = prev && (prev.src === 'permreq'
|
|
64
|
+
|| (prev.src === 'notify' && (prev.payload || {}).notification_type === 'permission_prompt'));
|
|
65
|
+
if (!prevPerm) { noop = true; return; }
|
|
66
|
+
obj[pane] = { ts: Number(ts) || 0, src, host, payload, agent };
|
|
56
67
|
} else {
|
|
57
|
-
|
|
68
|
+
// agent tag lets the server dispatch classify + liveness per agent (Codex passes 'codex'); omitted for
|
|
69
|
+
// Claude so legacy entries stay byte-identical and default to claude server-side.
|
|
70
|
+
obj[pane] = { ts: Number(ts) || 0, src, host, payload, ...(agent ? { agent } : {}) };
|
|
58
71
|
}
|
|
59
72
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
60
73
|
fs.writeFileSync(tmp, JSON.stringify(obj));
|
|
@@ -86,7 +99,7 @@ function claudeDot(s, p) {
|
|
|
86
99
|
}
|
|
87
100
|
try {
|
|
88
101
|
const dot = (src === 'end' || cleared) ? '' : claudeDot(src, payload);
|
|
89
|
-
if (dot !== null) {
|
|
102
|
+
if (dot !== null && !noop) {
|
|
90
103
|
require('node:child_process').execFileSync('tmux', ['set-option', '-w', '-t', pane, '@claude_dot', dot], { stdio: 'ignore', timeout: 1000 });
|
|
91
104
|
}
|
|
92
105
|
} catch { /* 不在 tmux / tmux 不可达 → 忽略 */ }
|