handmux 0.12.1 → 0.12.3
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-DJNPeU_h.css → index-CpBDvLzi.css} +1 -1
- package/public/assets/{index-C5v5QBzw.js → index-yrOcjnt0.js} +25 -25
- package/public/index.html +10 -6
- package/public/manifest.webmanifest +4 -4
- package/public/sw.js +2 -2
- package/src/agents/scanUtils.js +17 -0
- package/src/orphans.js +17 -6
- package/src/staticCache.js +14 -5
package/public/index.html
CHANGED
|
@@ -5,17 +5,21 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
|
6
6
|
<title>handmux</title>
|
|
7
7
|
|
|
8
|
-
<!-- Installable PWA: "Add to Home Screen" + standalone (no browser address bar).
|
|
9
|
-
|
|
8
|
+
<!-- Installable PWA: "Add to Home Screen" + standalone (no browser address bar).
|
|
9
|
+
?v=2 one-time cache-bust: pre-0.12.2 servers sent these stable-url assets as
|
|
10
|
+
`immutable, max-age=1y`, so returning browsers pinned the OLD icon/manifest for a year.
|
|
11
|
+
index.html is no-store (always fresh), so a new query on the refs escapes those poisoned
|
|
12
|
+
caches at once; staticCache.js now serves them `no-cache`, so no future bump is needed. -->
|
|
13
|
+
<link rel="manifest" href="/manifest.webmanifest?v=2" />
|
|
10
14
|
<meta name="theme-color" content="#1a1b1e" />
|
|
11
|
-
<link rel="icon" type="image/png" href="/icons/icon-192.png" />
|
|
15
|
+
<link rel="icon" type="image/png" href="/icons/icon-192.png?v=2" />
|
|
12
16
|
<!-- iOS Safari ignores the manifest's display mode, so these drive standalone + the
|
|
13
17
|
home-screen icon/label. "black" keeps the white status-bar text readable on the dark topbar. -->
|
|
14
18
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
15
19
|
<meta name="mobile-web-app-capable" content="yes" />
|
|
16
20
|
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
|
17
21
|
<meta name="apple-mobile-web-app-title" content="handmux" />
|
|
18
|
-
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
|
22
|
+
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png?v=2" />
|
|
19
23
|
|
|
20
24
|
<!-- Inline boot splash: paints instantly (before the JS bundle loads) so a cold launch shows the
|
|
21
25
|
brand, not a blank dark screen. main.jsx calls window.__hideBootSplash() once React has painted;
|
|
@@ -44,8 +48,8 @@
|
|
|
44
48
|
@keyframes bootDot { 0%,100% { opacity: .22; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-3px); } }
|
|
45
49
|
@media (prefers-reduced-motion: reduce) { .boot-glow, .boot-dots i { animation: none; opacity: .8; } }
|
|
46
50
|
</style>
|
|
47
|
-
<script type="module" crossorigin src="/assets/index-
|
|
48
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
51
|
+
<script type="module" crossorigin src="/assets/index-yrOcjnt0.js"></script>
|
|
52
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CpBDvLzi.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-CpBDvLzi.css"></noscript>
|
|
49
53
|
</head>
|
|
50
54
|
<body>
|
|
51
55
|
<div id="boot-splash" aria-hidden="true">
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
"background_color": "#1a1b1e",
|
|
10
10
|
"theme_color": "#1a1b1e",
|
|
11
11
|
"icons": [
|
|
12
|
-
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
|
13
|
-
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
|
14
|
-
{ "src": "icons/icon-192-maskable.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
|
|
15
|
-
{ "src": "icons/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
|
12
|
+
{ "src": "icons/icon-192.png?v=2", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
|
13
|
+
{ "src": "icons/icon-512.png?v=2", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
|
14
|
+
{ "src": "icons/icon-192-maskable.png?v=2", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
|
|
15
|
+
{ "src": "icons/icon-512-maskable.png?v=2", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
|
16
16
|
],
|
|
17
17
|
"share_target": {
|
|
18
18
|
"action": "/share-target",
|
package/public/sw.js
CHANGED
|
@@ -81,11 +81,11 @@ self.addEventListener('push', (event) => {
|
|
|
81
81
|
// icon = the large right-side logo. We MUST set it: HyperOS/MIUI always reserves that slot, and an
|
|
82
82
|
// empty `icon` makes it back-fill the slot with the PWA's own icon at a stray size (the `>` reads as
|
|
83
83
|
// a lone "v") plus leaves a gap. An explicit app icon fills the slot cleanly.
|
|
84
|
-
icon: '/icons/icon-192.png',
|
|
84
|
+
icon: '/icons/icon-192.png?v=2',
|
|
85
85
|
// badge = the small monochrome status-bar icon. Android reads only its ALPHA and tints the
|
|
86
86
|
// silhouette; an opaque image (icon-192 has no alpha) can't be used, so it falls back to the
|
|
87
87
|
// browser's own Chrome logo. badge-96.png is a white `>▮` silhouette on transparent → our mark.
|
|
88
|
-
badge: '/icons/badge-96.png',
|
|
88
|
+
badge: '/icons/badge-96.png?v=2',
|
|
89
89
|
data: d.data || {},
|
|
90
90
|
}));
|
|
91
91
|
});
|
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/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 || '',
|
package/src/staticCache.js
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
|
-
// Cache-Control policy for the static web bundle.
|
|
2
|
-
// HTTP-cached — an installed PWA would otherwise
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Cache-Control policy for the static web bundle.
|
|
2
|
+
// • index.html + the service worker must NEVER be HTTP-cached — an installed PWA would otherwise
|
|
3
|
+
// keep loading a stale shell (pointing at missing hashed assets) or a stale service worker.
|
|
4
|
+
// • Vite build output under /assets/ carries a content hash in its filename, so the URL itself
|
|
5
|
+
// changes whenever the bytes change → safe to pin forever (immutable).
|
|
6
|
+
// • Everything else is a hand-authored public/ file served at a STABLE url whose bytes change in
|
|
7
|
+
// place across releases (icons, manifest.webmanifest, favicon, og cards, offline assets). Those
|
|
8
|
+
// must NOT be immutable — pinning them would freeze an old copy in every returning browser for a
|
|
9
|
+
// year (a changed app icon / manifest would never reach anyone who had visited before). They get
|
|
10
|
+
// `no-cache` = store but always revalidate; the ETag/Last-Modified express.static already sends
|
|
11
|
+
// make the recheck a cheap 304 when nothing changed, and an actual change is picked up at once.
|
|
12
|
+
// Pure so it unit-tests on its own.
|
|
5
13
|
export function cacheControlFor(filePath) {
|
|
6
14
|
if (filePath.endsWith('.html') || filePath.endsWith('/sw.js')) return 'no-store';
|
|
7
|
-
return 'public, max-age=31536000, immutable';
|
|
15
|
+
if (/[\\/]assets[\\/]/.test(filePath)) return 'public, max-age=31536000, immutable';
|
|
16
|
+
return 'no-cache';
|
|
8
17
|
}
|