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 @@
|
|
|
1
|
+
(function(){"use strict";class s extends AudioWorkletProcessor{process(e){const r=e[0]&&e[0][0];return r&&r.length&&this.port.postMessage(r.slice(0)),!0}}registerProcessor("pcm-forwarder",s)})();
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png?v=2" />
|
|
23
23
|
|
|
24
24
|
<!-- Inline boot splash: paints instantly (before the JS bundle loads) so a cold launch shows the
|
|
25
|
-
brand, not a blank dark screen. main.
|
|
25
|
+
brand, not a blank dark screen. main.tsx calls window.__hideBootSplash() once React has painted.
|
|
26
26
|
Until then it must remain visible: a slow bundle should never expose the still-empty app root. -->
|
|
27
27
|
<style>
|
|
28
28
|
html, body { margin: 0; background: #1a1b1e; } /* opaque dark from frame one — no wallpaper showing through */
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
@keyframes bootDot { 0%,100% { opacity: .22; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-3px); } }
|
|
49
49
|
@media (prefers-reduced-motion: reduce) { .boot-glow, .boot-dots i { animation: none; opacity: .8; } }
|
|
50
50
|
</style>
|
|
51
|
-
<script type="module" crossorigin src="/assets/index-
|
|
52
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
51
|
+
<script type="module" crossorigin src="/assets/index-D4VP5t8h.js"></script>
|
|
52
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CicdY9E9.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-CicdY9E9.css"></noscript>
|
|
53
53
|
</head>
|
|
54
54
|
<body>
|
|
55
55
|
<div id="boot-splash" aria-hidden="true">
|
|
@@ -61,8 +61,9 @@
|
|
|
61
61
|
<div class="boot-dots"><i></i><i></i><i></i></div>
|
|
62
62
|
</div>
|
|
63
63
|
<div id="root"></div>
|
|
64
|
+
<div id="overlay-root"></div>
|
|
64
65
|
<script>
|
|
65
|
-
// Hide the splash once the app has painted (main.
|
|
66
|
+
// Hide the splash once the app has painted (main.tsx), but keep it up at least ~650ms so the glow
|
|
66
67
|
// is actually seen, not a flicker. Slow or unavailable bundles leave this branded loading state visible.
|
|
67
68
|
window.__bootStart = Date.now();
|
|
68
69
|
window.__hideBootSplash = function () {
|
|
@@ -13,7 +13,10 @@ import os from 'node:os';
|
|
|
13
13
|
import { resolveEncodedDirSession, isSessionUuid } from './scanUtils.js';
|
|
14
14
|
import { resolveByExecutable, executableBasename } from './processIdentity.js';
|
|
15
15
|
import { createTranscriptParser, parseTranscript } from '../transcriptParse.js';
|
|
16
|
-
|
|
16
|
+
const record = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
17
|
+
? value
|
|
18
|
+
: null);
|
|
19
|
+
const text = (value) => typeof value === 'string' ? value : '';
|
|
17
20
|
// The NATIVE installer names the real binary by version (~/.local/share/claude/versions/2.1.196 —
|
|
18
21
|
// ~/.local/bin/claude is only a symlink to it), and tmux #{pane_current_command} follows that basename
|
|
19
22
|
// ("2_1_196", dots sanitized to underscores) instead of "claude". ps can't tie it: ps `comm` shows the
|
|
@@ -29,61 +32,70 @@ import { createTranscriptParser, parseTranscript } from '../transcriptParse.js';
|
|
|
29
32
|
// transient negative probe retries after 3s instead of poisoning the pane until server restart.
|
|
30
33
|
const VERSION_COMM_RE = /^\d+[._]\d+[._]\d+$/;
|
|
31
34
|
const CLAUDE_PATH_RE = /claude/;
|
|
32
|
-
|
|
33
35
|
export async function resolveVersionedComms(panes, run, verdicts = new Map(), opts = {}) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
36
|
+
return resolveByExecutable(panes, run, verdicts, {
|
|
37
|
+
candidate: (cmd) => VERSION_COMM_RE.test(cmd),
|
|
38
|
+
normalized: 'claude',
|
|
39
|
+
matches: (exe, cmd) => {
|
|
40
|
+
const base = executableBasename(exe);
|
|
41
|
+
return (base === cmd || base === cmd.replace(/_/g, '.')) && CLAUDE_PATH_RE.test(exe);
|
|
42
|
+
},
|
|
43
|
+
...opts,
|
|
44
|
+
});
|
|
43
45
|
}
|
|
44
|
-
|
|
45
46
|
// Build the 需要你 one-liner for a PermissionRequest, from the tool it's gating on (PermissionRequest
|
|
46
47
|
// carries tool_name + tool_input, unlike the later permission_prompt Notification which only has an
|
|
47
48
|
// English message).
|
|
48
49
|
function permMsg(body) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
50
|
+
const t = text(body.tool_name);
|
|
51
|
+
if (t === 'AskUserQuestion') {
|
|
52
|
+
const toolInput = record(body.tool_input);
|
|
53
|
+
const questions = toolInput?.questions;
|
|
54
|
+
const question = Array.isArray(questions) ? record(questions[0]) : null;
|
|
55
|
+
const prompt = text(question?.question) || text(question?.header);
|
|
56
|
+
return prompt ? `需要你回答:${prompt}` : '需要你回答';
|
|
57
|
+
}
|
|
58
|
+
if (t === 'ExitPlanMode')
|
|
59
|
+
return '需要你批准计划';
|
|
60
|
+
return t ? `需要你授权:${t}` : '需要你';
|
|
57
61
|
}
|
|
58
|
-
|
|
59
62
|
// Friendly Chinese for the StopFailure error type (matcher values, see the hooks doc). The payload shape
|
|
60
63
|
// isn't verified against a live rate-limit yet, so read the type defensively from several likely fields and
|
|
61
64
|
// always fall back to a bare 本轮出错 — a wrong field name degrades to the generic label, never throws.
|
|
62
65
|
const STOPFAIL_LABEL = {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
+
rate_limit: '触发限流', overloaded: '服务过载', authentication_failed: '认证失败',
|
|
67
|
+
oauth_org_not_allowed: '组织未授权', billing_error: '额度/账单问题', invalid_request: '请求无效',
|
|
68
|
+
model_not_found: '模型不可用', server_error: '服务端错误', max_output_tokens: '输出超长', unknown: '未知错误',
|
|
66
69
|
};
|
|
67
|
-
function stopFailMsg(body
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
function stopFailMsg(body) {
|
|
71
|
+
const error = record(body.error);
|
|
72
|
+
const type = text(body.error_type) || text(body.reason) || text(body.type)
|
|
73
|
+
|| text(body.error) || text(error?.type);
|
|
74
|
+
const label = STOPFAIL_LABEL[type];
|
|
75
|
+
if (label)
|
|
76
|
+
return label;
|
|
77
|
+
const raw = text(body.error) || text(body.message);
|
|
78
|
+
return raw ? String(raw).replace(/\s+/g, ' ').trim().slice(0, 80) : '';
|
|
73
79
|
}
|
|
74
|
-
|
|
75
80
|
// Build the 进行中 one-liner for a resume (PostToolUse after the user answered/approved), surfacing the
|
|
76
81
|
// choice they just made — AskUserQuestion stores it in tool_input.answers, keyed by question.
|
|
77
82
|
function resumeMsg(body) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
if (
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
const toolInput = record(body.tool_input);
|
|
84
|
+
const toolResponse = record(body.tool_response);
|
|
85
|
+
const answers = record(toolInput?.answers) || record(toolResponse?.answers);
|
|
86
|
+
if (answers) {
|
|
87
|
+
const picks = Object.values(answers)
|
|
88
|
+
.flatMap((value) => Array.isArray(value) ? value : [value])
|
|
89
|
+
.filter(Boolean)
|
|
90
|
+
.map(String)
|
|
91
|
+
.join('、');
|
|
92
|
+
if (picks)
|
|
93
|
+
return `已答:${picks}`;
|
|
94
|
+
}
|
|
95
|
+
if (body.tool_name === 'ExitPlanMode')
|
|
96
|
+
return '已批准计划';
|
|
97
|
+
return '';
|
|
85
98
|
}
|
|
86
|
-
|
|
87
99
|
// Map a hook event (src + raw Claude payload) to a notification "kind". Pure — no I/O, easy to test.
|
|
88
100
|
// stop → done (turn finished; carries last message)
|
|
89
101
|
// prompt → working (UserPromptSubmit; carries the prompt)
|
|
@@ -109,51 +121,61 @@ function resumeMsg(body) {
|
|
|
109
121
|
// stopfail → error (StopFailure: the turn ended on an API error — no Stop fires, so
|
|
110
122
|
// this is the only signal that un-sticks the pane from 进行中)
|
|
111
123
|
// anything else → null (ignored: auth_success, elicitation_*, etc.)
|
|
112
|
-
export function classifyClaude(src,
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (
|
|
125
|
-
|
|
126
|
-
|
|
124
|
+
export function classifyClaude(src, rawBody = {}) {
|
|
125
|
+
const body = record(rawBody) || {};
|
|
126
|
+
if (src === 'stop')
|
|
127
|
+
return { kind: 'done', msg: text(body.last_assistant_message) };
|
|
128
|
+
if (src === 'prompt')
|
|
129
|
+
return { kind: 'working', msg: text(body.prompt) };
|
|
130
|
+
if (src === 'resume')
|
|
131
|
+
return { kind: 'working', msg: resumeMsg(body) };
|
|
132
|
+
if (src === 'permreq')
|
|
133
|
+
return { kind: 'permission', msg: permMsg(body) };
|
|
134
|
+
if (src === 'compacting')
|
|
135
|
+
return { kind: 'compacting', msg: '' }; // PreCompact: 压缩上下文进行中
|
|
136
|
+
if (src === 'compact')
|
|
137
|
+
return null; // PostCompact: done → clear 压缩中/进行中
|
|
138
|
+
if (src === 'stopfail')
|
|
139
|
+
return { kind: 'error', msg: stopFailMsg(body) }; // turn died on an API error
|
|
140
|
+
if (src === 'end')
|
|
141
|
+
return { kind: 'end' };
|
|
142
|
+
if (src === 'start')
|
|
143
|
+
return null; // SessionStart: only (re)binds pane→session
|
|
144
|
+
if (src === 'notify') {
|
|
145
|
+
if (body.notification_type === 'idle_prompt')
|
|
146
|
+
return { kind: 'idle', msg: text(body.message) };
|
|
147
|
+
if (body.notification_type === 'permission_prompt')
|
|
148
|
+
return { kind: 'permission', msg: text(body.message) };
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
127
151
|
}
|
|
128
|
-
|
|
129
152
|
export const projectsDir = (home = os.homedir()) => path.join(home, '.claude', 'projects');
|
|
130
|
-
|
|
131
153
|
export const claude = {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
154
|
+
id: 'claude',
|
|
155
|
+
label: 'Claude Code',
|
|
156
|
+
procName: 'claude',
|
|
157
|
+
// Which tmux #{pane_current_command} values mean "this agent is still the pane's foreground app" (inbox
|
|
158
|
+
// liveness). Claude sets its process title to "claude", so an exact single-name match is right — for
|
|
159
|
+
// native-install machines (whose comm is the VERSION string) resolveVersionedComms below normalizes the
|
|
160
|
+
// cmd back to 'claude' at ingest, keeping every match here exact-name.
|
|
161
|
+
procNames: ['claude'],
|
|
162
|
+
// The `claude` CLI sets its process title to "claude" (verified via ps): match the program token at the
|
|
163
|
+
// start of argv — bare "claude", "claude --continue", or an absolute path ending in /claude. Anchored so
|
|
164
|
+
// "vim claude.js" / "node build/claude.js" don't match.
|
|
165
|
+
procMatch: /^(\S*\/)?claude(\s|$)/,
|
|
166
|
+
takeoverPrefix: 'cc', // tmux session name prefix for a takeover (cc-<label>-<n>)
|
|
167
|
+
classify: classifyClaude,
|
|
168
|
+
transcript: { createParser: createTranscriptParser, parse: parseTranscript },
|
|
169
|
+
sessions: {
|
|
170
|
+
isId: isSessionUuid,
|
|
171
|
+
dirOptKey: 'projectsDir', // scanOrphans option that overrides `dir` (back-compat + tests)
|
|
172
|
+
dir: projectsDir,
|
|
173
|
+
// Resolve a cwd to its Claude session within `dir`: newest jsonl in the encoded project dir whose
|
|
174
|
+
// recorded cwd matches. Layout: ~/.claude/projects/<enc-cwd>/<uuid>.jsonl.
|
|
175
|
+
resolve: (dir, cwd, opts = {}) => resolveEncodedDirSession(dir, cwd, opts),
|
|
176
|
+
// `claude --resume <uuid>` appends to the SAME jsonl with no OS lock, so the takeover kills the original
|
|
177
|
+
// to guarantee a single writer (see orphans.js).
|
|
178
|
+
resumeArgs: (id) => ['claude', '--resume', id],
|
|
179
|
+
resumeCmd: (id) => `claude --resume ${id}`,
|
|
180
|
+
},
|
|
159
181
|
};
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Codex process/session discovery. App Server owns chat, approvals, stopping, inbox state and exact thread
|
|
2
|
+
// identity; plain Codex is identified only as a live process until the user opts into managed mode.
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { promises as fsp } from 'node:fs';
|
|
6
|
+
import { readHead, readTail, firstCwd, isSessionUuid } from './scanUtils.js';
|
|
7
|
+
import { resolveByExecutable, executableBasename } from './processIdentity.js';
|
|
8
|
+
import { createCodexTranscriptParser, isCodexSyntheticUserText, parseCodexTranscript, } from '../codexTranscriptParse.js';
|
|
9
|
+
const isRecord = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value));
|
|
10
|
+
export const sessionsDir = (home = os.homedir()) => path.join(home, '.codex', 'sessions');
|
|
11
|
+
// A Codex rollout file is `rollout-<ISO-ish timestamp>-<uuid>.jsonl`; the session id Codex's `resume` wants
|
|
12
|
+
// is that trailing uuid. Pull it out of the basename.
|
|
13
|
+
export function rolloutSessionId(name) {
|
|
14
|
+
const m = String(name).match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i);
|
|
15
|
+
return m?.[1] || null;
|
|
16
|
+
}
|
|
17
|
+
const UUID_PARTS = '([0-9a-f]{8})\\s*-\\s*([0-9a-f]{4})\\s*-\\s*([0-9a-f]{4})\\s*-\\s*([0-9a-f]{4})\\s*-\\s*([0-9a-f]{12})';
|
|
18
|
+
function lastUuidAfter(text, prefix) {
|
|
19
|
+
const matches = [...String(text || '').matchAll(new RegExp(`${prefix}\\s*${UUID_PARTS}`, 'gi'))];
|
|
20
|
+
const match = matches.at(-1);
|
|
21
|
+
if (!match)
|
|
22
|
+
return null;
|
|
23
|
+
const parts = match.slice(1, 6);
|
|
24
|
+
return parts.length === 5 && parts.every(Boolean) ? parts.join('-').toLowerCase() : null;
|
|
25
|
+
}
|
|
26
|
+
// A normal Codex exit prints the exact command for the session that just exited. Keep this deliberately
|
|
27
|
+
// stricter than a generic `codex resume` search so conversation text or shell history is not mistaken for
|
|
28
|
+
// the current exit notice.
|
|
29
|
+
export function codexExitSessionId(text) {
|
|
30
|
+
return lastUuidAfter(text, 'To continue this session, run\\s+codex\\s+resume');
|
|
31
|
+
}
|
|
32
|
+
// Last user turn out of a Codex rollout tail, for a recognizable one-line label. Codex records turns as
|
|
33
|
+
// response_item messages: {payload:{type:'message',role:'user',content:[{type:'input_text',text}]}} (and a
|
|
34
|
+
// flatter {type:'message',role:'user',...} in some versions). Skips the synthetic context turns Codex
|
|
35
|
+
// injects (including repository instructions after a resume). Best-effort → '' on miss.
|
|
36
|
+
export function codexUserSnippet(tailText, max = 80) {
|
|
37
|
+
const rows = String(tailText).split('\n');
|
|
38
|
+
for (let i = rows.length - 1; i >= 0; i--) {
|
|
39
|
+
const line = rows[i]?.trim() ?? '';
|
|
40
|
+
if (!line || line[0] !== '{')
|
|
41
|
+
continue;
|
|
42
|
+
let data;
|
|
43
|
+
try {
|
|
44
|
+
data = JSON.parse(line);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!isRecord(data))
|
|
50
|
+
continue;
|
|
51
|
+
const payload = isRecord(data.payload) ? data.payload : null;
|
|
52
|
+
const msg = payload?.type === 'message' ? payload
|
|
53
|
+
: data.type === 'message' ? data : null;
|
|
54
|
+
if (!msg || msg.role !== 'user')
|
|
55
|
+
continue;
|
|
56
|
+
const c = msg.content;
|
|
57
|
+
let text = '';
|
|
58
|
+
if (typeof c === 'string')
|
|
59
|
+
text = c;
|
|
60
|
+
else if (Array.isArray(c))
|
|
61
|
+
text = c
|
|
62
|
+
.filter((block) => isRecord(block)
|
|
63
|
+
&& (block.type === 'input_text' || block.type === 'text'))
|
|
64
|
+
.map((block) => typeof block.text === 'string' ? block.text : '')
|
|
65
|
+
.join(' ');
|
|
66
|
+
if (!text || isCodexSyntheticUserText(text))
|
|
67
|
+
continue;
|
|
68
|
+
text = text.replace(/\s+/g, ' ').trim();
|
|
69
|
+
if (!text)
|
|
70
|
+
continue;
|
|
71
|
+
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
72
|
+
}
|
|
73
|
+
return '';
|
|
74
|
+
}
|
|
75
|
+
// The npm-installed Codex launcher leaves `node` as tmux's pane_current_command. Never accept that
|
|
76
|
+
// ambiguous name by itself: prove a foreground process on the same TTY has a real executable named
|
|
77
|
+
// `codex` from a Codex path, then normalize the pane to the canonical agent name.
|
|
78
|
+
export async function resolveCodexComms(panes, run, verdicts = new Map(), opts = {}) {
|
|
79
|
+
return resolveByExecutable(panes, run, verdicts, {
|
|
80
|
+
candidate: (cmd) => cmd === 'node',
|
|
81
|
+
normalized: 'codex',
|
|
82
|
+
matches: (exe) => /^codex(?:\.exe)?$/i.test(executableBasename(exe)) && /codex/i.test(exe),
|
|
83
|
+
...opts,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
// Walk the date-nested sessions tree (…/YYYY/MM/DD/rollout-*.jsonl) newest-first and collect up to `limit`
|
|
87
|
+
// rollout files with their mtimes. Unlike Claude's flat encoded-cwd dir, Codex partitions by date, so we
|
|
88
|
+
// descend year→month→day in descending name order and stop once we have enough — the newest sessions are
|
|
89
|
+
// always in the latest date dir, so a live orphan's session is found without reading the whole history.
|
|
90
|
+
async function recentRollouts(dir, limit = 80) {
|
|
91
|
+
const out = [];
|
|
92
|
+
async function descend(d, depth) {
|
|
93
|
+
if (out.length >= limit)
|
|
94
|
+
return;
|
|
95
|
+
let entries;
|
|
96
|
+
try {
|
|
97
|
+
entries = await fsp.readdir(d, { withFileTypes: true });
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (depth < 3) {
|
|
103
|
+
const subdirs = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort().reverse();
|
|
104
|
+
for (const name of subdirs) {
|
|
105
|
+
if (out.length >= limit)
|
|
106
|
+
break;
|
|
107
|
+
await descend(path.join(d, name), depth + 1);
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const files = entries.filter((e) => e.isFile() && e.name.startsWith('rollout-') && e.name.endsWith('.jsonl'));
|
|
112
|
+
for (const f of files) {
|
|
113
|
+
try {
|
|
114
|
+
out.push({ file: path.join(d, f.name), name: f.name, mtime: (await fsp.stat(path.join(d, f.name))).mtimeMs });
|
|
115
|
+
}
|
|
116
|
+
catch { /* gone */ }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
await descend(dir, 0);
|
|
120
|
+
out.sort((a, b) => b.mtime - a.mtime);
|
|
121
|
+
return out.slice(0, limit);
|
|
122
|
+
}
|
|
123
|
+
// Resolve one known App Server thread id to its exact rollout. This never falls back by cwd: several
|
|
124
|
+
// Codex panes can share a project, while the UUID suffix is the durable one-to-one session identity.
|
|
125
|
+
const rolloutPathCache = new Map();
|
|
126
|
+
export async function resolveCodexRollout(dir, sessionId) {
|
|
127
|
+
if (!isSessionUuid(sessionId))
|
|
128
|
+
return null;
|
|
129
|
+
const key = `${dir}\0${sessionId}`;
|
|
130
|
+
const cached = rolloutPathCache.get(key);
|
|
131
|
+
if (cached) {
|
|
132
|
+
try {
|
|
133
|
+
await fsp.access(cached);
|
|
134
|
+
return cached;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
rolloutPathCache.delete(key);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const suffix = `-${sessionId}.jsonl`;
|
|
141
|
+
let found = null;
|
|
142
|
+
async function descend(current, depth) {
|
|
143
|
+
if (found)
|
|
144
|
+
return;
|
|
145
|
+
let entries;
|
|
146
|
+
try {
|
|
147
|
+
entries = await fsp.readdir(current, { withFileTypes: true });
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (depth < 3) {
|
|
153
|
+
for (const entry of entries) {
|
|
154
|
+
if (!entry.isDirectory())
|
|
155
|
+
continue;
|
|
156
|
+
await descend(path.join(current, entry.name), depth + 1);
|
|
157
|
+
if (found)
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const match = entries.find((entry) => entry.isFile()
|
|
163
|
+
&& entry.name.startsWith('rollout-') && entry.name.endsWith(suffix));
|
|
164
|
+
if (match)
|
|
165
|
+
found = path.join(current, match.name);
|
|
166
|
+
}
|
|
167
|
+
await descend(dir, 0);
|
|
168
|
+
if (found)
|
|
169
|
+
rolloutPathCache.set(key, found);
|
|
170
|
+
return found;
|
|
171
|
+
}
|
|
172
|
+
// Resolve a live orphan's cwd to its Codex session: the newest rollout whose recorded cwd matches. Same
|
|
173
|
+
// shape as Claude's resolver ({ sessionId, state, snippet, lastActivity }) so the orphan engine is agnostic.
|
|
174
|
+
export async function resolveCodexSession(dir, cwd, { busyMs = 8000, now = Date.now } = {}) {
|
|
175
|
+
const files = await recentRollouts(dir);
|
|
176
|
+
for (const { file, name, mtime } of files) {
|
|
177
|
+
let head;
|
|
178
|
+
try {
|
|
179
|
+
head = await readHead(file);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (firstCwd(head) !== cwd)
|
|
185
|
+
continue;
|
|
186
|
+
const sessionId = rolloutSessionId(name);
|
|
187
|
+
if (!sessionId)
|
|
188
|
+
continue;
|
|
189
|
+
let snippet = '';
|
|
190
|
+
try {
|
|
191
|
+
snippet = codexUserSnippet(await readTail(file));
|
|
192
|
+
}
|
|
193
|
+
catch { /* best effort */ }
|
|
194
|
+
return { sessionId, state: now() - mtime < busyMs ? 'busy' : 'idle', snippet, lastActivity: Math.round(mtime), file };
|
|
195
|
+
}
|
|
196
|
+
return {};
|
|
197
|
+
}
|
|
198
|
+
export const codex = {
|
|
199
|
+
id: 'codex',
|
|
200
|
+
label: 'Codex CLI',
|
|
201
|
+
procName: 'codex',
|
|
202
|
+
// Ambiguous `node` launchers are normalized by resolveCodexComms only after real-executable proof.
|
|
203
|
+
procNames: ['codex'],
|
|
204
|
+
procMatch: /^(\S*\/)?codex(\s|$)/,
|
|
205
|
+
takeoverPrefix: 'cx', // tmux session name prefix for a takeover (cx-<label>-<n>)
|
|
206
|
+
transcript: { createParser: createCodexTranscriptParser, parse: parseCodexTranscript },
|
|
207
|
+
sessions: {
|
|
208
|
+
isId: isSessionUuid,
|
|
209
|
+
dirOptKey: 'sessionsDir', // scanOrphans option that overrides `dir`
|
|
210
|
+
dir: sessionsDir,
|
|
211
|
+
resolve: (dir, cwd, opts = {}) => resolveCodexSession(dir, cwd, opts),
|
|
212
|
+
// `codex resume <uuid>` continues the session from its rollout file.
|
|
213
|
+
resumeArgs: (id) => ['codex', 'resume', id],
|
|
214
|
+
resumeCmd: (id) => `codex resume ${id}`,
|
|
215
|
+
managedResumeCmd: (id) => `handmux codex resume ${id}`,
|
|
216
|
+
},
|
|
217
|
+
};
|
|
@@ -3,19 +3,19 @@
|
|
|
3
3
|
// engine consume drivers through this list, so process and session behavior stays centralized.
|
|
4
4
|
import { claude } from './claude.js';
|
|
5
5
|
import { codex } from './codex.js';
|
|
6
|
-
|
|
7
6
|
// Order matters for proc matching (parseAgentProcs takes the first match) — keep the patterns disjoint so
|
|
8
7
|
// order is irrelevant in practice, but list the flagship first.
|
|
9
8
|
export const AGENTS = [claude, codex];
|
|
10
|
-
|
|
11
|
-
const BY_ID = new Map(AGENTS.map((a) => [a.id, a]));
|
|
12
|
-
|
|
9
|
+
const BY_ID = new Map(AGENTS.map((agent) => [agent.id, agent]));
|
|
13
10
|
// Resolve a driver by id. State-file entries written before agents existed carry no `agent` field, and the
|
|
14
11
|
// only writer then was Claude — so an unknown/missing id defaults to Claude (back-compat, never undefined).
|
|
15
|
-
export function getAgent(id) {
|
|
16
|
-
|
|
12
|
+
export function getAgent(id) {
|
|
13
|
+
return typeof id === 'string' ? BY_ID.get(id) || claude : claude;
|
|
14
|
+
}
|
|
17
15
|
// The driver whose foreground process this tmux #{pane_current_command} is, or null. Used by the inbox to
|
|
18
16
|
// decide a recorded pane is still running its agent (vs. the agent having exited to a shell). Matches the
|
|
19
17
|
// canonical procName only — never the ambiguous extras in procNames (e.g. Codex's 'node'); native-install
|
|
20
18
|
// Claude binaries (comm = version string) are normalized to 'claude' at ingest (resolveVersionedComms).
|
|
21
|
-
export function agentForProc(cmd) {
|
|
19
|
+
export function agentForProc(cmd) {
|
|
20
|
+
return AGENTS.find((agent) => agent.procName === cmd) || null;
|
|
21
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { promises as fsp } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { normTty } from './scanUtils.js';
|
|
4
|
+
const SUCCESS_TTL_MS = 30_000;
|
|
5
|
+
const FAILURE_TTL_MS = 3_000;
|
|
6
|
+
// Real executable path of a pid. lsof resolves launch symlinks on macOS; /proc is the cheap Linux path.
|
|
7
|
+
// A transient lsof failure is inconclusive, not a negative identity verdict that should escape the call.
|
|
8
|
+
export async function executablePath(run, pid) {
|
|
9
|
+
let out = '';
|
|
10
|
+
try {
|
|
11
|
+
out = await run('lsof', ['-a', '-p', String(pid), '-d', 'txt', '-Fn']);
|
|
12
|
+
}
|
|
13
|
+
catch { /* try /proc */ }
|
|
14
|
+
for (const line of String(out).split('\n'))
|
|
15
|
+
if (line[0] === 'n')
|
|
16
|
+
return line.slice(1).trim();
|
|
17
|
+
try {
|
|
18
|
+
return await fsp.readlink(`/proc/${pid}/exe`);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return '';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function parseForegroundProcesses(out) {
|
|
25
|
+
const rows = [];
|
|
26
|
+
for (const line of String(out).split('\n')) {
|
|
27
|
+
const m = line.match(/^\s*(\S+)\s+(\d+)\s+(\S+)\s*$/);
|
|
28
|
+
if (!m)
|
|
29
|
+
continue;
|
|
30
|
+
const [, rawTty, pid, stat] = m;
|
|
31
|
+
if (!rawTty || !pid || !stat)
|
|
32
|
+
continue;
|
|
33
|
+
const tty = normTty(rawTty);
|
|
34
|
+
if (tty && stat.includes('+'))
|
|
35
|
+
rows.push({ tty, pid });
|
|
36
|
+
}
|
|
37
|
+
return rows;
|
|
38
|
+
}
|
|
39
|
+
// Normalize ambiguous tmux pane_current_command values only after tying the pane's TTY to a foreground
|
|
40
|
+
// process whose REAL executable proves the agent identity. Every call refreshes the cheap ps snapshot;
|
|
41
|
+
// the cache only skips lsof while the exact foreground pid set is unchanged and its short TTL is live.
|
|
42
|
+
// This makes TTY reuse/process replacement invalidate immediately, and failed probes retry quickly.
|
|
43
|
+
export async function resolveByExecutable(panes, run, verdicts, { candidate, normalized, matches, now = () => Date.now(), successTtlMs = SUCCESS_TTL_MS, failureTtlMs = FAILURE_TTL_MS, }) {
|
|
44
|
+
const candidates = panes.filter((p) => p && p.tty && candidate(p.cmd || ''));
|
|
45
|
+
if (!candidates.length)
|
|
46
|
+
return panes;
|
|
47
|
+
const rows = parseForegroundProcesses(await run('ps', ['-Ao', 'tty=,pid=,stat=']));
|
|
48
|
+
for (const pane of candidates) {
|
|
49
|
+
const tty = normTty(pane.tty);
|
|
50
|
+
const procs = rows.filter((r) => r.tty === tty);
|
|
51
|
+
const signature = procs.map((r) => r.pid).sort().join(',');
|
|
52
|
+
const key = `${normalized}|${tty}|${pane.cmd}`;
|
|
53
|
+
const cached = verdicts.get(key);
|
|
54
|
+
if (cached && cached.signature === signature && cached.expiresAt > now()) {
|
|
55
|
+
if (cached.ok)
|
|
56
|
+
pane.cmd = normalized;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
let ok = false;
|
|
60
|
+
let matchedPid = null;
|
|
61
|
+
for (const proc of procs) {
|
|
62
|
+
const exe = await executablePath(run, proc.pid);
|
|
63
|
+
if (matches(exe, pane.cmd || '')) {
|
|
64
|
+
ok = true;
|
|
65
|
+
matchedPid = proc.pid;
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
verdicts.set(key, {
|
|
70
|
+
ok,
|
|
71
|
+
pid: matchedPid,
|
|
72
|
+
signature,
|
|
73
|
+
expiresAt: now() + (ok ? successTtlMs : failureTtlMs),
|
|
74
|
+
});
|
|
75
|
+
if (ok)
|
|
76
|
+
pane.cmd = normalized;
|
|
77
|
+
}
|
|
78
|
+
return panes;
|
|
79
|
+
}
|
|
80
|
+
export const executableBasename = (file) => path.basename(String(file || ''));
|