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.
- package/README.md +18 -0
- package/README.zh-CN.md +16 -0
- package/bin/handmux.js +158 -106
- package/hooks/handmux-notify.sh +4 -2
- package/hooks/handmux-write.cjs +16 -3
- package/package.json +1 -1
- package/public/assets/index-DlO8ul0J.js +157 -0
- package/public/assets/{index-CSX2d9C7.css → index-_nvN0yNe.css} +1 -1
- package/public/index.html +2 -2
- package/src/agents/claude.js +95 -0
- package/src/agents/codex.js +110 -0
- package/src/agents/index.js +20 -0
- package/src/agents/scanUtils.js +209 -0
- package/src/claudeEvents.js +25 -60
- package/src/cli/cloudflared.js +49 -3
- package/src/cli/codexHooks.js +125 -0
- package/src/cli/i18n/en.js +186 -0
- package/src/cli/i18n/index.js +53 -0
- package/src/cli/i18n/zh.js +185 -0
- package/src/cli/options.js +3 -0
- package/src/cli/setupWizard.js +57 -46
- package/src/cli/updateCheck.js +90 -0
- package/src/httpApi.js +47 -5
- package/src/orphans.js +138 -0
- package/tmux/claude-tab-seed.py +6 -1
- package/public/assets/index-CNEiBm9y.js +0 -157
package/src/cli/setupWizard.js
CHANGED
|
@@ -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('
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
log.log('
|
|
114
|
-
log.log('
|
|
115
|
-
log.log('
|
|
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
|
|
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
|
|
120
|
-
const port = Number(await ask(rl, '
|
|
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, '
|
|
125
|
-
answers.cfTunnelName = await ask(rl, '
|
|
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, '
|
|
129
|
-
answers.remotePort = Number(await ask(rl, '
|
|
130
|
-
answers.publicUrl = await ask(rl, '
|
|
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(
|
|
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, '
|
|
163
|
+
if (await askYesNo(rl, t('setup.pushKeep'), true)) return existing;
|
|
153
164
|
return undefined;
|
|
154
165
|
}
|
|
155
|
-
if (!await askYesNo(rl, '
|
|
166
|
+
if (!await askYesNo(rl, t('setup.pushSetup'), false)) return undefined;
|
|
156
167
|
const { publicKey, privateKey } = webpush.generateVAPIDKeys();
|
|
157
|
-
const subject = await ask(rl, '
|
|
158
|
-
log.log('
|
|
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, '
|
|
176
|
+
if (await askYesNo(rl, t('setup.voiceKeep'), true)) return existing;
|
|
166
177
|
return undefined;
|
|
167
178
|
}
|
|
168
|
-
if (!await askYesNo(rl, '
|
|
169
|
-
const appId = await ask(rl, '
|
|
170
|
-
const apiKey = await ask(rl, '
|
|
171
|
-
const apiSecret = await ask(rl, '
|
|
172
|
-
if (!appId || !apiKey || !apiSecret) { log.log(
|
|
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(
|
|
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(
|
|
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(
|
|
195
|
-
log.error(
|
|
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(
|
|
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(
|
|
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(
|
|
211
|
-
log.error('
|
|
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(
|
|
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('
|
|
223
|
-
log.log(
|
|
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('
|
|
230
|
-
log.log(
|
|
231
|
-
log.log(
|
|
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(
|
|
241
|
-
log.log('
|
|
251
|
+
log.log(t('setup.previewHelp1'));
|
|
252
|
+
log.log(t('setup.previewHelp2'));
|
|
242
253
|
if (tunnel === 'cloudflare-named') {
|
|
243
|
-
log.log(
|
|
254
|
+
log.log(t('setup.previewTlsCf'));
|
|
244
255
|
} else {
|
|
245
|
-
log.log(
|
|
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:
|
|
456
|
+
res.json({ asr: isAsrConfigured(asrEnv), claudeHooks: combinedHooksStatus(home) });
|
|
442
457
|
});
|
|
443
458
|
|
|
444
|
-
// One-tap enable from the phone: install the
|
|
445
|
-
// here). Opt-in — the inbox only offers this when status is 'absent'.
|
|
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
|
-
|
|
449
|
-
|
|
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
|
+
}
|
package/tmux/claude-tab-seed.py
CHANGED
|
@@ -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]
|
|
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)
|