handmux 0.23.0 → 0.25.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 +31 -8
- package/README.zh-CN.md +31 -8
- package/bin/handmux.js +14 -688
- package/bin/node-version.js +20 -0
- package/dist/bin/handmux.js +1015 -0
- package/dist/connectors/bridgeClient.js +385 -0
- package/dist/connectors/claude/index.js +476 -0
- package/dist/connectors/pi/bridgeClient.js +8 -0
- package/dist/connectors/pi/index.js +851 -0
- package/dist/hooks/handmux-notify.sh +64 -0
- package/{hooks → dist/hooks}/handmux-statusline.cjs +28 -6
- package/dist/hooks/handmux-write.cjs +264 -0
- package/dist/package.json +127 -0
- package/dist/public/assets/index-CTrqTqtT.css +32 -0
- package/dist/public/assets/index-DmjeMSQA.js +346 -0
- package/dist/public/assets/pcm-worklet-CxcDy7PB.js +1 -0
- package/{public → dist/public}/index.html +5 -4
- package/dist/src/agent-runtime/adapter.js +204 -0
- package/dist/src/agent-runtime/bridge.js +1006 -0
- package/dist/src/agent-runtime/bridgeStore.js +25 -0
- package/dist/src/agent-runtime/bridgeTransport.js +619 -0
- package/dist/src/agent-runtime/bridgeTypes.js +13 -0
- package/dist/src/agent-runtime/builtinRuntime.js +193 -0
- package/dist/src/agent-runtime/conversation.js +2179 -0
- package/dist/src/agent-runtime/conversationActivation.js +75 -0
- package/dist/src/agent-runtime/conversationActivity.js +69 -0
- package/dist/src/agent-runtime/conversationControls.js +206 -0
- package/dist/src/agent-runtime/conversationLiveHub.js +276 -0
- package/dist/src/agent-runtime/conversationStore.js +352 -0
- package/dist/src/agent-runtime/conversationTypes.js +1 -0
- package/dist/src/agent-runtime/conversationValidation.js +339 -0
- package/dist/src/agent-runtime/inbox.js +973 -0
- package/dist/src/agent-runtime/inboxPushProjection.js +69 -0
- package/dist/src/agent-runtime/inboxStore.js +25 -0
- package/dist/src/agent-runtime/inboxTypes.js +1 -0
- package/dist/src/agent-runtime/interaction.js +829 -0
- package/dist/src/agent-runtime/interactionLiveHub.js +225 -0
- package/dist/src/agent-runtime/interactionStore.js +18 -0
- package/dist/src/agent-runtime/interactionTypes.js +1 -0
- package/dist/src/agent-runtime/legacyInboxProjection.js +70 -0
- package/dist/src/agent-runtime/migrateLegacyCodexOutbox.js +336 -0
- package/dist/src/agent-runtime/resourceTypes.js +1 -0
- package/dist/src/agent-runtime/resources.js +245 -0
- package/dist/src/agent-runtime/run.js +328 -0
- package/dist/src/agent-runtime/runtime.js +793 -0
- package/dist/src/agent-runtime/sessionControl.js +164 -0
- package/dist/src/agent-runtime/subscriptionUsage.js +190 -0
- package/dist/src/agent-runtime/subscriptionUsageLegacy.js +18 -0
- package/dist/src/agent-runtime/tmuxRuntime.js +156 -0
- package/dist/src/agents/claude.js +203 -0
- package/dist/src/agents/claudeConversation.js +362 -0
- package/dist/src/agents/claudeInteraction.js +134 -0
- package/dist/src/agents/claudeSubscriptionUsage.js +61 -0
- package/dist/src/agents/codex.js +262 -0
- package/dist/src/agents/codexConversation.js +1103 -0
- package/dist/src/agents/codexConversationActivation.js +121 -0
- package/dist/src/agents/codexConversationControls.js +256 -0
- package/dist/src/agents/codexInteraction.js +306 -0
- package/dist/src/agents/codexSessionControl.js +118 -0
- package/dist/src/agents/codexSubscriptionUsage.js +313 -0
- package/dist/src/agents/codexTerminalControl.js +23 -0
- package/dist/src/agents/conversationProjectionSafety.js +416 -0
- package/dist/src/agents/index.js +39 -0
- package/dist/src/agents/nativeInbox.js +264 -0
- package/dist/src/agents/nativeInboxSources.js +115 -0
- package/dist/src/agents/pi.js +28 -0
- package/dist/src/agents/piConversation.js +523 -0
- package/dist/src/agents/piConversationHistory.js +502 -0
- package/dist/src/agents/piInboxBridge.js +368 -0
- package/dist/src/agents/piSessionControl.js +32 -0
- package/dist/src/agents/processIdentity.js +94 -0
- package/dist/src/agents/scanUtils.js +262 -0
- package/dist/src/apiAccountProviders.js +194 -0
- package/dist/src/apiAccounts.js +592 -0
- package/dist/src/apiErrors.js +86 -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 +525 -0
- package/dist/src/cli/agentIntegration.js +131 -0
- package/dist/src/cli/agentLauncher.js +26 -0
- package/dist/src/cli/claudeHooks.js +290 -0
- package/{src → dist/src}/cli/cloudflareUrl.js +2 -3
- package/dist/src/cli/cloudflared.js +106 -0
- package/dist/src/cli/codexManaged.js +80 -0
- package/{src → dist/src}/cli/cpolarUrl.js +8 -9
- package/dist/src/cli/drivers.js +98 -0
- package/dist/src/cli/hookScaffold.js +39 -0
- package/dist/src/cli/i18n/en.js +392 -0
- package/{src → dist/src}/cli/i18n/index.js +27 -25
- package/dist/src/cli/i18n/zh.js +391 -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/piExtension.js +155 -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 +154 -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 +117 -0
- package/dist/src/cli/statusLine.js +115 -0
- 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 +3436 -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 +137 -0
- package/dist/src/codexStreamProtocol.js +207 -0
- package/dist/src/codexToolProtocol.js +68 -0
- package/dist/src/codexTranscriptParse.js +835 -0
- package/dist/src/codexUsageSnapshot.js +228 -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 +284 -0
- package/dist/src/healthProtocol.js +97 -0
- package/dist/src/httpApi.js +96 -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 +167 -0
- package/dist/src/paneInput.js +42 -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 +124 -0
- package/dist/src/projectTask/backup.js +224 -0
- package/dist/src/projectTask/lock.js +108 -0
- package/dist/src/projectTask/migrations.js +90 -0
- package/dist/src/projectTask/routes.js +155 -0
- package/dist/src/projectTask/runtime.js +182 -0
- package/dist/src/projectTask/schema.js +56 -0
- package/dist/src/projectTask/store.js +457 -0
- package/dist/src/push.js +224 -0
- package/dist/src/routes/agents.js +631 -0
- package/dist/src/routes/apiAccounts.js +82 -0
- package/dist/src/routes/files.js +230 -0
- package/dist/src/routes/git.js +89 -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 +244 -0
- package/dist/src/routes/system.js +220 -0
- package/dist/src/routes/terminal.js +244 -0
- package/dist/src/routes/workspace.js +267 -0
- package/dist/src/server.js +315 -0
- 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 +185 -172
- package/dist/src/tmux/format.js +47 -0
- package/dist/src/transcriptParse.js +261 -0
- package/dist/src/transcriptReader.js +209 -0
- package/{src → dist/src}/trimCapture.js +11 -11
- package/dist/src/uploadTypes.js +31 -0
- package/dist/src/urlPolicy.js +18 -0
- package/dist/src/usagePaths.js +15 -0
- package/dist/src/workspace/agentRunner.js +301 -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 +363 -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 +704 -0
- package/dist/src/workspaceProtocol.js +158 -0
- package/package.json +30 -17
- package/hooks/handmux-notify.sh +0 -22
- package/hooks/handmux-write.cjs +0 -78
- package/public/assets/index-C3N7OkyP.css +0 -32
- package/public/assets/index-D1DWcC5u.js +0 -337
- package/src/.gitkeep +0 -0
- package/src/agents/claude.js +0 -159
- package/src/agents/codex.js +0 -168
- package/src/agents/index.js +0 -21
- 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/claudeHooks.js +0 -206
- 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/statusLine.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/server.js +0 -186
- 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/{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
|
@@ -1,115 +1,114 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import { parseTmuxRows, tmuxFormat } from './format.js';
|
|
4
|
-
|
|
5
|
-
export const
|
|
6
|
-
export const
|
|
7
|
-
export const isSessionId = (s) => typeof s === 'string' && /^\$\d+$/.test(s);
|
|
8
|
-
|
|
4
|
+
export const isPaneId = (value) => typeof value === 'string' && /^%\d+$/.test(value);
|
|
5
|
+
export const isWindowId = (value) => typeof value === 'string' && /^@\d+$/.test(value);
|
|
6
|
+
export const isSessionId = (value) => typeof value === 'string' && /^\$\d+$/.test(value);
|
|
9
7
|
// Letters, digits and hyphens only (1-16 chars). A positive allowlist sidesteps tmux's target
|
|
10
8
|
// syntax entirely (no '.'/':' separators, no whitespace, no control chars) and is trivial to
|
|
11
9
|
// mirror on the client. Only NEW session names are validated — binding an existing PC-made name
|
|
12
10
|
// (which may contain spaces) checks existence first and never calls this.
|
|
13
|
-
export const isValidSessionName = (
|
|
14
|
-
typeof s === 'string' && /^[A-Za-z0-9-]{1,16}$/.test(s);
|
|
15
|
-
|
|
11
|
+
export const isValidSessionName = (value) => typeof value === 'string' && /^[A-Za-z0-9-]{1,16}$/.test(value);
|
|
16
12
|
// Optional startup command run in a freshly-created window/session (e.g. "claude"). It's typed via
|
|
17
13
|
// send-keys + Enter, so it must be a single line: reject control chars (newline/CR/tab included) and
|
|
18
14
|
// cap the length. No shell-arg restriction — it runs in the new shell exactly as if typed, same trust
|
|
19
15
|
// model as the existing sendText. Empty means "no command" and is handled by the caller, not here.
|
|
20
|
-
export const isValidStartupCmd = (
|
|
21
|
-
|
|
22
|
-
|
|
16
|
+
export const isValidStartupCmd = (value) => typeof value === 'string' && value.length > 0 && value.length <= 200
|
|
17
|
+
&& [...value].every((character) => {
|
|
18
|
+
const code = character.charCodeAt(0);
|
|
19
|
+
return code >= 0x20 && code !== 0x7f;
|
|
20
|
+
});
|
|
23
21
|
export function runTmux(args) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
execFile('tmux', args, { maxBuffer: 32 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
24
|
+
if (err)
|
|
25
|
+
reject(new Error(stderr?.toString() || err.message));
|
|
26
|
+
else
|
|
27
|
+
resolve(stdout.toString());
|
|
28
|
+
});
|
|
28
29
|
});
|
|
29
|
-
});
|
|
30
30
|
}
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
31
|
+
const lines = (output) => output.split('\n').filter((line) => line.length > 0);
|
|
32
|
+
const firstRow = (rows, label) => {
|
|
33
|
+
const row = rows[0];
|
|
34
|
+
if (!row)
|
|
35
|
+
throw new Error(`invalid ${label} format: expected row`);
|
|
36
|
+
return row;
|
|
37
|
+
};
|
|
34
38
|
export async function listSessions() {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
39
|
+
try {
|
|
40
|
+
const out = await runTmux(['list-sessions', '-F', tmuxFormat(['session_id', 'session_name'])]);
|
|
41
|
+
return parseTmuxRows(out, 2, 'session').map(([id, name]) => ({ id, name }));
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
// tmux exits non-zero with "no server running" or "no sessions" when nothing is up yet.
|
|
45
|
+
const msg = error instanceof Error ? error.message : '';
|
|
46
|
+
if (msg.includes('no server') || msg.includes('no sessions') || msg.includes('error connecting'))
|
|
47
|
+
return [];
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
46
51
|
export async function listWindows(sessionId) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
52
|
+
const out = await runTmux(['list-windows', '-t', sessionId, '-F', tmuxFormat([
|
|
53
|
+
'window_id', 'window_name', 'window_active', 'window_panes', 'window_width', 'window_height', 'pane_id',
|
|
54
|
+
])]);
|
|
55
|
+
return parseTmuxRows(out, 7, 'window').map(([id, name, active, panes, width, height, activePaneId]) => {
|
|
56
|
+
return { id, name, active: active === '1', panes: Number(panes), width: Number(width), height: Number(height), activePaneId };
|
|
57
|
+
});
|
|
58
|
+
}
|
|
55
59
|
export async function listPanes(windowId) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
+
const out = await runTmux(['list-panes', '-t', windowId, '-F', tmuxFormat(['pane_id', 'pane_active', 'pane_width', 'pane_height', 'pane_current_command', 'pane_current_path', 'pane_left', 'pane_top', 'pane_tty'])]);
|
|
61
|
+
return parseTmuxRows(out, 9, 'pane').map(([id, active, width, height, command, cwd, left, top, tty]) => {
|
|
62
|
+
return { id, active: active === '1', width: Number(width), height: Number(height), command, cwd, left: Number(left), top: Number(top), tty };
|
|
63
|
+
});
|
|
60
64
|
}
|
|
61
|
-
|
|
62
65
|
// All live pane ids across every session — used to reconcile the in-memory Claude paneState against
|
|
63
66
|
// reality. A hard-killed pane fires no hook, so its last state would otherwise linger as a ghost.
|
|
64
67
|
// If a target is provided (window id or session id), list only panes in that target.
|
|
65
68
|
export async function listPaneIds(target) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
69
|
+
const args = ['list-panes', '-F', '#{pane_id}'];
|
|
70
|
+
if (target) {
|
|
71
|
+
args.push('-t', target);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
args.push('-a');
|
|
75
|
+
}
|
|
76
|
+
return lines(await runTmux(args));
|
|
77
|
+
}
|
|
75
78
|
// Every live pane across all sessions WITH its foreground command AND its tmux location, in ONE call.
|
|
76
79
|
// getStates uses this to (a) catch "pane alive but Claude exited" (cmd flips from 'claude' back to the
|
|
77
80
|
// shell — a hard kill / crash / Ctrl-C-out fires no SessionEnd yet the shell keeps the pane, so it
|
|
78
81
|
// still EXISTS) and (b) resolve each recorded pane to its session/window for the inbox roster without a
|
|
79
82
|
// per-pane display-message. The hook only records the pane id; location comes from here, always fresh.
|
|
80
83
|
export async function listLivePanes() {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
84
|
+
return parseTmuxRows(await runTmux(['list-panes', '-a', '-F',
|
|
85
|
+
tmuxFormat(['pane_id', 'pane_current_command', 'pane_tty', 'session_name', 'window_id', 'window_name'])]), 6, 'live pane')
|
|
86
|
+
.map(([id, cmd, tty, session, window, windowName]) => {
|
|
87
|
+
return { id, cmd, tty, session, window, windowName };
|
|
85
88
|
});
|
|
86
89
|
}
|
|
87
|
-
|
|
88
90
|
// -N preserves trailing whitespace. Without it, capture-pane trims trailing cells at each line
|
|
89
91
|
// end — including background-colored padding — which both drops the right-hand part of a
|
|
90
92
|
// full-width highlight (Claude Code's sent-message bar) AND loses the SGR reset that closes the
|
|
91
93
|
// background, so the highlight bleeds onto the rows below when re-rendered. With -N the capture
|
|
92
94
|
// faithfully reproduces the pane, so the client can write it verbatim (see prepareSeed).
|
|
93
95
|
export async function capturePane(paneId, linesBack) {
|
|
94
|
-
|
|
96
|
+
return runTmux(['capture-pane', '-p', '-e', '-N', '-S', String(-Math.abs(linesBack)), '-t', paneId]);
|
|
95
97
|
}
|
|
96
|
-
|
|
97
98
|
// A single row capture re-emits that row's real starting attributes. The combined capture above
|
|
98
99
|
// intentionally compresses SGR state across newlines, which makes an inherited background ambiguous.
|
|
99
100
|
export async function capturePaneRow(paneId, row) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
101
|
+
return runTmux([
|
|
102
|
+
'capture-pane', '-p', '-e', '-N',
|
|
103
|
+
'-S', String(row), '-E', String(row),
|
|
104
|
+
'-t', paneId,
|
|
105
|
+
]);
|
|
105
106
|
}
|
|
106
|
-
|
|
107
107
|
// Plain visible-screen capture — NO SGR escapes (unlike capturePane's `-e`), so the text parses cleanly.
|
|
108
108
|
// Used to scrape a pending prompt/menu off the screen (see pendingPrompt.js).
|
|
109
109
|
export async function capturePlain(paneId) {
|
|
110
|
-
|
|
110
|
+
return runTmux(['capture-pane', '-p', '-t', paneId]);
|
|
111
111
|
}
|
|
112
|
-
|
|
113
112
|
// Size AND cursor in one display-message (capture-pane carries neither the cursor position nor its
|
|
114
113
|
// visibility — it snapshots cells only — so we read them here for the client to re-place xterm's own
|
|
115
114
|
// cursor onto Claude's input cell). cursor_x/cursor_y are 0-based, relative to the visible screen;
|
|
@@ -119,16 +118,15 @@ export async function capturePlain(paneId) {
|
|
|
119
118
|
// mouse reporting (any mode); mouse_sgr_flag = it negotiated the SGR (1006) encoding. Together they let the
|
|
120
119
|
// client translate a swipe into wheel events the app scrolls on (sendWheel) instead of a dead swipe.
|
|
121
120
|
export async function paneInfo(paneId) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
|
|
121
|
+
const out = await runTmux(['display-message', '-p', '-t', paneId,
|
|
122
|
+
tmuxFormat(['pane_width', 'pane_height', 'cursor_x', 'cursor_y', 'cursor_flag', 'alternate_on', 'mouse_any_flag', 'mouse_sgr_flag'])]);
|
|
123
|
+
const [width, height, cx, cy, cflag, alt, mAny, mSgr] = firstRow(parseTmuxRows(out, 8, 'pane info'), 'pane info');
|
|
124
|
+
return {
|
|
125
|
+
width: Number(width), height: Number(height),
|
|
126
|
+
cursorX: Number(cx), cursorY: Number(cy), cursorVisible: cflag === '1',
|
|
127
|
+
altScreen: alt === '1', mouseAware: mAny === '1', mouseSgr: mSgr === '1',
|
|
128
|
+
};
|
|
129
|
+
}
|
|
132
130
|
// Inject mouse-wheel scroll events so a full-screen app on the ALT screen scrolls itself — exactly what a
|
|
133
131
|
// desktop wheel does. dir 'up' scrolls toward earlier content (SGR button 64), 'down' toward later (65);
|
|
134
132
|
// `count` notches go out in ONE send-keys as a repeated escape run. Only meaningful when the app requested
|
|
@@ -137,195 +135,210 @@ export async function paneInfo(paneId) {
|
|
|
137
135
|
// `col`/`row` are the 1-based pointer position the event reports at (the pane centre — irrelevant to a lone
|
|
138
136
|
// full-screen app, but lets a split-aware app scroll the region under the finger).
|
|
139
137
|
export async function sendWheel(paneId, dir, count, opts = {}) {
|
|
140
|
-
|
|
138
|
+
await runTmux(['send-keys', '-t', paneId, '-l', '--', wheelSeq(dir, count, opts)]);
|
|
141
139
|
}
|
|
142
|
-
|
|
143
140
|
// Pure: build the terminal byte run for `count` wheel notches (kept separate so the encoding is unit-
|
|
144
141
|
// tested without spawning tmux). `count` is clamped to 1..60 (one flick shouldn't inject hundreds).
|
|
145
142
|
export function wheelSeq(dir, count, { sgr = true, col = 1, row = 1 } = {}) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
|
|
143
|
+
const n = Math.min(Math.max(Math.trunc(Number(count)) || 1, 1), 60);
|
|
144
|
+
const btn = dir === 'up' ? 64 : 65;
|
|
145
|
+
const c = Math.max(1, Math.trunc(col) || 1);
|
|
146
|
+
const r = Math.max(1, Math.trunc(row) || 1);
|
|
147
|
+
const unit = sgr
|
|
148
|
+
? `\x1b[<${btn};${c};${r}M`
|
|
149
|
+
// legacy X10: ESC [ M <btn+32> <col+32> <row+32>, each coordinate byte capped at 223.
|
|
150
|
+
: `\x1b[M${String.fromCharCode(btn + 32, Math.min(c, 223) + 32, Math.min(r, 223) + 32)}`;
|
|
151
|
+
return unit.repeat(n);
|
|
152
|
+
}
|
|
157
153
|
// Resolve a pane to its tmux session name + window for routing a notification back to it. The hook
|
|
158
154
|
// gives us only $TMUX_PANE; this turns "%263" into the session/window the phone navigates by.
|
|
159
155
|
export async function paneLocation(paneId) {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
156
|
+
const out = await runTmux(['display-message', '-p', '-t', paneId,
|
|
157
|
+
tmuxFormat(['session_name', 'window_id', 'window_name'])]);
|
|
158
|
+
const [session, windowId, windowName] = firstRow(parseTmuxRows(out, 3, 'pane location'), 'pane location');
|
|
159
|
+
return { session, window: windowId, windowName };
|
|
164
160
|
}
|
|
165
|
-
|
|
166
161
|
export async function paneSession(paneId) {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
162
|
+
const out = await runTmux(['display-message', '-p', '-t', paneId, '#{session_id}']);
|
|
163
|
+
const id = out.trim();
|
|
164
|
+
if (!isSessionId(id))
|
|
165
|
+
throw new Error('pane session not found');
|
|
166
|
+
return id;
|
|
171
167
|
}
|
|
172
|
-
|
|
173
168
|
// Exit tmux copy/scroll mode if the pane is currently in it. Called before any user input so
|
|
174
169
|
// text and keys reach the shell instead of being swallowed by tmux's mode key-bindings.
|
|
175
170
|
export async function exitCopyModeIfActive(paneId) {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
171
|
+
const out = await runTmux(['display-message', '-p', '-t', paneId, '#{pane_in_mode}']);
|
|
172
|
+
if (out.trim() === '1')
|
|
173
|
+
await runTmux(['send-keys', '-t', paneId, 'Escape']);
|
|
174
|
+
}
|
|
175
|
+
let inputBufferSerial = 0;
|
|
176
|
+
const nextInputBufferName = () => {
|
|
177
|
+
inputBufferSerial = (inputBufferSerial + 1) % Number.MAX_SAFE_INTEGER;
|
|
178
|
+
return `handmux-input-${process.pid}-${inputBufferSerial}`;
|
|
179
|
+
};
|
|
180
180
|
export async function sendText(paneId, text) {
|
|
181
|
-
|
|
181
|
+
if (!text)
|
|
182
|
+
return;
|
|
183
|
+
const buffer = nextInputBufferName();
|
|
184
|
+
await runTmux(['set-buffer', '-b', buffer, '--', text]);
|
|
185
|
+
try {
|
|
186
|
+
// `-p` asks tmux to wrap the payload in bracketed-paste markers when the target application has
|
|
187
|
+
// enabled that terminal mode. The explicit end marker lets TUIs distinguish the pasted body from a
|
|
188
|
+
// following Enter without guessing from byte timing; shells/apps without the mode still receive the
|
|
189
|
+
// same literal text. A per-call named buffer prevents concurrent panes from overwriting each other.
|
|
190
|
+
await runTmux(['paste-buffer', '-p', '-d', '-b', buffer, '-t', paneId]);
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
// `-d` removes the buffer on success. If the target disappeared first, avoid retaining the payload in
|
|
194
|
+
// tmux's global buffer list; cleanup failure must not hide the original send error.
|
|
195
|
+
try {
|
|
196
|
+
await runTmux(['delete-buffer', '-b', buffer]);
|
|
197
|
+
}
|
|
198
|
+
catch { /* best effort */ }
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
182
201
|
}
|
|
183
|
-
|
|
184
202
|
export async function sendHexInput(paneId, hex) {
|
|
185
|
-
|
|
186
|
-
|
|
203
|
+
const bytes = hex.match(/../g) || [];
|
|
204
|
+
await runTmux(['send-keys', '-t', paneId, '-H', ...bytes]);
|
|
187
205
|
}
|
|
188
|
-
|
|
189
206
|
export async function sendEnter(paneId) {
|
|
190
|
-
|
|
207
|
+
await runTmux(['send-keys', '-t', paneId, 'Enter']);
|
|
191
208
|
}
|
|
192
|
-
|
|
193
209
|
export async function sendKey(paneId, key) {
|
|
194
|
-
|
|
210
|
+
await runTmux(['send-keys', '-t', paneId, key]);
|
|
195
211
|
}
|
|
196
|
-
|
|
197
212
|
// Force the window to an explicit width (and optionally height) so tmux reflows to it. This
|
|
198
213
|
// sets the window-size option to `manual`, so it sticks (and applies to every client on this
|
|
199
214
|
// window — including the PC) until restoreWindowSize is called. Pass rows = null to change
|
|
200
215
|
// only the column count and leave the height untouched.
|
|
201
216
|
export async function resizeWindow(windowId, cols, rows) {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
217
|
+
const args = ['resize-window', '-t', windowId, '-x', String(cols)];
|
|
218
|
+
if (rows != null)
|
|
219
|
+
args.push('-y', String(rows));
|
|
220
|
+
await runTmux(args);
|
|
205
221
|
}
|
|
206
|
-
|
|
207
222
|
// Hand sizing back to the attached clients (tmux default), so the PC's terminal dictates
|
|
208
223
|
// the window size again instead of the phone-sized grid left by resizeWindow.
|
|
209
224
|
export async function restoreWindowSize(windowId) {
|
|
210
|
-
|
|
225
|
+
await runTmux(['set-window-option', '-t', windowId, 'window-size', 'latest']);
|
|
211
226
|
}
|
|
212
|
-
|
|
213
227
|
// Resize just one pane's width inside its window (siblings absorb the difference; the window
|
|
214
228
|
// total is unchanged). Use this for a pane in a split — resizing the whole window would
|
|
215
229
|
// shrink every pane. A lone pane can't be resized this way (it fills the window).
|
|
216
230
|
export async function resizePane(paneId, cols) {
|
|
217
|
-
|
|
231
|
+
await runTmux(['resize-pane', '-t', paneId, '-x', String(cols)]);
|
|
218
232
|
}
|
|
219
|
-
|
|
220
233
|
// The window's exact pane arrangement, captured so "restore" can put a split back the way it
|
|
221
234
|
// was after resizePane changed the ratio. resizePane doesn't touch window size, so window-size
|
|
222
235
|
// latest alone can't undo it — select-layout with this string does.
|
|
223
236
|
export async function getWindowLayout(windowId) {
|
|
224
|
-
|
|
225
|
-
|
|
237
|
+
const out = await runTmux(['display-message', '-p', '-t', windowId, '#{window_layout}']);
|
|
238
|
+
return out.trim();
|
|
226
239
|
}
|
|
227
|
-
|
|
228
240
|
export async function applyWindowLayout(windowId, layout) {
|
|
229
|
-
|
|
241
|
+
await runTmux(['select-layout', '-t', windowId, layout]);
|
|
230
242
|
}
|
|
231
|
-
|
|
232
243
|
// -d: detached (the node server has no tty). -c cwd (defaults to $HOME): start dir for the session.
|
|
233
244
|
// -P -F prints the new session id so the caller can confirm/route. A duplicate name makes tmux
|
|
234
245
|
// error (runTmux rejects) — the route pre-checks and returns a clean 409 before reaching here.
|
|
235
246
|
// Self-guard the name even though the route validates first: this is an exported boundary to tmux,
|
|
236
247
|
// and a name with target-syntax chars ('$', ':', …) would create a hard-to-address session.
|
|
237
248
|
export async function newSession(name, cwd, cmd) {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
249
|
+
if (!isValidSessionName(name))
|
|
250
|
+
throw new Error(`invalid session name: ${JSON.stringify(name)}`);
|
|
251
|
+
const out = await runTmux(['new-session', '-d', '-s', name, '-c', cwd || os.homedir(), '-P', '-F', '#{session_id}']);
|
|
252
|
+
const id = out.trim(); // e.g. "$7"
|
|
253
|
+
if (cmd)
|
|
254
|
+
await runStartupCmd(id, cmd); // auto-run the startup command in the new session's first pane
|
|
255
|
+
return id;
|
|
243
256
|
}
|
|
244
|
-
|
|
245
257
|
// Type a startup command into a freshly-created window/session and press Enter — same path as a user
|
|
246
258
|
// typing it. The target ($id / @id) resolves to the new shell's active pane. Runs inside the shell (we
|
|
247
259
|
// don't pass it to new-window/new-session as the pane command) so the pane survives the command exiting.
|
|
248
260
|
async function runStartupCmd(target, cmd) {
|
|
249
|
-
|
|
250
|
-
|
|
261
|
+
await sendText(target, cmd);
|
|
262
|
+
// tmux has handed the paste to the pane at this point, but the foreground shell still consumes PTY
|
|
263
|
+
// input asynchronously. Match the interactive /send pacing so a long startup command cannot absorb the
|
|
264
|
+
// following Enter as part of its paste.
|
|
265
|
+
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
266
|
+
await sendEnter(target);
|
|
251
267
|
}
|
|
252
|
-
|
|
253
268
|
// Type a validated single-line command into an existing pane after its foreground TUI has returned to
|
|
254
269
|
// the shell. This does not kill or replace anything; callers must first prove the previous process exited.
|
|
255
270
|
export async function runPaneCommand(paneId, cmd) {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
271
|
+
if (!isPaneId(paneId))
|
|
272
|
+
throw new Error(`invalid pane id: ${JSON.stringify(paneId)}`);
|
|
273
|
+
if (!isValidStartupCmd(cmd))
|
|
274
|
+
throw new Error('invalid startup command');
|
|
275
|
+
await runStartupCmd(paneId, cmd);
|
|
259
276
|
}
|
|
260
|
-
|
|
261
277
|
// Read a pane's working directory, so a new window can open in the dir you're working in.
|
|
262
278
|
export async function paneCurrentPath(paneId) {
|
|
263
|
-
|
|
264
|
-
|
|
279
|
+
const out = await runTmux(['display-message', '-p', '-t', paneId, '#{pane_current_path}']);
|
|
280
|
+
return out.trim();
|
|
265
281
|
}
|
|
266
|
-
|
|
267
282
|
// -d: don't steal the active window from the PC (the phone navigates to it itself). -c: start dir
|
|
268
283
|
// (omitted when cwd is falsy → tmux uses the session default). -n: window name (omitted when falsy →
|
|
269
284
|
// tmux auto-names after the running command). -P -F prints the new window id.
|
|
270
285
|
export async function newWindow(sessionId, cwd, name, cmd) {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
286
|
+
const args = ['new-window', '-d', '-t', sessionId, '-P', '-F', '#{window_id}'];
|
|
287
|
+
if (cwd)
|
|
288
|
+
args.push('-c', cwd); // tmux accepts -c in any position; push mirrors resizeWindow's style
|
|
289
|
+
if (name)
|
|
290
|
+
args.push('-n', name);
|
|
291
|
+
const id = (await runTmux(args)).trim(); // e.g. "@32"
|
|
292
|
+
if (cmd)
|
|
293
|
+
await runStartupCmd(id, cmd); // auto-run the startup command in the new window's pane
|
|
294
|
+
return id;
|
|
295
|
+
}
|
|
279
296
|
// rename-session keeps the session's $id — only the name changes. Self-guard the name (exported
|
|
280
297
|
// boundary to tmux; a name with target-syntax chars would create a hard-to-address session). A
|
|
281
298
|
// duplicate name makes tmux error (runTmux rejects); the route pre-checks and returns a clean 409.
|
|
282
299
|
export async function renameSession(id, name) {
|
|
283
|
-
|
|
284
|
-
|
|
300
|
+
if (!isValidSessionName(name))
|
|
301
|
+
throw new Error(`invalid session name: ${JSON.stringify(name)}`);
|
|
302
|
+
await runTmux(['rename-session', '-t', id, name]);
|
|
285
303
|
}
|
|
286
|
-
|
|
287
304
|
// rename-window sets the name manually (and implicitly turns off that window's automatic-rename,
|
|
288
305
|
// so the chosen name sticks instead of tracking the running command — the expected tmux behavior).
|
|
289
306
|
export async function renameWindow(id, name) {
|
|
290
|
-
|
|
291
|
-
|
|
307
|
+
if (!isValidSessionName(name))
|
|
308
|
+
throw new Error(`invalid window name: ${JSON.stringify(name)}`);
|
|
309
|
+
await runTmux(['rename-window', '-t', id, name]);
|
|
292
310
|
}
|
|
293
|
-
|
|
294
311
|
// The number of windows in the window's session. The delete guard refuses to kill the last one
|
|
295
312
|
// (killing it would take the whole session with it).
|
|
296
313
|
export async function sessionWindowCount(id) {
|
|
297
|
-
|
|
298
|
-
|
|
314
|
+
const out = await runTmux(['display-message', '-p', '-t', id, '#{session_windows}']);
|
|
315
|
+
return Number(out.trim());
|
|
299
316
|
}
|
|
300
|
-
|
|
301
317
|
// Split a pane into two. -d: don't move the PC's active pane (the phone navigates to the new pane
|
|
302
318
|
// itself, client-side). dir 'h' → left|right (`-h`), 'v' → top/bottom (`-v`). -c: the new pane's
|
|
303
319
|
// start dir (the target pane's cwd), omitted when falsy. -P -F prints the new pane id.
|
|
304
320
|
export async function splitPane(paneId, dir, cwd) {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
321
|
+
const flag = dir === 'v' ? '-v' : '-h';
|
|
322
|
+
const args = ['split-window', '-d', flag, '-t', paneId, '-P', '-F', '#{pane_id}'];
|
|
323
|
+
if (cwd)
|
|
324
|
+
args.push('-c', cwd);
|
|
325
|
+
return (await runTmux(args)).trim(); // e.g. "%91"
|
|
309
326
|
}
|
|
310
|
-
|
|
311
327
|
// Panes in the window that owns this pane — the kill guard refuses to kill the last one (killing it
|
|
312
328
|
// would take the window, and if it's the last window, the whole session).
|
|
313
329
|
export async function windowPaneCount(paneId) {
|
|
314
|
-
|
|
315
|
-
|
|
330
|
+
const out = await runTmux(['display-message', '-p', '-t', paneId, '#{window_panes}']);
|
|
331
|
+
return Number(out.trim());
|
|
316
332
|
}
|
|
317
|
-
|
|
318
333
|
export async function killPane(paneId) {
|
|
319
|
-
|
|
334
|
+
await runTmux(['kill-pane', '-t', paneId]);
|
|
320
335
|
}
|
|
321
|
-
|
|
322
336
|
export async function killWindow(id) {
|
|
323
|
-
|
|
337
|
+
await runTmux(['kill-window', '-t', id]);
|
|
324
338
|
}
|
|
325
|
-
|
|
326
339
|
// Swap two windows' positions (indices) within a session. -d keeps the active window unchanged so
|
|
327
340
|
// reordering from the phone doesn't yank the PC's focus to the swapped window. The window ids are
|
|
328
341
|
// unchanged — only their order in list-windows flips.
|
|
329
342
|
export async function swapWindows(a, b) {
|
|
330
|
-
|
|
343
|
+
await runTmux(['swap-window', '-d', '-s', a, '-t', b]);
|
|
331
344
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const Q_ESCAPED = new Set([
|
|
2
|
+
'|', '&', ';', '<', '>', '(', ')', '$', '`', '\\', '"', "'", '*', '?', '[', '#', ' ', '=', '%',
|
|
3
|
+
]);
|
|
4
|
+
export function tmuxFormat(fields) {
|
|
5
|
+
if (!Array.isArray(fields) || fields.length === 0)
|
|
6
|
+
throw new Error('tmux format fields are required');
|
|
7
|
+
return fields.map((field) => {
|
|
8
|
+
if (typeof field !== 'string' || !/^@?[A-Za-z0-9_]+$/.test(field)) {
|
|
9
|
+
throw new Error(`invalid tmux format field: ${String(field)}`);
|
|
10
|
+
}
|
|
11
|
+
return `#{q:${field}}`;
|
|
12
|
+
}).join('|');
|
|
13
|
+
}
|
|
14
|
+
export function parseTmuxFields(line, columns, label = 'tmux') {
|
|
15
|
+
const value = String(line);
|
|
16
|
+
const fields = [];
|
|
17
|
+
let field = '';
|
|
18
|
+
for (let i = 0; i < value.length; i++) {
|
|
19
|
+
const char = value.charAt(i);
|
|
20
|
+
if (char === '|') {
|
|
21
|
+
fields.push(field);
|
|
22
|
+
field = '';
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (char !== '\\') {
|
|
26
|
+
field += char;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (i + 1 >= value.length)
|
|
30
|
+
throw new Error(`invalid ${label} format: dangling escape`);
|
|
31
|
+
const escaped = value.charAt(++i);
|
|
32
|
+
if (!Q_ESCAPED.has(escaped))
|
|
33
|
+
throw new Error(`invalid ${label} format: unsupported escape`);
|
|
34
|
+
field += escaped;
|
|
35
|
+
}
|
|
36
|
+
fields.push(field);
|
|
37
|
+
if (fields.length !== columns)
|
|
38
|
+
throw new Error(`invalid ${label} format: expected ${columns} fields`);
|
|
39
|
+
return fields;
|
|
40
|
+
}
|
|
41
|
+
export function parseTmuxRows(output, columns, label = 'tmux') {
|
|
42
|
+
const value = String(output ?? '');
|
|
43
|
+
if (value === '' || value === '\n' || value === '\r\n')
|
|
44
|
+
return [];
|
|
45
|
+
return value.replace(/\r?\n$/, '').split(/\r?\n/)
|
|
46
|
+
.map((line) => parseTmuxFields(line, columns, label));
|
|
47
|
+
}
|