handmux 0.5.3 → 0.7.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.
@@ -135,10 +153,6 @@ hook events in `~/.claude/settings.json` (idempotent; leaves your own hooks alon
135
153
  this too, and you can turn it on from the phone the first time you open the inbox. `handmux hooks uninstall`
136
154
  removes it. If you don't use Claude Code, this is skipped — nothing touches `~/.claude`.
137
155
 
138
- After installing the hooks it also offers (opt-in) to add a small block to `~/.tmux.conf` so each tmux
139
- window tab shows a live Claude status dot (working / needs you / done) — the same signal as the inbox, on
140
- your terminal. Decline it and nothing is changed; the block is marked and easy to remove later.
141
-
142
156
  ### start flags
143
157
 
144
158
  Flags override the config file for **one run** and never persist — handy for a quick try
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 配的一整个**手机驾驶舱**。
@@ -125,10 +141,6 @@ Agent 收件箱和「需要你就推给你」靠的是 Claude Code 的生命周
125
141
  注册六个 hook 事件(幂等;不动你自己的 hook)。`handmux setup` 也会问一句,首次在手机上打开收件箱时
126
142
  还能一键开启。`handmux hooks uninstall` 移除它。不用 Claude Code 的话直接跳过——绝不碰 `~/.claude`。
127
143
 
128
- 装完 hook 后,它还会(opt-in)问一句要不要往 `~/.tmux.conf` 加一小段配置,让每个 tmux 窗页签也显示一个
129
- 实时的 Claude 状态点(进行中 / 需要你 / 已完成)——和收件箱同一个信号,直接画在你的终端上。拒绝则什么都不改;
130
- 这段配置带标记,以后想删很容易。
131
-
132
144
  ### start 参数
133
145
 
134
146
  参数只对**这一次运行**覆盖配置文件、绝不落盘——适合不动已存配置就快速试一下
package/bin/handmux.js CHANGED
@@ -31,8 +31,12 @@ 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 { tmuxDotStatus, installTmuxDot, tmuxConfPath } from '../src/cli/tmuxConf.js';
34
+ import { codexHooksStatus, installCodexHooks, uninstallCodexHooks } from '../src/cli/codexHooks.js';
35
+ import { statusLineStatus, installStatusLine, uninstallStatusLine, composeHint } from '../src/cli/statusLine.js';
36
+ import { claudeUsagePath } from '../src/usage.js';
35
37
  import { probe } from '../src/cli/probe.js';
38
+ import { notifyUpdate, runUpdateCheck, PKG_NAME } from '../src/cli/updateCheck.js';
39
+ import { t, initLocale, setLocale } from '../src/cli/i18n/index.js';
36
40
 
37
41
  const HOME = homedir();
38
42
  const SELF = fileURLToPath(import.meta.url);
@@ -41,6 +45,17 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
41
45
 
42
46
  const { command, flags } = parseArgs(process.argv.slice(2));
43
47
 
48
+ // Resolve the CLI language ONCE, up front, so every command (help, errors, access block) prints in it.
49
+ // Priority: --lang > config `lang` > shell locale (LANG/LC_*) > English. The config peek is lenient — a
50
+ // missing/broken file just means "no language hint here"; the real validation happens per-command later.
51
+ function peekConfigLang() {
52
+ try {
53
+ const p = flags.config ? path.resolve(flags.config) : configPath(HOME);
54
+ return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : {};
55
+ } catch { return {}; }
56
+ }
57
+ initLocale(flags, peekConfigLang(), process.env);
58
+
44
59
  // There is ONE config file location: ~/.handmux/config.json (written by `handmux setup`). `--config PATH`
45
60
  // points elsewhere — that's the only escape, and it covers dev/multi-config without any cwd magic (a
46
61
  // stray ./config.json never gets picked up silently). No file merging or inheritance: at most one file is
@@ -51,19 +66,32 @@ function resolveFileConfig() {
51
66
  let p = null;
52
67
  if (flags.config) { // explicit: must exist
53
68
  p = path.resolve(flags.config);
54
- if (!fs.existsSync(p)) { console.error(`✗ --config ${p}: not found`); process.exit(2); }
69
+ if (!fs.existsSync(p)) { console.error(t('err.configNotFound', { path: p })); process.exit(2); }
55
70
  } else {
56
71
  const homeP = configPath(HOME);
57
72
  if (fs.existsSync(homeP)) p = homeP;
58
73
  }
59
74
  if (!p) return { path: null, cfg: {} };
60
75
  try { return { path: p, cfg: JSON.parse(fs.readFileSync(p, 'utf8')) }; }
61
- catch (e) { console.error(`✗ bad config ${p}: ${e.message}`); process.exit(2); }
76
+ catch (e) { console.error(t('err.badConfig', { path: p, msg: e.message })); process.exit(2); }
62
77
  }
63
78
 
64
79
  // Human-readable summary of which config file a run loaded.
65
80
  function describeConfig(p) {
66
- return p || '(none — flags + defaults)';
81
+ return p || t('config.none');
82
+ }
83
+
84
+ // Which user-visible settings THIS run would use differ from what's already running (from state.json)?
85
+ // Kept to the two people actually re-run `start` to change — the tunnel and the port; each row is ready to
86
+ // drop straight into the `start.running.changedRow` message ({key, from, to}). Only compares fields the
87
+ // running state actually recorded, so an older state.json can't manufacture phantom diffs.
88
+ function configChanges(cfg, st) {
89
+ const out = [];
90
+ for (const key of ['tunnel', 'port']) {
91
+ const running = st[key];
92
+ if (running != null && String(cfg[key]) !== String(running)) out.push({ key, from: running, to: cfg[key] });
93
+ }
94
+ return out;
67
95
  }
68
96
 
69
97
  // 一次性 [Y/n] 提问(默认 Yes)。非 TTY 直接返回 false,绝不卡住。
@@ -80,11 +108,11 @@ async function confirm(question) {
80
108
  async function preflightSsh(cfg) {
81
109
  cfg.tunliteBin = resolveTunlite(); // 抛出 → 调用方打印并退出
82
110
  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?`)) {
111
+ if (process.stdin.isTTY && await confirm(t('ssh.confirmSetup', { host: cfg.sshHost }))) {
84
112
  spawnSync(cfg.tunliteBin, ['setup-key', cfg.sshHost], { stdio: 'inherit' });
85
113
  if (checkSshAuth(cfg.sshHost, { bin: cfg.tunliteBin }) === 0) return;
86
114
  }
87
- throw new Error(`passwordless SSH not set up — run: ${cfg.tunliteBin} setup-key ${cfg.sshHost}`);
115
+ throw new Error(t('ssh.notSetup', { bin: cfg.tunliteBin, host: cfg.sshHost }));
88
116
  }
89
117
 
90
118
  async function main() {
@@ -98,7 +126,9 @@ async function main() {
98
126
  case 'setup': return setupCmd();
99
127
  case 'hooks': return hooksCmd();
100
128
  case 'service': return serviceCmd();
129
+ case 'update': case 'upgrade': return updateCmd();
101
130
  case '__supervise': return runSupervise();
131
+ case '__update-check': return runUpdateCheck(HOME);
102
132
  case 'version': case '--version': case '-v': return version();
103
133
  default: return help();
104
134
  }
@@ -110,59 +140,97 @@ function version() {
110
140
  console.log(requireOpt('../package.json').version);
111
141
  }
112
142
 
143
+ // `handmux update` (alias `upgrade`) — run the plain global install for the user. We don't self-patch or
144
+ // restart a running instance; on success we refresh the update cache so the "upgrade available" notice
145
+ // clears, and remind them to `handmux restart` to actually run the new code.
146
+ function updateCmd() {
147
+ console.log(t('update.running'));
148
+ const r = spawnSync('npm', ['install', '-g', `${PKG_NAME}@latest`], { stdio: 'inherit' });
149
+ if (r.status === 0) {
150
+ runUpdateCheck(HOME);
151
+ console.log(t('update.done'));
152
+ console.log(t('update.restartHint'));
153
+ } else {
154
+ console.log(t('update.failed', { pkg: PKG_NAME }));
155
+ process.exitCode = 1;
156
+ }
157
+ }
158
+
159
+ // Best-effort upgrade notice from the cached "latest version" (never blocks; refreshes in the background).
160
+ function maybeNotifyUpdate() {
161
+ notifyUpdate(HOME, { version: requireOpt('../package.json').version, selfPath: SELF });
162
+ }
163
+
113
164
  async function start() {
114
165
  const { path: cfgPath, cfg: fileCfg } = resolveFileConfig();
115
- console.log(`config: ${describeConfig(cfgPath)}`);
166
+ console.log(t('config.loaded', { path: describeConfig(cfgPath) }));
116
167
  let cfg;
117
168
  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
- }
169
+ catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(2); }
124
170
 
125
171
  // tmux is the whole point — absent is fatal; an untested-old version only warns (rendering may drift).
126
172
  const tmux = checkTmux();
127
173
  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.');
174
+ console.error(t('tmux.notFound'));
175
+ console.error(t('tmux.explain1'));
176
+ console.error(t('tmux.explain2'));
131
177
  console.error('');
132
- console.error(` Install it: ${tmuxInstallHint()}`);
133
- console.error(' Then run `handmux start` again.');
178
+ console.error(t('tmux.install', { hint: tmuxInstallHint() }));
179
+ console.error(t('tmux.thenStart'));
134
180
  process.exit(1);
135
181
  }
136
- if (!tmux.ok) console.warn(`⚠ tmux ${tmux.raw} is below the tested minimum ${MIN_TMUX}; terminal rendering may be off`);
182
+ if (!tmux.ok) console.warn(t('tmux.tooOld', { raw: tmux.raw, min: MIN_TMUX }));
137
183
 
184
+ // Already running? `start` never disrupts a live instance on its own. If this run's config matches
185
+ // what's running, just reassure + reprint the address. If it DIFFERS (e.g. you changed the tunnel and
186
+ // re-ran `start` expecting it to apply), spell out the difference and — interactively — offer to restart
187
+ // into it; otherwise point at `handmux restart`. The principle stays intact: we only restart on an
188
+ // explicit yes.
138
189
  const existing = readState(HOME);
139
190
  if (existing && isAlive(existing.supervisorPid)) {
140
- console.log(`handmux already running (pid ${existing.supervisorPid}) — use 'handmux restart'`);
191
+ const changed = configChanges(cfg, existing);
192
+ if (!changed.length) {
193
+ console.log(t('start.running.same'));
194
+ await printAccess(existing);
195
+ return;
196
+ }
197
+ console.log(t('start.running.changedHead', { tunnel: existing.tunnel }));
198
+ for (const c of changed) console.log(t('start.running.changedRow', c));
199
+ if (process.stdin.isTTY && await confirm(t('start.running.switchQ'))) {
200
+ stop(); await sleep(600); return start();
201
+ }
202
+ console.log(t('start.running.hint'));
141
203
  await printAccess(existing);
142
204
  return;
143
205
  }
144
206
 
207
+ // Make a one-run tunnel override visible (printed only now that we're actually starting, so it can't be
208
+ // mistaken for a switch when the instance was already running): a --tunnel flag shadowing the file.
209
+ if (flags.tunnel && fileCfg.tunnel && flags.tunnel !== fileCfg.tunnel) {
210
+ console.log(t('start.overrides', { flag: flags.tunnel, file: fileCfg.tunnel }));
211
+ }
212
+
145
213
  // cloudflare needs a cloudflared binary; resolve (and auto-download) it up front so the failure is a
146
214
  // clear message here rather than a silent child that never prints a URL.
147
215
  if (cfg.tunnel === 'cloudflare') {
148
216
  try { cfg.cloudflaredBin = await resolveCloudflared(HOME); }
149
- catch (e) { console.error(`✗ ${e.message}`); process.exit(1); }
217
+ catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(1); }
150
218
  }
151
219
  if (cfg.tunnel === 'cloudflare-named') {
152
220
  try { cfg.cloudflaredBin = await resolveCloudflared(HOME); }
153
- catch (e) { console.error(`✗ ${e.message}`); process.exit(1); }
221
+ catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(1); }
154
222
  if (!fs.existsSync(path.join(HOME, '.cloudflared', 'config.yml'))) {
155
- console.error('✗ named tunnel not provisioned — run `handmux setup` first'); process.exit(1);
223
+ console.error(t('err.namedNotProvisioned')); process.exit(1);
156
224
  }
157
225
  }
158
226
  if (cfg.tunnel === 'ssh') {
159
227
  try { await preflightSsh(cfg); }
160
- catch (e) { console.error(`✗ ${e.message}`); process.exit(1); }
228
+ catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(1); }
161
229
  }
162
230
 
163
231
  if (cfg.foreground) {
164
232
  supervise(cfg, { home: HOME });
165
- console.log(`starting handmux (tunnel: ${cfg.tunnel}, port: ${cfg.port}) — Ctrl-C to stop`);
233
+ console.log(t('start.foreground', { tunnel: cfg.tunnel, port: cfg.port }));
166
234
  await waitAndPrint(false);
167
235
  return;
168
236
  }
@@ -173,21 +241,21 @@ async function start() {
173
241
  const child = spawn(process.execPath, [SELF, '__supervise', '--payload', payload],
174
242
  { detached: true, stdio: ['ignore', out, out] });
175
243
  child.unref();
176
- console.log(`starting handmux (tunnel: ${cfg.tunnel}, port: ${cfg.port}) …`);
244
+ console.log(t('start.starting', { tunnel: cfg.tunnel, port: cfg.port }));
177
245
  await waitAndPrint(true);
178
246
  }
179
247
 
180
248
  function stop() {
181
249
  const st = readState(HOME);
182
- if (!st || !isAlive(st.supervisorPid)) { console.log('handmux not running'); clearState(HOME); return; }
250
+ if (!st || !isAlive(st.supervisorPid)) { console.log(t('stop.notRunning')); clearState(HOME); return; }
183
251
  try { process.kill(st.supervisorPid, 'SIGTERM'); } catch { /* race: already gone */ }
184
- console.log(`stopped handmux (pid ${st.supervisorPid})`);
252
+ console.log(t('stop.stopped', { pid: st.supervisorPid }));
185
253
  }
186
254
 
187
255
  async function status() {
188
256
  const st = readState(HOME);
189
- if (!st || !isAlive(st.supervisorPid)) { console.log('● handmux stopped'); return; }
190
- console.log('● handmux running');
257
+ if (!st || !isAlive(st.supervisorPid)) { console.log(t('status.stopped')); return; }
258
+ console.log(t('status.running'));
191
259
  await printAccess(st);
192
260
  }
193
261
 
@@ -198,7 +266,7 @@ function runSupervise() {
198
266
 
199
267
  function logs() {
200
268
  const p = logPath(HOME);
201
- if (!fs.existsSync(p)) { console.log('(no log yet — start handmux first)'); return; }
269
+ if (!fs.existsSync(p)) { console.log(t('logs.none')); return; }
202
270
  const lines = String(flags.lines || 200);
203
271
  const args = flags.follow ? ['-n', lines, '-f', p] : ['-n', lines, p];
204
272
  spawn('tail', args, { stdio: 'inherit' });
@@ -212,90 +280,114 @@ async function serviceCmd() {
212
280
  if (sub === 'install') return serviceInstall();
213
281
  if (sub === 'uninstall') {
214
282
  try { uninstallService({ home: HOME }); }
215
- catch (e) { console.error(`✗ ${e.message}`); process.exit(1); }
283
+ catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(1); }
216
284
  return;
217
285
  }
218
- console.error('usage: handmux service install [start-flags] | handmux service uninstall');
286
+ console.error(t('service.usage'));
219
287
  process.exit(2);
220
288
  }
221
289
 
222
290
  async function serviceInstall() {
223
291
  const { path: cfgPath, cfg: fileCfg } = resolveFileConfig();
224
- console.log(`config: ${describeConfig(cfgPath)}`);
292
+ console.log(t('config.loaded', { path: describeConfig(cfgPath) }));
225
293
  let cfg;
226
294
  try { cfg = resolveConfig(flags, fileCfg); }
227
- catch (e) { console.error(`✗ ${e.message}`); process.exit(2); }
295
+ catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(2); }
228
296
  if (cfg.tunnel === 'cloudflare' || cfg.tunnel === 'cloudflare-named') {
229
297
  try { cfg.cloudflaredBin = await resolveCloudflared(HOME); }
230
- catch (e) { console.error(`✗ ${e.message}`); process.exit(1); }
298
+ catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(1); }
231
299
  }
232
300
  if (cfg.tunnel === 'ssh') {
233
301
  // 开机自启无 TTY:要求事先已配好免密,否则快速失败。
234
302
  cfg.tunliteBin = resolveTunlite();
235
303
  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);
304
+ console.error(t('err.generic', { msg: t('ssh.notSetup', { bin: cfg.tunliteBin, host: cfg.sshHost }) })); process.exit(1);
237
305
  }
238
306
  }
239
307
  const payload = Buffer.from(JSON.stringify(cfg)).toString('base64');
240
308
  const args = [process.execPath, SELF, '__supervise', '--payload', payload];
241
309
  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.");
310
+ catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(1); }
311
+ console.log(t('service.installed'));
244
312
  }
245
313
 
246
314
  async function setupCmd() {
247
315
  const target = flags.config ? path.resolve(flags.config) : configPath(HOME);
248
316
  const cfg = await runSetup({ home: HOME, target });
249
317
  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();
318
+ // Offer to enable the inbox hooks when an agent is present but not yet wired (Claude 'absent', or Codex
319
+ // 'absent'). installAgentHooks() then wires every present agent (idempotent for any already installed).
320
+ const offerHooks = hooksStatus(HOME) === 'absent' || codexHooksStatus(HOME) === 'absent';
321
+ if (offerHooks && await confirm(t('hooks.confirmEnable'))) {
322
+ installAgentHooks();
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
+ await maybeOfferStatusLine();
325
+ if (await confirm(t('setup.confirmStart'))) { Object.assign(flags, cfg); return start(); }
326
+ console.log(t('setup.later'));
259
327
  }
260
328
 
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).`);
329
+ // Offer to enable the Claude statusLine usage capturer it feeds the phone Usage page's 5h/weekly bars
330
+ // (Claude Code's statusLine stdin is the only documented local source of those %). Opt-in and NON-
331
+ // DESTRUCTIVE: auto-installs only when there's no statusLine yet; if the user already has one we print a
332
+ // one-line compose snippet and change nothing. Codex needs no capturer its rollout already carries the
333
+ // quota. No-op when Claude Code isn't installed or ours is already in place.
334
+ async function maybeOfferStatusLine() {
335
+ const st = statusLineStatus(HOME);
336
+ if (st === 'no-claude' || st === 'ours') return;
337
+ if (st === 'foreign') {
338
+ // Deploy the capturer script (doesn't touch their statusLine) so the compose one-liner is runnable.
339
+ installStatusLine(HOME, { srcDir: HOOKS_SRC, usageFile: claudeUsagePath(HOME) });
340
+ console.log(t('statusline.foreignHint'));
341
+ console.log(' ' + composeHint(HOME, { usageFile: claudeUsagePath(HOME) }));
269
342
  return;
270
343
  }
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).');
344
+ if (await confirm(t('statusline.confirmEnable'))) {
345
+ installStatusLine(HOME, { srcDir: HOOKS_SRC, usageFile: claudeUsagePath(HOME) });
346
+ console.log(t('statusline.installed'));
347
+ console.log(t('statusline.reload'));
275
348
  }
276
349
  }
277
350
 
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).
351
+ // Install the inbox hooks for every coding agent present on this host (Claude Code, Codex the state file
352
+ // is shared, entries are agent-tagged). Each is opt-in by the mere presence of its config dir. Prints a
353
+ // per-agent line and returns how many were wired, so callers can gate the "reload" hint. Codex's single
354
+ // `notify` slot may already hold the user's OWN program — we never clobber it, we warn.
355
+ function installAgentHooks() {
356
+ let installed = 0;
357
+ if (hooksStatus(HOME) !== 'no-claude') {
358
+ installHooks(HOME, { srcDir: HOOKS_SRC, stateFile: claudeStatePath(HOME) });
359
+ console.log(t('hooks.installedClaude'));
360
+ installed++;
361
+ }
362
+ if (codexHooksStatus(HOME) !== 'no-codex') {
363
+ installCodexHooks(HOME, { srcDir: HOOKS_SRC, stateFile: claudeStatePath(HOME) });
364
+ console.log(t('hooks.installedCodex'));
365
+ installed++;
366
+ }
367
+ return installed;
368
+ }
369
+
370
+ // `handmux hooks install|uninstall` — opt-in wiring of the coding-agent lifecycle hooks that drive the
371
+ // inbox/push. Never creates ~/.claude or ~/.codex; if neither agent is present we say so and exit 0.
280
372
  async function hooksCmd() {
281
373
  const sub = process.argv[3];
282
374
  if (sub === 'install') {
283
- if (hooksStatus(HOME) === 'no-claude') {
284
- console.log('Claude Code not detected (~/.claude missing) — nothing to install.');
375
+ if (hooksStatus(HOME) === 'no-claude' && codexHooksStatus(HOME) === 'no-codex') {
376
+ console.log(t('hooks.noAgents'));
285
377
  return;
286
378
  }
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();
379
+ if (installAgentHooks() > 0) console.log(t('hooks.installedHint'));
380
+ await maybeOfferStatusLine();
291
381
  return;
292
382
  }
293
383
  if (sub === 'uninstall') {
294
384
  uninstallHooks(HOME);
295
- console.log('✓ Claude hooks removed.');
385
+ uninstallCodexHooks(HOME);
386
+ uninstallStatusLine(HOME);
387
+ console.log(t('hooks.removed'));
296
388
  return;
297
389
  }
298
- console.error('usage: handmux hooks install|uninstall');
390
+ console.error(t('hooks.usage'));
299
391
  process.exit(2);
300
392
  }
301
393
 
@@ -306,15 +398,15 @@ function configCmd() {
306
398
  const { path: cfgPath, cfg: fileCfg } = resolveFileConfig();
307
399
  let rows;
308
400
  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)'}`);
401
+ catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(2); }
402
+ console.log(t('configcmd.file', { path: cfgPath || t('configcmd.fileNone') }));
311
403
  console.log('');
