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,240 @@
|
|
|
1
|
+
// Pure: a Claude Code jsonl session log (array of lines) → normalized chat messages. No I/O.
|
|
2
|
+
// Only user/assistant turns become bubbles; every other top-level `type` (attachment, system, last-prompt,
|
|
3
|
+
// mode, permission-mode, ai-title, file-history-snapshot, queue-operation, …) is skipped. tool_use and its
|
|
4
|
+
// later tool_result are folded into ONE tool message (paired by tool_use_id); an unmatched tool_result is
|
|
5
|
+
// dropped (its tool_use is in an earlier, not-yet-loaded chunk). Bad/blank lines are skipped, never thrown.
|
|
6
|
+
//
|
|
7
|
+
// Meta scaffolding is DROPPED so the 对话 lens shows only real turns; the reliable signal is the top-level
|
|
8
|
+
// boolean flags — `isMeta` (skill/workflow injections, "Base directory for this skill…", caveats) and
|
|
9
|
+
// `isCompactSummary` (the "session is being continued…" wall) — NOT text matching.
|
|
10
|
+
//
|
|
11
|
+
// Slash commands (`/compact`, `/model`, `/clear`, …) are NOT dropped — they surface as a quiet 'slash'
|
|
12
|
+
// marker (a centered system row), because a command the user ran IS part of the conversation and hiding it
|
|
13
|
+
// leaves the phone with no feedback. Claude Code logs each as a `<command-name>/x</command-name>` USER turn
|
|
14
|
+
// (the canonical form; the bare "/x" input is a separate queue-operation / plain user line we still drop, to
|
|
15
|
+
// avoid a double). The command's `<local-command-stdout>` echo is folded onto the preceding marker as its
|
|
16
|
+
// `.result` (ANSI-stripped, capped) — and its PRESENCE means the command COMPLETED. An interactive picker
|
|
17
|
+
// (bare `/model`, `/plugin`, …) still open in the terminal has written no result yet, which is exactly how
|
|
18
|
+
// the UI tells a finished command from one that needs the user to drop to the terminal lens and pick.
|
|
19
|
+
// Matching is a tag prefix ANCHORED at the start of a USER turn's text: an assistant reply that merely
|
|
20
|
+
// mentions `<command-name>` in prose (e.g. discussing this very code) must NOT be caught.
|
|
21
|
+
const KEEP = new Set(['user', 'assistant']);
|
|
22
|
+
const SCAFFOLD_RE = /^\s*<(?:command-name|command-message|command-args|local-command-stdout|local-command-caveat|bash-input|bash-stdout|bash-stderr)>/;
|
|
23
|
+
// Besides the <command-name> scaffold form, Claude Code ALSO logs the raw slash-command input as a plain,
|
|
24
|
+
// flagless user turn (content exactly "/compact", "/model sonnet", …) — redundant with the scaffold marker,
|
|
25
|
+
// and after a /compact it's the LAST turn, so leaving it in would make the 对话 lens read a trailing user
|
|
26
|
+
// bubble and light the "reply coming" typing wave forever. Drop it — anchored to a single leading /command
|
|
27
|
+
// token followed by whitespace/EOL, so a path-like message ("/Users/demo/foo.js …") or prose is NOT eaten.
|
|
28
|
+
const SLASH_CMD_RE = /^\s*\/[a-z][\w-]*(?:\s|$)/i;
|
|
29
|
+
// The canonical scaffold form of a slash command: capture the name and any args, so the marker can tell a
|
|
30
|
+
// bare `/model` (may open a picker) from `/model sonnet` (applies directly). The stdout echo is a separate
|
|
31
|
+
// user turn right after; its inner text (closing tag stripped, ANSI stripped, capped) becomes the result.
|
|
32
|
+
const CMD_NAME_RE = /^\s*<command-name>\s*\/?([\w-]+)\s*<\/command-name>/i;
|
|
33
|
+
const CMD_ARGS_RE = /<command-args>([\s\S]*?)<\/command-args>/i;
|
|
34
|
+
const STDOUT_RE = /^\s*<local-command-stdout>([\s\S]*)$/i;
|
|
35
|
+
const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
36
|
+
const SLASH_RESULT_CAP = 140;
|
|
37
|
+
const stripAnsi = (value) => typeof value === 'string' ? value.replace(ANSI_RE, '') : '';
|
|
38
|
+
const record = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
39
|
+
? value
|
|
40
|
+
: null);
|
|
41
|
+
// A file-edit diff from a jsonl line's top-level `toolUseResult`, or null if the tool didn't edit a file.
|
|
42
|
+
// Non-empty structuredPatch → count +/- lines and keep the hunks (for the expandable coloured view). Empty
|
|
43
|
+
// patch but a create → every content line is an addition. Anything else (Bash/Read/…) → null.
|
|
44
|
+
function extractDiff(value) {
|
|
45
|
+
const r = record(value);
|
|
46
|
+
if (!r)
|
|
47
|
+
return null;
|
|
48
|
+
const patch = Array.isArray(r.structuredPatch) ? r.structuredPatch : null;
|
|
49
|
+
if (patch && patch.length) {
|
|
50
|
+
let added = 0, removed = 0;
|
|
51
|
+
const hunks = [];
|
|
52
|
+
for (const rawHunk of patch) {
|
|
53
|
+
const h = record(rawHunk) || {};
|
|
54
|
+
const lines = Array.isArray(h.lines) ? h.lines.filter((line) => typeof line === 'string') : [];
|
|
55
|
+
for (const ln of lines) {
|
|
56
|
+
const c = typeof ln === 'string' ? ln[0] : '';
|
|
57
|
+
if (c === '+')
|
|
58
|
+
added++;
|
|
59
|
+
else if (c === '-')
|
|
60
|
+
removed++;
|
|
61
|
+
}
|
|
62
|
+
hunks.push({ oldStart: h.oldStart, newStart: h.newStart, lines });
|
|
63
|
+
}
|
|
64
|
+
return { added, removed, hunks };
|
|
65
|
+
}
|
|
66
|
+
if (r.type === 'create' && typeof r.content === 'string') {
|
|
67
|
+
return { added: r.content ? r.content.split('\n').length : 0, removed: 0, hunks: null, created: true };
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
function resultText(content) {
|
|
72
|
+
if (typeof content === 'string')
|
|
73
|
+
return content;
|
|
74
|
+
if (Array.isArray(content))
|
|
75
|
+
return content.map((item) => {
|
|
76
|
+
const c = record(item);
|
|
77
|
+
return c?.type === 'text' && typeof c.text === 'string' ? c.text : '';
|
|
78
|
+
}).join('');
|
|
79
|
+
return '';
|
|
80
|
+
}
|
|
81
|
+
// Leading text of a message's content (string as-is, or the first text item of an array) — used only to
|
|
82
|
+
// probe for a scaffolding tag at the very start. tool_result-only user turns yield '' and are never matched.
|
|
83
|
+
function leadingText(content) {
|
|
84
|
+
if (typeof content === 'string')
|
|
85
|
+
return content;
|
|
86
|
+
if (Array.isArray(content)) {
|
|
87
|
+
const t = content.map(record).find((item) => item?.type === 'text');
|
|
88
|
+
return t && typeof t.text === 'string' ? t.text : '';
|
|
89
|
+
}
|
|
90
|
+
return '';
|
|
91
|
+
}
|
|
92
|
+
// Stateful form used by the transcript route's append-only file cache. `push()` may be called with
|
|
93
|
+
// successive complete JSONL batches: tool results and slash-command stdout in a later batch can still
|
|
94
|
+
// update the tool/slash message created by an earlier batch. The one-shot `parseTranscript()` wrapper
|
|
95
|
+
// below preserves the public pure-function API used everywhere else.
|
|
96
|
+
export function createTranscriptParser() {
|
|
97
|
+
const msgs = [];
|
|
98
|
+
const byToolId = new Map(); // tool_use_id → the tool message awaiting its result
|
|
99
|
+
let i = 0;
|
|
100
|
+
function push(lines) {
|
|
101
|
+
for (const raw of lines) {
|
|
102
|
+
const s = typeof raw === 'string' ? raw.trim() : '';
|
|
103
|
+
if (!s) {
|
|
104
|
+
i++;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
let parsed;
|
|
108
|
+
try {
|
|
109
|
+
parsed = JSON.parse(s);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
i++;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const o = record(parsed);
|
|
116
|
+
const m = record(o?.message);
|
|
117
|
+
if (!o || !KEEP.has(typeof o.type === 'string' ? o.type : '') || !m) {
|
|
118
|
+
i++;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (o.isMeta === true) {
|
|
122
|
+
i++;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
// A compaction wall (isCompactSummary): don't render the (huge) summary text as a bubble, but DO leave a
|
|
126
|
+
// quiet divider marker where it happened, so the 对话 lens shows "上下文已压缩" between the old and new
|
|
127
|
+
// context instead of the conversation silently jumping. (A no-op /compact writes no such entry → no
|
|
128
|
+
// divider, correctly.) Rendered centered like the interrupt marker.
|
|
129
|
+
if (o.isCompactSummary === true) {
|
|
130
|
+
msgs.push({ i, type: 'compact', ts: typeof o.timestamp === 'string' ? o.timestamp : undefined });
|
|
131
|
+
i++;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
// The jsonl line's wall-clock (ISO string) — carried onto each message so the 对话 lens can show a
|
|
135
|
+
// time separator between turns. Absent on some lines (older logs) → undefined; the UI shows nothing
|
|
136
|
+
// rather than a fabricated time ("有地方取就要,没有就不要").
|
|
137
|
+
const ts = typeof o.timestamp === 'string' ? o.timestamp : undefined;
|
|
138
|
+
const role = m.role === 'user' ? 'user' : 'assistant';
|
|
139
|
+
if (role === 'user') {
|
|
140
|
+
const lead = leadingText(m.content);
|
|
141
|
+
// A slash command → a quiet centered 'slash' marker (see the header note). Name + optional args.
|
|
142
|
+
const nameM = CMD_NAME_RE.exec(lead);
|
|
143
|
+
if (nameM) {
|
|
144
|
+
const argsM = CMD_ARGS_RE.exec(lead);
|
|
145
|
+
const args = argsM?.[1]?.trim() || '';
|
|
146
|
+
const name = nameM[1];
|
|
147
|
+
if (!name) {
|
|
148
|
+
i++;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const mk = { i, type: 'slash', name: `/${name}`, ts };
|
|
152
|
+
if (args)
|
|
153
|
+
mk.args = args;
|
|
154
|
+
msgs.push(mk);
|
|
155
|
+
i++;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
// Its stdout echo → fold onto the marker just pushed as `.result` (presence ⇒ the command completed).
|
|
159
|
+
const outM = STDOUT_RE.exec(lead);
|
|
160
|
+
if (outM) {
|
|
161
|
+
const last = msgs[msgs.length - 1];
|
|
162
|
+
if (last && last.type === 'slash' && last.result === undefined) {
|
|
163
|
+
const txt = stripAnsi((outM[1] || '').replace(/<\/local-command-stdout>\s*$/i, '')).trim();
|
|
164
|
+
if (txt)
|
|
165
|
+
last.result = txt.length > SLASH_RESULT_CAP ? txt.slice(0, SLASH_RESULT_CAP) + '…' : txt;
|
|
166
|
+
}
|
|
167
|
+
i++;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
// Other scaffolding (<command-message>, caveat, bash-*) and the bare "/x" input line: still dropped.
|
|
171
|
+
if (SCAFFOLD_RE.test(lead) || SLASH_CMD_RE.test(lead)) {
|
|
172
|
+
i++;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// ESC-interrupt: Claude Code appends a standalone user line carrying a top-level `interruptedMessageId`
|
|
177
|
+
// (content is just "[Request interrupted by user…]"). It's NOT something the user typed — surface it as a
|
|
178
|
+
// quiet 'interrupt' marker (rendered as a small centered hint), never as a prominent user bubble. Detect
|
|
179
|
+
// by the structural field, falling back to the marker text for older logs.
|
|
180
|
+
if (typeof o.interruptedMessageId === 'string' || (role === 'user' && /^\[Request interrupted by user/.test(leadingText(m.content)))) {
|
|
181
|
+
msgs.push({ i, type: 'interrupt', ts });
|
|
182
|
+
i++;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const items = typeof m.content === 'string'
|
|
186
|
+
? [{ type: 'text', text: m.content }]
|
|
187
|
+
: Array.isArray(m.content) ? m.content : [];
|
|
188
|
+
for (const rawItem of items) {
|
|
189
|
+
const it = record(rawItem);
|
|
190
|
+
if (!it)
|
|
191
|
+
continue;
|
|
192
|
+
// .trim() here is a truthiness guard only (dropping whitespace-only text items to avoid empty
|
|
193
|
+
// bubbles) — the pushed `it.text` below is the untrimmed original, so real text's internal/
|
|
194
|
+
// leading/trailing whitespace is preserved verbatim.
|
|
195
|
+
if (it.type === 'text' && typeof it.text === 'string' && it.text.trim()) {
|
|
196
|
+
msgs.push({ i, role, type: 'text', text: it.text, ts });
|
|
197
|
+
}
|
|
198
|
+
else if (it.type === 'thinking' && typeof it.thinking === 'string' && it.thinking) {
|
|
199
|
+
msgs.push({ i, role: 'assistant', type: 'thinking', text: it.thinking, ts });
|
|
200
|
+
}
|
|
201
|
+
else if (it.type === 'tool_use') {
|
|
202
|
+
const tm = {
|
|
203
|
+
i,
|
|
204
|
+
role: 'assistant',
|
|
205
|
+
type: 'tool',
|
|
206
|
+
ts,
|
|
207
|
+
tool: {
|
|
208
|
+
name: typeof it.name === 'string' ? it.name : '',
|
|
209
|
+
input: it.input || {},
|
|
210
|
+
result: null,
|
|
211
|
+
isError: false,
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
if (typeof it.id === 'string' && it.id)
|
|
215
|
+
byToolId.set(it.id, tm);
|
|
216
|
+
msgs.push(tm);
|
|
217
|
+
}
|
|
218
|
+
else if (it.type === 'tool_result') {
|
|
219
|
+
const tm = typeof it.tool_use_id === 'string' ? byToolId.get(it.tool_use_id) : undefined;
|
|
220
|
+
if (tm) {
|
|
221
|
+
tm.tool.result = resultText(it.content);
|
|
222
|
+
tm.tool.isError = !!it.is_error;
|
|
223
|
+
// Claude Code stores a real per-hunk diff for file edits on the SAME line's top-level
|
|
224
|
+
// `toolUseResult.structuredPatch` (hunks of {oldStart,newStart,lines[]}, each line prefixed
|
|
225
|
+
// +/-/space) — the exact data the CLI renders. Fold it into the tool message so the 对话 chip
|
|
226
|
+
// can show +A/−B and open the coloured diff, instead of the bland "…updated successfully" string.
|
|
227
|
+
// A file CREATE has an empty patch but `type:'create'` + `content` → treat every line as added.
|
|
228
|
+
tm.tool.diff = extractDiff(o.toolUseResult);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
i++;
|
|
233
|
+
}
|
|
234
|
+
return msgs;
|
|
235
|
+
}
|
|
236
|
+
return { push, messages: msgs };
|
|
237
|
+
}
|
|
238
|
+
export function parseTranscript(lines) {
|
|
239
|
+
return createTranscriptParser().push(lines);
|
|
240
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { createReadStream, promises as fsp } from 'node:fs';
|
|
2
|
+
import { setImmediate as yieldImmediate } from 'node:timers/promises';
|
|
3
|
+
import { createTranscriptParser } from './transcriptParse.js';
|
|
4
|
+
// Append-aware JSONL reader for chat transcripts. A live Claude session is append-only, so after the
|
|
5
|
+
// first asynchronous scan we read and parse only newly completed lines. Replacement/truncation resets
|
|
6
|
+
// the parser; a small LRU bounds server memory across panes. Parsing yields periodically so the initial
|
|
7
|
+
// scan of a long session cannot monopolize the Node event loop.
|
|
8
|
+
export function createTranscriptReader({ maxEntries = 8, yieldEvery = 500 } = {}) {
|
|
9
|
+
const cache = new Map();
|
|
10
|
+
const inflight = new Map();
|
|
11
|
+
async function readCompleteLines(file, start) {
|
|
12
|
+
const lines = [];
|
|
13
|
+
let pending = Buffer.alloc(0);
|
|
14
|
+
let consumed = 0;
|
|
15
|
+
let bytesRead = 0;
|
|
16
|
+
for await (const rawChunk of createReadStream(file, { start })) {
|
|
17
|
+
const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk);
|
|
18
|
+
bytesRead += chunk.length;
|
|
19
|
+
const data = pending.length ? Buffer.concat([pending, chunk]) : chunk;
|
|
20
|
+
let pos = 0;
|
|
21
|
+
for (;;) {
|
|
22
|
+
const nl = data.indexOf(0x0a, pos);
|
|
23
|
+
if (nl === -1)
|
|
24
|
+
break;
|
|
25
|
+
lines.push(data.subarray(pos, nl).toString('utf8'));
|
|
26
|
+
consumed += nl - pos + 1;
|
|
27
|
+
pos = nl + 1;
|
|
28
|
+
}
|
|
29
|
+
pending = data.subarray(pos);
|
|
30
|
+
}
|
|
31
|
+
return { lines, offset: start + consumed, size: start + bytesRead };
|
|
32
|
+
}
|
|
33
|
+
async function applyLines(parser, lines) {
|
|
34
|
+
for (let i = 0; i < lines.length; i += yieldEvery) {
|
|
35
|
+
parser.push(lines.slice(i, i + yieldEvery));
|
|
36
|
+
if (i + yieldEvery < lines.length)
|
|
37
|
+
await yieldImmediate();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function trim() {
|
|
41
|
+
if (cache.size <= maxEntries)
|
|
42
|
+
return;
|
|
43
|
+
const oldest = Array.from(cache.entries()).sort((a, b) => a[1].usedAt - b[1].usedAt);
|
|
44
|
+
for (const [file] of oldest) {
|
|
45
|
+
if (cache.size <= maxEntries)
|
|
46
|
+
break;
|
|
47
|
+
if (!inflight.has(file))
|
|
48
|
+
cache.delete(file);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function load(file, createParser) {
|
|
52
|
+
let st;
|
|
53
|
+
try {
|
|
54
|
+
st = await fsp.stat(file);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
cache.delete(file);
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
if (!st.isFile()) {
|
|
61
|
+
cache.delete(file);
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
let entry = cache.get(file);
|
|
65
|
+
const sameFile = Boolean(entry
|
|
66
|
+
&& entry.createParser === createParser
|
|
67
|
+
&& entry.dev === st.dev
|
|
68
|
+
&& entry.ino === st.ino);
|
|
69
|
+
if (entry && sameFile && entry.size === st.size && entry.mtimeMs === st.mtimeMs) {
|
|
70
|
+
entry.usedAt = Date.now();
|
|
71
|
+
return entry.parser.messages;
|
|
72
|
+
}
|
|
73
|
+
// A larger file with the same inode is the normal append path. Anything else (truncate, atomic
|
|
74
|
+
// replace, or an in-place rewrite with unchanged size) gets a clean parser so stale messages cannot
|
|
75
|
+
// leak across /clear or log rotation.
|
|
76
|
+
const append = Boolean(sameFile && entry && st.size > entry.size);
|
|
77
|
+
if (!append || !entry) {
|
|
78
|
+
entry = { parser: createParser(), createParser, offset: 0, dev: st.dev, ino: st.ino, size: 0, mtimeMs: 0, usedAt: 0 };
|
|
79
|
+
}
|
|
80
|
+
const { lines, offset, size } = await readCompleteLines(file, entry.offset);
|
|
81
|
+
await applyLines(entry.parser, lines);
|
|
82
|
+
// Re-stat after reading: the writer may have appended while the stream was open. Metadata reflects
|
|
83
|
+
// the latest observed file; any bytes not consumed as complete lines are deliberately retried later.
|
|
84
|
+
let end = st;
|
|
85
|
+
try {
|
|
86
|
+
end = await fsp.stat(file);
|
|
87
|
+
}
|
|
88
|
+
catch { /* return the last complete snapshot */ }
|
|
89
|
+
entry.offset = offset;
|
|
90
|
+
// Only adopt the post-read metadata if it describes exactly the bytes the stream consumed. If the
|
|
91
|
+
// file grew or was replaced in the race window, retaining the pre-read identity/observed size forces
|
|
92
|
+
// the next poll down the append/reset path instead of accidentally declaring unread bytes unchanged.
|
|
93
|
+
const caughtUp = end.dev === st.dev && end.ino === st.ino && end.size === size;
|
|
94
|
+
entry.dev = caughtUp ? end.dev : st.dev;
|
|
95
|
+
entry.ino = caughtUp ? end.ino : st.ino;
|
|
96
|
+
entry.size = size;
|
|
97
|
+
entry.mtimeMs = caughtUp ? end.mtimeMs : st.mtimeMs;
|
|
98
|
+
entry.usedAt = Date.now();
|
|
99
|
+
cache.set(file, entry);
|
|
100
|
+
trim();
|
|
101
|
+
return entry.parser.messages;
|
|
102
|
+
}
|
|
103
|
+
async function read(file, createParser = createTranscriptParser) {
|
|
104
|
+
const active = inflight.get(file);
|
|
105
|
+
if (active)
|
|
106
|
+
return active;
|
|
107
|
+
const run = load(file, createParser).finally(() => inflight.delete(file));
|
|
108
|
+
inflight.set(file, run);
|
|
109
|
+
return run;
|
|
110
|
+
}
|
|
111
|
+
return { read, clear: () => cache.clear(), size: () => cache.size };
|
|
112
|
+
}
|
|
113
|
+
export const transcriptReader = createTranscriptReader();
|
|
@@ -12,17 +12,17 @@
|
|
|
12
12
|
// ambiguous blank rows before this step, so genuinely shaded padding carries its explicit SGR while
|
|
13
13
|
// a default-background blank may be trimmed normally.
|
|
14
14
|
export const MAX_TRAILING_BLANK = 3;
|
|
15
|
-
|
|
16
15
|
const isBlank = (row) => row === '' || (/^[ \t]*$/.test(row) && !row.includes('\x1b'));
|
|
17
|
-
|
|
18
16
|
export function capTrailingBlankRows(ansi, max = MAX_TRAILING_BLANK) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
17
|
+
// capture-pane terminates every row with \n (including a trailing one). Peel that off so split
|
|
18
|
+
// gives exactly the rows, then restore it so the output keeps capture-pane's shape (prepareSeed
|
|
19
|
+
// drops a single trailing newline downstream).
|
|
20
|
+
const hadNL = ansi.endsWith('\n');
|
|
21
|
+
const rows = (hadNL ? ansi.slice(0, -1) : ansi).split('\n');
|
|
22
|
+
let end = rows.length;
|
|
23
|
+
while (end > 0 && isBlank(rows[end - 1] ?? ''))
|
|
24
|
+
end -= 1;
|
|
25
|
+
if (rows.length - end > max)
|
|
26
|
+
rows.length = end + max;
|
|
27
|
+
return rows.join('\n') + (hadNL ? '\n' : '');
|
|
28
28
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Extension allow-list for uploads. By extension only (not magic bytes) — this is a "be tidy"
|
|
2
|
+
// guard, not a trust boundary (a token holder can write anything from the terminal anyway; see spec
|
|
3
|
+
// threat model). Override the whole set with HANDMUX_UPLOAD_EXTS (comma-separated).
|
|
4
|
+
export const DEFAULT_UPLOAD_EXTS = new Set([
|
|
5
|
+
// images
|
|
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',
|
|
9
|
+
// text / code
|
|
10
|
+
'txt', 'md', 'markdown', 'rst', 'log', 'csv', 'tsv', 'json', 'yaml', 'yml', 'toml', 'ini',
|
|
11
|
+
'conf', 'xml', 'html', 'htm', 'css', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'py', 'rb', 'go',
|
|
12
|
+
'rs', 'java', 'c', 'h', 'cpp', 'sh',
|
|
13
|
+
// documents / office
|
|
14
|
+
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'rtf',
|
|
15
|
+
]);
|
|
16
|
+
// Read HANDMUX_UPLOAD_EXTS (comma-separated) → a Set, normalising leading dots/case/whitespace.
|
|
17
|
+
// Blank/absent → the default set (returned by reference so callers can `=== DEFAULT_UPLOAD_EXTS`).
|
|
18
|
+
export function loadUploadExts(env = process.env) {
|
|
19
|
+
const raw = env.HANDMUX_UPLOAD_EXTS;
|
|
20
|
+
if (!raw || !raw.trim())
|
|
21
|
+
return DEFAULT_UPLOAD_EXTS;
|
|
22
|
+
const exts = raw.split(',').map((s) => s.trim().replace(/^\.+/, '').toLowerCase()).filter(Boolean);
|
|
23
|
+
return new Set(exts);
|
|
24
|
+
}
|
|
25
|
+
// True if `name`'s final extension is in `exts`. No extension → false.
|
|
26
|
+
export function isAllowedUploadExt(name, exts) {
|
|
27
|
+
const m = /\.([A-Za-z0-9]+)$/.exec(name || '');
|
|
28
|
+
return m?.[1] ? exts.has(m[1].toLowerCase()) : false;
|
|
29
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Notification links are user-supplied by `handmux push --url`. Keep relative in-app links, but reject
|
|
2
|
+
// executable/non-web schemes (javascript:, data:, file:, …) before they reach storage or an <a href>.
|
|
3
|
+
export function sanitizeNotificationUrl(value) {
|
|
4
|
+
if (typeof value !== 'string')
|
|
5
|
+
return null;
|
|
6
|
+
const raw = value.trim();
|
|
7
|
+
if (!raw)
|
|
8
|
+
return null;
|
|
9
|
+
try {
|
|
10
|
+
const parsed = new URL(raw, 'https://handmux.invalid/');
|
|
11
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
|
|
12
|
+
return null;
|
|
13
|
+
return raw;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|