handmux 0.17.5 → 0.17.7
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 +2 -0
- package/README.zh-CN.md +2 -0
- package/bin/handmux.js +131 -26
- package/package.json +4 -4
- package/public/assets/{index-CKy8gVDA.css → index-CiaHwIWj.css} +1 -1
- package/public/assets/index-cjqG9l7v.js +330 -0
- package/public/index.html +2 -2
- package/src/cli/i18n/en.js +14 -2
- package/src/cli/i18n/zh.js +14 -2
- package/src/cli/pushCmd.js +7 -1
- package/src/cli/service.js +30 -2
- package/src/cli/state.js +41 -0
- package/src/cli/supervisor.js +4 -0
- package/src/cli/supervisorProcesses.js +57 -0
- package/src/notifications.js +16 -1
- package/src/push.js +21 -3
- package/src/routes/push.js +22 -4
- package/src/routes/transcript.js +19 -7
- package/public/assets/index-BojPm0vc.js +0 -330
package/README.md
CHANGED
|
@@ -124,6 +124,8 @@ No tunnel by default — the phone connects **straight to your own computer**, n
|
|
|
124
124
|
|
|
125
125
|
> Tunnel config, server-side reverse proxy, autostart, voice/push credentials, and port previews → see the **[docs](https://handmux.com/docs)**.
|
|
126
126
|
|
|
127
|
+
Once autostart is installed, `handmux start` / `stop` / `restart` coordinate with that same launchd/systemd service (including after an upgrade). A lifecycle lock prevents concurrent launches; `status` shows the running version and warns with PIDs if stale/duplicate supervisors exist, while `stop` reaps every copy.
|
|
128
|
+
|
|
127
129
|
## Requirements
|
|
128
130
|
|
|
129
131
|
Your computer needs **Node ≥ 18** and **tmux ≥ 3.0**; the phone just needs a browser. On **Windows**, run it inside **WSL2** (a real Linux kernel + real tmux) — see the [docs](https://handmux.com/docs#windows).
|
package/README.zh-CN.md
CHANGED
|
@@ -124,6 +124,8 @@ handmux push <title> <body> [选项]
|
|
|
124
124
|
|
|
125
125
|
> 隧道配置、服务端反向代理、开机自启、语音 / 推送凭证、端口预览等细节 → 见 **[文档](https://handmux.com/docs)**。
|
|
126
126
|
|
|
127
|
+
安装开机自启后,`handmux start` / `stop` / `restart` 会始终与同一个 launchd/systemd 服务协同(升级后也一样)。生命周期锁会阻止并发启动;`status` 显示实际运行版本,并列出未登记/重复 supervisor 的 PID;`stop` 会回收全部副本。
|
|
128
|
+
|
|
127
129
|
## 环境要求
|
|
128
130
|
|
|
129
131
|
电脑需 **Node ≥ 18** 与 **tmux ≥ 3.0**;手机只要浏览器。**Windows** 请装进 **WSL2**(真 Linux 内核 + 真 tmux)——见 [文档](https://handmux.com/docs#windows)。
|
package/bin/handmux.js
CHANGED
|
@@ -27,9 +27,10 @@ import { supervise, bareUrl, publicUrlWithToken } from '../src/cli/supervisor.js
|
|
|
27
27
|
import { resolveCloudflared } from '../src/cli/cloudflared.js';
|
|
28
28
|
import { resolveTunlite, checkSshAuth } from '../src/cli/tunlite.js';
|
|
29
29
|
import { resolveNatapp, resolveCpolar } from '../src/cli/tunnelClients.js';
|
|
30
|
-
import { installService, uninstallService } from '../src/cli/service.js';
|
|
30
|
+
import { installService, uninstallService, isServiceInstalled, stopService } from '../src/cli/service.js';
|
|
31
31
|
import { checkTmux, MIN_TMUX, tmuxInstallHint } from '../src/cli/tmuxVersion.js';
|
|
32
|
-
import { readState, clearState, isAlive, pocketHome, logPath, configPath, claudeStatePath } from '../src/cli/state.js';
|
|
32
|
+
import { readState, clearState, isAlive, acquireLifecycleLock, pocketHome, logPath, configPath, claudeStatePath } from '../src/cli/state.js';
|
|
33
|
+
import { scanSupervisorPids, terminateSupervisorPids } from '../src/cli/supervisorProcesses.js';
|
|
33
34
|
import { runSetup } from '../src/cli/setupWizard.js';
|
|
34
35
|
import { hooksStatus, installHooks, uninstallHooks } from '../src/cli/claudeHooks.js';
|
|
35
36
|
import { codexHooksStatus, installCodexHooks, uninstallCodexHooks } from '../src/cli/codexHooks.js';
|
|
@@ -136,17 +137,17 @@ async function preflightNgrok(cfg) {
|
|
|
136
137
|
|
|
137
138
|
async function main() {
|
|
138
139
|
switch (command) {
|
|
139
|
-
case 'start': return start
|
|
140
|
+
case 'start': return withLifecycleLock(start);
|
|
140
141
|
case 'open': return openCmd();
|
|
141
|
-
case 'stop':
|
|
142
|
-
case 'restart':
|
|
143
|
-
case 'status':
|
|
142
|
+
case 'stop': return withLifecycleLock(stopAndWait);
|
|
143
|
+
case 'restart': return withLifecycleLock(async () => { if (!await stopAndWait()) return; return start(); });
|
|
144
|
+
case 'status': await status(); process.exit(process.exitCode || 0);
|
|
144
145
|
case 'logs': return logs();
|
|
145
146
|
case 'push': process.exitCode = await pushCmd(); return;
|
|
146
147
|
case 'config': return configCmd();
|
|
147
148
|
case 'setup': return setupCmd();
|
|
148
149
|
case 'hooks': return hooksCmd();
|
|
149
|
-
case 'service': return serviceCmd
|
|
150
|
+
case 'service': return withLifecycleLock(serviceCmd);
|
|
150
151
|
case 'update': case 'upgrade': return updateCmd();
|
|
151
152
|
case '__supervise': return runSupervise();
|
|
152
153
|
case '__update-check': return runUpdateCheck(HOME);
|
|
@@ -155,12 +156,28 @@ async function main() {
|
|
|
155
156
|
}
|
|
156
157
|
}
|
|
157
158
|
|
|
159
|
+
async function withLifecycleLock(fn) {
|
|
160
|
+
let release;
|
|
161
|
+
try { release = acquireLifecycleLock(HOME); }
|
|
162
|
+
catch (e) {
|
|
163
|
+
console.error(e.ownerPid
|
|
164
|
+
? t('lifecycle.busy', { pid: e.ownerPid })
|
|
165
|
+
: t('err.generic', { msg: e.message }));
|
|
166
|
+
process.exitCode = 1;
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
try { return await fn(); }
|
|
170
|
+
finally { release(); }
|
|
171
|
+
}
|
|
172
|
+
|
|
158
173
|
// `handmux --version` / `-v` — print the package version (read from this package's package.json, so it
|
|
159
174
|
// stays in lockstep with what npm installed; no hardcoded string to forget to bump).
|
|
160
175
|
function version() {
|
|
161
|
-
console.log(
|
|
176
|
+
console.log(currentVersion());
|
|
162
177
|
}
|
|
163
178
|
|
|
179
|
+
function currentVersion() { return requireOpt('../package.json').version; }
|
|
180
|
+
|
|
164
181
|
// `handmux update` (alias `upgrade`) — run the plain global install for the user. We don't self-patch or
|
|
165
182
|
// restart a running instance; on success we refresh the update cache so the "upgrade available" notice
|
|
166
183
|
// clears, and remind them to `handmux restart` to actually run the new code.
|
|
@@ -203,12 +220,28 @@ async function start() {
|
|
|
203
220
|
}
|
|
204
221
|
if (!tmux.ok) console.warn(t('tmux.tooOld', { raw: tmux.raw, min: MIN_TMUX }));
|
|
205
222
|
|
|
223
|
+
// state.json cannot represent two supervisors. Refuse to add another when the real process table says
|
|
224
|
+
// the state is missing/stale or a historical duplicate already exists; `handmux stop` is the repair path.
|
|
225
|
+
const supervisors = scanSupervisorPids();
|
|
226
|
+
if (!supervisors.ok) {
|
|
227
|
+
console.error(t('lifecycle.scanFailed'));
|
|
228
|
+
process.exitCode = 1;
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
206
232
|
// Already running? `start` never disrupts a live instance on its own. If this run's config matches
|
|
207
233
|
// what's running, just reassure + reprint the address. If it DIFFERS (e.g. you changed the tunnel and
|
|
208
234
|
// re-ran `start` expecting it to apply), spell out the difference and — interactively — offer to restart
|
|
209
235
|
// into it; otherwise point at `handmux restart`. The principle stays intact: we only restart on an
|
|
210
236
|
// explicit yes.
|
|
211
237
|
const existing = readState(HOME);
|
|
238
|
+
const statePid = existing && isAlive(existing.supervisorPid) ? existing.supervisorPid : null;
|
|
239
|
+
if (supervisors.pids.length > 1 || (supervisors.pids.length === 1 && supervisors.pids[0] !== statePid)) {
|
|
240
|
+
console.error(t('start.instancesFound', { pids: supervisors.pids.join(', ') }));
|
|
241
|
+
console.error(t('start.instancesHint'));
|
|
242
|
+
process.exitCode = 1;
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
212
245
|
if (existing && isAlive(existing.supervisorPid)) {
|
|
213
246
|
const changed = configChanges(cfg, existing);
|
|
214
247
|
if (!changed.length) {
|
|
@@ -219,7 +252,8 @@ async function start() {
|
|
|
219
252
|
console.log(t('start.running.changedHead', { tunnel: existing.tunnel }));
|
|
220
253
|
for (const c of changed) console.log(t('start.running.changedRow', c));
|
|
221
254
|
if (process.stdin.isTTY && await confirm(t('start.running.switchQ'))) {
|
|
222
|
-
|
|
255
|
+
if (!await stopAndWait()) return;
|
|
256
|
+
return start();
|
|
223
257
|
}
|
|
224
258
|
console.log(t('start.running.hint'));
|
|
225
259
|
await printAccess(existing);
|
|
@@ -260,11 +294,20 @@ async function start() {
|
|
|
260
294
|
await waitAndPrint(false);
|
|
261
295
|
return;
|
|
262
296
|
}
|
|
297
|
+
// Once autostart is registered, the service manager must remain the sole owner of the supervisor.
|
|
298
|
+
// Rewriting + restarting the entry also refreshes baked config and upgrade-sensitive executable paths.
|
|
299
|
+
if (isServiceInstalled(HOME)) {
|
|
300
|
+
try { installService(supervisorArgs(cfg), { home: HOME, log: { log() {} } }); }
|
|
301
|
+
catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(1); }
|
|
302
|
+
console.log(t('start.starting', { tunnel: cfg.tunnel, port: cfg.port }));
|
|
303
|
+
await waitAndPrint(true);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
263
306
|
|
|
264
307
|
fs.mkdirSync(pocketHome(HOME), { recursive: true });
|
|
265
308
|
const out = fs.openSync(logPath(HOME), 'a');
|
|
266
|
-
const
|
|
267
|
-
const child = spawn(
|
|
309
|
+
const [supervisorBin, ...supervisorArgv] = supervisorArgs(cfg);
|
|
310
|
+
const child = spawn(supervisorBin, supervisorArgv,
|
|
268
311
|
{ detached: true, stdio: ['ignore', out, out] });
|
|
269
312
|
child.unref();
|
|
270
313
|
console.log(t('start.starting', { tunnel: cfg.tunnel, port: cfg.port }));
|
|
@@ -281,24 +324,68 @@ function reapOrphans(st) {
|
|
|
281
324
|
}
|
|
282
325
|
}
|
|
283
326
|
|
|
284
|
-
|
|
327
|
+
// A lifecycle command must not start the replacement while the previous supervisor can still own the
|
|
328
|
+
// port/tunnel. Stop every real supervisor, not only the one last writer recorded in state.json, then rescan
|
|
329
|
+
// until none remain. This both repairs historical duplicates and prevents restart from racing a straggler.
|
|
330
|
+
async function stopAndWait() {
|
|
285
331
|
const st = readState(HOME);
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
332
|
+
const initial = scanSupervisorPids();
|
|
333
|
+
if (!initial.ok) {
|
|
334
|
+
console.error(t('lifecycle.scanFailed'));
|
|
335
|
+
process.exitCode = 1;
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
const managed = isServiceInstalled(HOME);
|
|
339
|
+
if (managed) {
|
|
340
|
+
try { stopService({ home: HOME }); }
|
|
341
|
+
catch (e) { console.error(t('err.generic', { msg: e.message })); process.exitCode = 1; return false; }
|
|
342
|
+
}
|
|
343
|
+
const seen = new Set(initial.pids);
|
|
344
|
+
if (st && isAlive(st.supervisorPid)) seen.add(st.supervisorPid);
|
|
345
|
+
if (!seen.size) {
|
|
289
346
|
if (st) reapOrphans(st);
|
|
290
|
-
console.log(t('stop.notRunning'));
|
|
291
347
|
clearState(HOME);
|
|
292
|
-
|
|
348
|
+
console.log(managed ? t('stop.stoppedManaged') : t('stop.notRunning'));
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
const result = await terminateSupervisorPids([...seen]);
|
|
352
|
+
if (!result.ok) {
|
|
353
|
+
console.error(t(result.reason === 'scan' ? 'lifecycle.scanFailed' : 'stop.timeout', {
|
|
354
|
+
pids: result.remaining.join(', '),
|
|
355
|
+
}));
|
|
356
|
+
process.exitCode = 1;
|
|
357
|
+
return false;
|
|
293
358
|
}
|
|
294
|
-
|
|
295
|
-
console.log(t('stop.
|
|
359
|
+
clearState(HOME);
|
|
360
|
+
console.log(t(result.pids.length > 1 ? 'stop.stoppedMany' : 'stop.stopped', {
|
|
361
|
+
pid: result.pids[0], pids: result.pids.join(', '),
|
|
362
|
+
}));
|
|
363
|
+
return true;
|
|
296
364
|
}
|
|
297
365
|
|
|
298
366
|
async function status() {
|
|
299
367
|
const st = readState(HOME);
|
|
300
|
-
|
|
301
|
-
|
|
368
|
+
const installed = currentVersion();
|
|
369
|
+
const supervisors = scanSupervisorPids();
|
|
370
|
+
if (!supervisors.ok) console.warn(t('status.scanFailed'));
|
|
371
|
+
const stateAlive = !!(st && isAlive(st.supervisorPid));
|
|
372
|
+
if (supervisors.ok && supervisors.pids.length && !stateAlive) {
|
|
373
|
+
console.log(t('status.untracked', { version: installed, pids: supervisors.pids.join(', ') }));
|
|
374
|
+
console.log(t('status.cleanupHint'));
|
|
375
|
+
process.exitCode = 1;
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (!stateAlive) { console.log(t('status.stopped', { version: installed })); return; }
|
|
379
|
+
const running = st.version || installed;
|
|
380
|
+
console.log(t('status.running', { version: running }));
|
|
381
|
+
if (running !== installed) console.log(t('status.installed', { version: installed }));
|
|
382
|
+
if (supervisors.ok && (supervisors.pids.length > 1
|
|
383
|
+
|| (supervisors.pids.length > 0 && !supervisors.pids.includes(st.supervisorPid)))) {
|
|
384
|
+
const all = [...new Set([st.supervisorPid, ...supervisors.pids])].sort((a, b) => a - b);
|
|
385
|
+
console.warn(t('status.duplicates', { pids: all.join(', ') }));
|
|
386
|
+
console.warn(t('status.cleanupHint'));
|
|
387
|
+
process.exitCode = 1;
|
|
388
|
+
}
|
|
302
389
|
await printAccess(st);
|
|
303
390
|
}
|
|
304
391
|
|
|
@@ -356,13 +443,29 @@ async function serviceInstall() {
|
|
|
356
443
|
try { await preflightNgrok(cfg); }
|
|
357
444
|
catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(1); }
|
|
358
445
|
}
|
|
359
|
-
|
|
360
|
-
|
|
446
|
+
// Converge a pre-existing manual run (and any old broken managed/manual pair) before loading the service.
|
|
447
|
+
// Otherwise the very first `service install` can itself create two supervisors fighting over one port.
|
|
448
|
+
const existing = readState(HOME);
|
|
449
|
+
const supervisors = scanSupervisorPids();
|
|
450
|
+
if (!supervisors.ok) {
|
|
451
|
+
console.error(t('lifecycle.scanFailed'));
|
|
452
|
+
process.exitCode = 1;
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (isServiceInstalled(HOME) || supervisors.pids.length || (existing && isAlive(existing.supervisorPid))) {
|
|
456
|
+
if (!await stopAndWait()) return;
|
|
457
|
+
}
|
|
458
|
+
const args = supervisorArgs(cfg);
|
|
361
459
|
try { installService(args, { home: HOME }); }
|
|
362
460
|
catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(1); }
|
|
363
461
|
console.log(t('service.installed'));
|
|
364
462
|
}
|
|
365
463
|
|
|
464
|
+
function supervisorArgs(cfg) {
|
|
465
|
+
const payload = Buffer.from(JSON.stringify(cfg)).toString('base64');
|
|
466
|
+
return [process.execPath, SELF, '__supervise', '--payload', payload];
|
|
467
|
+
}
|
|
468
|
+
|
|
366
469
|
async function setupCmd() {
|
|
367
470
|
const target = flags.config ? path.resolve(flags.config) : configPath(HOME);
|
|
368
471
|
// Is an instance already up? Then the run-action reads "Save & restart" and applying means a real restart
|
|
@@ -381,9 +484,11 @@ async function setupCmd() {
|
|
|
381
484
|
}
|
|
382
485
|
await maybeOfferStatusLine();
|
|
383
486
|
if (doStart) {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
487
|
+
return withLifecycleLock(async () => {
|
|
488
|
+
Object.assign(flags, cfg);
|
|
489
|
+
if (running && !await stopAndWait()) return; // restart into the new config
|
|
490
|
+
return start();
|
|
491
|
+
});
|
|
387
492
|
}
|
|
388
493
|
console.log(t(running ? 'setup.laterRestart' : 'setup.later'));
|
|
389
494
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handmux",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.7",
|
|
4
4
|
"description": "A mobile vibe-coding cockpit — built on tmux: drive your live session, Claude Code / Codex — anything a terminal can run — from your phone.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "AGPL-3.0-only",
|
|
@@ -57,13 +57,13 @@
|
|
|
57
57
|
"vitest": "^2.0.0"
|
|
58
58
|
},
|
|
59
59
|
"whatsNew": [
|
|
60
|
+
{"version":"0.17.7","date":"2026-07-20","zh":"单实例启动兜底 · WSL 状态命令修复","en":"Single-instance startup guard · WSL status fix"},
|
|
61
|
+
{"version":"0.17.6","date":"2026-07-20","zh":"推送结果可追踪 · 对话与权限操作更可靠","en":"Traceable push results · safer chats and permissions"},
|
|
60
62
|
{"version":"0.17.5","date":"2026-07-20","zh":"通知开启修复 · 长会话与设置可靠性提升","en":"Notification setup fixed · stronger chat and settings reliability"},
|
|
61
63
|
{"version":"0.17.4","date":"2026-07-19","zh":"HTTPS 本机预览修复 · 通知链接安全加固","en":"HTTPS localhost previews fixed · safer notification links"},
|
|
62
64
|
{"version":"0.17.3","date":"2026-07-19","zh":"三修官方一键安装的 Claude 识别,真机已验证","en":"Third fix for native-install Claude detection, device-verified"},
|
|
63
65
|
{"version":"0.17.0","date":"2026-07-18","zh":"对话视图(实验性)· 通知记录 · 本机地址一键预览","en":"Chat view (experimental) · notification inbox · one-tap localhost preview"},
|
|
64
66
|
{"version":"0.16.0","date":"2026-07-15","zh":"分屏布局地图 · 手机也能建/删窗格","en":"Split-layout map · create & close panes from the phone"},
|
|
65
|
-
{"version":"0.15.0","date":"2026-07-14","zh":"键盘弹起终端自适应 · 全屏程序可上下滚动","en":"Keyboard-aware terminal · scroll inside full-screen apps"}
|
|
66
|
-
{"version":"0.14.0","date":"2026-07-13","zh":"终端文字选中拷贝 · 脚本复用推送通道","en":"Terminal select & copy · scripts reuse the push channel"},
|
|
67
|
-
{"version":"0.13.0","date":"2026-07-12","zh":"终端文件路径高亮可点","en":"Tappable, highlighted file paths"}
|
|
67
|
+
{"version":"0.15.0","date":"2026-07-14","zh":"键盘弹起终端自适应 · 全屏程序可上下滚动","en":"Keyboard-aware terminal · scroll inside full-screen apps"}
|
|
68
68
|
]
|
|
69
69
|
}
|