312
404
  const w = Math.max(...rows.map((r) => r.key.length));
313
405
  for (const r of rows) {
314
406
  console.log(` ${r.key.padEnd(w)} ${r.display} ${r.origin === 'default' ? '' : `· ${r.origin}`}`.trimEnd());
315
407
  }
316
408
  console.log('');
317
- console.log(' origin: flag (this run only) · file · env · default');
409
+ console.log(t('configcmd.legend'));
318
410
  }
319
411
 
320
412
  // Poll state.json until the public URL (or an error) shows up, then print access info. cloudflare needs
@@ -334,26 +426,27 @@ async function waitAndPrint(exitWhenDone) {
334
426
  }
335
427
 
336
428
  async function printAccess(st) {
337
- if (!st) { console.log(' (no state)'); return; }
338
- if (st.error) { console.error(` ✗ ${st.error}`); return; }
429
+ if (!st) { console.log(t('access.noState')); return; }
430
+ if (st.error) { console.error(t('access.error', { msg: st.error })); return; }
339
431
  const scan = bareUrl(st.publicUrl);
340
432
  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}`);
433
+ console.log(t('access.tunnel', { tunnel: st.tunnel, pid: st.supervisorPid }));
434
+ console.log(t('access.open', { url: scan || t('access.pending') }));
435
+ if (st.tunnel === 'none' && st.lanUrl) console.log(t('access.lan', { url: bareUrl(st.lanUrl) }));
436
+ console.log(t('access.local', { url: bareUrl(st.localUrl) }));
437
+ console.log(t('access.token', { token: st.token }));
346
438
  // The QR carries the token so a phone scan signs in one-tap; the PRINTED links above stay token-free
347
439
  // (safe to screenshot/share — paste the token shown above to sign in there).
348
440
  await maybeQr(st.publicUrl ? publicUrlWithToken(st.publicUrl, st.token) : scan, st);
349
441
  if (st.publicUrl && st.tunnel !== 'none') {
350
442
  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`);
443
+ if (ok) console.log(t('access.reachable'));
444
+ else console.log(t('access.unreachable', { url: st.publicUrl }));
353
445
  }
354
446
  console.log('');
355
- console.log(` handmux status | stop`);
447
+ console.log(t('access.hint'));
356
448
  console.log('');
449
+ maybeNotifyUpdate();
357
450
  }
358
451
 
359
452
  // Best-effort QR (optional dependency). We borrow qrcode-terminal's QR *model* (vendored, dependency-free)
@@ -377,41 +470,7 @@ async function maybeQr(url, st) {
377
470
  }
378
471
 
379
472
  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
- `);
473
+ console.log(t('help.body'));
415
474
  }
416
475
 
417
476
  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