handmux 0.6.0 → 0.8.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 +0 -4
- package/README.zh-CN.md +0 -4
- package/bin/handmux.js +24 -17
- package/hooks/handmux-statusline.cjs +75 -0
- package/hooks/handmux-write.cjs +6 -26
- package/package.json +1 -2
- package/public/assets/index-CKVeisd1.css +32 -0
- package/public/assets/index-aNwLKI4h.js +159 -0
- package/public/index.html +2 -2
- package/src/claudeEvents.js +2 -9
- package/src/cli/i18n/en.js +5 -5
- package/src/cli/i18n/zh.js +5 -5
- package/src/cli/setupWizard.js +4 -1
- package/src/cli/statusLine.js +79 -0
- package/src/cli/updateCheck.js +19 -1
- package/src/git.js +27 -4
- package/src/httpApi.js +85 -16
- package/src/uploadTypes.js +2 -0
- package/src/usage.js +94 -0
- package/public/assets/index-DlO8ul0J.js +0 -157
- package/public/assets/index-_nvN0yNe.css +0 -32
- package/src/cli/tmuxConf.js +0 -90
- package/tmux/README.md +0 -77
- package/tmux/claude-tab-seed.py +0 -72
- package/tmux/claude-tab-seen.sh +0 -14
package/public/index.html
CHANGED
|
@@ -42,8 +42,8 @@
|
|
|
42
42
|
@keyframes bootDot { 0%,100% { opacity: .22; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-3px); } }
|
|
43
43
|
@media (prefers-reduced-motion: reduce) { .boot-glow, .boot-dots i { animation: none; opacity: .8; } }
|
|
44
44
|
</style>
|
|
45
|
-
<script type="module" crossorigin src="/assets/index-
|
|
46
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
45
|
+
<script type="module" crossorigin src="/assets/index-aNwLKI4h.js"></script>
|
|
46
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CKVeisd1.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-CKVeisd1.css"></noscript>
|
|
47
47
|
</head>
|
|
48
48
|
<body>
|
|
49
49
|
<div id="boot-splash" aria-hidden="true">
|
package/src/claudeEvents.js
CHANGED
|
@@ -62,7 +62,6 @@ function readStateFile(file) {
|
|
|
62
62
|
// change (the watcher, for push). No persisted state of our own — the file IS the persistence.
|
|
63
63
|
export function createClaudeEvents({ commands, push, file = DEFAULT_STATE_FILE, now = () => Date.now() } = {}) {
|
|
64
64
|
const lastPushed = {}; // pane → 'needs' | 'done' | null (in-process push-transition dedup, by display view)
|
|
65
|
-
const dotClearedFor = {}; // pane → ts of the stuck 进行中 we already cleared @claude_dot for (clear once)
|
|
66
65
|
// The dedup above is in-process ONLY: a restart (e.g. ./deploy.sh) wipes it while the hook's state
|
|
67
66
|
// file on disk keeps every pane's latest 需要你/已完成. Without priming, the first read after boot
|
|
68
67
|
// would see an empty dedup and re-push every resting pane — a flood of "historical" notifications on
|
|
@@ -136,14 +135,8 @@ export function createClaudeEvents({ commands, push, file = DEFAULT_STATE_FILE,
|
|
|
136
135
|
// (2) roster — drop ended / dead / claude-exited panes; resolve location from the live tmux row.
|
|
137
136
|
if (!c || c.kind === 'end' || gone) continue;
|
|
138
137
|
// Expire a 进行中 latched past the TTL (an ESC-interrupt / walk-away that never got a Stop): drop it
|
|
139
|
-
// from the roster
|
|
140
|
-
if (c.kind === 'working' && now() - (rec.ts || 0) > WORKING_TTL_MS)
|
|
141
|
-
if (live && typeof commands.runTmux === 'function' && dotClearedFor[pane] !== rec.ts) {
|
|
142
|
-
dotClearedFor[pane] = rec.ts;
|
|
143
|
-
try { await commands.runTmux(['set-option', '-w', '-t', pane, '@claude_dot', '']); } catch { /* best effort */ }
|
|
144
|
-
}
|
|
145
|
-
continue;
|
|
146
|
-
}
|
|
138
|
+
// from the roster so the stuck working pane goes away. See WORKING_TTL_MS.
|
|
139
|
+
if (c.kind === 'working' && now() - (rec.ts || 0) > WORKING_TTL_MS) continue;
|
|
147
140
|
const loc = lp ? { session: lp.session, window: lp.window, windowName: lp.windowName } : {};
|
|
148
141
|
if (allow && !allow.has(loc.session)) continue;
|
|
149
142
|
out[pane] = { ...loc, kind: c.kind, msg: c.msg || '', ts: rec.ts || 0, agent: agent.id };
|
package/src/cli/i18n/en.js
CHANGED
|
@@ -80,11 +80,11 @@ export default {
|
|
|
80
80
|
'hooks.removed': '✓ Agent hooks removed.',
|
|
81
81
|
'hooks.usage': 'usage: handmux hooks install|uninstall',
|
|
82
82
|
|
|
83
|
-
//
|
|
84
|
-
'
|
|
85
|
-
'
|
|
86
|
-
'
|
|
87
|
-
'
|
|
83
|
+
// Claude statusLine usage capturer (powers the phone Usage page's 5h/weekly bars)
|
|
84
|
+
'statusline.confirmEnable': "Show Claude's 5h/weekly usage on the phone? (installs a Claude statusLine)",
|
|
85
|
+
'statusline.installed': '✓ Claude statusLine installed → ~/.claude/settings.json',
|
|
86
|
+
'statusline.reload': ' Open a new Claude session to load it; the Usage page fills in as it reports.',
|
|
87
|
+
'statusline.foreignHint': 'You already have a Claude statusLine — leaving it untouched. To also feed the phone Usage page, pipe it through our capturer:',
|
|
88
88
|
|
|
89
89
|
// service
|
|
90
90
|
'service.usage': 'usage: handmux service install [start-flags] | handmux service uninstall',
|
package/src/cli/i18n/zh.js
CHANGED
|
@@ -79,11 +79,11 @@ export default {
|
|
|
79
79
|
'hooks.removed': '✓ agent hooks 已移除。',
|
|
80
80
|
'hooks.usage': '用法:handmux hooks install|uninstall',
|
|
81
81
|
|
|
82
|
-
//
|
|
83
|
-
'
|
|
84
|
-
'
|
|
85
|
-
'
|
|
86
|
-
'
|
|
82
|
+
// Claude statusLine 用量捕获(点亮手机用量页的 5h/周额度条)
|
|
83
|
+
'statusline.confirmEnable': '在手机上显示 Claude 的 5h/周额度?(会安装一个 Claude statusLine)',
|
|
84
|
+
'statusline.installed': '✓ Claude statusLine 已安装 → ~/.claude/settings.json',
|
|
85
|
+
'statusline.reload': ' 新开一个 Claude 会话以加载;用量页会随上报逐渐填上。',
|
|
86
|
+
'statusline.foreignHint': '你已经有自己的 Claude statusLine —— 保持不动。想同时点亮手机用量页,把它接到我们的捕获器后面:',
|
|
87
87
|
|
|
88
88
|
// service
|
|
89
89
|
'service.usage': '用法:handmux service install [start-flags] | handmux service uninstall',
|
package/src/cli/setupWizard.js
CHANGED
|
@@ -124,7 +124,10 @@ export async function runSetup({ home = homedir(), target = configPath(home), lo
|
|
|
124
124
|
log.log(t('setup.tunnel2'));
|
|
125
125
|
log.log(t('setup.tunnel3'));
|
|
126
126
|
log.log(t('setup.tunnel4'));
|
|
127
|
-
|
|
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';
|
|
128
131
|
const pick = await ask(rl, t('setup.choose'), curPick);
|
|
129
132
|
const tunnel = { 1: 'none', 2: 'cloudflare', 3: 'cloudflare-named', 4: 'ssh' }[pick];
|
|
130
133
|
if (!tunnel) { log.error(t('setup.invalid')); return null; }
|
|
@@ -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
|
+
}
|
package/src/cli/updateCheck.js
CHANGED
|
@@ -14,7 +14,7 @@ import { pocketHome } from './state.js';
|
|
|
14
14
|
import { t } from './i18n/index.js';
|
|
15
15
|
|
|
16
16
|
export const PKG_NAME = 'handmux';
|
|
17
|
-
export const CHECK_INTERVAL_MS =
|
|
17
|
+
export const CHECK_INTERVAL_MS = 60 * 60 * 1000; // refresh the cached "latest" at most once an hour
|
|
18
18
|
|
|
19
19
|
export function updateCachePath(home) { return path.join(pocketHome(home), 'update-check.json'); }
|
|
20
20
|
|
|
@@ -60,6 +60,24 @@ export function fetchLatestVersion({ timeoutMs = 4000, run = spawnSync } = {}) {
|
|
|
60
60
|
} catch { return null; }
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// Non-blocking refresh for the long-running server: query npm asynchronously (never stalls the event loop
|
|
64
|
+
// the way the CLI's spawnSync path would) and persist the same {checkedAt, latest} cache the CLI reads. The
|
|
65
|
+
// /api/version route calls this when the cache is stale, so the phone opening the app keeps `latest` current
|
|
66
|
+
// without the user re-running the CLI. Best-effort: npm missing/offline/blocked leaves the prior latest.
|
|
67
|
+
export function refreshLatestAsync(home, { now = Date.now(), spawnFn = spawn, timeoutMs = 4000 } = {}) {
|
|
68
|
+
try {
|
|
69
|
+
const child = spawnFn('npm', ['view', PKG_NAME, 'version'], { timeout: timeoutMs });
|
|
70
|
+
let out = '';
|
|
71
|
+
child.stdout?.on('data', (d) => { out += d; });
|
|
72
|
+
child.on('close', (code) => {
|
|
73
|
+
const v = String(out).trim();
|
|
74
|
+
const latest = (code === 0 && parts(v)) ? v : (readCache(home)?.latest ?? null);
|
|
75
|
+
writeCache(home, { checkedAt: now, latest });
|
|
76
|
+
});
|
|
77
|
+
child.on('error', () => { /* npm missing/offline — leave the cache untouched */ });
|
|
78
|
+
} catch { /* best effort */ }
|
|
79
|
+
}
|
|
80
|
+
|
|
63
81
|
// The hidden `__update-check` worker (runs detached, prints nothing): refresh the cache. On a failed fetch
|
|
64
82
|
// keep the previously-known latest but still stamp checkedAt, so a flaky network doesn't re-spawn every run.
|
|
65
83
|
export function runUpdateCheck(home, { now = Date.now(), ...opts } = {}) {
|
package/src/git.js
CHANGED
|
@@ -3,13 +3,37 @@ import { homedir } from 'node:os';
|
|
|
3
3
|
import { join, basename, isAbsolute } from 'node:path';
|
|
4
4
|
import { execFile } from 'node:child_process';
|
|
5
5
|
import { isUnder } from './docPath.js';
|
|
6
|
+
import { defaultExtraRoots } from './docs.js';
|
|
6
7
|
|
|
7
8
|
// 只读子命令白名单:命令层硬过滤,杜绝任何写操作混入。
|
|
8
9
|
const READONLY = new Set(['rev-parse', 'status', 'log', 'for-each-ref', 'diff', 'show', 'diff-tree']);
|
|
9
10
|
const MAX_BUFFER = 8 * 1024 * 1024;
|
|
10
11
|
|
|
11
|
-
export function createGit({ home } = {}) {
|
|
12
|
+
export function createGit({ home, extraRoots = [] } = {}) {
|
|
12
13
|
const realHomeP = fs.realpath(home);
|
|
14
|
+
// Same multi-root allow-list as createDocs: $HOME plus a few roots OUTSIDE it (/tmp, $TMPDIR) so a repo
|
|
15
|
+
// an agent is working in under /tmp is reachable from the phone. Resolved once — realpath'd, deduped,
|
|
16
|
+
// missing ones skipped, extras already inside home dropped (home covers them). Keeps git browsing in
|
|
17
|
+
// lock-step with the file/doc browser; git.js used to be home-only, which rejected legit repos under
|
|
18
|
+
// /tmp with a red "outside home".
|
|
19
|
+
const rootsP = (async () => {
|
|
20
|
+
const rh = await realHomeP;
|
|
21
|
+
const out = [rh];
|
|
22
|
+
for (const r of extraRoots) {
|
|
23
|
+
if (typeof r !== 'string' || !r) continue;
|
|
24
|
+
let real;
|
|
25
|
+
try { real = await fs.realpath(r); } catch { continue; } // not present on this host → skip
|
|
26
|
+
if (isUnder(real, rh) || out.includes(real)) continue; // already covered by home / dup
|
|
27
|
+
out.push(real);
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
})();
|
|
31
|
+
// The allowed root that contains `real` (longest match wins should roots ever nest), or null.
|
|
32
|
+
const rootOf = (real, roots) => {
|
|
33
|
+
let best = null;
|
|
34
|
+
for (const r of roots) if (isUnder(real, r) && (!best || r.length > best.length)) best = r;
|
|
35
|
+
return best;
|
|
36
|
+
};
|
|
13
37
|
|
|
14
38
|
function git(cwd, args) {
|
|
15
39
|
const sub = args[0];
|
|
@@ -23,10 +47,9 @@ export function createGit({ home } = {}) {
|
|
|
23
47
|
|
|
24
48
|
async function resolveRepo(rawPath) {
|
|
25
49
|
if (typeof rawPath !== 'string' || !isAbsolute(rawPath)) return { error: 'not absolute', status: 400 };
|
|
26
|
-
const rh = await realHomeP;
|
|
27
50
|
let real;
|
|
28
51
|
try { real = await fs.realpath(rawPath); } catch { return { error: 'not found', status: 404 }; }
|
|
29
|
-
if (!
|
|
52
|
+
if (!rootOf(real, await rootsP)) return { error: 'outside home', status: 400 };
|
|
30
53
|
return { real };
|
|
31
54
|
}
|
|
32
55
|
|
|
@@ -182,4 +205,4 @@ export function createGit({ home } = {}) {
|
|
|
182
205
|
return { resolveRepo, isRepo, detectRepos, status, log, branches, diff, commit };
|
|
183
206
|
}
|
|
184
207
|
|
|
185
|
-
export const defaultGit = createGit({ home: homedir() });
|
|
208
|
+
export const defaultGit = createGit({ home: homedir(), extraRoots: defaultExtraRoots() });
|
package/src/httpApi.js
CHANGED
|
@@ -24,10 +24,20 @@ import { hooksStatus, installHooks } from './cli/claudeHooks.js';
|
|
|
24
24
|
import { codexHooksStatus, installCodexHooks } from './cli/codexHooks.js';
|
|
25
25
|
import { claudeStatePath } from './cli/state.js';
|
|
26
26
|
import { scanOrphans, takeoverOrphan, defaultProjectsDir } from './orphans.js';
|
|
27
|
+
import { getUsageCached } from './usage.js';
|
|
28
|
+
import { readFileSync } from 'node:fs';
|
|
29
|
+
import { readCache, isNewer, shouldRefresh, refreshLatestAsync } from './cli/updateCheck.js';
|
|
27
30
|
|
|
28
31
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
29
32
|
const HOOKS_SRC = resolvePath(here, '../hooks'); // server/hooks (bundled scripts)
|
|
30
33
|
|
|
34
|
+
// The installed CLI version (server/package.json) — read once. The phone compares this against the cached
|
|
35
|
+
// npm "latest" to surface an update hint ("run `handmux update` on your computer"); see the /version route.
|
|
36
|
+
const PKG_VERSION = (() => {
|
|
37
|
+
try { return JSON.parse(readFileSync(resolvePath(here, '../package.json'), 'utf8')).version || null; }
|
|
38
|
+
catch { return null; }
|
|
39
|
+
})();
|
|
40
|
+
|
|
31
41
|
// Summarize inbox-hook state across every coding agent for the phone: 'installed' if any agent is wired,
|
|
32
42
|
// 'absent' if an agent is present but none wired (→ offer the one-tap enable), 'no-claude' if there's no
|
|
33
43
|
// agent at all (→ hide the prompt).
|
|
@@ -39,10 +49,24 @@ function combinedHooksStatus(home) {
|
|
|
39
49
|
return 'no-claude';
|
|
40
50
|
}
|
|
41
51
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
52
|
+
// Keys the mobile keyboard may send via /keys. A controlled vocabulary of named tmux keys, PLUS
|
|
53
|
+
// live-modifier combinations (Ctrl/Alt + a single letter or digit) so the keyboard's Ctrl modifier
|
|
54
|
+
// can compose any readline/tmux binding (C-r, C-w, C-a, the tmux prefix, …) without enumerating each
|
|
55
|
+
// one here. tmux send-keys key names are themselves a closed set, so this stays a strict allowlist
|
|
56
|
+
// (never a passthrough): a key either names an approved token or matches the modifier shape, or it's
|
|
57
|
+
// rejected. The old fixed C-c/C-d/C-z/C-l/C-r/C-o/C-e all still match `C-[a-z0-9]`.
|
|
58
|
+
const NAMED = 'Up|Down|Left|Right|Space|Enter|Escape|Tab|BTab|BSpace|Home|End|PageUp|PageDown';
|
|
59
|
+
// A named tmux key with any (optional) modifier prefixes, in the canonical C- M- S- order buildChord emits.
|
|
60
|
+
// Covers the plain keys (Up, Tab, …) AND modifier combos on them — C-Up, M-Up, S-Left, C-Tab, C-S-Up, … —
|
|
61
|
+
// so the 按键 editor can bind e.g. Ctrl+Arrow or Ctrl+Tab, not just Ctrl + a letter.
|
|
62
|
+
const NAMED_KEY = new RegExp(`^(?:C-)?(?:M-)?(?:S-)?(?:${NAMED})$`);
|
|
63
|
+
// Ctrl and/or Alt + one letter or digit — C-r, C-a, M-b, C-M-k. Must carry a modifier (never a bare char;
|
|
64
|
+
// Shift+letter folds to the uppercase character, so no S- here).
|
|
65
|
+
const CHAR_KEY = /^(?:C-)?(?:M-)?[a-z0-9]$/;
|
|
66
|
+
export function isAllowedKey(k) {
|
|
67
|
+
if (typeof k !== 'string') return false;
|
|
68
|
+
return NAMED_KEY.test(k) || (CHAR_KEY.test(k) && k.includes('-'));
|
|
69
|
+
}
|
|
46
70
|
|
|
47
71
|
// Pause between typing the text and pressing Enter on a /send. A TUI like Claude Code needs a
|
|
48
72
|
// beat to ingest the pasted line; without it, the Enter can fold into the input as a newline
|
|
@@ -270,11 +294,26 @@ export function createApiRouter({
|
|
|
270
294
|
} catch (e) { next(e); }
|
|
271
295
|
});
|
|
272
296
|
|
|
297
|
+
// First free filename in `dir`: the name as-is, else Finder-style "base (1).ext", "base (2).ext", …
|
|
298
|
+
// (the suffix goes before the extension). Bounded; a pathological fallback keeps it from looping.
|
|
299
|
+
async function freeUploadName(dir, name) {
|
|
300
|
+
const dot = name.lastIndexOf('.');
|
|
301
|
+
const base = dot > 0 ? name.slice(0, dot) : name;
|
|
302
|
+
const ext = dot > 0 ? name.slice(dot) : '';
|
|
303
|
+
let cand = name;
|
|
304
|
+
for (let n = 1; n <= 999; n++) {
|
|
305
|
+
try { await fsp.access(joinPath(dir, cand)); } // exists → try the next suffix
|
|
306
|
+
catch { return cand; } // ENOENT → free
|
|
307
|
+
cand = `${base} (${n})${ext}`;
|
|
308
|
+
}
|
|
309
|
+
return `${base} (${randomBytes(3).toString('hex')})${ext}`;
|
|
310
|
+
}
|
|
311
|
+
|
|
273
312
|
// Upload a file into a directory under $HOME. Multipart streamed via busboy (the file never fully
|
|
274
313
|
// buffers in memory, and the size cap aborts mid-stream). Guards, in order: target dir must be a
|
|
275
314
|
// non-hidden subdir of home (resolveUploadDir); filename sanitised to a dotless basename; extension
|
|
276
|
-
// in the allow-list
|
|
277
|
-
// part, so the field is known by the time the file event fires.
|
|
315
|
+
// in the allow-list. A name clash auto-suffixes (never overwrites). The client appends `dir` BEFORE
|
|
316
|
+
// the file part, so the field is known by the time the file event fires.
|
|
278
317
|
r.post('/upload', (req, res) => {
|
|
279
318
|
let bb;
|
|
280
319
|
// defParamCharset:'utf8' — browsers put a non-ASCII (e.g. Chinese) filename into the multipart
|
|
@@ -306,11 +345,13 @@ export function createApiRouter({
|
|
|
306
345
|
const target = stash ? await docs.resolveStashDir(dir) : await docs.resolveUploadDir(dir);
|
|
307
346
|
if (target.error) { file.resume(); return done(target.status, { error: target.error }); }
|
|
308
347
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
348
|
+
// Finder-style de-dup: never 409 / overwrite on a name clash — pick the first free "base (n).ext".
|
|
349
|
+
// Resolved up front for the response; re-resolved at link time if the race is lost (see below).
|
|
350
|
+
const origName = name;
|
|
351
|
+
let finalName = await freeUploadName(target.real, origName);
|
|
352
|
+
let dest = joinPath(target.real, finalName);
|
|
312
353
|
|
|
313
|
-
tmp = joinPath(target.real, `.${
|
|
354
|
+
tmp = joinPath(target.real, `.${finalName}.uploading-${randomBytes(6).toString('hex')}`);
|
|
314
355
|
ws = createWriteStream(tmp);
|
|
315
356
|
ws.on('error', () => { file.resume(); cleanup().finally(() => done(500, { error: 'write failed' })); });
|
|
316
357
|
ws.on('finish', async () => {
|
|
@@ -319,13 +360,22 @@ export function createApiRouter({
|
|
|
319
360
|
// maxUploadBytes is allowed; only strictly-larger trips it.)
|
|
320
361
|
if (file.truncated) { await cleanup(); return done(413, { error: 'too large' }); }
|
|
321
362
|
try {
|
|
322
|
-
// link (NOT rename): if the name appeared meanwhile (a concurrent upload won the race)
|
|
323
|
-
//
|
|
324
|
-
|
|
325
|
-
|
|
363
|
+
// link (NOT rename): if the name appeared meanwhile (a concurrent upload won the race) link
|
|
364
|
+
// throws EEXIST → we pick the NEXT free suffix and retry, so we still never overwrite another
|
|
365
|
+
// file, and a clash auto-suffixes rather than 409s.
|
|
366
|
+
let linked = false;
|
|
367
|
+
for (let attempt = 0; attempt < 6 && !linked; attempt++) {
|
|
368
|
+
try { await fsp.link(tmp, dest); linked = true; }
|
|
369
|
+
catch (e) {
|
|
370
|
+
if (e.code !== 'EEXIST') throw e;
|
|
371
|
+
finalName = await freeUploadName(target.real, origName);
|
|
372
|
+
dest = joinPath(target.real, finalName);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (!linked) { await cleanup(); return done(409, { error: 'exists' }); }
|
|
326
376
|
await cleanup(); // link made dest a second name for the data; drop the temp name
|
|
327
377
|
const st = await fsp.stat(dest);
|
|
328
|
-
done(201, { name, size: st.size, path: dest }); // absolute path: the dock pastes it
|
|
378
|
+
done(201, { name: finalName, size: st.size, path: dest }); // absolute path: the dock pastes it in
|
|
329
379
|
} catch { await cleanup(); done(500, { error: 'finalize failed' }); }
|
|
330
380
|
});
|
|
331
381
|
file.pipe(ws);
|
|
@@ -436,7 +486,7 @@ export function createApiRouter({
|
|
|
436
486
|
r.post('/keys', async (req, res, next) => {
|
|
437
487
|
const { pane, keys } = req.body || {};
|
|
438
488
|
if (!isPaneId(pane)) return res.status(400).json({ error: 'bad pane id' });
|
|
439
|
-
if (!Array.isArray(keys) || keys.some((k) => !
|
|
489
|
+
if (!Array.isArray(keys) || keys.some((k) => !isAllowedKey(k))) {
|
|
440
490
|
return res.status(400).json({ error: 'disallowed key' });
|
|
441
491
|
}
|
|
442
492
|
try {
|
|
@@ -456,6 +506,18 @@ export function createApiRouter({
|
|
|
456
506
|
res.json({ asr: isAsrConfigured(asrEnv), claudeHooks: combinedHooksStatus(home) });
|
|
457
507
|
});
|
|
458
508
|
|
|
509
|
+
// Update hint for the phone: is the globally-installed CLI behind the latest npm release? `current` is
|
|
510
|
+
// this server's version; `latest` comes from the same cache the CLI maintains (~/.handmux/update-check.json).
|
|
511
|
+
// We never block on the network here — if the cache is stale we kick a best-effort async refresh (throttled
|
|
512
|
+
// to once an hour, like the CLI) and return the currently-known value. The upgrade itself is a computer-side
|
|
513
|
+
// `handmux update`; the phone only shows the notice.
|
|
514
|
+
r.get('/version', (req, res) => {
|
|
515
|
+
const cache = readCache(home);
|
|
516
|
+
if (shouldRefresh(cache)) refreshLatestAsync(home);
|
|
517
|
+
const latest = cache?.latest ?? null;
|
|
518
|
+
res.json({ current: PKG_VERSION, latest, updateAvailable: !!(latest && PKG_VERSION && isNewer(latest, PKG_VERSION)) });
|
|
519
|
+
});
|
|
520
|
+
|
|
459
521
|
// One-tap enable from the phone: install the hooks for every present agent (Claude Code, Codex) on the
|
|
460
522
|
// host (token-gated, like every API here). Opt-in — the inbox only offers this when status is 'absent'.
|
|
461
523
|
// Never creates ~/.claude or ~/.codex; a user's own Codex `notify` is left untouched (see codexHooks.js).
|
|
@@ -530,6 +592,13 @@ export function createApiRouter({
|
|
|
530
592
|
try { res.json(await claudeEvents.getStates(allowed)); } catch (e) { next(e); }
|
|
531
593
|
});
|
|
532
594
|
|
|
595
|
+
// Agent usage/quota for the Usage page. Disk-only, no credentials: Claude's 5h/weekly % from the
|
|
596
|
+
// statusLine snapshot (if the capturer is opted in), Codex's rate_limits + tokens from its newest
|
|
597
|
+
// rollout. Either side is null when unavailable. Cached briefly (see usage.js); never throws.
|
|
598
|
+
r.get('/usage', (req, res, next) => {
|
|
599
|
+
try { res.json(getUsageCached(home)); } catch (e) { next(e); }
|
|
600
|
+
});
|
|
601
|
+
|
|
533
602
|
// Orphan Claude sessions: `claude` processes running on this host but NOT inside a tmux pane, so
|
|
534
603
|
// handmux can't steer them. Surfaced at the bottom of the Inbox with a "takeover" (spawn
|
|
535
604
|
// `claude --resume` in tmux). Best-effort process scan (see orphans.js); never throws.
|
package/src/uploadTypes.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
export const DEFAULT_UPLOAD_EXTS = new Set([
|
|
5
5
|
// images
|
|
6
6
|
'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'heic', 'heif', 'avif', 'ico', 'tiff',
|
|
7
|
+
// video
|
|
8
|
+
'mp4', 'm4v', 'mov', 'webm', 'mkv', 'avi', 'wmv', 'flv', '3gp', 'ogv', 'mpeg', 'mpg',
|
|
7
9
|
// text / code
|
|
8
10
|
'txt', 'md', 'markdown', 'rst', 'log', 'csv', 'tsv', 'json', 'yaml', 'yml', 'toml', 'ini',
|
|
9
11
|
'conf', 'xml', 'html', 'htm', 'css', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'py', 'rb', 'go',
|
package/src/usage.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Usage/quota reader for the phone's Usage page. Purely reads what each agent already puts on disk — no
|
|
2
|
+
// API calls, no credentials:
|
|
3
|
+
// • Claude — the snapshot the statusLine capturer writes to ~/.handmux/claude-usage.json. Claude Code's
|
|
4
|
+
// statusLine stdin is the ONLY documented local source of the 5h/weekly rate-limit % (see
|
|
5
|
+
// server/hooks/handmux-statusline.cjs). Absent until the user opts the capturer in → returns null.
|
|
6
|
+
// • Codex — the newest rollout's most recent `token_count` event, which carries `rate_limits` (used %,
|
|
7
|
+
// reset, window) and cumulative token usage. Always available once Codex has run, no wiring needed.
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { pocketHome } from './cli/state.js';
|
|
12
|
+
|
|
13
|
+
export function claudeUsagePath(home = homedir()) { return path.join(pocketHome(home), 'claude-usage.json'); }
|
|
14
|
+
export function codexSessionsDir(home = homedir()) { return path.join(home, '.codex', 'sessions'); }
|
|
15
|
+
|
|
16
|
+
// Claude: read the statusLine snapshot. null if the capturer isn't wired / never populated it.
|
|
17
|
+
export function readClaudeUsage(home = homedir()) {
|
|
18
|
+
try {
|
|
19
|
+
const snap = JSON.parse(fs.readFileSync(claudeUsagePath(home), 'utf8'));
|
|
20
|
+
return (snap && typeof snap === 'object' && !Array.isArray(snap)) ? snap : null;
|
|
21
|
+
} catch { return null; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The rollout tree is date-nested (sessions/YYYY/MM/DD/rollout-<ISO>-<uuid>.jsonl) and every path segment
|
|
25
|
+
// sorts lexically = chronologically, so the newest rollout is the lexically-largest entry at each level —
|
|
26
|
+
// found without walking the whole tree.
|
|
27
|
+
function newestRollout(dir) {
|
|
28
|
+
const maxEntry = (d, pred) => {
|
|
29
|
+
let names;
|
|
30
|
+
try { names = fs.readdirSync(d); } catch { return null; }
|
|
31
|
+
names = names.filter((n) => !n.startsWith('.') && (!pred || pred(n))).sort();
|
|
32
|
+
return names.length ? names[names.length - 1] : null;
|
|
33
|
+
};
|
|
34
|
+
const y = maxEntry(dir); if (!y) return null;
|
|
35
|
+
const m = maxEntry(path.join(dir, y)); if (!m) return null;
|
|
36
|
+
const d = maxEntry(path.join(dir, y, m)); if (!d) return null;
|
|
37
|
+
const dayDir = path.join(dir, y, m, d);
|
|
38
|
+
const f = maxEntry(dayDir, (n) => n.startsWith('rollout-') && n.endsWith('.jsonl'));
|
|
39
|
+
return f ? path.join(dayDir, f) : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// One Codex rate-limit window → our shape, or null if absent (secondary is often null on plans without it).
|
|
43
|
+
function codexWindow(w) {
|
|
44
|
+
if (!w || typeof w.used_percent !== 'number') return null;
|
|
45
|
+
return {
|
|
46
|
+
usedPercent: w.used_percent,
|
|
47
|
+
windowMinutes: typeof w.window_minutes === 'number' ? w.window_minutes : null,
|
|
48
|
+
resetsAt: typeof w.resets_at === 'number' ? w.resets_at : null,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Codex: scan the newest rollout from the end for the last `token_count` event (carries the account-wide
|
|
53
|
+
// rate_limits + the session's cumulative tokens). null if Codex hasn't run or the rollout has none yet.
|
|
54
|
+
export function readCodexUsage(home = homedir()) {
|
|
55
|
+
const f = newestRollout(codexSessionsDir(home));
|
|
56
|
+
if (!f) return null;
|
|
57
|
+
let lines;
|
|
58
|
+
try { lines = fs.readFileSync(f, 'utf8').split('\n'); } catch { return null; }
|
|
59
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
60
|
+
const ln = lines[i];
|
|
61
|
+
if (!ln || ln.indexOf('token_count') === -1) continue;
|
|
62
|
+
let rec; try { rec = JSON.parse(ln); } catch { continue; }
|
|
63
|
+
const p = rec.payload;
|
|
64
|
+
if (!p || p.type !== 'token_count') continue;
|
|
65
|
+
const info = p.info || {};
|
|
66
|
+
const tu = info.total_token_usage || {};
|
|
67
|
+
const rl = p.rate_limits || {};
|
|
68
|
+
return {
|
|
69
|
+
updatedAt: Date.parse(rec.timestamp) || null,
|
|
70
|
+
rateLimits: { primary: codexWindow(rl.primary), secondary: codexWindow(rl.secondary) },
|
|
71
|
+
tokens: {
|
|
72
|
+
total: tu.total_tokens ?? null,
|
|
73
|
+
input: tu.input_tokens ?? null,
|
|
74
|
+
cachedInput: tu.cached_input_tokens ?? null,
|
|
75
|
+
output: tu.output_tokens ?? null,
|
|
76
|
+
reasoning: tu.reasoning_output_tokens ?? null,
|
|
77
|
+
},
|
|
78
|
+
contextWindow: typeof info.model_context_window === 'number' ? info.model_context_window : null,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function getUsage(home = homedir()) {
|
|
85
|
+
return { claude: readClaudeUsage(home), codex: readCodexUsage(home) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Small TTL cache so a phone that re-polls doesn't rescan the rollout every few seconds.
|
|
89
|
+
let _cache = { at: 0, home: null, data: null };
|
|
90
|
+
export function getUsageCached(home = homedir(), { ttlMs = 15000, now = Date.now() } = {}) {
|
|
91
|
+
if (_cache.data && _cache.home === home && (now - _cache.at) < ttlMs) return _cache.data;
|
|
92
|
+
_cache = { at: now, home, data: getUsage(home) };
|
|
93
|
+
return _cache.data;
|
|
94
|
+
}
|