handmux 0.5.2 → 0.6.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,41 @@ 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 || '');
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);
110
119
 
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');
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'));
116
127
  const curPick = { none: '1', cloudflare: '2', 'cloudflare-named': '3', ssh: '4' }[cur.tunnel] || '3';
117
- const pick = await ask(rl, 'choose 1-4', curPick);
128
+ const pick = await ask(rl, t('setup.choose'), curPick);
118
129
  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)));
130
+ if (!tunnel) { log.error(t('setup.invalid')); return null; }
131
+ const port = Number(await ask(rl, t('setup.askPort'), String(cur.port || 19999)));
121
132
 
122
- const answers = { name, tunnel, port };
133
+ const answers = { lang, name, tunnel, port };
123
134
  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');
135
+ answers.cfHostname = await ask(rl, t('setup.askHostname'), cur.cfHostname || '');
136
+ answers.cfTunnelName = await ask(rl, t('setup.askTunnelName'), cur.cfTunnelName || 'handmux');
126
137
  await provisionCloudflareNamed({ home, hostname: answers.cfHostname, tunnelName: answers.cfTunnelName, port, log });
127
138
  } 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 || '');
139
+ answers.sshHost = await ask(rl, t('setup.askSshHost'), cur.sshHost || '');
140
+ answers.remotePort = Number(await ask(rl, t('setup.askRemotePort'), String(cur.remotePort || port)));
141
+ answers.publicUrl = await ask(rl, t('setup.askPublicUrl'), cur.publicUrl || '');
131
142
  await provisionSsh({ sshHost: answers.sshHost, log });
132
143
  }
133
144
 
