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 +18 -4
- package/README.zh-CN.md +16 -4
- package/bin/handmux.js +176 -117
- package/hooks/handmux-notify.sh +4 -2
- package/hooks/handmux-statusline.cjs +75 -0
- package/hooks/handmux-write.cjs +17 -24
- package/package.json +1 -2
- package/public/assets/index-CCvTPJwI.js +157 -0
- package/public/assets/{index-CSX2d9C7.css → index-CYEn5HNU.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 +27 -69
- 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 +62 -48
- package/src/cli/statusLine.js +79 -0
- package/src/cli/updateCheck.js +90 -0
- package/src/httpApi.js +55 -5
- package/src/orphans.js +138 -0
- package/src/usage.js +94 -0
- package/public/assets/index-CHabGCEm.js +0 -157
- package/src/cli/tmuxConf.js +0 -90
- package/tmux/README.md +0 -77
- package/tmux/claude-tab-seed.py +0 -67
- package/tmux/claude-tab-seen.sh +0 -14
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 {
|
|
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(
|
|
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(
|
|
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 || '
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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('
|
|
129
|
-
console.error('
|
|
130
|
-
console.error('
|
|
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(
|
|
133
|
-
console.error('
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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('
|
|
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(
|
|
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(
|
|
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(
|
|
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('
|
|
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(
|
|
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('
|
|
190
|
-
console.log('
|
|
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(
|
|
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(
|
|
283
|
+
catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(1); }
|
|
216
284
|
return;
|
|
217
285
|
}
|
|
218
|
-
console.error('usage
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
243
|
-
console.log(
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
258
|
-
|
|
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
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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('
|
|
272
|
-
|
|
273
|
-
console.log(
|
|
274
|
-
console.log('
|
|
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
|
-
//
|
|
279
|
-
//
|
|
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('
|
|
375
|
+
if (hooksStatus(HOME) === 'no-claude' && codexHooksStatus(HOME) === 'no-codex') {
|
|
376
|
+
console.log(t('hooks.noAgents'));
|
|
285
377
|
return;
|
|
286
378
|
}
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
385
|
+
uninstallCodexHooks(HOME);
|
|
386
|
+
uninstallStatusLine(HOME);
|
|
387
|
+
console.log(t('hooks.removed'));
|
|
296
388
|
return;
|
|
297
389
|
}
|
|
298
|
-
console.error('usage
|
|
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(
|
|
310
|
-
console.log(
|
|
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(
|
|
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(
|
|
338
|
-
if (st.error) { console.error(
|
|
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(
|
|
342
|
-
console.log(
|
|
343
|
-
if (st.tunnel === 'none' && st.lanUrl) console.log(
|
|
344
|
-
console.log(
|
|
345
|
-
console.log(
|
|
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('
|
|
352
|
-
else console.log(
|
|
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(
|
|
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(
|
|
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();
|
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
|