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 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(`✗ --config ${p}: not found`); process.exit(2); }
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(`✗ bad config ${p}: ${e.message}`); process.exit(2); }
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 || '(none — flags + defaults)';
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(`passwordless SSH to ${cfg.sshHost} is not set up. Configure it now?`)) {
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(`passwordless SSH not set up — run: ${cfg.tunliteBin} setup-key ${cfg.sshHost}`);
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(`config: ${describeConfig(cfgPath)}`);
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(`✗ ${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
- }
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('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.');
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(` Install it: ${tmuxInstallHint()}`);
133
- console.error(' Then run `handmux start` again.');
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(`⚠ tmux ${tmux.raw} is below the tested minimum ${MIN_TMUX}; terminal rendering may be off`);
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
- console.log(`handmux already running (pid ${existing.supervisorPid}) — use 'handmux restart'`);
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(`✗ ${e.message}`); process.exit(1); }
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(`✗ ${e.message}`); process.exit(1); }
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('✗ named tunnel not provisioned — run `handmux setup` first'); process.exit(1);
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(`✗ ${e.message}`); process.exit(1); }
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(`starting handmux (tunnel: ${cfg.tunnel}, port: ${cfg.port}) — Ctrl-C to stop`);
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(`starting handmux (tunnel: ${cfg.tunnel}, port: ${cfg.port}) …`);
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('handmux not running'); clearState(HOME); return; }
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(`stopped handmux (pid ${st.supervisorPid})`);
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('● handmux stopped'); return; }
190
- console.log('● handmux running');
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('(no log yet — start handmux first)'); return; }
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(`✗ ${e.message}`); process.exit(1); }
282
+ catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(1); }
216
283
  return;
217
284
  }
218
- console.error('usage: handmux service install [start-flags] | handmux service uninstall');
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(`config: ${describeConfig(cfgPath)}`);
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(`✗ ${e.message}`); process.exit(2); }
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(`✗ ${e.message}`); process.exit(1); }
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(`✗ passwordless SSH not set up — run: ${cfg.tunliteBin} setup-key ${cfg.sshHost}`); process.exit(1);
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(`✗ ${e.message}`); process.exit(1); }
243
- console.log("handmux will now start at login. 'handmux service uninstall' to remove.");
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
- 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.');
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('Start handmux now?')) { Object.assign(flags, cfg); return start(); }
258
- console.log("run 'handmux start' when you're ready.");
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(` 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).`);
335
+ console.log(t('tmuxdot.tip', { conf: tmuxConfPath(HOME) }));
269
336
  return;
270
337
  }
271
- if (await confirm('Also show a per-window Claude status dot in tmux? (adds a block to ~/.tmux.conf)')) {
338
+ if (await confirm(t('tmuxdot.confirm'))) {
272
339
  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).');
340
+ console.log(t('tmuxdot.added', { path: tmuxConfPath(HOME) }));
341
+ console.log(t('tmuxdot.apply'));
275
342
  }
276
343
  }
277
344
 
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).
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('Claude Code not detected (~/.claude missing) — nothing to install.');
369
+ if (hooksStatus(HOME) === 'no-claude' && codexHooksStatus(HOME) === 'no-codex') {
370
+ console.log(t('hooks.noAgents'));
285
371
  return;
286
372
  }
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.');
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
- console.log('✓ Claude hooks removed.');
379
+ uninstallCodexHooks(HOME);
380
+ console.log(t('hooks.removed'));
296
381
  return;
297
382
  }
298
- console.error('usage: handmux hooks install|uninstall');
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(`✗ ${e.message}`); process.exit(2); }
310
- console.log(`config file: ${cfgPath || '(none — using defaults; run `handmux setup` to create one)'}`);
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(' origin: flag (this run only) · file · env · default');
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(' (no state)'); return; }
338
- if (st.error) { console.error(` ✗ ${st.error}`); return; }
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(` 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}`);
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('reachable');
352
- else console.log(` ⚠ tunnel up but ${st.publicUrl} did not answer — check the server-side reverse proxy / DNS`);
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(` handmux status | stop`);
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(`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
- `);
466
+ console.log(t('help.body'));
415
467
  }
416
468
 
417
469
  main();
@@ -1,5 +1,7 @@
1
1
  #!/bin/sh
2
- # handmux 上报 hook. $1 = stop | notify | prompt | end | resume | permreq. stdin = Claude 原始 payload(JSON).
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
@@ -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
- obj[pane] = { ts: Number(ts) || 0, src, host, payload };
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 不可达 → 忽略 */ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handmux",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Mobile vibe coding — drive your real tmux session (and Claude Code) from your phone.",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-only",