handmux 0.22.1 → 0.24.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 +3 -3
- package/README.zh-CN.md +3 -3
- package/bin/handmux.js +3 -688
- package/dist/bin/handmux.js +850 -0
- package/{hooks → dist/hooks}/handmux-notify.sh +2 -4
- package/{hooks → dist/hooks}/handmux-statusline.cjs +28 -6
- package/{hooks → dist/hooks}/handmux-write.cjs +25 -26
- package/dist/package.json +126 -0
- package/dist/public/assets/index-CicdY9E9.css +32 -0
- package/dist/public/assets/index-D4VP5t8h.js +337 -0
- package/dist/public/assets/pcm-worklet-CxcDy7PB.js +1 -0
- package/{public → dist/public}/index.html +5 -4
- package/{src → dist/src}/agents/claude.js +106 -82
- package/dist/src/agents/codex.js +217 -0
- package/{src → dist/src}/agents/index.js +9 -10
- package/dist/src/agents/processIdentity.js +80 -0
- package/dist/src/agents/scanUtils.js +262 -0
- package/dist/src/apiErrors.js +77 -0
- package/{src → dist/src}/appName.js +12 -13
- package/dist/src/asr/iflyConfig.js +7 -0
- package/dist/src/asr/iflySign.js +14 -0
- package/dist/src/auth.js +27 -0
- package/dist/src/browser/bootstrap.js +72 -0
- package/dist/src/browser/cookieProfiles.js +413 -0
- package/dist/src/browser/coordinator.js +51 -0
- package/dist/src/browser/hammerheadCompat.js +104 -0
- package/dist/src/browser/hammerheadRedirectCompat.js +37 -0
- package/dist/src/browser/manager.js +560 -0
- package/dist/src/browser/originLabel.js +15 -0
- package/dist/src/browser/profilePersistence.js +235 -0
- package/dist/src/browser/publicProxy.js +176 -0
- package/dist/src/browser/routes.js +159 -0
- package/dist/src/browser/sessionStore.js +72 -0
- package/dist/src/browser/siteVersion.js +121 -0
- package/dist/src/browser/targetPolicy.js +163 -0
- package/{src → dist/src}/browser/worker.js +14 -18
- package/dist/src/browser/workerClient.js +286 -0
- package/dist/src/browser/workerServer.js +91 -0
- package/dist/src/captureBackground.js +95 -0
- package/dist/src/claudeEvents.js +499 -0
- package/{src → dist/src}/cli/claudeHooks.js +146 -108
- package/{src → dist/src}/cli/cloudflareUrl.js +2 -3
- package/dist/src/cli/cloudflared.js +106 -0
- package/dist/src/cli/codexManaged.js +77 -0
- package/{src → dist/src}/cli/cpolarUrl.js +8 -9
- package/dist/src/cli/drivers.js +98 -0
- package/dist/src/cli/hookScaffold.js +36 -0
- package/dist/src/cli/i18n/en.js +366 -0
- package/{src → dist/src}/cli/i18n/index.js +27 -25
- package/dist/src/cli/i18n/zh.js +365 -0
- package/dist/src/cli/legacyCodexHooks.js +52 -0
- package/{src → dist/src}/cli/natappUrl.js +2 -3
- package/dist/src/cli/options.js +245 -0
- package/dist/src/cli/probe.js +16 -0
- package/{src → dist/src}/cli/prompt.js +4 -6
- package/dist/src/cli/pushCmd.js +102 -0
- package/{src → dist/src}/cli/qr.js +19 -19
- package/dist/src/cli/service.js +144 -0
- package/dist/src/cli/setupModel.js +194 -0
- package/dist/src/cli/setupWizard.js +647 -0
- package/dist/src/cli/shortcutEditor.js +351 -0
- package/dist/src/cli/sshTunnel.js +18 -0
- package/dist/src/cli/state.js +114 -0
- package/{src → dist/src}/cli/statusLine.js +63 -46
- package/dist/src/cli/supervisor.js +278 -0
- package/dist/src/cli/supervisorHealth.js +21 -0
- package/dist/src/cli/supervisorLaunch.js +148 -0
- package/dist/src/cli/supervisorProcesses.js +62 -0
- package/dist/src/cli/supervisorState.js +62 -0
- package/dist/src/cli/tmuxVersion.js +53 -0
- package/{src → dist/src}/cli/tunlite.js +7 -10
- package/dist/src/cli/tunnelClients.js +72 -0
- package/{src → dist/src}/cli/updateCheck.js +98 -69
- package/{src → dist/src}/cli/urlHost.js +8 -4
- package/dist/src/cli/workspaceCmd.js +347 -0
- package/dist/src/codexAppServer.js +2299 -0
- package/dist/src/codexConversationProjection.js +199 -0
- package/dist/src/codexDiff.js +118 -0
- package/dist/src/codexMessageIdentity.js +18 -0
- package/dist/src/codexPlan.js +30 -0
- package/dist/src/codexQueueProtocol.js +96 -0
- package/dist/src/codexStreamProtocol.js +207 -0
- package/dist/src/codexToolProtocol.js +68 -0
- package/dist/src/codexTranscriptParse.js +724 -0
- package/dist/src/codexUsageSnapshot.js +205 -0
- package/dist/src/config.js +8 -0
- package/{src → dist/src}/docPath.js +22 -24
- package/dist/src/docs.js +337 -0
- package/dist/src/git.js +272 -0
- package/dist/src/healthProtocol.js +97 -0
- package/dist/src/httpApi.js +68 -0
- package/dist/src/jsonStore.js +15 -0
- package/{src → dist/src}/keyNames.js +3 -3
- package/dist/src/notifications.js +111 -0
- package/dist/src/orphans.js +157 -0
- package/dist/src/pendingPrompt.js +194 -0
- package/dist/src/previewServer.js +202 -0
- package/dist/src/previews.js +179 -0
- package/dist/src/privateStateStore.js +89 -0
- package/dist/src/push.js +224 -0
- package/dist/src/routes/codex.js +606 -0
- package/dist/src/routes/files.js +230 -0
- package/dist/src/routes/git.js +78 -0
- package/dist/src/routes/health.js +16 -0
- package/dist/src/routes/notifications.js +16 -0
- package/dist/src/routes/previews.js +43 -0
- package/dist/src/routes/push.js +145 -0
- package/dist/src/routes/sessions.js +232 -0
- package/dist/src/routes/system.js +156 -0
- package/dist/src/routes/terminal.js +238 -0
- package/dist/src/routes/transcript.js +197 -0
- package/dist/src/routes/workspace.js +267 -0
- package/{src → dist/src}/server.js +110 -76
- package/dist/src/shortcutConfig.js +83 -0
- package/{src → dist/src}/staticCache.js +5 -3
- package/{src → dist/src}/staticCompression.js +5 -6
- package/dist/src/terminalStream.js +441 -0
- package/{src → dist/src}/tmux/commands.js +160 -167
- package/dist/src/tmux/format.js +47 -0
- package/dist/src/transcriptParse.js +240 -0
- package/dist/src/transcriptReader.js +113 -0
- package/{src → dist/src}/trimCapture.js +11 -11
- package/dist/src/uploadTypes.js +29 -0
- package/dist/src/urlPolicy.js +18 -0
- package/dist/src/usage.js +260 -0
- package/dist/src/workspace/agentRunner.js +204 -0
- package/dist/src/workspace/atomicJson.js +31 -0
- package/dist/src/workspace/capture.js +139 -0
- package/dist/src/workspace/checkpointer.js +228 -0
- package/dist/src/workspace/environment.js +71 -0
- package/dist/src/workspace/lock.js +137 -0
- package/dist/src/workspace/mapping.js +128 -0
- package/dist/src/workspace/operations.js +271 -0
- package/dist/src/workspace/paths.js +15 -0
- package/dist/src/workspace/planner.js +292 -0
- package/dist/src/workspace/restore.js +314 -0
- package/dist/src/workspace/runtime.js +318 -0
- package/dist/src/workspace/schema.js +156 -0
- package/dist/src/workspace/store.js +464 -0
- package/dist/src/workspace/tmuxAdapter.js +694 -0
- package/dist/src/workspaceProtocol.js +158 -0
- package/package.json +28 -16
- package/hooks/handmux-codex-usage.cjs +0 -141
- package/public/assets/index-ClOByNCn.js +0 -335
- package/public/assets/index-rjrtUwoJ.css +0 -32
- package/src/.gitkeep +0 -0
- package/src/agents/codex.js +0 -121
- package/src/agents/processIdentity.js +0 -72
- package/src/agents/scanUtils.js +0 -225
- package/src/asr/iflyConfig.js +0 -10
- package/src/asr/iflySign.js +0 -16
- package/src/auth.js +0 -30
- package/src/browser/bootstrap.js +0 -68
- package/src/browser/cookieProfiles.js +0 -403
- package/src/browser/coordinator.js +0 -47
- package/src/browser/hammerheadCompat.js +0 -86
- package/src/browser/hammerheadRedirectCompat.js +0 -38
- package/src/browser/manager.js +0 -556
- package/src/browser/originLabel.js +0 -17
- package/src/browser/profilePersistence.js +0 -241
- package/src/browser/publicProxy.js +0 -175
- package/src/browser/routes.js +0 -154
- package/src/browser/sessionStore.js +0 -78
- package/src/browser/siteVersion.js +0 -124
- package/src/browser/targetPolicy.js +0 -137
- package/src/browser/workerClient.js +0 -253
- package/src/browser/workerServer.js +0 -98
- package/src/captureBackground.js +0 -93
- package/src/claudeEvents.js +0 -356
- package/src/cli/cloudflared.js +0 -99
- package/src/cli/codexHooks.js +0 -123
- package/src/cli/drivers.js +0 -97
- package/src/cli/hookScaffold.js +0 -35
- package/src/cli/i18n/en.js +0 -388
- package/src/cli/i18n/zh.js +0 -387
- package/src/cli/options.js +0 -211
- package/src/cli/probe.js +0 -16
- package/src/cli/pushCmd.js +0 -60
- package/src/cli/service.js +0 -126
- package/src/cli/setupModel.js +0 -154
- package/src/cli/setupWizard.js +0 -506
- package/src/cli/shortcutEditor.js +0 -288
- package/src/cli/sshTunnel.js +0 -12
- package/src/cli/state.js +0 -90
- package/src/cli/supervisor.js +0 -183
- package/src/cli/supervisorProcesses.js +0 -57
- package/src/cli/tmuxVersion.js +0 -49
- package/src/cli/tunnelClients.js +0 -62
- package/src/cli/workspaceCmd.js +0 -300
- package/src/config.js +0 -9
- package/src/docs.js +0 -228
- package/src/git.js +0 -208
- package/src/httpApi.js +0 -69
- package/src/jsonStore.js +0 -24
- package/src/notifications.js +0 -68
- package/src/orphans.js +0 -150
- package/src/pendingPrompt.js +0 -163
- package/src/previewServer.js +0 -168
- package/src/previews.js +0 -116
- package/src/push.js +0 -183
- package/src/routes/files.js +0 -172
- package/src/routes/git.js +0 -57
- package/src/routes/notifications.js +0 -19
- package/src/routes/previews.js +0 -35
- package/src/routes/push.js +0 -119
- package/src/routes/sessions.js +0 -163
- package/src/routes/system.js +0 -145
- package/src/routes/terminal.js +0 -210
- package/src/routes/transcript.js +0 -115
- package/src/routes/workspace.js +0 -267
- package/src/shortcutConfig.js +0 -75
- package/src/terminalStream.js +0 -391
- package/src/tmux/format.js +0 -45
- package/src/transcriptParse.js +0 -180
- package/src/transcriptReader.js +0 -101
- package/src/uploadTypes.js +0 -30
- package/src/urlPolicy.js +0 -14
- package/src/usage.js +0 -261
- package/src/workspace/agentRunner.js +0 -185
- package/src/workspace/atomicJson.js +0 -32
- package/src/workspace/capture.js +0 -87
- package/src/workspace/checkpointer.js +0 -183
- package/src/workspace/environment.js +0 -75
- package/src/workspace/lock.js +0 -128
- package/src/workspace/mapping.js +0 -124
- package/src/workspace/operations.js +0 -252
- package/src/workspace/paths.js +0 -16
- package/src/workspace/planner.js +0 -247
- package/src/workspace/restore.js +0 -279
- package/src/workspace/runtime.js +0 -314
- package/src/workspace/schema.js +0 -136
- package/src/workspace/store.js +0 -382
- package/src/workspace/tmuxAdapter.js +0 -554
- /package/{public → dist/public}/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
- /package/{public → dist/public}/fonts/TWUnifont.woff2 +0 -0
- /package/{public → dist/public}/icons/apple-touch-icon.png +0 -0
- /package/{public → dist/public}/icons/badge-96.png +0 -0
- /package/{public → dist/public}/icons/icon-192-maskable.png +0 -0
- /package/{public → dist/public}/icons/icon-192.png +0 -0
- /package/{public → dist/public}/icons/icon-512-maskable.png +0 -0
- /package/{public → dist/public}/icons/icon-512.png +0 -0
- /package/{public → dist/public}/icons/logo.svg +0 -0
- /package/{public → dist/public}/manifest.webmanifest +0 -0
- /package/{public → dist/public}/offline.html +0 -0
- /package/{public → dist/public}/sw.js +0 -0
- /package/{src → dist/src}/browser/protocol.js +0 -0
- /package/{src → dist/src}/cli/cfNamed.js +0 -0
package/bin/handmux.js
CHANGED
|
@@ -1,689 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
// handmux start — just run it. No config needed: defaults to `none` (LAN-only), auto-generates a
|
|
6
|
-
// token, prints the (token-free) URL + a QR of it, and the token on its own line. Flags
|
|
7
|
-
// let you try variations for one run (e.g. --tunnel cloudflare).
|
|
8
|
-
// handmux setup — the one place to configure persistently. Interactive; writes ~/.handmux/config.json
|
|
9
|
-
// (name, tunnel, push, voice). Re-run it to change anything.
|
|
10
|
-
// `start` reads that file; with no file it uses defaults. Precedence: flag > file > env (HANDMUX_*) >
|
|
11
|
-
// default — a flag overrides one value for one run and never persists. Advanced: `--config PATH` (a different file, for
|
|
12
|
-
// dev / multiple configs), `handmux config` (show what's in effect and where each value came from).
|
|
13
|
-
//
|
|
14
|
-
// Tunnels: `none` (LAN only, nothing exposed) · `cloudflare` (instant random https URL) ·
|
|
15
|
-
// `cloudflare-named` (stable URL on your own Cloudflare domain) · `ssh` (reverse-forward to your own
|
|
16
|
-
// server via `tunlite run`). `handmux setup` wires any of these up interactively.
|
|
17
|
-
import fs from 'node:fs';
|
|
18
|
-
import path from 'node:path';
|
|
19
|
-
import { spawn, spawnSync } from 'node:child_process';
|
|
20
|
-
import { createInterface } from 'node:readline';
|
|
21
|
-
import { fileURLToPath } from 'node:url';
|
|
22
|
-
import { homedir } from 'node:os';
|
|
23
|
-
import { createRequire } from 'node:module';
|
|
24
|
-
import { parseArgs, resolveConfig, explainConfig } from '../src/cli/options.js';
|
|
25
|
-
import { renderCompactQr } from '../src/cli/qr.js';
|
|
26
|
-
import { supervise, bareUrl, publicUrlWithToken } from '../src/cli/supervisor.js';
|
|
27
|
-
import { resolveCloudflared } from '../src/cli/cloudflared.js';
|
|
28
|
-
import { resolveTunlite, checkSshAuth } from '../src/cli/tunlite.js';
|
|
29
|
-
import { resolveNatapp, resolveCpolar } from '../src/cli/tunnelClients.js';
|
|
30
|
-
import { installService, uninstallService, isServiceInstalled, stopService } from '../src/cli/service.js';
|
|
31
|
-
import { checkTmux, MIN_TMUX, tmuxInstallHint } from '../src/cli/tmuxVersion.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';
|
|
34
|
-
import { runSetup } from '../src/cli/setupWizard.js';
|
|
35
|
-
import { commitShortcuts, reportShortcutCommit, runShortcutEditor } from '../src/cli/shortcutEditor.js';
|
|
36
|
-
import { hooksStatus, installHooks, uninstallHooks } from '../src/cli/claudeHooks.js';
|
|
37
|
-
import { codexHooksStatus, installCodexHooks, uninstallCodexHooks } from '../src/cli/codexHooks.js';
|
|
38
|
-
import { statusLineStatus, installStatusLine, uninstallStatusLine, composeHint, refreshStatusLineScript } from '../src/cli/statusLine.js';
|
|
39
|
-
import { claudeUsagePath } from '../src/usage.js';
|
|
40
|
-
import { probe } from '../src/cli/probe.js';
|
|
41
|
-
import { notifyUpdate, runUpdateCheck, isBrewInstall, PKG_NAME } from '../src/cli/updateCheck.js';
|
|
42
|
-
import { t, initLocale, setLocale } from '../src/cli/i18n/index.js';
|
|
43
|
-
import { runPush } from '../src/cli/pushCmd.js';
|
|
44
|
-
import { runWorkspaceCommand } from '../src/cli/workspaceCmd.js';
|
|
45
|
-
|
|
46
|
-
const HOME = homedir();
|
|
47
|
-
const SELF = fileURLToPath(import.meta.url);
|
|
48
|
-
// Symlink-resolved entry path — a brew (tap) install is symlinked from the prefix's bin into the Cellar,
|
|
49
|
-
// so isBrewInstall() needs the real target, not the symlink, to spot the `/Cellar/` segment.
|
|
50
|
-
const SELF_REAL = (() => { try { return fs.realpathSync(SELF); } catch { return SELF; } })();
|
|
51
|
-
const HOOKS_SRC = path.resolve(path.dirname(SELF), '../hooks'); // server/hooks (bundled scripts)
|
|
52
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
53
|
-
|
|
54
|
-
const { command, flags, positionals = [], unknownShortFlags = [] } = parseArgs(process.argv.slice(2));
|
|
55
|
-
|
|
56
|
-
// Resolve the CLI language ONCE, up front, so every command (help, errors, access block) prints in it.
|
|
57
|
-
// Priority: --lang > config `lang` > shell locale (LANG/LC_*) > English. The config peek is lenient — a
|
|
58
|
-
// missing/broken file just means "no language hint here"; the real validation happens per-command later.
|
|
59
|
-
function peekConfigLang() {
|
|
60
|
-
try {
|
|
61
|
-
const p = flags.config ? path.resolve(flags.config) : configPath(HOME);
|
|
62
|
-
return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : {};
|
|
63
|
-
} catch { return {}; }
|
|
64
|
-
}
|
|
65
|
-
initLocale(flags, peekConfigLang(), process.env);
|
|
66
|
-
|
|
67
|
-
// There is ONE config file location: ~/.handmux/config.json (written by `handmux setup`). `--config PATH`
|
|
68
|
-
// points elsewhere — that's the only escape, and it covers dev/multi-config without any cwd magic (a
|
|
69
|
-
// stray ./config.json never gets picked up silently). No file merging or inheritance: at most one file is
|
|
70
|
-
// read. Flags (applied later in resolveConfig) override individual settings from it for that one run and
|
|
71
|
-
// never persist. Returns { path, cfg } — path is the file used (or null), for the startup print so it's
|
|
72
|
-
// never ambiguous what a run loaded.
|
|
73
|
-
function resolveFileConfig() {
|
|
74
|
-
let p = null;
|
|
75
|
-
if (flags.config) { // explicit: must exist
|
|
76
|
-
p = path.resolve(flags.config);
|
|
77
|
-
if (!fs.existsSync(p)) { console.error(t('err.configNotFound', { path: p })); process.exit(2); }
|
|
78
|
-
} else {
|
|
79
|
-
const homeP = configPath(HOME);
|
|
80
|
-
if (fs.existsSync(homeP)) p = homeP;
|
|
81
|
-
}
|
|
82
|
-
if (!p) return { path: null, cfg: {} };
|
|
83
|
-
try { return { path: p, cfg: JSON.parse(fs.readFileSync(p, 'utf8')) }; }
|
|
84
|
-
catch (e) { console.error(t('err.badConfig', { path: p, msg: e.message })); process.exit(2); }
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Human-readable summary of which config file a run loaded.
|
|
88
|
-
function describeConfig(p) {
|
|
89
|
-
return p || t('config.none');
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// Which user-visible settings THIS run would use differ from what's already running (from state.json)?
|
|
93
|
-
// Kept to the two people actually re-run `start` to change — the tunnel and the port; each row is ready to
|
|
94
|
-
// drop straight into the `start.running.changedRow` message ({key, from, to}). Only compares fields the
|
|
95
|
-
// running state actually recorded, so an older state.json can't manufacture phantom diffs.
|
|
96
|
-
function configChanges(cfg, st) {
|
|
97
|
-
const out = [];
|
|
98
|
-
for (const key of ['tunnel', 'port']) {
|
|
99
|
-
const running = st[key];
|
|
100
|
-
if (running != null && String(cfg[key]) !== String(running)) out.push({ key, from: running, to: cfg[key] });
|
|
101
|
-
}
|
|
102
|
-
return out;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// 一次性 [Y/n] 提问(默认 Yes)。非 TTY 直接返回 false,绝不卡住。
|
|
106
|
-
async function confirm(question) {
|
|
107
|
-
if (!process.stdin.isTTY) return false;
|
|
108
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
109
|
-
try {
|
|
110
|
-
const a = (await new Promise((res) => rl.question(`${question} [Y/n] `, res))).trim().toLowerCase();
|
|
111
|
-
return a === '' || a === 'y' || a === 'yes';
|
|
112
|
-
} finally { rl.close(); }
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// ssh 隧道预检:解析 tunlite → 免密就绪?有 TTY 就内嵌 setup-key(输一次密码)再复检,无 TTY 快速失败。
|
|
116
|
-
async function preflightSsh(cfg) {
|
|
117
|
-
cfg.tunliteBin = resolveTunlite(); // 抛出 → 调用方打印并退出
|
|
118
|
-
if (checkSshAuth(cfg.sshHost, { bin: cfg.tunliteBin }) === 0) return;
|
|
119
|
-
if (process.stdin.isTTY && await confirm(t('ssh.confirmSetup', { host: cfg.sshHost }))) {
|
|
120
|
-
spawnSync(cfg.tunliteBin, ['setup-key', cfg.sshHost], { stdio: 'inherit' });
|
|
121
|
-
if (checkSshAuth(cfg.sshHost, { bin: cfg.tunliteBin }) === 0) return;
|
|
122
|
-
}
|
|
123
|
-
throw new Error(t('ssh.notSetup', { bin: cfg.tunliteBin, host: cfg.sshHost }));
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// natapp/cpolar preflight: resolve the client binary (cpolar auto-downloads; natapp must be pre-installed),
|
|
127
|
-
// and for cpolar seed the authtoken into its own config so the detached `cpolar http` authenticates. Throws
|
|
128
|
-
// a friendly message the caller prints.
|
|
129
|
-
async function preflightNgrok(cfg) {
|
|
130
|
-
if (cfg.tunnel === 'natapp') {
|
|
131
|
-
cfg.natappBin = resolveNatapp(HOME);
|
|
132
|
-
} else {
|
|
133
|
-
cfg.cpolarBin = await resolveCpolar(HOME);
|
|
134
|
-
if (spawnSync(cfg.cpolarBin, ['authtoken', cfg.authtoken], { stdio: 'ignore' }).status !== 0) {
|
|
135
|
-
throw new Error(t('client.cpolarAuthFail'));
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
async function main() {
|
|
141
|
-
switch (command) {
|
|
142
|
-
case 'start': return withLifecycleLock(start);
|
|
143
|
-
case 'open': return openCmd();
|
|
144
|
-
case 'stop': return withLifecycleLock(stopAndWait);
|
|
145
|
-
case 'restart': return withLifecycleLock(async () => { if (!await stopAndWait()) return; return start(); });
|
|
146
|
-
case 'status': await status(); process.exit(process.exitCode || 0);
|
|
147
|
-
case 'logs': return logs();
|
|
148
|
-
case 'push': process.exitCode = await pushCmd(); return;
|
|
149
|
-
case 'restore': process.exitCode = await runWorkspaceCommand({ flags, positionals, unknownShortFlags, home: HOME }); return;
|
|
150
|
-
case 'config': return configCmd();
|
|
151
|
-
case 'setup': return setupCmd();
|
|
152
|
-
case 'shortcuts': return shortcutsCmd();
|
|
153
|
-
case 'hooks': return hooksCmd();
|
|
154
|
-
case 'service': return withLifecycleLock(serviceCmd);
|
|
155
|
-
case 'update': case 'upgrade': return updateCmd();
|
|
156
|
-
case '__supervise': return runSupervise();
|
|
157
|
-
case '__update-check': return runUpdateCheck(HOME);
|
|
158
|
-
case 'version': case '--version': case '-v': return version();
|
|
159
|
-
default: return help();
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
async function withLifecycleLock(fn) {
|
|
164
|
-
let release;
|
|
165
|
-
try { release = acquireLifecycleLock(HOME); }
|
|
166
|
-
catch (e) {
|
|
167
|
-
console.error(e.ownerPid
|
|
168
|
-
? t('lifecycle.busy', { pid: e.ownerPid })
|
|
169
|
-
: t('err.generic', { msg: e.message }));
|
|
170
|
-
process.exitCode = 1;
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
try { return await fn(); }
|
|
174
|
-
finally { release(); }
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// `handmux --version` / `-v` — print the package version (read from this package's package.json, so it
|
|
178
|
-
// stays in lockstep with what npm installed; no hardcoded string to forget to bump).
|
|
179
|
-
function version() {
|
|
180
|
-
console.log(currentVersion());
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function currentVersion() { return requireOpt('../package.json').version; }
|
|
184
|
-
|
|
185
|
-
// `handmux update` (alias `upgrade`) — run the plain global install for the user. We don't self-patch or
|
|
186
|
-
// restart a running instance; on success we refresh the update cache so the "upgrade available" notice
|
|
187
|
-
// clears, and remind them to `handmux restart` to actually run the new code.
|
|
188
|
-
function updateCmd() {
|
|
189
|
-
if (isBrewInstall(SELF_REAL)) { console.log(t('update.brew')); return; }
|
|
190
|
-
console.log(t('update.running'));
|
|
191
|
-
const r = spawnSync('npm', ['install', '-g', `${PKG_NAME}@latest`], { stdio: 'inherit' });
|
|
192
|
-
if (r.status === 0) {
|
|
193
|
-
runUpdateCheck(HOME);
|
|
194
|
-
console.log(t('update.done'));
|
|
195
|
-
console.log(t('update.restartHint'));
|
|
196
|
-
} else {
|
|
197
|
-
console.log(t('update.failed', { pkg: PKG_NAME }));
|
|
198
|
-
process.exitCode = 1;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// Best-effort upgrade notice from the cached "latest version" (never blocks; refreshes in the background).
|
|
203
|
-
function maybeNotifyUpdate() {
|
|
204
|
-
notifyUpdate(HOME, { version: requireOpt('../package.json').version, selfPath: SELF_REAL });
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
async function start() {
|
|
208
|
-
const { path: cfgPath, cfg: fileCfg } = resolveFileConfig();
|
|
209
|
-
console.log(t('config.loaded', { path: describeConfig(cfgPath) }));
|
|
210
|
-
let cfg;
|
|
211
|
-
try { cfg = resolveConfig(flags, fileCfg); }
|
|
212
|
-
catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(2); }
|
|
213
|
-
|
|
214
|
-
// tmux is the whole point — absent is fatal; an untested-old version only warns (rendering may drift).
|
|
215
|
-
const tmux = checkTmux();
|
|
216
|
-
if (!tmux.present) {
|
|
217
|
-
console.error(t('tmux.notFound'));
|
|
218
|
-
console.error(t('tmux.explain1'));
|
|
219
|
-
console.error(t('tmux.explain2'));
|
|
220
|
-
console.error('');
|
|
221
|
-
console.error(t('tmux.install', { hint: tmuxInstallHint() }));
|
|
222
|
-
console.error(t('tmux.thenStart'));
|
|
223
|
-
process.exit(1);
|
|
224
|
-
}
|
|
225
|
-
if (!tmux.ok) console.warn(t('tmux.tooOld', { raw: tmux.raw, min: MIN_TMUX }));
|
|
226
|
-
|
|
227
|
-
// state.json cannot represent two supervisors. Refuse to add another when the real process table says
|
|
228
|
-
// the state is missing/stale or a historical duplicate already exists; `handmux stop` is the repair path.
|
|
229
|
-
const supervisors = scanSupervisorPids();
|
|
230
|
-
if (!supervisors.ok) {
|
|
231
|
-
console.error(t('lifecycle.scanFailed'));
|
|
232
|
-
process.exitCode = 1;
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// Already running? `start` never disrupts a live instance on its own. If this run's config matches
|
|
237
|
-
// what's running, just reassure + reprint the address. If it DIFFERS (e.g. you changed the tunnel and
|
|
238
|
-
// re-ran `start` expecting it to apply), spell out the difference and — interactively — offer to restart
|
|
239
|
-
// into it; otherwise point at `handmux restart`. The principle stays intact: we only restart on an
|
|
240
|
-
// explicit yes.
|
|
241
|
-
const existing = readState(HOME);
|
|
242
|
-
const statePid = existing && isAlive(existing.supervisorPid) ? existing.supervisorPid : null;
|
|
243
|
-
if (supervisors.pids.length > 1 || (supervisors.pids.length === 1 && supervisors.pids[0] !== statePid)) {
|
|
244
|
-
console.error(t('start.instancesFound', { pids: supervisors.pids.join(', ') }));
|
|
245
|
-
console.error(t('start.instancesHint'));
|
|
246
|
-
process.exitCode = 1;
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
if (existing && isAlive(existing.supervisorPid)) {
|
|
250
|
-
const changed = configChanges(cfg, existing);
|
|
251
|
-
if (!changed.length) {
|
|
252
|
-
console.log(t('start.running.same'));
|
|
253
|
-
await printAccess(existing);
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
console.log(t('start.running.changedHead', { tunnel: existing.tunnel }));
|
|
257
|
-
for (const c of changed) console.log(t('start.running.changedRow', c));
|
|
258
|
-
if (process.stdin.isTTY && await confirm(t('start.running.switchQ'))) {
|
|
259
|
-
if (!await stopAndWait()) return;
|
|
260
|
-
return start();
|
|
261
|
-
}
|
|
262
|
-
console.log(t('start.running.hint'));
|
|
263
|
-
await printAccess(existing);
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Make a one-run tunnel override visible (printed only now that we're actually starting, so it can't be
|
|
268
|
-
// mistaken for a switch when the instance was already running): a --tunnel flag shadowing the file.
|
|
269
|
-
if (flags.tunnel && fileCfg.tunnel && flags.tunnel !== fileCfg.tunnel) {
|
|
270
|
-
console.log(t('start.overrides', { flag: flags.tunnel, file: fileCfg.tunnel }));
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// cloudflare needs a cloudflared binary; resolve (and auto-download) it up front so the failure is a
|
|
274
|
-
// clear message here rather than a silent child that never prints a URL.
|
|
275
|
-
if (cfg.tunnel === 'cloudflare') {
|
|
276
|
-
try { cfg.cloudflaredBin = await resolveCloudflared(HOME); }
|
|
277
|
-
catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(1); }
|
|
278
|
-
}
|
|
279
|
-
if (cfg.tunnel === 'cloudflare-named') {
|
|
280
|
-
try { cfg.cloudflaredBin = await resolveCloudflared(HOME); }
|
|
281
|
-
catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(1); }
|
|
282
|
-
if (!fs.existsSync(path.join(HOME, '.cloudflared', 'config.yml'))) {
|
|
283
|
-
console.error(t('err.namedNotProvisioned')); process.exit(1);
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
if (cfg.tunnel === 'ssh') {
|
|
287
|
-
try { await preflightSsh(cfg); }
|
|
288
|
-
catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(1); }
|
|
289
|
-
}
|
|
290
|
-
if (cfg.tunnel === 'natapp' || cfg.tunnel === 'cpolar') {
|
|
291
|
-
try { await preflightNgrok(cfg); }
|
|
292
|
-
catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(1); }
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
if (cfg.foreground) {
|
|
296
|
-
supervise(cfg, { home: HOME });
|
|
297
|
-
console.log(t('start.foreground', { tunnel: cfg.tunnel, port: cfg.port }));
|
|
298
|
-
await waitAndPrint(false);
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
// Once autostart is registered, the service manager must remain the sole owner of the supervisor.
|
|
302
|
-
// Rewriting + restarting the entry also refreshes baked config and upgrade-sensitive executable paths.
|
|
303
|
-
if (isServiceInstalled(HOME)) {
|
|
304
|
-
try { installService(supervisorArgs(cfg), { home: HOME, log: { log() {} } }); }
|
|
305
|
-
catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(1); }
|
|
306
|
-
console.log(t('start.starting', { tunnel: cfg.tunnel, port: cfg.port }));
|
|
307
|
-
await waitAndPrint(true);
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
fs.mkdirSync(pocketHome(HOME), { recursive: true });
|
|
312
|
-
const out = fs.openSync(logPath(HOME), 'a');
|
|
313
|
-
const [supervisorBin, ...supervisorArgv] = supervisorArgs(cfg);
|
|
314
|
-
const child = spawn(supervisorBin, supervisorArgv,
|
|
315
|
-
{ detached: true, stdio: ['ignore', out, out] });
|
|
316
|
-
child.unref();
|
|
317
|
-
console.log(t('start.starting', { tunnel: cfg.tunnel, port: cfg.port }));
|
|
318
|
-
await waitAndPrint(true);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// Best-effort SIGTERM to the supervisor's recorded children. The supervisor reaps its own children on a
|
|
322
|
-
// graceful SIGTERM (supervisor.js shutdown), but if it was SIGKILL'd or crashed, its server/tunnel children
|
|
323
|
-
// are orphaned with no reaper — the classic "stray cloudflared after stop". Only called on the dead-
|
|
324
|
-
// supervisor branch, so these pids have no live parent to clean them up.
|
|
325
|
-
function reapOrphans(st) {
|
|
326
|
-
for (const pid of [st.serverPid, st.tunnelPid]) {
|
|
327
|
-
if (isAlive(pid)) { try { process.kill(pid, 'SIGTERM'); } catch { /* raced away */ } }
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// A lifecycle command must not start the replacement while the previous supervisor can still own the
|
|
332
|
-
// port/tunnel. Stop every real supervisor, not only the one last writer recorded in state.json, then rescan
|
|
333
|
-
// until none remain. This both repairs historical duplicates and prevents restart from racing a straggler.
|
|
334
|
-
async function stopAndWait() {
|
|
335
|
-
const st = readState(HOME);
|
|
336
|
-
const initial = scanSupervisorPids();
|
|
337
|
-
if (!initial.ok) {
|
|
338
|
-
console.error(t('lifecycle.scanFailed'));
|
|
339
|
-
process.exitCode = 1;
|
|
340
|
-
return false;
|
|
341
|
-
}
|
|
342
|
-
const managed = isServiceInstalled(HOME);
|
|
343
|
-
if (managed) {
|
|
344
|
-
try { stopService({ home: HOME }); }
|
|
345
|
-
catch (e) { console.error(t('err.generic', { msg: e.message })); process.exitCode = 1; return false; }
|
|
346
|
-
}
|
|
347
|
-
const seen = new Set(initial.pids);
|
|
348
|
-
if (st && isAlive(st.supervisorPid)) seen.add(st.supervisorPid);
|
|
349
|
-
if (!seen.size) {
|
|
350
|
-
if (st) reapOrphans(st);
|
|
351
|
-
clearState(HOME);
|
|
352
|
-
console.log(managed ? t('stop.stoppedManaged') : t('stop.notRunning'));
|
|
353
|
-
return true;
|
|
354
|
-
}
|
|
355
|
-
const result = await terminateSupervisorPids([...seen]);
|
|
356
|
-
if (!result.ok) {
|
|
357
|
-
console.error(t(result.reason === 'scan' ? 'lifecycle.scanFailed' : 'stop.timeout', {
|
|
358
|
-
pids: result.remaining.join(', '),
|
|
359
|
-
}));
|
|
360
|
-
process.exitCode = 1;
|
|
361
|
-
return false;
|
|
362
|
-
}
|
|
363
|
-
clearState(HOME);
|
|
364
|
-
console.log(t(result.pids.length > 1 ? 'stop.stoppedMany' : 'stop.stopped', {
|
|
365
|
-
pid: result.pids[0], pids: result.pids.join(', '),
|
|
366
|
-
}));
|
|
367
|
-
return true;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
async function status() {
|
|
371
|
-
const st = readState(HOME);
|
|
372
|
-
const installed = currentVersion();
|
|
373
|
-
const supervisors = scanSupervisorPids();
|
|
374
|
-
if (!supervisors.ok) console.warn(t('status.scanFailed'));
|
|
375
|
-
const stateAlive = !!(st && isAlive(st.supervisorPid));
|
|
376
|
-
if (supervisors.ok && supervisors.pids.length && !stateAlive) {
|
|
377
|
-
console.log(t('status.untracked', { version: installed, pids: supervisors.pids.join(', ') }));
|
|
378
|
-
console.log(t('status.cleanupHint'));
|
|
379
|
-
process.exitCode = 1;
|
|
380
|
-
return;
|
|
381
|
-
}
|
|
382
|
-
if (!stateAlive) { console.log(t('status.stopped', { version: installed })); return; }
|
|
383
|
-
const running = st.version || installed;
|
|
384
|
-
console.log(t('status.running', { version: running }));
|
|
385
|
-
if (running !== installed) console.log(t('status.installed', { version: installed }));
|
|
386
|
-
if (supervisors.ok && (supervisors.pids.length > 1
|
|
387
|
-
|| (supervisors.pids.length > 0 && !supervisors.pids.includes(st.supervisorPid)))) {
|
|
388
|
-
const all = [...new Set([st.supervisorPid, ...supervisors.pids])].sort((a, b) => a - b);
|
|
389
|
-
console.warn(t('status.duplicates', { pids: all.join(', ') }));
|
|
390
|
-
console.warn(t('status.cleanupHint'));
|
|
391
|
-
process.exitCode = 1;
|
|
392
|
-
}
|
|
393
|
-
await printAccess(st);
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
function runSupervise() {
|
|
397
|
-
const cfg = JSON.parse(Buffer.from(flags.payload, 'base64').toString('utf8'));
|
|
398
|
-
supervise(cfg, { home: HOME });
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
// `handmux push <title> <body> [--session X]... [--device K]... [--tag T] [--url U]` — see src/cli/pushCmd.js.
|
|
402
|
-
function pushCmd() {
|
|
403
|
-
return runPush({ argv: process.argv.slice(2), home: HOME });
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
function logs() {
|
|
407
|
-
const p = logPath(HOME);
|
|
408
|
-
if (!fs.existsSync(p)) { console.log(t('logs.none')); return; }
|
|
409
|
-
const lines = String(flags.lines || 200);
|
|
410
|
-
const args = flags.follow ? ['-n', lines, '-f', p] : ['-n', lines, p];
|
|
411
|
-
spawn('tail', args, { stdio: 'inherit' });
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
// `handmux service install|uninstall` — the autostart subsystem, mirroring `handmux hooks …`: subsystem
|
|
415
|
-
// name first, then the action. `install` bakes the resolved config into an OS autostart entry (launchd /
|
|
416
|
-
// systemd --user) that runs the supervisor at login; `uninstall` removes it. The action is argv[3].
|
|
417
|
-
async function serviceCmd() {
|
|
418
|
-
const sub = process.argv[3];
|
|
419
|
-
if (sub === 'install') return serviceInstall();
|
|
420
|
-
if (sub === 'uninstall') {
|
|
421
|
-
try { uninstallService({ home: HOME }); }
|
|
422
|
-
catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(1); }
|
|
423
|
-
return;
|
|
424
|
-
}
|
|
425
|
-
console.error(t('service.usage'));
|
|
426
|
-
process.exit(2);
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
async function serviceInstall() {
|
|
430
|
-
const { path: cfgPath, cfg: fileCfg } = resolveFileConfig();
|
|
431
|
-
console.log(t('config.loaded', { path: describeConfig(cfgPath) }));
|
|
432
|
-
let cfg;
|
|
433
|
-
try { cfg = resolveConfig(flags, fileCfg); }
|
|
434
|
-
catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(2); }
|
|
435
|
-
if (cfg.tunnel === 'cloudflare' || cfg.tunnel === 'cloudflare-named') {
|
|
436
|
-
try { cfg.cloudflaredBin = await resolveCloudflared(HOME); }
|
|
437
|
-
catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(1); }
|
|
438
|
-
}
|
|
439
|
-
if (cfg.tunnel === 'ssh') {
|
|
440
|
-
// 开机自启无 TTY:要求事先已配好免密,否则快速失败。
|
|
441
|
-
cfg.tunliteBin = resolveTunlite();
|
|
442
|
-
if (checkSshAuth(cfg.sshHost, { bin: cfg.tunliteBin }) !== 0) {
|
|
443
|
-
console.error(t('err.generic', { msg: t('ssh.notSetup', { bin: cfg.tunliteBin, host: cfg.sshHost }) })); process.exit(1);
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
if (cfg.tunnel === 'natapp' || cfg.tunnel === 'cpolar') {
|
|
447
|
-
try { await preflightNgrok(cfg); }
|
|
448
|
-
catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(1); }
|
|
449
|
-
}
|
|
450
|
-
// Converge a pre-existing manual run (and any old broken managed/manual pair) before loading the service.
|
|
451
|
-
// Otherwise the very first `service install` can itself create two supervisors fighting over one port.
|
|
452
|
-
const existing = readState(HOME);
|
|
453
|
-
const supervisors = scanSupervisorPids();
|
|
454
|
-
if (!supervisors.ok) {
|
|
455
|
-
console.error(t('lifecycle.scanFailed'));
|
|
456
|
-
process.exitCode = 1;
|
|
457
|
-
return;
|
|
458
|
-
}
|
|
459
|
-
if (isServiceInstalled(HOME) || supervisors.pids.length || (existing && isAlive(existing.supervisorPid))) {
|
|
460
|
-
if (!await stopAndWait()) return;
|
|
461
|
-
}
|
|
462
|
-
const args = supervisorArgs(cfg);
|
|
463
|
-
try { installService(args, { home: HOME }); }
|
|
464
|
-
catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(1); }
|
|
465
|
-
console.log(t('service.installed'));
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
function supervisorArgs(cfg) {
|
|
469
|
-
const payload = Buffer.from(JSON.stringify(cfg)).toString('base64');
|
|
470
|
-
return [process.execPath, SELF, '__supervise', '--payload', payload];
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
async function setupCmd() {
|
|
474
|
-
const target = flags.config ? path.resolve(flags.config) : configPath(HOME);
|
|
475
|
-
// Is an instance already up? Then the run-action reads "Save & restart" and applying means a real restart
|
|
476
|
-
// (a running supervisor won't pick up the new config on its own). Captured before the interactive setup so
|
|
477
|
-
// the label and the action agree; stop() below is a safe no-op if it died meanwhile.
|
|
478
|
-
const st = readState(HOME);
|
|
479
|
-
const running = !!(st && isAlive(st.supervisorPid));
|
|
480
|
-
const res = await runSetup({ home: HOME, target, running });
|
|
481
|
-
if (!res) { process.exit(2); }
|
|
482
|
-
const { cfg, start: doStart } = res; // hub's "save & start/restart" carries the intent — no separate confirm
|
|
483
|
-
// Offer to enable the inbox hooks when an agent is present but not yet wired (Claude 'absent', or Codex
|
|
484
|
-
// 'absent'). installAgentHooks() then wires every present agent (idempotent for any already installed).
|
|
485
|
-
const offerHooks = hooksStatus(HOME) === 'absent' || codexHooksStatus(HOME) === 'absent';
|
|
486
|
-
if (offerHooks && await confirm(t('hooks.confirmEnable'))) {
|
|
487
|
-
installAgentHooks();
|
|
488
|
-
}
|
|
489
|
-
await maybeOfferStatusLine();
|
|
490
|
-
if (doStart) {
|
|
491
|
-
return withLifecycleLock(async () => {
|
|
492
|
-
Object.assign(flags, cfg);
|
|
493
|
-
if (running && !await stopAndWait()) return; // restart into the new config
|
|
494
|
-
return start();
|
|
495
|
-
});
|
|
496
|
-
}
|
|
497
|
-
console.log(t(running ? 'setup.laterRestart' : 'setup.later'));
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
async function shortcutsCmd() {
|
|
501
|
-
const target = flags.config ? path.resolve(flags.config) : configPath(HOME);
|
|
502
|
-
let res;
|
|
503
|
-
try {
|
|
504
|
-
res = await runShortcutEditor({
|
|
505
|
-
target,
|
|
506
|
-
commit: (file, shortcuts) => commitShortcuts({ home: HOME, target: file, shortcuts }),
|
|
507
|
-
});
|
|
508
|
-
} catch (error) {
|
|
509
|
-
console.error(error.ownerPid
|
|
510
|
-
? t('lifecycle.busy', { pid: error.ownerPid })
|
|
511
|
-
: t('err.badConfig', { path: target, msg: error.message }));
|
|
512
|
-
process.exitCode = 2;
|
|
513
|
-
return;
|
|
514
|
-
}
|
|
515
|
-
if (!res) return;
|
|
516
|
-
if (res.error) { process.exitCode = 2; return; }
|
|
517
|
-
process.exitCode = reportShortcutCommit(res) || process.exitCode;
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
// Offer to enable the Claude statusLine usage capturer — it feeds the phone Usage page's 5h/weekly bars
|
|
521
|
-
// (Claude Code's statusLine stdin is the only documented local source of those %). Opt-in and NON-
|
|
522
|
-
// DESTRUCTIVE: auto-installs only when there's no statusLine yet; if the user already has one we print a
|
|
523
|
-
// one-line compose snippet and change nothing. Codex needs no capturer — its rollout already carries the
|
|
524
|
-
// quota. No-op when Claude Code isn't installed or ours is already in place.
|
|
525
|
-
async function maybeOfferStatusLine() {
|
|
526
|
-
const st = statusLineStatus(HOME);
|
|
527
|
-
if (st === 'no-claude') return;
|
|
528
|
-
// Already ours (plain OR composed into the user's own statusline): don't re-prompt, but silently REFRESH
|
|
529
|
-
// the capturer script to the bundled version so an npm upgrade reaches the on-disk copy. Settings-safe —
|
|
530
|
-
// refreshStatusLineScript never rewrites settings.statusLine, so a composed downstream renderer is kept.
|
|
531
|
-
if (st === 'ours') { refreshStatusLineScript(HOME, { srcDir: HOOKS_SRC }); return; }
|
|
532
|
-
if (st === 'foreign') {
|
|
533
|
-
// Deploy the capturer script (doesn't touch their statusLine) so the compose one-liner is runnable.
|
|
534
|
-
installStatusLine(HOME, { srcDir: HOOKS_SRC, usageFile: claudeUsagePath(HOME) });
|
|
535
|
-
console.log(t('statusline.foreignHint'));
|
|
536
|
-
console.log(' ' + composeHint(HOME, { usageFile: claudeUsagePath(HOME) }));
|
|
537
|
-
return;
|
|
538
|
-
}
|
|
539
|
-
if (await confirm(t('statusline.confirmEnable'))) {
|
|
540
|
-
installStatusLine(HOME, { srcDir: HOOKS_SRC, usageFile: claudeUsagePath(HOME) });
|
|
541
|
-
console.log(t('statusline.installed'));
|
|
542
|
-
console.log(t('statusline.reload'));
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
// Install the inbox hooks for every coding agent present on this host (Claude Code, Codex — the state file
|
|
547
|
-
// is shared, entries are agent-tagged). Each is opt-in by the mere presence of its config dir. Prints a
|
|
548
|
-
// per-agent line and returns how many were wired, so callers can gate the "reload" hint. Codex's single
|
|
549
|
-
// `notify` slot may already hold the user's OWN program — we never clobber it, we warn.
|
|
550
|
-
function installAgentHooks() {
|
|
551
|
-
let installed = 0;
|
|
552
|
-
if (hooksStatus(HOME) !== 'no-claude') {
|
|
553
|
-
installHooks(HOME, { srcDir: HOOKS_SRC, stateFile: claudeStatePath(HOME) });
|
|
554
|
-
console.log(t('hooks.installedClaude'));
|
|
555
|
-
installed++;
|
|
556
|
-
}
|
|
557
|
-
if (codexHooksStatus(HOME) !== 'no-codex') {
|
|
558
|
-
installCodexHooks(HOME, { srcDir: HOOKS_SRC, stateFile: claudeStatePath(HOME) });
|
|
559
|
-
console.log(t('hooks.installedCodex'));
|
|
560
|
-
installed++;
|
|
561
|
-
}
|
|
562
|
-
return installed;
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
// `handmux hooks install|uninstall` — opt-in wiring of the coding-agent lifecycle hooks that drive the
|
|
566
|
-
// inbox/push. Never creates ~/.claude or ~/.codex; if neither agent is present we say so and exit 0.
|
|
567
|
-
async function hooksCmd() {
|
|
568
|
-
const sub = process.argv[3];
|
|
569
|
-
if (sub === 'install') {
|
|
570
|
-
if (hooksStatus(HOME) === 'no-claude' && codexHooksStatus(HOME) === 'no-codex') {
|
|
571
|
-
console.log(t('hooks.noAgents'));
|
|
572
|
-
return;
|
|
573
|
-
}
|
|
574
|
-
if (installAgentHooks() > 0) console.log(t('hooks.installedHint'));
|
|
575
|
-
await maybeOfferStatusLine();
|
|
576
|
-
return;
|
|
577
|
-
}
|
|
578
|
-
if (sub === 'uninstall') {
|
|
579
|
-
uninstallHooks(HOME);
|
|
580
|
-
uninstallCodexHooks(HOME);
|
|
581
|
-
uninstallStatusLine(HOME);
|
|
582
|
-
console.log(t('hooks.removed'));
|
|
583
|
-
return;
|
|
584
|
-
}
|
|
585
|
-
console.error(t('hooks.usage'));
|
|
586
|
-
process.exit(2);
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
// `handmux config` — read-only: print the config that WOULD be used, with each value's origin (flag /
|
|
590
|
-
// the config file path / env / default). This is the answer to "what's actually in effect and where did
|
|
591
|
-
// it come from", so flag-vs-file is never a mystery. Secrets are masked.
|
|
592
|
-
function configCmd() {
|
|
593
|
-
const { path: cfgPath, cfg: fileCfg } = resolveFileConfig();
|
|
594
|
-
let rows;
|
|
595
|
-
try { rows = explainConfig(flags, fileCfg, cfgPath); }
|
|
596
|
-
catch (e) { console.error(t('err.generic', { msg: e.message })); process.exit(2); }
|
|
597
|
-
console.log(t('configcmd.file', { path: cfgPath || t('configcmd.fileNone') }));
|
|
598
|
-
console.log('');
|
|
599
|
-
const w = Math.max(...rows.map((r) => r.key.length));
|
|
600
|
-
for (const r of rows) {
|
|
601
|
-
console.log(` ${r.key.padEnd(w)} ${r.display} ${r.origin === 'default' ? '' : `· ${r.origin}`}`.trimEnd());
|
|
602
|
-
}
|
|
603
|
-
console.log('');
|
|
604
|
-
console.log(t('configcmd.legend'));
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
// Poll state.json until the public URL (or an error) shows up, then print access info. cloudflare needs
|
|
608
|
-
// a few seconds to hand back its hostname; none is instant.
|
|
609
|
-
async function waitAndPrint(exitWhenDone) {
|
|
610
|
-
const deadline = Date.now() + 25000;
|
|
611
|
-
let st;
|
|
612
|
-
for (;;) {
|
|
613
|
-
st = readState(HOME);
|
|
614
|
-
if (st && ((st.publicUrl && st.ready) || st.error)) break;
|
|
615
|
-
if (Date.now() > deadline) break;
|
|
616
|
-
await sleep(300);
|
|
617
|
-
}
|
|
618
|
-
await printAccess(st);
|
|
619
|
-
if (st?.error) process.exitCode = 1;
|
|
620
|
-
if (exitWhenDone) process.exit(process.exitCode || 0);
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
async function printAccess(st) {
|
|
624
|
-
if (!st) { console.log(t('access.noState')); return; }
|
|
625
|
-
if (st.error) { console.error(t('access.error', { msg: st.error })); return; }
|
|
626
|
-
const scan = bareUrl(st.publicUrl);
|
|
627
|
-
console.log('');
|
|
628
|
-
console.log(t('access.tunnel', { tunnel: st.tunnel, pid: st.supervisorPid }));
|
|
629
|
-
console.log(t('access.open', { url: scan || t('access.pending') }));
|
|
630
|
-
if (st.tunnel === 'none' && st.lanUrl) console.log(t('access.lan', { url: bareUrl(st.lanUrl) }));
|
|
631
|
-
console.log(t('access.local', { url: bareUrl(st.localUrl) }));
|
|
632
|
-
console.log(t('access.token', { token: st.token }));
|
|
633
|
-
// The QR carries the token so a phone scan signs in one-tap; the PRINTED links above stay token-free
|
|
634
|
-
// (safe to screenshot/share — paste the token shown above to sign in there).
|
|
635
|
-
await maybeQr(st.publicUrl ? publicUrlWithToken(st.publicUrl, st.token) : scan, st);
|
|
636
|
-
if (st.publicUrl && st.tunnel !== 'none') {
|
|
637
|
-
const ok = await probe(st.publicUrl);
|
|
638
|
-
if (ok) console.log(t('access.reachable'));
|
|
639
|
-
else console.log(t('access.unreachable', { url: st.publicUrl }));
|
|
640
|
-
}
|
|
641
|
-
console.log('');
|
|
642
|
-
console.log(t('access.hint'));
|
|
643
|
-
console.log('');
|
|
644
|
-
maybeNotifyUpdate();
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
// Best-effort QR (optional dependency). We borrow qrcode-terminal's QR *model* (vendored, dependency-free)
|
|
648
|
-
// to get the module matrix, then render it ourselves with vertical half-blocks (see qr.js) so it comes out
|
|
649
|
-
// square on a 2:1 terminal cell. If qrcode-terminal isn't installed we just skip it — the URL above is
|
|
650
|
-
// always printed.
|
|
651
|
-
const requireOpt = createRequire(import.meta.url);
|
|
652
|
-
async function maybeQr(url, st) {
|
|
653
|
-
if (!url) return;
|
|
654
|
-
try {
|
|
655
|
-
const QRCode = requireOpt('qrcode-terminal/vendor/QRCode');
|
|
656
|
-
const ECL = requireOpt('qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel');
|
|
657
|
-
const qr = new QRCode(-1, ECL.L);
|
|
658
|
-
qr.addData(url);
|
|
659
|
-
qr.make();
|
|
660
|
-
const n = qr.getModuleCount();
|
|
661
|
-
const matrix = Array.from({ length: n }, (_, r) =>
|
|
662
|
-
Array.from({ length: n }, (_, c) => qr.isDark(r, c)));
|
|
663
|
-
process.stdout.write(renderCompactQr(matrix) + '\n');
|
|
664
|
-
} catch { /* no qrcode-terminal — URL alone is fine */ }
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
// `open` is deliberately DECOUPLED from the server lifecycle: it never starts/stops anything. It's the
|
|
668
|
-
// desk-side quick attach — `handmux open foo` attaches session foo (creating it if missing), including
|
|
669
|
-
// sessions that were created from the phone. Inside tmux it refuses (don't nest tmux in tmux).
|
|
670
|
-
function openCmd() {
|
|
671
|
-
const name = process.argv[3];
|
|
672
|
-
if (!name || name.startsWith('-')) { console.error(t('open.usage')); process.exit(2); }
|
|
673
|
-
if (process.env.TMUX) { console.error(t('open.insideTmux')); process.exit(1); }
|
|
674
|
-
const tmux = checkTmux();
|
|
675
|
-
if (!tmux.present) {
|
|
676
|
-
console.error(t('tmux.notFound'));
|
|
677
|
-
console.error(t('tmux.install', { hint: tmuxInstallHint() }));
|
|
678
|
-
process.exit(1);
|
|
679
|
-
}
|
|
680
|
-
const r = spawnSync('tmux', ['new-session', '-A', '-s', name], { stdio: 'inherit' });
|
|
681
|
-
process.exit(r.status ?? 0);
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
function help() {
|
|
685
|
-
if (process.argv[3] === 'flags') { console.log(t('help.flags')); return; }
|
|
686
|
-
console.log(t('help.body'));
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
main();
|
|
2
|
+
// Stable JavaScript launcher for Node 20. Product source is compiled to dist before development, tests,
|
|
3
|
+
// packing or publishing; production never needs a TypeScript runtime loader.
|
|
4
|
+
import '../dist/bin/handmux.js';
|