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/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-BXd3a0v9.js"></script>
52
- <link rel="stylesheet" crossorigin href="/assets/index-DJNPeU_h.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-DJNPeU_h.css"></noscript>
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">
@@ -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) {
@@ -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
 
@@ -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',
@@ -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': '语音',
@@ -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;
@@ -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 (token, staticDir, previewDomain…)
55
- // is preserved untouched.
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
- for (let i = 1; i < 1000 && !name; i++) {
80
- const cand = takeoverSessionName(o.cwdLabel, i, agent.takeoverPrefix);
81
- if (!existing.has(cand)) name = cand;
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: cwd ? path.basename(cwd) : '',
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 || '',