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.
@@ -11,6 +11,7 @@ import webpush from 'web-push';
11
11
  import { configPath, pocketHome } from './state.js';
12
12
  import { resolveCloudflared } from './cloudflared.js';
13
13
  import { resolveTunlite, checkSshAuth } from './tunlite.js';
14
+ import { t, setLocale, getLocale } from './i18n/index.js';
14
15
 
15
16
  // ~/.cloudflared/config.yml for a named tunnel: route the hostname to the local handmux port.
16
17
  export function cfConfigYaml({ tunnelName, credentialsFile, hostname, port }) {
@@ -50,7 +51,7 @@ export function findTunnelId(listJsonOut, name) {
50
51
  // old value instead of leaving a stale field behind. Anything NOT here (token, staticDir, previewDomain…)
51
52
  // is preserved untouched.
52
53
  const WIZARD_KEYS = [
53
- 'name', 'port', 'tunnel',
54
+ 'lang', 'name', 'port', 'tunnel',
54
55
  'sshHost', 'remotePort', 'sshJump', 'cfHostname', 'cfTunnelName', 'publicUrl',
55
56
  'vapid', 'xfyun',
56
57
  ];
@@ -58,6 +59,7 @@ const WIZARD_KEYS = [
58
59
  // Wizard answers → the config fragment the user actually set (omit empty optional fields).
59
60
  export function configFromAnswers(a) {
60
61
  const cfg = { tunnel: a.tunnel, port: a.port };
62
+ if (a.lang) cfg.lang = a.lang;
61
63
  if (a.name) cfg.name = a.name;
62
64
  if (a.tunnel === 'ssh') {
63
65
  cfg.sshHost = a.sshHost;
@@ -102,32 +104,44 @@ function readExisting(file) {
102
104
  // (preserving fields it didn't ask about). Returns the resolved config (or null on abort). `home` and the
103
105
  // write `target` are injectable for tests / `--config`.
104
106
  export async function runSetup({ home = homedir(), target = configPath(home), log = console } = {}) {
105
- if (!process.stdin.isTTY) { log.error('handmux setup needs an interactive terminal'); return null; }
107
+ if (!process.stdin.isTTY) { log.error(t('setup.needTty')); return null; }
106
108
  const cur = readExisting(target);
107
109
  const rl = createInterface({ input: process.stdin, output: process.stdout });
108
110
  try {
109
- const name = await ask(rl, 'app name (shown in the browser tab / home-screen icon; blank = default)', cur.name || '');
110
-
111
- log.log('How should your phone reach this machine?');
112
- log.log(' 1) none — same Wi-Fi / LAN only');
113
- log.log(' 2) cloudflare — instant, random temporary https URL');
114
- log.log(' 3) cloudflare-named — your domain, stable HTTPS (most hands-off)');
115
- log.log(' 4) ssh (tunlite) — your own server / edge');
116
- const curPick = { none: '1', cloudflare: '2', 'cloudflare-named': '3', ssh: '4' }[cur.tunnel] || '3';
117
- const pick = await ask(rl, 'choose 1-4', curPick);
111
+ // Language first — pick it, apply it immediately, so the rest of the wizard speaks the chosen language.
112
+ // Default reflects the locale already resolved (from config/shell); Enter keeps it.
113
+ log.log(t('setup.langQ'));
114
+ log.log(t('setup.lang1'));
115
+ log.log(t('setup.lang2'));
116
+ const langPick = await ask(rl, t('setup.choose').replace('1-4', '1-2'), getLocale() === 'zh' ? '2' : '1');
117
+ const lang = { 1: 'en', 2: 'zh' }[langPick] || getLocale();
118
+ setLocale(lang);
119
+
120
+ const name = await ask(rl, t('setup.askName'), cur.name || '');
121
+
122
+ log.log(t('setup.tunnelQ'));
123
+ log.log(t('setup.tunnel1'));
124
+ log.log(t('setup.tunnel2'));
125
+ log.log(t('setup.tunnel3'));
126
+ log.log(t('setup.tunnel4'));
127
+ // Default to the CURRENT tunnel when re-running; for a brand-new user (no config) default to '2'
128
+ // (cloudflare quick tunnel — zero-config, instant public URL) rather than '3' (cloudflare-named),
129
+ // which a bare-Enter newcomer can't complete without a Cloudflare login + their own domain.
130
+ const curPick = { none: '1', cloudflare: '2', 'cloudflare-named': '3', ssh: '4' }[cur.tunnel] || '2';
131
+ const pick = await ask(rl, t('setup.choose'), curPick);
118
132
  const tunnel = { 1: 'none', 2: 'cloudflare', 3: 'cloudflare-named', 4: 'ssh' }[pick];
119
- if (!tunnel) { log.error('invalid choice'); return null; }
120
- const port = Number(await ask(rl, 'server port', String(cur.port || 19999)));
133
+ if (!tunnel) { log.error(t('setup.invalid')); return null; }
134
+ const port = Number(await ask(rl, t('setup.askPort'), String(cur.port || 19999)));
121
135
 
122
- const answers = { name, tunnel, port };
136
+ const answers = { lang, name, tunnel, port };
123
137
  if (tunnel === 'cloudflare-named') {
124
- answers.cfHostname = await ask(rl, 'public hostname (e.g. handmux.example.com)', cur.cfHostname || '');
125
- answers.cfTunnelName = await ask(rl, 'tunnel name', cur.cfTunnelName || 'handmux');
138
+ answers.cfHostname = await ask(rl, t('setup.askHostname'), cur.cfHostname || '');
139
+ answers.cfTunnelName = await ask(rl, t('setup.askTunnelName'), cur.cfTunnelName || 'handmux');
126
140
  await provisionCloudflareNamed({ home, hostname: answers.cfHostname, tunnelName: answers.cfTunnelName, port, log });
127
141
  } else if (tunnel === 'ssh') {
128
- answers.sshHost = await ask(rl, 'ssh host (user@host[:port])', cur.sshHost || '');
129
- answers.remotePort = Number(await ask(rl, 'remote port on the ssh host', String(cur.remotePort || port)));
130
- answers.publicUrl = await ask(rl, 'public url (blank = http://host:remotePort)', cur.publicUrl || '');
142
+ answers.sshHost = await ask(rl, t('setup.askSshHost'), cur.sshHost || '');
143
+ answers.remotePort = Number(await ask(rl, t('setup.askRemotePort'), String(cur.remotePort || port)));
144
+ answers.publicUrl = await ask(rl, t('setup.askPublicUrl'), cur.publicUrl || '');
131
145
  await provisionSsh({ sshHost: answers.sshHost, log });
132
146
  }
133
147
 
@@ -137,7 +151,7 @@ export async function runSetup({ home = homedir(), target = configPath(home), lo
137
151
  const cfg = mergeConfig(cur, answers);
138
152
  fs.mkdirSync(path.dirname(target), { recursive: true });
139
153
  fs.writeFileSync(target, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
140
- log.log(`✓ wrote ${target}`);
154
+ log.log(t('setup.wrote', { path: target }));
141
155
  if (tunnel === 'ssh') printSshServerHelp(answers, log);
142
156
  if (tunnel === 'cloudflare-named' || tunnel === 'ssh') printPreviewHelp(tunnel, log);
143
157
  return cfg;
@@ -149,27 +163,27 @@ export async function runSetup({ home = homedir(), target = configPath(home), lo
149
163
  // part of push setup, done for the user. Returns the vapid object, or undefined to leave push off.
150
164
  async function askPush(rl, existing, log) {
151
165
  if (existing) {
152
- if (await askYesNo(rl, 'keep push notifications configured?', true)) return existing;
166
+ if (await askYesNo(rl, t('setup.pushKeep'), true)) return existing;
153
167
  return undefined;
154
168
  }
155
- if (!await askYesNo(rl, 'set up push notifications now? (generates VAPID keys)', false)) return undefined;
169
+ if (!await askYesNo(rl, t('setup.pushSetup'), false)) return undefined;
156
170
  const { publicKey, privateKey } = webpush.generateVAPIDKeys();
157
- const subject = await ask(rl, 'contact (mailto: or https URL, for the push service)', 'mailto:admin@example.com');
158
- log.log('✓ generated VAPID keypair');
171
+ const subject = await ask(rl, t('setup.pushContact'), 'mailto:admin@example.com');
172
+ log.log(t('setup.pushGenerated'));
159
173
  return { public: publicKey, private: privateKey, subject };
160
174
  }
161
175
 
162
176
  // Voice input (iFlytek/xfyun) — three credentials from their console; no generation possible, just paste.
163
177
  async function askVoice(rl, existing, log) {
164
178
  if (existing) {
165
- if (await askYesNo(rl, 'keep voice input configured?', true)) return existing;
179
+ if (await askYesNo(rl, t('setup.voiceKeep'), true)) return existing;
166
180
  return undefined;
167
181
  }
168
- if (!await askYesNo(rl, 'set up voice input now? (needs iFlytek/xfyun keys)', false)) return undefined;
169
- const appId = await ask(rl, 'xfyun appId');
170
- const apiKey = await ask(rl, 'xfyun apiKey');
171
- const apiSecret = await ask(rl, 'xfyun apiSecret');
172
- if (!appId || !apiKey || !apiSecret) { log.log(' (skipped — missing fields)'); return undefined; }
182
+ if (!await askYesNo(rl, t('setup.voiceSetup'), false)) return undefined;
183
+ const appId = await ask(rl, t('setup.voiceAppId'));
184
+ const apiKey = await ask(rl, t('setup.voiceApiKey'));
185
+ const apiSecret = await ask(rl, t('setup.voiceApiSecret'));
186
+ if (!appId || !apiKey || !apiSecret) { log.log(t('setup.voiceSkipped')); return undefined; }
173
187
  return { appId, apiKey, apiSecret };
174
188
  }
175
189
 
@@ -178,7 +192,7 @@ async function provisionCloudflareNamed({ home, hostname, tunnelName, port, log
178
192
  const bin = await resolveCloudflared(home);
179
193
  const cfDir = path.join(home, '.cloudflared');
180
194
  if (!fs.existsSync(path.join(cfDir, 'cert.pem'))) {
181
- log.log('→ logging in to Cloudflare (a browser will open) …');
195
+ log.log(t('setup.cfLogin'));
182
196
  spawnSync(bin, ['tunnel', 'login'], { stdio: 'inherit' });
183
197
  }
184
198
  // Idempotent: reuse the tunnel if it already exists (re-running setup, or after a stop), else create it.
@@ -188,47 +202,47 @@ async function provisionCloudflareNamed({ home, hostname, tunnelName, port, log
188
202
  let id = findTunnelId(listed.stdout, tunnelName);
189
203
  let credentialsFile = null;
190
204
  if (id) {
191
- log.log(`✓ reusing existing tunnel ${tunnelName} (${id})`);
205
+ log.log(t('setup.cfReuse', { name: tunnelName, id }));
192
206
  credentialsFile = path.join(cfDir, `${id}.json`);
193
207
  if (!fs.existsSync(credentialsFile)) {
194
- log.error(`⚠ credentials file ${credentialsFile} not found on this machine — the tunnel was likely`);
195
- log.error(` created elsewhere. Run \`${bin} tunnel delete ${tunnelName}\` and re-run setup to recreate it here.`);
208
+ log.error(t('setup.cfCredMissing1', { file: credentialsFile }));
209
+ log.error(t('setup.cfCredMissing2', { bin, name: tunnelName }));
196
210
  }
197
211
  } else {
198
- log.log(`→ creating tunnel ${tunnelName} …`);
212
+ log.log(t('setup.cfCreate', { name: tunnelName }));
199
213
  const created = spawnSync(bin, ['tunnel', 'create', tunnelName], { encoding: 'utf8' });
200
214
  process.stdout.write(created.stdout || ''); process.stderr.write(created.stderr || '');
201
215
  const parsed = parseTunnelCreate(`${created.stdout || ''}\n${created.stderr || ''}`);
202
216
  id = parsed.id;
203
217
  credentialsFile = parsed.credentialsFile;
204
218
  }
205
- log.log(`→ routing ${hostname} → tunnel …`);
219
+ log.log(t('setup.cfRoute', { host: hostname }));
206
220
  // --overwrite-dns: re-running setup (or pointing a hostname already routed) must not error on the DNS step.
207
221
  const routed = spawnSync(bin, ['tunnel', 'route', 'dns', '--overwrite-dns', tunnelName, hostname], { encoding: 'utf8' });
208
222
  if (routed.status !== 0) {
209
223
  process.stderr.write(routed.stderr || '');
210
- log.error(`⚠ route dns failed — is ${hostname.split('.').slice(-2).join('.')}'s DNS hosted on Cloudflare?`);
211
- log.error(' Add the domain on Cloudflare (free) and point its nameservers there, then re-run setup.');
224
+ log.error(t('setup.cfRouteFail', { domain: hostname.split('.').slice(-2).join('.') }));
225
+ log.error(t('setup.cfRouteFail2'));
212
226
  }
213
227
  fs.mkdirSync(cfDir, { recursive: true });
214
228
  fs.writeFileSync(path.join(cfDir, 'config.yml'),
215
229
  cfConfigYaml({ tunnelName, credentialsFile: credentialsFile || path.join(cfDir, `${id || tunnelName}.json`), hostname, port }));
216
- log.log(`✓ wrote ${path.join(cfDir, 'config.yml')}`);
230
+ log.log(t('setup.wrote', { path: path.join(cfDir, 'config.yml') }));
217
231
  }
218
232
 
219
233
  // drive tunlite passwordless setup inline (one password) if not already set up.
220
234
  async function provisionSsh({ sshHost, log }) {
221
235
  const bin = resolveTunlite();
222
- if (checkSshAuth(sshHost, { bin }) === 0) { log.log('✓ passwordless SSH already set up'); return; }
223
- log.log(`→ setting up passwordless SSH to ${sshHost} (you'll enter the password once) …`);
236
+ if (checkSshAuth(sshHost, { bin }) === 0) { log.log(t('setup.sshReady')); return; }
237
+ log.log(t('setup.sshSetup', { host: sshHost }));
224
238
  spawnSync(bin, ['setup-key', sshHost], { stdio: 'inherit' });
225
239
  }
226
240
 
227
241
  function printSshServerHelp(a, log) {
228
242
  log.log('');
229
- log.log('Server side (one-time): point a reverse proxy at the forwarded loopback port.');
230
- log.log(` nginx: proxy_pass http://127.0.0.1:${a.remotePort}; (add client_max_body_size 60m; proxy_read_timeout 90s;)`);
231
- log.log(` caddy: ${a.publicUrl || '<your-domain>'} { reverse_proxy 127.0.0.1:${a.remotePort} }`);
243
+ log.log(t('setup.sshHelp1'));
244
+ log.log(t('setup.sshHelpNginx', { port: a.remotePort }));
245
+ log.log(t('setup.sshHelpCaddy', { url: a.publicUrl || '<your-domain>', port: a.remotePort }));
232
246
  log.log('');
233
247
  }
234
248
 
@@ -237,12 +251,12 @@ function printSshServerHelp(a, log) {
237
251
  // (so deeper needs ACM), whereas on the ssh/own-edge path the user serves their own wildcard cert. Shown
238
252
  // only for wildcard-capable tunnels (a quick tunnel can't do wildcards at all).
239
253
  function printPreviewHelp(tunnel, log) {
240
- log.log('Optional — dynamic port preview (open a dev server by port on your phone):');
241
- log.log(' set "previewDomain": "..." in the config, and route the wildcard preview domain to the gateway.');
254
+ log.log(t('setup.previewHelp1'));
255
+ log.log(t('setup.previewHelp2'));
242
256
  if (tunnel === 'cloudflare-named') {
243
- log.log(" TLS: Cloudflare's free cert covers ONE level (*.example.com); deeper (*.preview.example.com) needs Advanced Certificate Manager.");
257
+ log.log(t('setup.previewTlsCf'));
244
258
  } else {
245
- log.log(" TLS: your edge serves the wildcard cert (e.g. a Let's Encrypt *.preview.your.domain).");
259
+ log.log(t('setup.previewTlsEdge'));
246
260
  }
247
261
  log.log('');
248
262
  }
@@ -0,0 +1,79 @@
1
+ // Install/uninstall the handmux Claude statusLine — the capturer that snapshots the 5h/weekly rate-limit %
2
+ // (from Claude Code's statusLine stdin, the only documented local source) to ~/.handmux/claude-usage.json
3
+ // for the phone's Usage page. Opt-in, and NON-DESTRUCTIVE by design: Claude allows exactly one statusLine,
4
+ // so if the user already has their OWN we NEVER clobber it — we report 'foreign' and the CLI prints a
5
+ // one-line compose snippet instead. We only ever write settings.statusLine when it's absent or already ours.
6
+ //
7
+ // Iron rule (same as claudeHooks): only ever touch ~/.handmux/ and — after opt-in — ~/.claude/. Never
8
+ // create ~/.claude.
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { homedir } from 'node:os';
12
+
13
+ const STATUS_MARK = 'handmux-statusline.cjs'; // identifies our statusLine command among the user's own
14
+ const SCRIPT = 'handmux-statusline.cjs';
15
+
16
+ function claudeDir(home = homedir()) { return path.join(home, '.claude'); }
17
+ function settingsPath(home = homedir()) { return path.join(claudeDir(home), 'settings.json'); }
18
+
19
+ function readSettings(home) {
20
+ try { return JSON.parse(fs.readFileSync(settingsPath(home), 'utf8')); } catch { return {}; }
21
+ }
22
+ function writeJsonAtomic(file, obj) {
23
+ const tmp = `${file}.tmp`;
24
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2));
25
+ fs.renameSync(tmp, file);
26
+ }
27
+
28
+ function isOurs(sl) {
29
+ return !!(sl && typeof sl.command === 'string' && sl.command.includes(STATUS_MARK));
30
+ }
31
+
32
+ // 'no-claude' → ~/.claude absent. 'ours' → our statusLine is installed. 'foreign' → the user has their own
33
+ // statusLine (we must not touch it). 'absent' → Claude Code is here but no statusLine configured.
34
+ export function statusLineStatus(home = homedir()) {
35
+ if (!fs.existsSync(claudeDir(home))) return 'no-claude';
36
+ const sl = readSettings(home).statusLine;
37
+ if (isOurs(sl)) return 'ours';
38
+ if (sl && (sl.command || sl.type)) return 'foreign';
39
+ return 'absent';
40
+ }
41
+
42
+ // The exact command a user with an EXISTING statusline appends to capture without changing their display:
43
+ // pipe their statusline's stdin through our capturer in TEE mode first. Returned so the CLI can print it.
44
+ export function composeHint(home = homedir(), { usageFile } = {}) {
45
+ const dest = path.join(claudeDir(home), 'hooks', SCRIPT);
46
+ return `HANDMUX_STATUS_TEE=1 node ${dest} ${usageFile} | <your existing statusline>`;
47
+ }
48
+
49
+ // Install (opt-in): copy the capturer to ~/.claude/hooks/ and point settings.statusLine at it — but ONLY
50
+ // when it's safe (absent or already ours). A 'foreign' statusLine is left untouched. Returns { status }.
51
+ // srcDir = bundled hooks dir (server/hooks)
52
+ // usageFile = ~/.handmux/claude-usage.json (the snapshot the server reads)
53
+ export function installStatusLine(home = homedir(), { srcDir, usageFile } = {}) {
54
+ if (!fs.existsSync(claudeDir(home))) return { status: 'no-claude' };
55
+ const status = statusLineStatus(home);
56
+ // Always deploy the capturer script (it's ours, inert until invoked) so the compose one-liner works even
57
+ // in the foreign case. Only the settings.statusLine write is gated on not clobbering the user's own.
58
+ const hooksDir = path.join(claudeDir(home), 'hooks');
59
+ fs.mkdirSync(hooksDir, { recursive: true });
60
+ const dest = path.join(hooksDir, SCRIPT);
61
+ fs.copyFileSync(path.join(srcDir, SCRIPT), dest);
62
+ if (status === 'foreign') return { status: 'foreign', script: dest }; // never touch their statusLine
63
+ const settings = readSettings(home);
64
+ settings.statusLine = { type: 'command', command: `node ${dest} ${usageFile}` };
65
+ writeJsonAtomic(settingsPath(home), settings);
66
+ return { status: 'installed' };
67
+ }
68
+
69
+ // Uninstall: drop settings.statusLine only if it's ours, and remove the copied script. Leaves a foreign
70
+ // statusLine and everything else intact.
71
+ export function uninstallStatusLine(home = homedir()) {
72
+ const settings = readSettings(home);
73
+ if (isOurs(settings.statusLine)) {
74
+ delete settings.statusLine;
75
+ if (fs.existsSync(settingsPath(home))) writeJsonAtomic(settingsPath(home), settings);
76
+ }
77
+ try { fs.unlinkSync(path.join(claudeDir(home), 'hooks', SCRIPT)); } catch { /* already gone */ }
78
+ return { status: 'absent' };
79
+ }
@@ -0,0 +1,90 @@
1
+ // Update notifier for the globally-installed `handmux` CLI. There is NO self-updating server: the notice
2
+ // is a hint, the upgrade is a plain `npm i -g handmux@latest` (the `handmux update` command runs that for
3
+ // you). Two rules keep it unobtrusive and China-friendly:
4
+ // 1. The hot path (start/status) NEVER touches the network — it prints from a cached "latest version"
5
+ // and, only if that cache is stale, spawns a DETACHED background worker to refresh it. So the first
6
+ // run after a new release is what surfaces it; the command itself is never delayed or blocked.
7
+ // 2. The version query goes through the user's own npm (`npm view handmux version`), so it honours a
8
+ // configured China mirror / private registry instead of hard-coding registry.npmjs.org. Any failure
9
+ // (offline, blocked, npm missing) is swallowed — the notifier is best-effort, never an error.
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { spawn, spawnSync } from 'node:child_process';
13
+ import { pocketHome } from './state.js';
14
+ import { t } from './i18n/index.js';
15
+
16
+ export const PKG_NAME = 'handmux';
17
+ export const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // refresh the cached "latest" at most once a day
18
+
19
+ export function updateCachePath(home) { return path.join(pocketHome(home), 'update-check.json'); }
20
+
21
+ export function readCache(home) {
22
+ try { return JSON.parse(fs.readFileSync(updateCachePath(home), 'utf8')); } catch { return null; }
23
+ }
24
+
25
+ export function writeCache(home, obj) {
26
+ try {
27
+ fs.mkdirSync(pocketHome(home), { recursive: true });
28
+ fs.writeFileSync(updateCachePath(home), JSON.stringify(obj));
29
+ } catch { /* best effort — a missing cache just means we re-check next time */ }
30
+ }
31
+
32
+ // "1.2.3" → [1,2,3]; a prerelease/build tail (`-rc.1`, `+meta`) is ignored. null if unparseable.
33
+ function parts(v) {
34
+ const m = String(v || '').trim().match(/^v?(\d+)\.(\d+)\.(\d+)/);
35
+ return m ? [+m[1], +m[2], +m[3]] : null;
36
+ }
37
+
38
+ // -1 / 0 / 1 by numeric major.minor.patch. Unparseable inputs compare equal (→ no false "upgrade").
39
+ export function compareVersions(a, b) {
40
+ const pa = parts(a), pb = parts(b);
41
+ if (!pa || !pb) return 0;
42
+ for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
43
+ return 0;
44
+ }
45
+
46
+ export function isNewer(latest, current) { return compareVersions(latest, current) > 0; }
47
+
48
+ export function shouldRefresh(cache, now = Date.now(), interval = CHECK_INTERVAL_MS) {
49
+ return !cache || typeof cache.checkedAt !== 'number' || (now - cache.checkedAt) > interval;
50
+ }
51
+
52
+ // Query the latest published version via the user's own npm (honours their registry/mirror). Hard timeout;
53
+ // any non-zero exit, empty/garbled output, or thrown error → null. Never throws.
54
+ export function fetchLatestVersion({ timeoutMs = 4000, run = spawnSync } = {}) {
55
+ try {
56
+ const r = run('npm', ['view', PKG_NAME, 'version'], { timeout: timeoutMs, encoding: 'utf8' });
57
+ if (!r || r.status !== 0 || !r.stdout) return null;
58
+ const v = String(r.stdout).trim();
59
+ return parts(v) ? v : null;
60
+ } catch { return null; }
61
+ }
62
+
63
+ // The hidden `__update-check` worker (runs detached, prints nothing): refresh the cache. On a failed fetch
64
+ // keep the previously-known latest but still stamp checkedAt, so a flaky network doesn't re-spawn every run.
65
+ export function runUpdateCheck(home, { now = Date.now(), ...opts } = {}) {
66
+ const latest = fetchLatestVersion(opts) || (readCache(home)?.latest ?? null);
67
+ writeCache(home, { checkedAt: now, latest });
68
+ }
69
+
70
+ // Fire-and-forget notifier for a foreground command. Prints an upgrade line straight from the cache (no
71
+ // network on this path), then — if the cache is stale — kicks off a detached refresh so the NEXT run is
72
+ // current. Returns true if a notice was printed (handy for tests). `selfPath` is the CLI entry so the
73
+ // background worker re-invokes this same binary.
74
+ export function notifyUpdate(home, { version, selfPath, now = Date.now(), log = console.log, spawnFn = spawn } = {}) {
75
+ const cache = readCache(home);
76
+ let shown = false;
77
+ if (cache && cache.latest && isNewer(cache.latest, version)) {
78
+ log('');
79
+ log(t('update.available', { current: version, latest: cache.latest }));
80
+ log(t('update.how'));
81
+ shown = true;
82
+ }
83
+ if (selfPath && shouldRefresh(cache, now)) {
84
+ try {
85
+ const child = spawnFn(process.execPath, [selfPath, '__update-check'], { detached: true, stdio: 'ignore' });
86
+ child.unref?.();
87
+ } catch { /* best effort */ }
88
+ }
89
+ return shown;
90
+ }
package/src/httpApi.js CHANGED
@@ -21,11 +21,25 @@ import { safeUploadName } from './docPath.js';
21
21
  import { safePreviewName } from './previews.js';
22
22
  import { isAllowedUploadExt, DEFAULT_UPLOAD_EXTS } from './uploadTypes.js';
23
23
  import { hooksStatus, installHooks } from './cli/claudeHooks.js';
24
+ import { codexHooksStatus, installCodexHooks } from './cli/codexHooks.js';
24
25
  import { claudeStatePath } from './cli/state.js';
26
+ import { scanOrphans, takeoverOrphan, defaultProjectsDir } from './orphans.js';
27
+ import { getUsageCached } from './usage.js';
25
28
 
26
29
  const here = dirname(fileURLToPath(import.meta.url));
27
30
  const HOOKS_SRC = resolvePath(here, '../hooks'); // server/hooks (bundled scripts)
28
31
 
32
+ // Summarize inbox-hook state across every coding agent for the phone: 'installed' if any agent is wired,
33
+ // 'absent' if an agent is present but none wired (→ offer the one-tap enable), 'no-claude' if there's no
34
+ // agent at all (→ hide the prompt).
35
+ function combinedHooksStatus(home) {
36
+ const c = hooksStatus(home); // 'no-claude' | 'installed' | 'absent'
37
+ const x = codexHooksStatus(home); // 'no-codex' | 'installed' | 'absent'
38
+ if (c === 'installed' || x === 'installed') return 'installed';
39
+ if (c !== 'no-claude' || x !== 'no-codex') return 'absent';
40
+ return 'no-claude';
41
+ }
42
+
29
43
  const ALLOWED_KEYS = new Set([
30
44
  'Up', 'Down', 'Left', 'Right', 'Space', 'Enter', 'Escape', 'Tab', 'BTab', 'BSpace',
31
45
  'C-c', 'C-d', 'C-z', 'C-l', 'C-r', 'C-o', 'C-e',
@@ -437,16 +451,21 @@ export function createApiRouter({
437
451
  // Optional integrations are configured per-install (open-source installs ship without keys), so the
438
452
  // client asks what's actually available and hides controls that can't work — e.g. the mic when no
439
453
  // ASR engine is configured. Add more flags here as optional integrations land.
454
+ // `claudeHooks` (name kept for web back-compat) now summarizes EVERY coding agent: 'installed' if any is
455
+ // wired, 'absent' if an agent is present but none wired (→ offer enable), 'no-claude' if no agent at all.
440
456
  r.get('/config', (req, res) => {
441
- res.json({ asr: isAsrConfigured(asrEnv), claudeHooks: hooksStatus(home) });
457
+ res.json({ asr: isAsrConfigured(asrEnv), claudeHooks: combinedHooksStatus(home) });
442
458
  });
443
459
 
444
- // One-tap enable from the phone: install the Claude Code hooks on the host (token-gated, like every API
445
- // here). Opt-in — the inbox only offers this when status is 'absent'. Never creates ~/.claude.
460
+ // One-tap enable from the phone: install the hooks for every present agent (Claude Code, Codex) on the
461
+ // host (token-gated, like every API here). Opt-in — the inbox only offers this when status is 'absent'.
462
+ // Never creates ~/.claude or ~/.codex; a user's own Codex `notify` is left untouched (see codexHooks.js).
446
463
  r.post('/hooks/install', (req, res) => {
447
464
  try {
448
- const { status } = installHooks(home, { srcDir: HOOKS_SRC, stateFile });
449
- res.json({ ok: status === 'installed', status });
465
+ let installed = 0;
466
+ if (hooksStatus(home) !== 'no-claude') { installHooks(home, { srcDir: HOOKS_SRC, stateFile }); installed++; }
467
+ if (codexHooksStatus(home) !== 'no-codex') { installCodexHooks(home, { srcDir: HOOKS_SRC, stateFile }); installed++; }
468
+ res.json({ ok: installed > 0, status: combinedHooksStatus(home) });
450
469
  } catch (e) { res.status(500).json({ ok: false, error: String(e) }); }
451
470
  });
452
471
 
@@ -512,6 +531,37 @@ export function createApiRouter({
512
531
  try { res.json(await claudeEvents.getStates(allowed)); } catch (e) { next(e); }
513
532
  });
514
533
 
534
+ // Agent usage/quota for the Usage page. Disk-only, no credentials: Claude's 5h/weekly % from the
535
+ // statusLine snapshot (if the capturer is opted in), Codex's rate_limits + tokens from its newest
536
+ // rollout. Either side is null when unavailable. Cached briefly (see usage.js); never throws.
537
+ r.get('/usage', (req, res, next) => {
538
+ try { res.json(getUsageCached(home)); } catch (e) { next(e); }
539
+ });
540
+
541
+ // Orphan Claude sessions: `claude` processes running on this host but NOT inside a tmux pane, so
542
+ // handmux can't steer them. Surfaced at the bottom of the Inbox with a "takeover" (spawn
543
+ // `claude --resume` in tmux). Best-effort process scan (see orphans.js); never throws.
544
+ r.get('/orphans', async (req, res, next) => {
545
+ try { res.json(await scanOrphans({ projectsDir: defaultProjectsDir(home) })); } catch (e) { next(e); }
546
+ });
547
+
548
+ // Take over an orphan: spawn `claude --resume <sessionId>` in tmux and (default) SIGTERM the original.
549
+ // pid/sessionId are re-verified against a fresh scan server-side; sessionId must be a UUID (it's typed
550
+ // into a shell). target.mode 'new' (fresh session) or 'window' (into an existing session id).
551
+ r.post('/orphans/takeover', async (req, res, next) => {
552
+ const { pid, sessionId, kill, target } = req.body || {};
553
+ const t = target && target.mode === 'window' && isSessionId(target.session)
554
+ ? { mode: 'window', session: target.session } : { mode: 'new' };
555
+ try {
556
+ const out = await takeoverOrphan(
557
+ { commands, scanOpts: { projectsDir: defaultProjectsDir(home) } },
558
+ { pid, sessionId, target: t, kill: kill !== false },
559
+ );
560
+ if (out.error) return res.status(out.status).json({ error: out.error });
561
+ res.json(out);
562
+ } catch (e) { next(e); }
563
+ });
564
+
515
565
  // --- Preview registry (static dir OR dynamic port) -----------------------------------------
516
566
  // POST {name,dir} registers a static dir served at /preview/<name>/; POST {name,port} registers a
517
567
  // dynamic reverse-proxy reachable at https://<name>.<DOMAIN>/ (only when previewDomain is set). The
package/src/orphans.js ADDED
@@ -0,0 +1,138 @@
1
+ // Detect "orphan" coding-agent sessions: a `claude`/`codex`/… process running on this host that is NOT
2
+ // inside a tmux pane, so handmux can't see or steer it. We can't migrate a live process into tmux (reptyr
3
+ // needs Linux ptrace+/proc — out on macOS — and breaks on multithreaded Node + child processes), so instead
4
+ // we surface these in the Inbox and offer a "takeover": spawn the agent's `resume` command in a fresh tmux
5
+ // pane (the agent's own persistence continues the conversation), then optionally kill the original.
6
+ //
7
+ // Detection is process-based, NOT a scan of the agents' session history (which is unbounded and can't tell
8
+ // a live session from a dead one). Cost scales with the number of LIVE agent processes only.
9
+ //
10
+ // tmux membership is decided by TTY/PPID match against `tmux list-panes`, NOT by reading the process
11
+ // environment: on macOS `ps eww` can't read another process's env (SIP), so `$TMUX` is a false signal.
12
+ // A proc whose controlling tty is one of tmux's pane ttys (or whose parent is a pane's shell) is in tmux;
13
+ // anything else with a real tty is an orphan.
14
+ //
15
+ // This module is now the agent-AGNOSTIC engine: which processes count, where a cwd's session lives, and how
16
+ // to resume it all come from the driver descriptors in ./agents (parseAgentProcs tags each proc with its
17
+ // agent; scan/takeover dispatch through getAgent). The pure parse/file helpers live in ./agents/scanUtils.
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+ import { AGENTS, getAgent } from './agents/index.js';
21
+ import { isSessionId } from './tmux/commands.js';
22
+ import {
23
+ defaultRun, parseAgentProcs, parsePaneMembership, findOrphans,
24
+ takeoverSessionName, isShell, lsofCwd, isSessionUuid,
25
+ } from './agents/scanUtils.js';
26
+
27
+ // Back-compat re-exports: these were originally defined here and are imported by tests and callers by this
28
+ // path. They're all agent-agnostic (or Claude's, kept as the default) and now live in scanUtils / claude.
29
+ export {
30
+ parsePaneMembership, findOrphans, encodeProjectDir, isSessionUuid,
31
+ lastUserSnippet, etimeToMs, takeoverSessionName,
32
+ resolveEncodedDirSession as resolveSession,
33
+ } from './agents/scanUtils.js';
34
+ export { projectsDir as defaultProjectsDir } from './agents/claude.js';
35
+
36
+ // Parse `ps …` to LIVE Claude CLI procs only — the original Claude-specific helper, kept for the tests.
37
+ // The general engine uses parseAgentProcs(psOut, AGENTS) directly.
38
+ export function parseClaudeProcs(psOut) {
39
+ return parseAgentProcs(psOut, [getAgent('claude')]);
40
+ }
41
+
42
+ // Take over an orphan: spawn the agent's `resume <sessionId>` in a fresh tmux session (target.mode 'new')
43
+ // or a new window of an existing session ('window'), so handmux can steer the continued conversation.
44
+ // Everything is re-verified server-side — the client's pid/sessionId are inputs to a fresh scan, never
45
+ // trusted directly. The original process is SIGTERM'd only AFTER the resumed agent is confirmed up
46
+ // (foreground command is no longer the shell) AND re-confirmed still the same orphan (guards pid reuse):
47
+ // `<agent> resume` appends to the SAME session file with no OS lock (verified for Claude), so two live
48
+ // writers corrupt history — killing guarantees a single writer. Injectable deps make it unit-testable.
49
+ export async function takeoverOrphan(
50
+ {
51
+ commands, scanFn = scanOrphans, scanOpts = {},
52
+ killProc = (pid, sig) => process.kill(pid, sig),
53
+ delay = (ms) => new Promise((r) => setTimeout(r, ms)),
54
+ pollTries = 16, pollMs = 400,
55
+ },
56
+ { pid, sessionId, target = { mode: 'new' }, kill = true } = {},
57
+ ) {
58
+ if (!Number.isInteger(pid) || pid <= 0) return { error: 'bad pid', status: 400 };
59
+ // Both agents use UUID session ids; validate up front (takeover types the id into a shell via send-keys).
60
+ if (!isSessionUuid(sessionId)) return { error: 'bad session id', status: 400 };
61
+
62
+ const o = (await scanFn(scanOpts)).find((x) => x.pid === pid);
63
+ if (!o) return { error: 'gone', status: 409 }; // no longer a live orphan
64
+ if (o.sessionId !== sessionId) return { error: 'session changed', status: 409 };
65
+ if (!o.cwd) return { error: 'no cwd', status: 409 };
66
+ const agent = getAgent(o.agent);
67
+ const cmd = agent.sessions.resumeCmd(sessionId);
68
+
69
+ let sid;
70
+ let wid;
71
+ let name; // the target session NAME — returned so the client can bind it into its session list
72
+ if (target.mode === 'window') {
73
+ if (!isSessionId(target.session)) return { error: 'bad target session', status: 400 };
74
+ sid = target.session;
75
+ name = (await commands.listSessions()).find((s) => s.id === sid)?.name || null;
76
+ wid = await commands.newWindow(sid, o.cwd, agent.procName, cmd);
77
+ } else {
78
+ const existing = new Set((await commands.listSessions()).map((s) => s.name));
79
+ for (let i = 1; i < 1000 && !name; i++) {
80
+ const cand = takeoverSessionName(o.cwdLabel, i, agent.takeoverPrefix);
81
+ if (!existing.has(cand)) name = cand;
82
+ }
83
+ sid = await commands.newSession(name, o.cwd, cmd);
84
+ wid = (await commands.listWindows(sid))[0]?.id;
85
+ }
86
+
87
+ let up = false;
88
+ let pane = null;
89
+ for (let i = 0; i < pollTries && !up; i++) {
90
+ await delay(pollMs);
91
+ try {
92
+ const p = (await commands.listPanes(wid))[0];
93
+ if (p) { pane = p.id; if (!isShell(p.command)) up = true; }
94
+ } catch { /* window/pane not ready yet */ }
95
+ }
96
+
97
+ let killed = false;
98
+ if (kill && up) {
99
+ const still = (await scanFn(scanOpts)).find((x) => x.pid === pid && x.sessionId === sessionId);
100
+ if (still) { try { killProc(pid, 'SIGTERM'); killed = true; } catch { /* already exited */ } }
101
+ }
102
+ // claudeUp kept in the response for back-compat with the existing web client; agentUp is the neutral name.
103
+ return { session: sid, name, window: wid, pane, agentUp: up, claudeUp: up, killed, agent: agent.id };
104
+ }
105
+
106
+ // Scan the host for orphan agent sessions across every registered driver. Best-effort: any failing
107
+ // sub-command degrades to fewer/no results rather than throwing. `projectsDir`/`sessionsDir` override a
108
+ // specific agent's session dir (each driver declares which option key it reads — used by tests and the
109
+ // server, which pin the dir off the resolved $HOME).
110
+ export async function scanOrphans({
111
+ run = defaultRun, home = os.homedir(), busyMs = 8000, now = Date.now, agents = AGENTS, ...dirOverrides
112
+ } = {}) {
113
+ const [psOut, tmuxOut] = await Promise.all([
114
+ run('ps', ['-Ao', 'pid=,ppid=,stat=,etime=,tty=,args=']),
115
+ run('tmux', ['list-panes', '-a', '-F', '#{pane_tty}\t#{pane_pid}']),
116
+ ]);
117
+ const orphans = findOrphans(parseAgentProcs(psOut, agents), parsePaneMembership(tmuxOut));
118
+ const results = [];
119
+ for (const o of orphans) {
120
+ const agent = getAgent(o.agent);
121
+ const cwd = await lsofCwd(run, o.pid);
122
+ const dir = dirOverrides[agent.sessions.dirOptKey] || agent.sessions.dir(home);
123
+ const meta = cwd ? await agent.sessions.resolve(dir, cwd, { busyMs, now }) : {};
124
+ results.push({
125
+ pid: o.pid,
126
+ agent: agent.id,
127
+ agentLabel: agent.label,
128
+ cwd: cwd || '',
129
+ cwdLabel: cwd ? path.basename(cwd) : '',
130
+ sessionId: meta.sessionId || null,
131
+ state: meta.state || 'unknown',
132
+ snippet: meta.snippet || '',
133
+ lastActivity: meta.lastActivity || 0,
134
+ startedAt: o.etimeMs ? Math.round(now() - o.etimeMs) : 0,
135
+ });
136
+ }
137
+ return results;
138
+ }