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.
- package/README.md +18 -4
- package/README.zh-CN.md +16 -4
- package/bin/handmux.js +176 -117
- package/hooks/handmux-notify.sh +4 -2
- package/hooks/handmux-statusline.cjs +75 -0
- package/hooks/handmux-write.cjs +17 -24
- package/package.json +1 -2
- package/public/assets/index-CCvTPJwI.js +157 -0
- package/public/assets/{index-CSX2d9C7.css → index-CYEn5HNU.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 +27 -69
- 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 +62 -48
- package/src/cli/statusLine.js +79 -0
- package/src/cli/updateCheck.js +90 -0
- package/src/httpApi.js +55 -5
- package/src/orphans.js +138 -0
- package/src/usage.js +94 -0
- package/public/assets/index-CHabGCEm.js +0 -157
- package/src/cli/tmuxConf.js +0 -90
- package/tmux/README.md +0 -77
- package/tmux/claude-tab-seed.py +0 -67
- package/tmux/claude-tab-seen.sh +0 -14
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,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('
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
log.log('
|
|
112
|
-
log.log('
|
|
113
|
-
log.log('
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
120
|
-
const port = Number(await ask(rl, '
|
|
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, '
|
|
125
|
-
answers.cfTunnelName = await ask(rl, '
|
|
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, '
|
|
129
|
-
answers.remotePort = Number(await ask(rl, '
|
|
130
|
-
answers.publicUrl = await ask(rl, '
|
|
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(
|
|
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, '
|
|
166
|
+
if (await askYesNo(rl, t('setup.pushKeep'), true)) return existing;
|
|
153
167
|
return undefined;
|
|
154
168
|
}
|
|
155
|
-
if (!await askYesNo(rl, '
|
|
169
|
+
if (!await askYesNo(rl, t('setup.pushSetup'), false)) return undefined;
|
|
156
170
|
const { publicKey, privateKey } = webpush.generateVAPIDKeys();
|
|
157
|
-
const subject = await ask(rl, '
|
|
158
|
-
log.log('
|
|
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, '
|
|
179
|
+
if (await askYesNo(rl, t('setup.voiceKeep'), true)) return existing;
|
|
166
180
|
return undefined;
|
|
167
181
|
}
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
195
|
-
log.error(
|
|
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(
|
|
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(
|
|
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(
|
|
211
|
-
log.error('
|
|
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(
|
|
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('
|
|
223
|
-
log.log(
|
|
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('
|
|
230
|
-
log.log(
|
|
231
|
-
log.log(
|
|
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(
|
|
241
|
-
log.log('
|
|
254
|
+
log.log(t('setup.previewHelp1'));
|
|
255
|
+
log.log(t('setup.previewHelp2'));
|
|
242
256
|
if (tunnel === 'cloudflare-named') {
|
|
243
|
-
log.log(
|
|
257
|
+
log.log(t('setup.previewTlsCf'));
|
|
244
258
|
} else {
|
|
245
|
-
log.log(
|
|
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:
|
|
457
|
+
res.json({ asr: isAsrConfigured(asrEnv), claudeHooks: combinedHooksStatus(home) });
|
|
442
458
|
});
|
|
443
459
|
|
|
444
|
-
// One-tap enable from the phone: install the
|
|
445
|
-
// here). Opt-in — the inbox only offers this when status is 'absent'.
|
|
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
|
-
|
|
449
|
-
|
|
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
|
+
}
|