handmux 0.12.2 → 0.13.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 +17 -4
- package/README.zh-CN.md +17 -4
- package/package.json +4 -4
- package/public/assets/index-Bew5DIWc.css +32 -0
- package/public/assets/index-Kniw-XQB.js +159 -0
- package/public/index.html +2 -2
- package/src/agents/scanUtils.js +17 -0
- package/src/claudeEvents.js +19 -1
- package/src/cli/i18n/en.js +9 -0
- package/src/cli/i18n/zh.js +9 -0
- package/src/cli/options.js +1 -1
- package/src/cli/setupWizard.js +45 -3
- package/src/orphans.js +17 -6
- package/public/assets/index-BXd3a0v9.js +0 -159
- package/public/assets/index-DJNPeU_h.css +0 -32
package/public/index.html
CHANGED
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
@keyframes bootDot { 0%,100% { opacity: .22; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-3px); } }
|
|
49
49
|
@media (prefers-reduced-motion: reduce) { .boot-glow, .boot-dots i { animation: none; opacity: .8; } }
|
|
50
50
|
</style>
|
|
51
|
-
<script type="module" crossorigin src="/assets/index-
|
|
52
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
51
|
+
<script type="module" crossorigin src="/assets/index-Kniw-XQB.js"></script>
|
|
52
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Bew5DIWc.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-Bew5DIWc.css"></noscript>
|
|
53
53
|
</head>
|
|
54
54
|
<body>
|
|
55
55
|
<div id="boot-splash" aria-hidden="true">
|
package/src/agents/scanUtils.js
CHANGED
|
@@ -200,6 +200,23 @@ export function takeoverSessionName(cwdLabel, n, prefix = 'cc') {
|
|
|
200
200
|
return `${prefix}-${base}-${n}`.slice(0, 16);
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
// Coerce a user-typed takeover name into a valid tmux session name (isValidSessionName: [A-Za-z0-9-], ≤16):
|
|
204
|
+
// non-alnum runs → a single '-', trimmed of edge hyphens, capped at 16. Returns '' if nothing usable is
|
|
205
|
+
// left (caller then falls back to the generated name).
|
|
206
|
+
export function sanitizeSessionName(s) {
|
|
207
|
+
return String(s || '').replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 16).replace(/-+$/, '');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// First name in `<base>, base-2, base-3, …` (each capped at 16) that isn't already taken. null if none free.
|
|
211
|
+
export function freeSessionName(base, taken) {
|
|
212
|
+
if (!taken.has(base)) return base;
|
|
213
|
+
for (let i = 2; i < 1000; i++) {
|
|
214
|
+
const cand = `${base}-${i}`.slice(0, 16);
|
|
215
|
+
if (!taken.has(cand)) return cand;
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
203
220
|
export const isShell = (c) => /^-?(zsh|bash|sh|fish|dash|tcsh|csh|ksh)$/.test(String(c || ''));
|
|
204
221
|
|
|
205
222
|
export async function lsofCwd(run, pid) {
|
package/src/claudeEvents.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import { getAgent } from './agents/index.js';
|
|
4
|
+
import { getAgent, agentForProc } from './agents/index.js';
|
|
5
5
|
import { claude } from './agents/claude.js';
|
|
6
6
|
|
|
7
7
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -141,6 +141,24 @@ export function createClaudeEvents({ commands, push, file = DEFAULT_STATE_FILE,
|
|
|
141
141
|
if (allow && !allow.has(loc.session)) continue;
|
|
142
142
|
out[pane] = { ...loc, kind: c.kind, msg: c.msg || '', ts: rec.ts || 0, agent: agent.id };
|
|
143
143
|
}
|
|
144
|
+
|
|
145
|
+
// (3) process presence — a pane whose FOREGROUND program IS a coding agent reads as "agent here" even
|
|
146
|
+
// with no active turn on record: a fresh session that hasn't prompted yet, or one right after `/clear`
|
|
147
|
+
// (whose SessionEnd dropped its roster entry above though the agent is still running). This is what the
|
|
148
|
+
// phone's per-window agent icon and the dock's default mode key off — process liveness, not the volatile
|
|
149
|
+
// activity roster. Emitted with kind:null so the inbox (inboxRows keys off VIEW[kind]) skips it and no
|
|
150
|
+
// push fires, and with NO ts so it stays inert to the done/read high-water marks (maxTs, the seen-mark
|
|
151
|
+
// effect). An active roster entry from (2) always wins (out[id] set → skip). agentForProc matches the
|
|
152
|
+
// canonical procName (never the ambiguous 'node'), so a plain node process is not mistaken for an agent.
|
|
153
|
+
if (live) {
|
|
154
|
+
for (const lp of live.values()) {
|
|
155
|
+
if (out[lp.id]) continue;
|
|
156
|
+
const a = agentForProc(lp.cmd);
|
|
157
|
+
if (!a) continue;
|
|
158
|
+
if (allow && !allow.has(lp.session)) continue;
|
|
159
|
+
out[lp.id] = { session: lp.session, window: lp.window, windowName: lp.windowName, kind: null, msg: '', agent: a.id };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
144
162
|
return out;
|
|
145
163
|
}
|
|
146
164
|
|
package/src/cli/i18n/en.js
CHANGED
|
@@ -142,6 +142,15 @@ export default {
|
|
|
142
142
|
'setup.secConnection': 'Connection',
|
|
143
143
|
'setup.secName': 'Name',
|
|
144
144
|
'setup.secPort': 'Port',
|
|
145
|
+
'setup.secToken': 'Token',
|
|
146
|
+
'setup.tokenAuto': 'auto · new each start',
|
|
147
|
+
'setup.tokenCustom': 'Set a custom token…',
|
|
148
|
+
'setup.tokenRandom': 'Generate a random one',
|
|
149
|
+
'setup.tokenReset': 'Reset to auto (new each start)',
|
|
150
|
+
'setup.askToken': 'Access token — it appears in the URL you open on the phone',
|
|
151
|
+
'setup.tokenGenerated': 'New token: {token}',
|
|
152
|
+
'setup.valToken': 'enter a token',
|
|
153
|
+
'setup.valTokenSpace': 'no spaces — the token goes in a URL',
|
|
145
154
|
'setup.secLanguage': 'CLI language / 命令行语言',
|
|
146
155
|
'setup.secPush': 'Push',
|
|
147
156
|
'setup.secVoice': 'Voice',
|
package/src/cli/i18n/zh.js
CHANGED
|
@@ -141,6 +141,15 @@ export default {
|
|
|
141
141
|
'setup.secConnection': '连接',
|
|
142
142
|
'setup.secName': '名称',
|
|
143
143
|
'setup.secPort': '端口',
|
|
144
|
+
'setup.secToken': '令牌',
|
|
145
|
+
'setup.tokenAuto': '自动 · 每次启动新生成',
|
|
146
|
+
'setup.tokenCustom': '自定义令牌…',
|
|
147
|
+
'setup.tokenRandom': '随机生成一个',
|
|
148
|
+
'setup.tokenReset': '恢复自动(每次启动新生成)',
|
|
149
|
+
'setup.askToken': '访问令牌 —— 会出现在手机打开的网址里',
|
|
150
|
+
'setup.tokenGenerated': '新令牌:{token}',
|
|
151
|
+
'setup.valToken': '请输入令牌',
|
|
152
|
+
'setup.valTokenSpace': '不能有空格 —— 令牌会放进网址',
|
|
144
153
|
'setup.secLanguage': '命令行语言 / CLI language',
|
|
145
154
|
'setup.secPush': '推送',
|
|
146
155
|
'setup.secVoice': '语音',
|
package/src/cli/options.js
CHANGED
|
@@ -119,7 +119,7 @@ function resolvePublicUrl(flags, fileCfg, env, tunnel) {
|
|
|
119
119
|
// so it's quick to thumb in on a phone. A user-supplied token (flag/config/env) is used verbatim — any
|
|
120
120
|
// length, never regenerated. 8 chars over a 32-char alphabet ≈ 40 bits, fine for a single secret URL.
|
|
121
121
|
const TOKEN_ALPHABET = '23456789abcdefghijkmnpqrstuvwxyz';
|
|
122
|
-
function defaultGen() {
|
|
122
|
+
export function defaultGen() {
|
|
123
123
|
let s = '';
|
|
124
124
|
for (let i = 0; i < 8; i++) s += TOKEN_ALPHABET[crypto.randomInt(TOKEN_ALPHABET.length)];
|
|
125
125
|
return s;
|
package/src/cli/setupWizard.js
CHANGED
|
@@ -10,6 +10,7 @@ import { homedir } from 'node:os';
|
|
|
10
10
|
import { spawnSync } from 'node:child_process';
|
|
11
11
|
import webpush from 'web-push';
|
|
12
12
|
import { configPath, pocketHome } from './state.js';
|
|
13
|
+
import { defaultGen as genToken } from './options.js';
|
|
13
14
|
import { resolveCloudflared } from './cloudflared.js';
|
|
14
15
|
import { resolveTunlite, checkSshAuth } from './tunlite.js';
|
|
15
16
|
import { resolveNatapp, resolveCpolar } from './tunnelClients.js';
|
|
@@ -51,10 +52,11 @@ export function findTunnelId(listJsonOut, name) {
|
|
|
51
52
|
|
|
52
53
|
// The config keys the wizard owns: everything it can set. mergeConfig wipes these from the existing config
|
|
53
54
|
// before re-applying the answers, so switching a tunnel (or clearing an optional field) cleanly drops the
|
|
54
|
-
// old value instead of leaving a stale field behind. Anything NOT here (
|
|
55
|
-
//
|
|
55
|
+
// old value instead of leaving a stale field behind. Anything NOT here (staticDir, previewDomain…) is
|
|
56
|
+
// preserved untouched. `token` IS owned so the Token row can pin one AND clear it back to auto — but it
|
|
57
|
+
// round-trips through answersFromConfig, so a re-run that never touches the row still writes it back.
|
|
56
58
|
const WIZARD_KEYS = [
|
|
57
|
-
'lang', 'name', 'port', 'tunnel',
|
|
59
|
+
'lang', 'name', 'port', 'tunnel', 'token',
|
|
58
60
|
'sshHost', 'remotePort', 'sshJump', 'cfHostname', 'cfTunnelName', 'publicUrl',
|
|
59
61
|
'authtoken', 'cpolarRegion',
|
|
60
62
|
'vapid', 'xfyun',
|
|
@@ -68,6 +70,7 @@ export function configFromAnswers(a) {
|
|
|
68
70
|
const cfg = { tunnel: a.tunnel, port: a.port };
|
|
69
71
|
if (a.lang) cfg.lang = a.lang;
|
|
70
72
|
if (a.name) cfg.name = a.name;
|
|
73
|
+
if (a.token) cfg.token = a.token; // blank = don't pin one → the server mints a fresh token each start
|
|
71
74
|
if (a.tunnel === 'ssh') {
|
|
72
75
|
cfg.sshHost = a.sshHost;
|
|
73
76
|
cfg.remotePort = a.remotePort;
|
|
@@ -103,6 +106,7 @@ export function answersFromConfig(cfg = {}) {
|
|
|
103
106
|
const a = {
|
|
104
107
|
lang: cfg.lang || getLocale(),
|
|
105
108
|
name: cfg.name || '',
|
|
109
|
+
token: cfg.token || '', // '' = not pinned (auto each start); seeded so an untouched re-run rewrites it
|
|
106
110
|
tunnel: cfg.tunnel || 'none',
|
|
107
111
|
port: Number(cfg.port) || 19999,
|
|
108
112
|
};
|
|
@@ -144,6 +148,14 @@ export function validateContact(v) {
|
|
|
144
148
|
if (!wellFormed || /\.local(?:[:/]|$)/i.test(s)) return t('setup.valContact');
|
|
145
149
|
return undefined;
|
|
146
150
|
}
|
|
151
|
+
// Access token: it rides in the phone's URL as ?token=…, so require something and reject whitespace (a space
|
|
152
|
+
// would break the link). Length/charset are otherwise up to the user — a pinned token is used verbatim.
|
|
153
|
+
export function validateToken(v) {
|
|
154
|
+
const s = String(v || '').trim();
|
|
155
|
+
if (!s) return t('setup.valToken');
|
|
156
|
+
if (/\s/.test(s)) return t('setup.valTokenSpace');
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
147
159
|
|
|
148
160
|
function readExisting(file) {
|
|
149
161
|
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return {}; }
|
|
@@ -185,6 +197,7 @@ export async function runSetup({ home = homedir(), target = configPath(home), lo
|
|
|
185
197
|
{ value: 'connection', label: t('setup.secConnection'), hint: summarizeConnection(a) },
|
|
186
198
|
{ value: 'name', label: t('setup.secName'), hint: a.name || t('setup.default') },
|
|
187
199
|
{ value: 'port', label: t('setup.secPort'), hint: String(a.port) },
|
|
200
|
+
{ value: 'token', label: t('setup.secToken'), hint: a.token ? maskSecret(a.token) : t('setup.tokenAuto') },
|
|
188
201
|
{ value: 'push', label: t('setup.secPush'), hint: a.vapid ? (a.vapid.subject || t('setup.on')) : t('setup.off') },
|
|
189
202
|
{ value: 'voice', label: t('setup.secVoice'), hint: a.xfyun ? (a.xfyun.appId || t('setup.on')) : t('setup.off') },
|
|
190
203
|
// A CLI-tool preference (language of handmux's own terminal output), not an app setting — so it
|
|
@@ -212,6 +225,7 @@ export async function runSetup({ home = homedir(), target = configPath(home), lo
|
|
|
212
225
|
if (choice === 'connection') a = await editConnection(a, { home, log });
|
|
213
226
|
else if (choice === 'name') a.name = await editName(a);
|
|
214
227
|
else if (choice === 'port') a.port = await editPort(a);
|
|
228
|
+
else if (choice === 'token') a.token = await editToken(a);
|
|
215
229
|
else if (choice === 'language') a.lang = await editLanguage(a);
|
|
216
230
|
else if (choice === 'push') a.vapid = await editPush(a);
|
|
217
231
|
else if (choice === 'voice') a.xfyun = await editVoice(a);
|
|
@@ -250,6 +264,34 @@ async function editPort(a) {
|
|
|
250
264
|
return Number(v);
|
|
251
265
|
}
|
|
252
266
|
|
|
267
|
+
// The access token — the one secret in the phone's URL. Unset = the server mints a fresh one each start
|
|
268
|
+
// (printed + QR'd), so the link changes every restart; pinning one keeps the same URL. A mini-hub (like
|
|
269
|
+
// push/voice): type your own, generate + pin a strong random one, or reset back to auto. Editing custom
|
|
270
|
+
// pre-fills the current value (so you can read it off); the hub hint masks it. Esc returns to the main hub,
|
|
271
|
+
// keeping the choice. Returns the token string ('' = auto).
|
|
272
|
+
async function editToken(a) {
|
|
273
|
+
let token = a.token || '';
|
|
274
|
+
for (;;) {
|
|
275
|
+
let pick;
|
|
276
|
+
try {
|
|
277
|
+
pick = await ask(select({
|
|
278
|
+
message: withBack(t('setup.secToken')),
|
|
279
|
+
options: [
|
|
280
|
+
{ value: 'custom', label: t('setup.tokenCustom'), hint: token ? maskSecret(token) : t('setup.tokenAuto') },
|
|
281
|
+
{ value: 'random', label: t('setup.tokenRandom') },
|
|
282
|
+
{ value: 'auto', label: t('setup.tokenReset'), hint: t('setup.tokenAuto') },
|
|
283
|
+
],
|
|
284
|
+
initialValue: 'custom',
|
|
285
|
+
}));
|
|
286
|
+
} catch (e) { if (e === CANCELLED) return token; throw e; } // back to the main hub, keeping the choice
|
|
287
|
+
try {
|
|
288
|
+
if (pick === 'custom') token = (await ask(text({ message: t('setup.askToken'), initialValue: token, validate: validateToken }))).trim();
|
|
289
|
+
else if (pick === 'random') { token = genToken(); note(t('setup.tokenGenerated', { token })); }
|
|
290
|
+
else if (pick === 'auto') token = '';
|
|
291
|
+
} catch (e) { if (e !== CANCELLED) throw e; } // Esc in a sub-edit → back to the mini-hub
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
253
295
|
const bareHost = (u) => String(u || '').replace(/^https?:\/\//, '');
|
|
254
296
|
// cloudflare's two drivers are ONE family in the UI: 'cloudflare' (quick, no login) and 'cloudflare-named'
|
|
255
297
|
// (your domain, needs a login) are picked as a mode INSIDE cloudflare, not as two separate tunnels.
|
package/src/orphans.js
CHANGED
|
@@ -21,7 +21,7 @@ import { AGENTS, getAgent } from './agents/index.js';
|
|
|
21
21
|
import { isSessionId } from './tmux/commands.js';
|
|
22
22
|
import {
|
|
23
23
|
defaultRun, parseAgentProcs, parsePaneMembership, findOrphans,
|
|
24
|
-
takeoverSessionName, isShell, lsofCwd, isSessionUuid,
|
|
24
|
+
takeoverSessionName, sanitizeSessionName, freeSessionName, isShell, lsofCwd, isSessionUuid,
|
|
25
25
|
} from './agents/scanUtils.js';
|
|
26
26
|
|
|
27
27
|
// Back-compat re-exports: these were originally defined here and are imported by tests and callers by this
|
|
@@ -53,7 +53,7 @@ export async function takeoverOrphan(
|
|
|
53
53
|
delay = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
54
54
|
pollTries = 16, pollMs = 400,
|
|
55
55
|
},
|
|
56
|
-
{ pid, sessionId, target = { mode: 'new' }, kill = true } = {},
|
|
56
|
+
{ pid, sessionId, target = { mode: 'new' }, kill = true, name: wantName } = {},
|
|
57
57
|
) {
|
|
58
58
|
if (!Number.isInteger(pid) || pid <= 0) return { error: 'bad pid', status: 400 };
|
|
59
59
|
// Both agents use UUID session ids; validate up front (takeover types the id into a shell via send-keys).
|
|
@@ -76,10 +76,18 @@ export async function takeoverOrphan(
|
|
|
76
76
|
wid = await commands.newWindow(sid, o.cwd, agent.procName, cmd);
|
|
77
77
|
} else {
|
|
78
78
|
const existing = new Set((await commands.listSessions()).map((s) => s.name));
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
// Honour a user-typed name (sanitized to tmux rules, deduped with a numeric suffix); fall back to the
|
|
80
|
+
// generated `<prefix>-<dir>-<n>` when the client sent nothing.
|
|
81
|
+
const wanted = sanitizeSessionName(wantName);
|
|
82
|
+
if (wanted) {
|
|
83
|
+
name = freeSessionName(wanted, existing);
|
|
84
|
+
} else {
|
|
85
|
+
for (let i = 1; i < 1000 && !name; i++) {
|
|
86
|
+
const cand = takeoverSessionName(o.cwdLabel, i, agent.takeoverPrefix);
|
|
87
|
+
if (!existing.has(cand)) name = cand;
|
|
88
|
+
}
|
|
82
89
|
}
|
|
90
|
+
if (!name) return { error: 'no free session name', status: 409 };
|
|
83
91
|
sid = await commands.newSession(name, o.cwd, cmd);
|
|
84
92
|
wid = (await commands.listWindows(sid))[0]?.id;
|
|
85
93
|
}
|
|
@@ -121,12 +129,15 @@ export async function scanOrphans({
|
|
|
121
129
|
const cwd = await lsofCwd(run, o.pid);
|
|
122
130
|
const dir = dirOverrides[agent.sessions.dirOptKey] || agent.sessions.dir(home);
|
|
123
131
|
const meta = cwd ? await agent.sessions.resolve(dir, cwd, { busyMs, now }) : {};
|
|
132
|
+
const cwdLabel = cwd ? path.basename(cwd) : '';
|
|
124
133
|
results.push({
|
|
125
134
|
pid: o.pid,
|
|
126
135
|
agent: agent.id,
|
|
127
136
|
agentLabel: agent.label,
|
|
128
137
|
cwd: cwd || '',
|
|
129
|
-
cwdLabel
|
|
138
|
+
cwdLabel,
|
|
139
|
+
// Default name the takeover sheet prefills (the same `<prefix>-<dir>-1` the server would auto-pick).
|
|
140
|
+
suggestedName: takeoverSessionName(cwdLabel, 1, agent.takeoverPrefix),
|
|
130
141
|
sessionId: meta.sessionId || null,
|
|
131
142
|
state: meta.state || 'unknown',
|
|
132
143
|
snippet: meta.snippet || '',
|