handmux 0.23.0 → 0.24.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 +3 -3
- package/README.zh-CN.md +3 -3
- package/bin/handmux.js +3 -689
- package/dist/bin/handmux.js +850 -0
- package/{hooks → dist/hooks}/handmux-statusline.cjs +28 -6
- package/{hooks → dist/hooks}/handmux-write.cjs +24 -3
- package/dist/package.json +126 -0
- package/dist/public/assets/index-CicdY9E9.css +32 -0
- package/dist/public/assets/index-D4VP5t8h.js +337 -0
- package/dist/public/assets/pcm-worklet-CxcDy7PB.js +1 -0
- package/{public → dist/public}/index.html +5 -4
- package/{src → dist/src}/agents/claude.js +105 -83
- package/dist/src/agents/codex.js +217 -0
- package/{src → dist/src}/agents/index.js +7 -7
- package/dist/src/agents/processIdentity.js +80 -0
- package/dist/src/agents/scanUtils.js +262 -0
- package/dist/src/apiErrors.js +77 -0
- package/{src → dist/src}/appName.js +12 -13
- package/dist/src/asr/iflyConfig.js +7 -0
- package/dist/src/asr/iflySign.js +14 -0
- package/dist/src/auth.js +27 -0
- package/dist/src/browser/bootstrap.js +72 -0
- package/dist/src/browser/cookieProfiles.js +413 -0
- package/dist/src/browser/coordinator.js +51 -0
- package/dist/src/browser/hammerheadCompat.js +104 -0
- package/dist/src/browser/hammerheadRedirectCompat.js +37 -0
- package/dist/src/browser/manager.js +560 -0
- package/dist/src/browser/originLabel.js +15 -0
- package/dist/src/browser/profilePersistence.js +235 -0
- package/dist/src/browser/publicProxy.js +176 -0
- package/dist/src/browser/routes.js +159 -0
- package/dist/src/browser/sessionStore.js +72 -0
- package/dist/src/browser/siteVersion.js +121 -0
- package/dist/src/browser/targetPolicy.js +163 -0
- package/{src → dist/src}/browser/worker.js +14 -18
- package/dist/src/browser/workerClient.js +286 -0
- package/dist/src/browser/workerServer.js +91 -0
- package/dist/src/captureBackground.js +95 -0
- package/dist/src/claudeEvents.js +499 -0
- package/{src → dist/src}/cli/claudeHooks.js +146 -108
- package/{src → dist/src}/cli/cloudflareUrl.js +2 -3
- package/dist/src/cli/cloudflared.js +106 -0
- package/dist/src/cli/codexManaged.js +77 -0
- package/{src → dist/src}/cli/cpolarUrl.js +8 -9
- package/dist/src/cli/drivers.js +98 -0
- package/dist/src/cli/hookScaffold.js +36 -0
- package/dist/src/cli/i18n/en.js +366 -0
- package/{src → dist/src}/cli/i18n/index.js +27 -25
- package/dist/src/cli/i18n/zh.js +365 -0
- package/dist/src/cli/legacyCodexHooks.js +52 -0
- package/{src → dist/src}/cli/natappUrl.js +2 -3
- package/dist/src/cli/options.js +245 -0
- package/dist/src/cli/probe.js +16 -0
- package/{src → dist/src}/cli/prompt.js +4 -6
- package/dist/src/cli/pushCmd.js +102 -0
- package/{src → dist/src}/cli/qr.js +19 -19
- package/dist/src/cli/service.js +144 -0
- package/dist/src/cli/setupModel.js +194 -0
- package/dist/src/cli/setupWizard.js +647 -0
- package/dist/src/cli/shortcutEditor.js +351 -0
- package/dist/src/cli/sshTunnel.js +18 -0
- package/dist/src/cli/state.js +114 -0
- package/{src → dist/src}/cli/statusLine.js +63 -46
- package/dist/src/cli/supervisor.js +278 -0
- package/dist/src/cli/supervisorHealth.js +21 -0
- package/dist/src/cli/supervisorLaunch.js +148 -0
- package/dist/src/cli/supervisorProcesses.js +62 -0
- package/dist/src/cli/supervisorState.js +62 -0
- package/dist/src/cli/tmuxVersion.js +53 -0
- package/{src → dist/src}/cli/tunlite.js +7 -10
- package/dist/src/cli/tunnelClients.js +72 -0
- package/{src → dist/src}/cli/updateCheck.js +98 -69
- package/{src → dist/src}/cli/urlHost.js +8 -4
- package/dist/src/cli/workspaceCmd.js +347 -0
- package/dist/src/codexAppServer.js +2299 -0
- package/dist/src/codexConversationProjection.js +199 -0
- package/dist/src/codexDiff.js +118 -0
- package/dist/src/codexMessageIdentity.js +18 -0
- package/dist/src/codexPlan.js +30 -0
- package/dist/src/codexQueueProtocol.js +96 -0
- package/dist/src/codexStreamProtocol.js +207 -0
- package/dist/src/codexToolProtocol.js +68 -0
- package/dist/src/codexTranscriptParse.js +724 -0
- package/dist/src/codexUsageSnapshot.js +205 -0
- package/dist/src/config.js +8 -0
- package/{src → dist/src}/docPath.js +22 -24
- package/dist/src/docs.js +337 -0
- package/dist/src/git.js +272 -0
- package/dist/src/healthProtocol.js +97 -0
- package/dist/src/httpApi.js +68 -0
- package/dist/src/jsonStore.js +15 -0
- package/{src → dist/src}/keyNames.js +3 -3
- package/dist/src/notifications.js +111 -0
- package/dist/src/orphans.js +157 -0
- package/dist/src/pendingPrompt.js +194 -0
- package/dist/src/previewServer.js +202 -0
- package/dist/src/previews.js +179 -0
- package/dist/src/privateStateStore.js +89 -0
- package/dist/src/push.js +224 -0
- package/dist/src/routes/codex.js +606 -0
- package/dist/src/routes/files.js +230 -0
- package/dist/src/routes/git.js +78 -0
- package/dist/src/routes/health.js +16 -0
- package/dist/src/routes/notifications.js +16 -0
- package/dist/src/routes/previews.js +43 -0
- package/dist/src/routes/push.js +145 -0
- package/dist/src/routes/sessions.js +232 -0
- package/dist/src/routes/system.js +156 -0
- package/dist/src/routes/terminal.js +238 -0
- package/dist/src/routes/transcript.js +197 -0
- package/dist/src/routes/workspace.js +267 -0
- package/{src → dist/src}/server.js +100 -74
- package/dist/src/shortcutConfig.js +83 -0
- package/{src → dist/src}/staticCache.js +5 -3
- package/{src → dist/src}/staticCompression.js +5 -6
- package/dist/src/terminalStream.js +441 -0
- package/{src → dist/src}/tmux/commands.js +156 -171
- package/dist/src/tmux/format.js +47 -0
- package/dist/src/transcriptParse.js +240 -0
- package/dist/src/transcriptReader.js +113 -0
- package/{src → dist/src}/trimCapture.js +11 -11
- package/dist/src/uploadTypes.js +29 -0
- package/dist/src/urlPolicy.js +18 -0
- package/dist/src/usage.js +260 -0
- package/dist/src/workspace/agentRunner.js +204 -0
- package/dist/src/workspace/atomicJson.js +31 -0
- package/dist/src/workspace/capture.js +139 -0
- package/dist/src/workspace/checkpointer.js +228 -0
- package/dist/src/workspace/environment.js +71 -0
- package/dist/src/workspace/lock.js +137 -0
- package/dist/src/workspace/mapping.js +128 -0
- package/dist/src/workspace/operations.js +271 -0
- package/dist/src/workspace/paths.js +15 -0
- package/dist/src/workspace/planner.js +292 -0
- package/dist/src/workspace/restore.js +314 -0
- package/dist/src/workspace/runtime.js +318 -0
- package/dist/src/workspace/schema.js +156 -0
- package/dist/src/workspace/store.js +464 -0
- package/dist/src/workspace/tmuxAdapter.js +694 -0
- package/dist/src/workspaceProtocol.js +158 -0
- package/package.json +27 -15
- package/public/assets/index-C3N7OkyP.css +0 -32
- package/public/assets/index-D1DWcC5u.js +0 -337
- package/src/.gitkeep +0 -0
- package/src/agents/codex.js +0 -168
- package/src/agents/processIdentity.js +0 -72
- package/src/agents/scanUtils.js +0 -225
- package/src/asr/iflyConfig.js +0 -10
- package/src/asr/iflySign.js +0 -16
- package/src/auth.js +0 -30
- package/src/browser/bootstrap.js +0 -68
- package/src/browser/cookieProfiles.js +0 -403
- package/src/browser/coordinator.js +0 -47
- package/src/browser/hammerheadCompat.js +0 -86
- package/src/browser/hammerheadRedirectCompat.js +0 -38
- package/src/browser/manager.js +0 -556
- package/src/browser/originLabel.js +0 -17
- package/src/browser/profilePersistence.js +0 -241
- package/src/browser/publicProxy.js +0 -175
- package/src/browser/routes.js +0 -154
- package/src/browser/sessionStore.js +0 -78
- package/src/browser/siteVersion.js +0 -124
- package/src/browser/targetPolicy.js +0 -137
- package/src/browser/workerClient.js +0 -253
- package/src/browser/workerServer.js +0 -98
- package/src/captureBackground.js +0 -93
- package/src/claudeEvents.js +0 -399
- package/src/cli/cloudflared.js +0 -99
- package/src/cli/codexManaged.js +0 -77
- package/src/cli/drivers.js +0 -97
- package/src/cli/hookScaffold.js +0 -33
- package/src/cli/i18n/en.js +0 -385
- package/src/cli/i18n/zh.js +0 -384
- package/src/cli/legacyCodexHooks.js +0 -49
- package/src/cli/options.js +0 -211
- package/src/cli/probe.js +0 -16
- package/src/cli/pushCmd.js +0 -60
- package/src/cli/service.js +0 -126
- package/src/cli/setupModel.js +0 -154
- package/src/cli/setupWizard.js +0 -506
- package/src/cli/shortcutEditor.js +0 -288
- package/src/cli/sshTunnel.js +0 -12
- package/src/cli/state.js +0 -90
- package/src/cli/supervisor.js +0 -183
- package/src/cli/supervisorProcesses.js +0 -57
- package/src/cli/tmuxVersion.js +0 -49
- package/src/cli/tunnelClients.js +0 -62
- package/src/cli/workspaceCmd.js +0 -300
- package/src/codexAppServer.js +0 -1620
- package/src/codexDiff.js +0 -108
- package/src/codexTranscriptParse.js +0 -413
- package/src/codexUsageSnapshot.cjs +0 -129
- package/src/config.js +0 -9
- package/src/docs.js +0 -228
- package/src/git.js +0 -208
- package/src/httpApi.js +0 -72
- package/src/jsonStore.js +0 -24
- package/src/notifications.js +0 -68
- package/src/orphans.js +0 -152
- package/src/pendingPrompt.js +0 -163
- package/src/previewServer.js +0 -168
- package/src/previews.js +0 -116
- package/src/push.js +0 -183
- package/src/routes/codex.js +0 -473
- package/src/routes/files.js +0 -172
- package/src/routes/git.js +0 -57
- package/src/routes/notifications.js +0 -19
- package/src/routes/previews.js +0 -35
- package/src/routes/push.js +0 -119
- package/src/routes/sessions.js +0 -168
- package/src/routes/system.js +0 -132
- package/src/routes/terminal.js +0 -210
- package/src/routes/transcript.js +0 -171
- package/src/routes/workspace.js +0 -267
- package/src/shortcutConfig.js +0 -75
- package/src/terminalStream.js +0 -391
- package/src/tmux/format.js +0 -45
- package/src/transcriptParse.js +0 -180
- package/src/transcriptReader.js +0 -101
- package/src/uploadTypes.js +0 -30
- package/src/urlPolicy.js +0 -14
- package/src/usage.js +0 -260
- package/src/workspace/agentRunner.js +0 -188
- package/src/workspace/atomicJson.js +0 -32
- package/src/workspace/capture.js +0 -139
- package/src/workspace/checkpointer.js +0 -187
- package/src/workspace/environment.js +0 -75
- package/src/workspace/lock.js +0 -128
- package/src/workspace/mapping.js +0 -124
- package/src/workspace/operations.js +0 -252
- package/src/workspace/paths.js +0 -16
- package/src/workspace/planner.js +0 -247
- package/src/workspace/restore.js +0 -279
- package/src/workspace/runtime.js +0 -314
- package/src/workspace/schema.js +0 -136
- package/src/workspace/store.js +0 -382
- package/src/workspace/tmuxAdapter.js +0 -554
- /package/{hooks → dist/hooks}/handmux-notify.sh +0 -0
- /package/{public → dist/public}/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
- /package/{public → dist/public}/fonts/TWUnifont.woff2 +0 -0
- /package/{public → dist/public}/icons/apple-touch-icon.png +0 -0
- /package/{public → dist/public}/icons/badge-96.png +0 -0
- /package/{public → dist/public}/icons/icon-192-maskable.png +0 -0
- /package/{public → dist/public}/icons/icon-192.png +0 -0
- /package/{public → dist/public}/icons/icon-512-maskable.png +0 -0
- /package/{public → dist/public}/icons/icon-512.png +0 -0
- /package/{public → dist/public}/icons/logo.svg +0 -0
- /package/{public → dist/public}/manifest.webmanifest +0 -0
- /package/{public → dist/public}/offline.html +0 -0
- /package/{public → dist/public}/sw.js +0 -0
- /package/{src → dist/src}/browser/protocol.js +0 -0
- /package/{src → dist/src}/cli/cfNamed.js +0 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// Agent-agnostic scan/parse helpers shared by every agent driver and the orphan engine. This is the LEAF
|
|
2
|
+
// layer: it imports nothing from the agent registry, so drivers (claude.js / codex.js) and the engine
|
|
3
|
+
// (orphans.js) can all depend on it without an import cycle.
|
|
4
|
+
//
|
|
5
|
+
// Everything here is about turning `ps`/`tmux`/`lsof` output and session jsonl files into structured data;
|
|
6
|
+
// none of it knows which coding agent it's looking at — the per-agent specifics (which process name, where
|
|
7
|
+
// sessions live, how to resume) live in the driver descriptors that USE these helpers.
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
import { promises as fsp } from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { parseTmuxRows } from '../tmux/format.js';
|
|
12
|
+
const isRecord = (value) => value !== null && typeof value === 'object';
|
|
13
|
+
// Tolerant promisified execFile: resolves '' on any error (no server, missing binary, non-zero exit).
|
|
14
|
+
// Detection is best-effort and must never throw the whole request.
|
|
15
|
+
export function defaultRun(cmd, args) {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
execFile(cmd, args, { maxBuffer: 8 * 1024 * 1024 }, (err, stdout) => {
|
|
18
|
+
resolve(err ? '' : String(stdout));
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
// A coding-agent session id is a UUID (Claude's jsonl filename; Codex's rollout-file trailing id). Validate
|
|
23
|
+
// strictly: takeover types `<bin> resume <id>` into a shell via send-keys, so a non-UUID id would be a
|
|
24
|
+
// shell-injection vector.
|
|
25
|
+
export const isSessionUuid = (value) => typeof value === 'string'
|
|
26
|
+
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
|
|
27
|
+
// Strip /dev/ and fold "no controlling terminal" markers (macOS '??', Linux '?') to '' so ps ttys and
|
|
28
|
+
// tmux pane_ttys compare equal: ps 'ttys010' / tmux '/dev/ttys010' → 'ttys010'; ps 'pts/3' / tmux
|
|
29
|
+
// '/dev/pts/3' → 'pts/3'.
|
|
30
|
+
export function normTty(value) {
|
|
31
|
+
const s = String(value || '').trim();
|
|
32
|
+
if (!s || s === '??' || s === '?' || s === '-')
|
|
33
|
+
return '';
|
|
34
|
+
return s.replace(/^\/dev\//, '');
|
|
35
|
+
}
|
|
36
|
+
// ps `etime` (elapsed since start) → milliseconds. Formats (macOS + Linux, no spaces): "MM:SS",
|
|
37
|
+
// "HH:MM:SS", "DD-HH:MM:SS". Used only to derive a startedAt for display/recognition (the "A加成"),
|
|
38
|
+
// NOT for session attribution — a resumed session's process starts long after its jsonl's first event.
|
|
39
|
+
export function etimeToMs(etime) {
|
|
40
|
+
const s = String(etime).trim();
|
|
41
|
+
if (!s)
|
|
42
|
+
return 0;
|
|
43
|
+
let days = 0;
|
|
44
|
+
let rest = s;
|
|
45
|
+
const dash = s.indexOf('-');
|
|
46
|
+
if (dash >= 0) {
|
|
47
|
+
days = Number(s.slice(0, dash)) || 0;
|
|
48
|
+
rest = s.slice(dash + 1);
|
|
49
|
+
}
|
|
50
|
+
let sec = 0;
|
|
51
|
+
for (const p of rest.split(':'))
|
|
52
|
+
sec = sec * 60 + (Number(p) || 0);
|
|
53
|
+
return (days * 86400 + sec) * 1000;
|
|
54
|
+
}
|
|
55
|
+
// Parse `ps -Ao pid=,ppid=,stat=,etime=,tty=,args=` → LIVE agent processes only, each tagged with the id of
|
|
56
|
+
// the FIRST driver whose `procMatch` regex matches its argv. `agents` is the driver list; a proc matching
|
|
57
|
+
// none is dropped. args (last column) may contain spaces; etime has none. STOPPED (STAT 'T', a Ctrl-Z-
|
|
58
|
+
// suspended job-control stack — verified real: one terminal can hold 8 suspended `claude`s) and ZOMBIE
|
|
59
|
+
// ('Z') processes are dropped: they aren't active sessions to steer, and a suspended original can't write
|
|
60
|
+
// its jsonl so there's nothing to race.
|
|
61
|
+
export function parseAgentProcs(psOut, agents) {
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const line of String(psOut).split('\n')) {
|
|
64
|
+
const m = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.*)$/);
|
|
65
|
+
if (!m)
|
|
66
|
+
continue;
|
|
67
|
+
const [, pid, ppid, stat, etime, tty, rawArgs] = m;
|
|
68
|
+
if (!pid || !ppid || !stat || !etime || !tty || rawArgs === undefined)
|
|
69
|
+
continue;
|
|
70
|
+
if (stat.startsWith('T') || stat.startsWith('Z'))
|
|
71
|
+
continue;
|
|
72
|
+
const args = rawArgs.trim();
|
|
73
|
+
const agent = agents.find((a) => a.procMatch.test(args));
|
|
74
|
+
if (!agent)
|
|
75
|
+
continue;
|
|
76
|
+
out.push({ pid: Number(pid), ppid: Number(ppid), etimeMs: etimeToMs(etime), tty: normTty(tty), args, agent: agent.id });
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
// Parse q-escaped `tmux list-panes` rows → the set of pane ttys and pane (shell) pids.
|
|
81
|
+
export function parsePaneMembership(tmuxOut) {
|
|
82
|
+
const ttys = new Set();
|
|
83
|
+
const pids = new Set();
|
|
84
|
+
for (const [tty, pid] of parseTmuxRows(tmuxOut, 2, 'pane membership')) {
|
|
85
|
+
const nt = normTty(tty);
|
|
86
|
+
if (nt)
|
|
87
|
+
ttys.add(nt);
|
|
88
|
+
const n = Number(pid);
|
|
89
|
+
if (n)
|
|
90
|
+
pids.add(n);
|
|
91
|
+
}
|
|
92
|
+
return { ttys, pids };
|
|
93
|
+
}
|
|
94
|
+
// Orphan = an agent proc WITH a real controlling tty that is neither one of tmux's pane ttys nor a child of
|
|
95
|
+
// a pane's shell. The tty requirement drops background/headless runs (SDK/`-p`/`exec` piped, tty '') —
|
|
96
|
+
// those aren't interactive sessions a user would "take over".
|
|
97
|
+
export function findOrphans(procs, membership) {
|
|
98
|
+
return procs.filter((p) => p.tty && !membership.ttys.has(p.tty) && !membership.pids.has(p.ppid));
|
|
99
|
+
}
|
|
100
|
+
// A real cwd → its ~/.claude/projects directory name. Claude replaces every non-alphanumeric char with
|
|
101
|
+
// '-' (verified: '/home/user/handmux' → '-home-user-handmux'; both '/'
|
|
102
|
+
// and '_' fold to '-'). The mapping is LOSSY (not reversible), so we only ever encode forward, then
|
|
103
|
+
// confirm each candidate jsonl's recorded `cwd` matches before trusting it.
|
|
104
|
+
export function encodeProjectDir(cwd) {
|
|
105
|
+
return String(cwd).replace(/[^A-Za-z0-9]/g, '-');
|
|
106
|
+
}
|
|
107
|
+
// Read the last `bytes` of a file (for the trailing conversation). The first line of the chunk may be
|
|
108
|
+
// truncated mid-JSON — callers skip lines that don't parse.
|
|
109
|
+
export async function readTail(file, bytes = 65536) {
|
|
110
|
+
const fh = await fsp.open(file, 'r');
|
|
111
|
+
try {
|
|
112
|
+
const { size } = await fh.stat();
|
|
113
|
+
const start = Math.max(0, size - bytes);
|
|
114
|
+
const len = size - start;
|
|
115
|
+
if (len <= 0)
|
|
116
|
+
return '';
|
|
117
|
+
const buf = Buffer.alloc(len);
|
|
118
|
+
await fh.read(buf, 0, len, start);
|
|
119
|
+
return buf.toString('utf8');
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
await fh.close();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// Read the first `bytes` of a file (the session header carries `cwd` early).
|
|
126
|
+
export async function readHead(file, bytes = 65536) {
|
|
127
|
+
const fh = await fsp.open(file, 'r');
|
|
128
|
+
try {
|
|
129
|
+
const buf = Buffer.alloc(bytes);
|
|
130
|
+
const { bytesRead } = await fh.read(buf, 0, bytes, 0);
|
|
131
|
+
return buf.slice(0, bytesRead).toString('utf8');
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
await fh.close();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// First `"cwd":"..."` anywhere in a chunk (both Claude and Codex record the session cwd early in the file).
|
|
138
|
+
export function firstCwd(headText) {
|
|
139
|
+
const m = String(headText).match(/"cwd"\s*:\s*"((?:[^"\\]|\\.)*)"/);
|
|
140
|
+
const encoded = m?.[1];
|
|
141
|
+
if (encoded === undefined)
|
|
142
|
+
return '';
|
|
143
|
+
try {
|
|
144
|
+
const parsed = JSON.parse(`"${encoded}"`);
|
|
145
|
+
return typeof parsed === 'string' ? parsed : encoded;
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return encoded;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Pull the last user-typed message text out of a Claude jsonl tail, for a recognizable one-line label.
|
|
152
|
+
// Entries are line-delimited JSON; conversational turns are type 'user' with message.role 'user', whose
|
|
153
|
+
// content is either a string or an array of blocks ({type:'text',text}). Meta rows (last-prompt/ai-title/
|
|
154
|
+
// mode/attachment/summary) are ignored. Scans newest-first.
|
|
155
|
+
export function lastUserSnippet(tailText, max = 80) {
|
|
156
|
+
const rows = String(tailText).split('\n');
|
|
157
|
+
for (let i = rows.length - 1; i >= 0; i--) {
|
|
158
|
+
const line = rows[i]?.trim() ?? '';
|
|
159
|
+
if (!line || line[0] !== '{')
|
|
160
|
+
continue;
|
|
161
|
+
let data;
|
|
162
|
+
try {
|
|
163
|
+
data = JSON.parse(line);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (!isRecord(data) || data.type !== 'user' || !isRecord(data.message) || data.message.role !== 'user')
|
|
169
|
+
continue;
|
|
170
|
+
const c = data.message.content;
|
|
171
|
+
let text = '';
|
|
172
|
+
if (typeof c === 'string')
|
|
173
|
+
text = c;
|
|
174
|
+
else if (Array.isArray(c))
|
|
175
|
+
text = c
|
|
176
|
+
.filter((block) => isRecord(block) && block.type === 'text')
|
|
177
|
+
.map((block) => typeof block.text === 'string' ? block.text : '')
|
|
178
|
+
.join(' ');
|
|
179
|
+
text = text.replace(/\s+/g, ' ').trim();
|
|
180
|
+
if (text)
|
|
181
|
+
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
182
|
+
}
|
|
183
|
+
return '';
|
|
184
|
+
}
|
|
185
|
+
// Resolve a cwd to the newest jsonl in its ENCODED project dir whose recorded cwd matches (guards against
|
|
186
|
+
// the lossy encoding colliding two real paths). This is Claude's layout (~/.claude/projects/<enc-cwd>/
|
|
187
|
+
// <uuid>.jsonl); Codex has its own resolver. `snippet` extracts the last user turn (parser injected so
|
|
188
|
+
// each agent's jsonl shape is handled). Returns sessionId, a busy/idle guess (mtime recency), the snippet,
|
|
189
|
+
// and the last-activity timestamp; {} when the dir is absent or nothing matches.
|
|
190
|
+
export async function resolveEncodedDirSession(projectsDir, cwd, { busyMs = 8000, now = Date.now, snippet = lastUserSnippet } = {}) {
|
|
191
|
+
const dir = path.join(projectsDir, encodeProjectDir(cwd));
|
|
192
|
+
let names;
|
|
193
|
+
try {
|
|
194
|
+
names = (await fsp.readdir(dir)).filter((n) => n.endsWith('.jsonl'));
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return {};
|
|
198
|
+
}
|
|
199
|
+
const stats = [];
|
|
200
|
+
for (const n of names) {
|
|
201
|
+
try {
|
|
202
|
+
stats.push({ n, mtime: (await fsp.stat(path.join(dir, n))).mtimeMs });
|
|
203
|
+
}
|
|
204
|
+
catch { /* gone */ }
|
|
205
|
+
}
|
|
206
|
+
stats.sort((a, b) => b.mtime - a.mtime);
|
|
207
|
+
for (const { n, mtime } of stats) {
|
|
208
|
+
const file = path.join(dir, n);
|
|
209
|
+
let head;
|
|
210
|
+
try {
|
|
211
|
+
head = await readHead(file);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (firstCwd(head) !== cwd)
|
|
217
|
+
continue; // different real path collided onto the same dir → skip
|
|
218
|
+
let snip = '';
|
|
219
|
+
try {
|
|
220
|
+
snip = snippet(await readTail(file));
|
|
221
|
+
}
|
|
222
|
+
catch { /* best effort */ }
|
|
223
|
+
return {
|
|
224
|
+
sessionId: n.replace(/\.jsonl$/, ''),
|
|
225
|
+
state: now() - mtime < busyMs ? 'busy' : 'idle',
|
|
226
|
+
snippet: snip,
|
|
227
|
+
lastActivity: Math.round(mtime),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
return {};
|
|
231
|
+
}
|
|
232
|
+
// A tmux session name derived from a cwd basename, kept within isValidSessionName ([A-Za-z0-9-], ≤16):
|
|
233
|
+
// `<prefix>-<alnum label, ≤8>-<n>`. n disambiguates against existing sessions.
|
|
234
|
+
export function takeoverSessionName(cwdLabel, n, prefix = 'cc') {
|
|
235
|
+
const base = String(cwdLabel || '').replace(/[^A-Za-z0-9]/g, '').slice(0, 8) || prefix;
|
|
236
|
+
return `${prefix}-${base}-${n}`.slice(0, 16);
|
|
237
|
+
}
|
|
238
|
+
// Coerce a user-typed takeover name into a valid tmux session name (isValidSessionName: [A-Za-z0-9-], ≤16):
|
|
239
|
+
// non-alnum runs → a single '-', trimmed of edge hyphens, capped at 16. Returns '' if nothing usable is
|
|
240
|
+
// left (caller then falls back to the generated name).
|
|
241
|
+
export function sanitizeSessionName(s) {
|
|
242
|
+
return String(s || '').replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 16).replace(/-+$/, '');
|
|
243
|
+
}
|
|
244
|
+
// First name in `<base>, base-2, base-3, …` (each capped at 16) that isn't already taken. null if none free.
|
|
245
|
+
export function freeSessionName(base, taken) {
|
|
246
|
+
if (!taken.has(base))
|
|
247
|
+
return base;
|
|
248
|
+
for (let i = 2; i < 1000; i++) {
|
|
249
|
+
const cand = `${base}-${i}`.slice(0, 16);
|
|
250
|
+
if (!taken.has(cand))
|
|
251
|
+
return cand;
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
export const isShell = (command) => /^-?(zsh|bash|sh|fish|dash|tcsh|csh|ksh)$/.test(String(command || ''));
|
|
256
|
+
export async function lsofCwd(run, pid) {
|
|
257
|
+
const out = await run('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn']);
|
|
258
|
+
for (const line of out.split('\n'))
|
|
259
|
+
if (line[0] === 'n')
|
|
260
|
+
return line.slice(1).trim();
|
|
261
|
+
return '';
|
|
262
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
export class PublicApiError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
code;
|
|
5
|
+
constructor(status, code, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = 'PublicApiError';
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.code = code;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function requestIdFor(locals, idFactory) {
|
|
13
|
+
if (typeof locals.requestId === 'string' && locals.requestId)
|
|
14
|
+
return locals.requestId;
|
|
15
|
+
const requestId = idFactory();
|
|
16
|
+
locals.requestId = requestId;
|
|
17
|
+
return requestId;
|
|
18
|
+
}
|
|
19
|
+
function errorFields(error) {
|
|
20
|
+
if (!(error instanceof Error))
|
|
21
|
+
return { value: String(error) };
|
|
22
|
+
return { name: error.name, message: error.message, stack: error.stack };
|
|
23
|
+
}
|
|
24
|
+
function isInvalidJson(error) {
|
|
25
|
+
if (!(error instanceof SyntaxError) || !error || typeof error !== 'object')
|
|
26
|
+
return false;
|
|
27
|
+
const candidate = error;
|
|
28
|
+
return candidate.status === 400 && candidate.type === 'entity.parse.failed';
|
|
29
|
+
}
|
|
30
|
+
export function apiRequestContext({ idFactory = randomUUID } = {}) {
|
|
31
|
+
return (_req, res, next) => {
|
|
32
|
+
const requestId = requestIdFor(res.locals, idFactory);
|
|
33
|
+
res.setHeader('X-Request-Id', requestId);
|
|
34
|
+
next();
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function apiNotFound({ idFactory = randomUUID } = {}) {
|
|
38
|
+
return (_req, res) => {
|
|
39
|
+
const requestId = requestIdFor(res.locals, idFactory);
|
|
40
|
+
res.setHeader('X-Request-Id', requestId);
|
|
41
|
+
const body = { error: 'not found', code: 'not_found', requestId };
|
|
42
|
+
res.status(404).json(body);
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export function apiErrorBoundary({ idFactory = randomUUID, log = console, } = {}) {
|
|
46
|
+
return (error, req, res, next) => {
|
|
47
|
+
if (res.headersSent) {
|
|
48
|
+
next(error);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const requestId = requestIdFor(res.locals, idFactory);
|
|
52
|
+
res.setHeader('X-Request-Id', requestId);
|
|
53
|
+
let status = 500;
|
|
54
|
+
let code = 'internal_error';
|
|
55
|
+
let message = 'internal server error';
|
|
56
|
+
if (error instanceof PublicApiError) {
|
|
57
|
+
status = error.status;
|
|
58
|
+
code = error.code;
|
|
59
|
+
message = error.message;
|
|
60
|
+
}
|
|
61
|
+
else if (isInvalidJson(error)) {
|
|
62
|
+
status = 400;
|
|
63
|
+
code = 'invalid_json';
|
|
64
|
+
message = 'invalid json';
|
|
65
|
+
}
|
|
66
|
+
if (status >= 500) {
|
|
67
|
+
log.error('[handmux] api error', {
|
|
68
|
+
requestId,
|
|
69
|
+
method: req.method,
|
|
70
|
+
path: req.originalUrl,
|
|
71
|
+
error: errorFields(error),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const body = { error: message, code, requestId };
|
|
75
|
+
res.status(status).json(body);
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -1,23 +1,22 @@
|
|
|
1
1
|
// Runtime app-name injection. The web bundle ships prebuilt, so a user's custom name (set at
|
|
2
2
|
// `handmux start --name`) can't be baked at build time — instead the server rewrites the name into
|
|
3
3
|
// the shell HTML and the PWA manifest as it serves them. Pure string/object transforms so they're
|
|
4
|
-
// unit-testable; the server side-effects (read file / send) live in server.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
.replace(/>/g, '>').replace(/"/g, '"');
|
|
8
|
-
|
|
4
|
+
// unit-testable; the server side-effects (read file / send) live in server.ts.
|
|
5
|
+
const escAttr = (s) => s.replace(/&/g, '&').replace(/</g, '<')
|
|
6
|
+
.replace(/>/g, '>').replace(/"/g, '"');
|
|
9
7
|
// Replace the browser-tab title and the iOS home-screen label in index.html. Matches by tag/attr,
|
|
10
8
|
// not by the default text, so it survives a changed default. No-op when a tag is absent.
|
|
11
9
|
export function applyAppName(html, name) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
10
|
+
if (!name)
|
|
11
|
+
return html;
|
|
12
|
+
const e = escAttr(name);
|
|
13
|
+
return html
|
|
14
|
+
.replace(/<title>[^<]*<\/title>/, `<title>${e}</title>`)
|
|
15
|
+
.replace(/(<meta name="apple-mobile-web-app-title" content=")[^"]*(")/, `$1${e}$2`);
|
|
17
16
|
}
|
|
18
|
-
|
|
19
17
|
// Override the PWA install name (Android uses short_name; both set so home-screen + app list agree).
|
|
20
18
|
export function applyManifestName(manifest, name) {
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
if (!name)
|
|
20
|
+
return manifest;
|
|
21
|
+
return { ...manifest, name, short_name: name };
|
|
23
22
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export function asrConfig(env = process.env) {
|
|
2
|
+
return { appId: env.XFYUN_APPID || '', apiKey: env.XFYUN_APIKEY || '', apiSecret: env.XFYUN_APISECRET || '' };
|
|
3
|
+
}
|
|
4
|
+
export function isAsrConfigured(env = process.env) {
|
|
5
|
+
const c = asrConfig(env);
|
|
6
|
+
return !!(c.appId && c.apiKey && c.apiSecret);
|
|
7
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
// Build a fully-signed iFlytek IAT v2 WebSocket URL. The secret (apiSecret) is used only here on the
|
|
3
|
+
// server — the browser receives only the resulting URL (HMAC output, not the key). `host`/`date` are
|
|
4
|
+
// injectable so this is deterministic and unit-testable; production passes host='iat-api.xfyun.cn'
|
|
5
|
+
// and date=new Date().toUTCString() (RFC1123 GMT, iFlytek allows ±300s skew).
|
|
6
|
+
export function buildIatSignedUrl({ appId, apiKey, apiSecret, host = 'iat-api.xfyun.cn', date, }) {
|
|
7
|
+
const requestLine = 'GET /v2/iat HTTP/1.1';
|
|
8
|
+
const signatureOrigin = `host: ${host}\ndate: ${date}\n${requestLine}`;
|
|
9
|
+
const signature = createHmac('sha256', apiSecret).update(signatureOrigin).digest('base64');
|
|
10
|
+
const authorizationOrigin = `api_key="${apiKey}", algorithm="hmac-sha256", headers="host date request-line", signature="${signature}"`;
|
|
11
|
+
const authorization = Buffer.from(authorizationOrigin, 'utf8').toString('base64');
|
|
12
|
+
const qs = new URLSearchParams({ authorization, date, host });
|
|
13
|
+
return { url: `wss://${host}/v2/iat?${qs.toString()}`, appId };
|
|
14
|
+
}
|
package/dist/src/auth.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
export function loadToken(env = process.env) {
|
|
3
|
+
let token = env.HANDMUX_TOKEN;
|
|
4
|
+
if (!token) {
|
|
5
|
+
token = crypto.randomBytes(24).toString('base64url');
|
|
6
|
+
console.log(`[handmux] no HANDMUX_TOKEN set; generated token: ${token}`);
|
|
7
|
+
}
|
|
8
|
+
return token;
|
|
9
|
+
}
|
|
10
|
+
const sha = (value) => crypto.createHash('sha256').update(String(value)).digest();
|
|
11
|
+
export function tokenEquals(a, b) {
|
|
12
|
+
return crypto.timingSafeEqual(sha(a), sha(b));
|
|
13
|
+
}
|
|
14
|
+
export function bearerFrom(header) {
|
|
15
|
+
if (!header)
|
|
16
|
+
return null;
|
|
17
|
+
const m = /^Bearer (.+)$/.exec(header);
|
|
18
|
+
return m?.[1] ?? null;
|
|
19
|
+
}
|
|
20
|
+
export function expressAuth(token) {
|
|
21
|
+
return (req, res, next) => {
|
|
22
|
+
const provided = bearerFrom(req.get('authorization'));
|
|
23
|
+
if (provided && tokenEquals(provided, token))
|
|
24
|
+
return next();
|
|
25
|
+
res.status(401).json({ error: 'unauthorized' });
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
const BOOTSTRAP_PREFIX = '/_browser-bootstrap/';
|
|
3
|
+
function normalizedOrigin(raw) {
|
|
4
|
+
const url = new URL(raw);
|
|
5
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
6
|
+
throw new Error('browser bootstrap origin must use http or https');
|
|
7
|
+
return url.origin;
|
|
8
|
+
}
|
|
9
|
+
export function isBrowserBootstrapPath(pathname) {
|
|
10
|
+
return String(pathname || '').startsWith(BOOTSTRAP_PREFIX);
|
|
11
|
+
}
|
|
12
|
+
export function createBrowserBootstrapStore({ randomToken = () => randomBytes(24).toString('base64url'), now = Date.now, ttlMs = 60_000, } = {}) {
|
|
13
|
+
const tickets = new Map();
|
|
14
|
+
const prune = () => {
|
|
15
|
+
const current = now();
|
|
16
|
+
for (const [token, entry] of tickets)
|
|
17
|
+
if (entry.expiresAt <= current)
|
|
18
|
+
tickets.delete(token);
|
|
19
|
+
};
|
|
20
|
+
return {
|
|
21
|
+
issue({ url, origin, deviceId, preserveMethod = false, redirectStatus = 302, }) {
|
|
22
|
+
prune();
|
|
23
|
+
const expectedOrigin = normalizedOrigin(origin);
|
|
24
|
+
const target = new URL(url);
|
|
25
|
+
if (target.origin !== expectedOrigin || !target.pathname.split('/')[1]?.startsWith('_browser-')) {
|
|
26
|
+
throw new Error('browser bootstrap target must be a session on its public origin');
|
|
27
|
+
}
|
|
28
|
+
const token = randomToken();
|
|
29
|
+
tickets.set(token, {
|
|
30
|
+
url: target.toString(),
|
|
31
|
+
origin: expectedOrigin,
|
|
32
|
+
deviceId,
|
|
33
|
+
preserveMethod,
|
|
34
|
+
redirectStatus,
|
|
35
|
+
expiresAt: now() + ttlMs,
|
|
36
|
+
});
|
|
37
|
+
return new URL(`${BOOTSTRAP_PREFIX}${encodeURIComponent(token)}`, expectedOrigin).toString();
|
|
38
|
+
},
|
|
39
|
+
consume(pathname, origin) {
|
|
40
|
+
prune();
|
|
41
|
+
if (!isBrowserBootstrapPath(pathname))
|
|
42
|
+
return null;
|
|
43
|
+
let token;
|
|
44
|
+
try {
|
|
45
|
+
token = decodeURIComponent(String(pathname).slice(BOOTSTRAP_PREFIX.length));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
const entry = tickets.get(token);
|
|
51
|
+
let requestedOrigin;
|
|
52
|
+
try {
|
|
53
|
+
requestedOrigin = normalizedOrigin(origin);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
if (!entry || entry.origin !== requestedOrigin)
|
|
59
|
+
return null;
|
|
60
|
+
tickets.delete(token);
|
|
61
|
+
if (entry.preserveMethod) {
|
|
62
|
+
return {
|
|
63
|
+
url: entry.url,
|
|
64
|
+
deviceId: entry.deviceId,
|
|
65
|
+
preserveMethod: true,
|
|
66
|
+
redirectStatus: entry.redirectStatus,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return { url: entry.url, deviceId: entry.deviceId };
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|