@@ -137,7 +148,7 @@ export async function runSetup({ home = homedir(), target = configPath(home), lo
137
148
  const cfg = mergeConfig(cur, answers);
138
149
  fs.mkdirSync(path.dirname(target), { recursive: true });
139
150
  fs.writeFileSync(target, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
140
- log.log(`✓ wrote ${target}`);
151
+ log.log(t('setup.wrote', { path: target }));
141
152
  if (tunnel === 'ssh') printSshServerHelp(answers, log);
142
153
  if (tunnel === 'cloudflare-named' || tunnel === 'ssh') printPreviewHelp(tunnel, log);
143
154
  return cfg;
@@ -149,27 +160,27 @@ export async function runSetup({ home = homedir(), target = configPath(home), lo
149
160
  // part of push setup, done for the user. Returns the vapid object, or undefined to leave push off.
150
161
  async function askPush(rl, existing, log) {
151
162
  if (existing) {
152
- if (await askYesNo(rl, 'keep push notifications configured?', true)) return existing;
163
+ if (await askYesNo(rl, t('setup.pushKeep'), true)) return existing;
153
164
  return undefined;
154
165
  }
155
- if (!await askYesNo(rl, 'set up push notifications now? (generates VAPID keys)', false)) return undefined;
166
+ if (!await askYesNo(rl, t('setup.pushSetup'), false)) return undefined;
156
167
  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');
168
+ const subject = await ask(rl, t('setup.pushContact'), 'mailto:admin@example.com');
169
+ log.log(t('setup.pushGenerated'));
159
170
  return { public: publicKey, private: privateKey, subject };
160
171
  }
161
172
 
162
173
  // Voice input (iFlytek/xfyun) — three credentials from their console; no generation possible, just paste.
163
174
  async function askVoice(rl, existing, log) {
164
175
  if (existing) {
165
- if (await askYesNo(rl, 'keep voice input configured?', true)) return existing;
176
+ if (await askYesNo(rl, t('setup.voiceKeep'), true)) return existing;
166
177
  return undefined;
167
178
  }
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; }
179
+ if (!await askYesNo(rl, t('setup.voiceSetup'), false)) return undefined;
180
+ const appId = await ask(rl, t('setup.voiceAppId'));
181
+ const apiKey = await ask(rl, t('setup.voiceApiKey'));
182
+ const apiSecret = await ask(rl, t('setup.voiceApiSecret'));
183
+ if (!appId || !apiKey || !apiSecret) { log.log(t('setup.voiceSkipped')); return undefined; }
173
184
  return { appId, apiKey, apiSecret };
174
185
  }
175
186
 
@@ -178,7 +189,7 @@ async function provisionCloudflareNamed({ home, hostname, tunnelName, port, log
178
189
  const bin = await resolveCloudflared(home);
179
190
  const cfDir = path.join(home, '.cloudflared');
180
191
  if (!fs.existsSync(path.join(cfDir, 'cert.pem'))) {
181
- log.log('→ logging in to Cloudflare (a browser will open) …');
192
+ log.log(t('setup.cfLogin'));
182
193
  spawnSync(bin, ['tunnel', 'login'], { stdio: 'inherit' });
183
194
  }
184
195
  // Idempotent: reuse the tunnel if it already exists (re-running setup, or after a stop), else create it.
@@ -188,47 +199,47 @@ async function provisionCloudflareNamed({ home, hostname, tunnelName, port, log
188
199
  let id = findTunnelId(listed.stdout, tunnelName);
189
200
  let credentialsFile = null;
190
201
  if (id) {
191
- log.log(`✓ reusing existing tunnel ${tunnelName} (${id})`);
202
+ log.log(t('setup.cfReuse', { name: tunnelName, id }));
192
203
  credentialsFile = path.join(cfDir, `${id}.json`);
193
204
  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.`);
205
+ log.error(t('setup.cfCredMissing1', { file: credentialsFile }));
206
+ log.error(t('setup.cfCredMissing2', { bin, name: tunnelName }));
196
207
  }
197
208
  } else {
198
- log.log(`→ creating tunnel ${tunnelName} …`);
209
+ log.log(t('setup.cfCreate', { name: tunnelName }));
199
210
  const created = spawnSync(bin, ['tunnel', 'create', tunnelName], { encoding: 'utf8' });
200
211
  process.stdout.write(created.stdout || ''); process.stderr.write(created.stderr || '');
201
212
  const parsed = parseTunnelCreate(`${created.stdout || ''}\n${created.stderr || ''}`);
202
213
  id = parsed.id;
203
214
  credentialsFile = parsed.credentialsFile;
204
215
  }
205
- log.log(`→ routing ${hostname} → tunnel …`);
216
+ log.log(t('setup.cfRoute', { host: hostname }));
206
217
  // --overwrite-dns: re-running setup (or pointing a hostname already routed) must not error on the DNS step.
207
218
  const routed = spawnSync(bin, ['tunnel', 'route', 'dns', '--overwrite-dns', tunnelName, hostname], { encoding: 'utf8' });
208
219
  if (routed.status !== 0) {
209
220
  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.');
221
+ log.error(t('setup.cfRouteFail', { domain: hostname.split('.').slice(-2).join('.') }));
222
+ log.error(t('setup.cfRouteFail2'));
212
223
  }
213
224
  fs.mkdirSync(cfDir, { recursive: true });
214
225
  fs.writeFileSync(path.join(cfDir, 'config.yml'),
215
226
  cfConfigYaml({ tunnelName, credentialsFile: credentialsFile || path.join(cfDir, `${id || tunnelName}.json`), hostname, port }));
216
- log.log(`✓ wrote ${path.join(cfDir, 'config.yml')}`);
227
+ log.log(t('setup.wrote', { path: path.join(cfDir, 'config.yml') }));
217
228
  }
218
229
 
219
230
  // drive tunlite passwordless setup inline (one password) if not already set up.
220
231
  async function provisionSsh({ sshHost, log }) {
221
232
  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) …`);
233
+ if (checkSshAuth(sshHost, { bin }) === 0) { log.log(t('setup.sshReady')); return; }
234
+ log.log(t('setup.sshSetup', { host: sshHost }));
224
235
  spawnSync(bin, ['setup-key', sshHost], { stdio: 'inherit' });
225
236
  }
226
237
 
227
238
  function printSshServerHelp(a, log) {
228
239
  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} }`);
240
+ log.log(t('setup.sshHelp1'));
241
+ log.log(t('setup.sshHelpNginx', { port: a.remotePort }));
242
+ log.log(t('setup.sshHelpCaddy', { url: a.publicUrl || '<your-domain>', port: a.remotePort }));
232
243
  log.log('');
233
244
  }
234
245
 
@@ -237,12 +248,12 @@ function printSshServerHelp(a, log) {
237
248
  // (so deeper needs ACM), whereas on the ssh/own-edge path the user serves their own wildcard cert. Shown
238
249
  // only for wildcard-capable tunnels (a quick tunnel can't do wildcards at all).
239
250
  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.');
251
+ log.log(t('setup.previewHelp1'));
252
+ log.log(t('setup.previewHelp2'));
242
253
  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.");
254
+ log.log(t('setup.previewTlsCf'));
244
255
  } else {
245
- log.log(" TLS: your edge serves the wildcard cert (e.g. a Let's Encrypt *.preview.your.domain).");
256
+ log.log(t('setup.previewTlsEdge'));
246
257
  }
247
258
  log.log('');
248
259
  }
@@ -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,24 @@ 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';
25
27
 
26
28
  const here = dirname(fileURLToPath(import.meta.url));
27
29
  const HOOKS_SRC = resolvePath(here, '../hooks'); // server/hooks (bundled scripts)
28
30
 
31
+ // Summarize inbox-hook state across every coding agent for the phone: 'installed' if any agent is wired,
32
+ // 'absent' if an agent is present but none wired (→ offer the one-tap enable), 'no-claude' if there's no
33
+ // agent at all (→ hide the prompt).
34
+ function combinedHooksStatus(home) {
35
+ const c = hooksStatus(home); // 'no-claude' | 'installed' | 'absent'
36
+ const x = codexHooksStatus(home); // 'no-codex' | 'installed' | 'absent'
37
+ if (c === 'installed' || x === 'installed') return 'installed';
38
+ if (c !== 'no-claude' || x !== 'no-codex') return 'absent';
39
+ return 'no-claude';
40
+ }
41
+
29
42
  const ALLOWED_KEYS = new Set([
30
43
  'Up', 'Down', 'Left', 'Right', 'Space', 'Enter', 'Escape', 'Tab', 'BTab', 'BSpace',
31
44
  'C-c', 'C-d', 'C-z', 'C-l', 'C-r', 'C-o', 'C-e',
@@ -437,16 +450,21 @@ export function createApiRouter({
437
450
  // Optional integrations are configured per-install (open-source installs ship without keys), so the
438
451
  // client asks what's actually available and hides controls that can't work — e.g. the mic when no
439
452
  // ASR engine is configured. Add more flags here as optional integrations land.
453
+ // `claudeHooks` (name kept for web back-compat) now summarizes EVERY coding agent: 'installed' if any is
454
+ // wired, 'absent' if an agent is present but none wired (→ offer enable), 'no-claude' if no agent at all.
440
455
  r.get('/config', (req, res) => {
441
- res.json({ asr: isAsrConfigured(asrEnv), claudeHooks: hooksStatus(home) });
456
+ res.json({ asr: isAsrConfigured(asrEnv), claudeHooks: combinedHooksStatus(home) });
442
457
  });
443
458
 
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.
459
+ // One-tap enable from the phone: install the hooks for every present agent (Claude Code, Codex) on the
460
+ // host (token-gated, like every API here). Opt-in — the inbox only offers this when status is 'absent'.
461
+ // Never creates ~/.claude or ~/.codex; a user's own Codex `notify` is left untouched (see codexHooks.js).
446
462
  r.post('/hooks/install', (req, res) => {
447
463
  try {
448
- const { status } = installHooks(home, { srcDir: HOOKS_SRC, stateFile });
449
- res.json({ ok: status === 'installed', status });
464
+ let installed = 0;
465
+ if (hooksStatus(home) !== 'no-claude') { installHooks(home, { srcDir: HOOKS_SRC, stateFile }); installed++; }
466
+ if (codexHooksStatus(home) !== 'no-codex') { installCodexHooks(home, { srcDir: HOOKS_SRC, stateFile }); installed++; }
467
+ res.json({ ok: installed > 0, status: combinedHooksStatus(home) });
450
468
  } catch (e) { res.status(500).json({ ok: false, error: String(e) }); }
451
469
  });
452
470
 
@@ -512,6 +530,30 @@ export function createApiRouter({
512
530
  try { res.json(await claudeEvents.getStates(allowed)); } catch (e) { next(e); }
513
531
  });
514
532
 
533
+ // Orphan Claude sessions: `claude` processes running on this host but NOT inside a tmux pane, so
534
+ // handmux can't steer them. Surfaced at the bottom of the Inbox with a "takeover" (spawn
535
+ // `claude --resume` in tmux). Best-effort process scan (see orphans.js); never throws.
536
+ r.get('/orphans', async (req, res, next) => {
537
+ try { res.json(await scanOrphans({ projectsDir: defaultProjectsDir(home) })); } catch (e) { next(e); }
538
+ });
539
+
540
+ // Take over an orphan: spawn `claude --resume <sessionId>` in tmux and (default) SIGTERM the original.
541
+ // pid/sessionId are re-verified against a fresh scan server-side; sessionId must be a UUID (it's typed
542
+ // into a shell). target.mode 'new' (fresh session) or 'window' (into an existing session id).
543
+ r.post('/orphans/takeover', async (req, res, next) => {
544
+ const { pid, sessionId, kill, target } = req.body || {};
545
+ const t = target && target.mode === 'window' && isSessionId(target.session)
546
+ ? { mode: 'window', session: target.session } : { mode: 'new' };
547
+ try {
548
+ const out = await takeoverOrphan(
549
+ { commands, scanOpts: { projectsDir: defaultProjectsDir(home) } },
550
+ { pid, sessionId, target: t, kill: kill !== false },
551
+ );
552
+ if (out.error) return res.status(out.status).json({ error: out.error });
553
+ res.json(out);
554
+ } catch (e) { next(e); }
555
+ });
556
+
515
557
  // --- Preview registry (static dir OR dynamic port) -----------------------------------------
516
558
  // POST {name,dir} registers a static dir served at /preview/<name>/; POST {name,port} registers a
517
559
  // 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
+ }
@@ -40,11 +40,16 @@ DOT = {
40
40
  "working": "#[fg=#2f6fed,blink]●#[default] ", # 蓝闪
41
41
  }
42
42
 
43
+ # 冷启动只给【正在跑某个 agent】的窗补点。Claude 的 pane_current_command 是 "claude";Codex 的 PATH 入口
44
+ # 是个 node 启动器,所以是 "node"(与 server 端 liveness 的 procNames 一致)。有状态条目 + 命令属于 agent
45
+ # 才补点,避免给回到 shell 的窗残留脏点。
46
+ AGENT_CMDS = {"claude", "codex", "node"}
47
+
43
48
  # window_id -> 最高优先级 kind
44
49
  top = {}
45
50
  for line in tmux("list-panes", "-a", "-F", "#{pane_current_command} #{pane_id} #{window_id}").splitlines():
46
51
  parts = line.split()
47
- if len(parts) < 3 or parts[0] != "claude":
52
+ if len(parts) < 3 or parts[0] not in AGENT_CMDS:
48
53
  continue
49
54
  _, pane, win = parts[0], parts[1], parts[2]
50
55
  e = state.get(pane)