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,288 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { normalizeShortcuts, shortcutIdentity } from '../shortcutConfig.js';
|
|
4
|
-
import { t } from './i18n/index.js';
|
|
5
|
-
import * as prompts from './prompt.js';
|
|
6
|
-
import { acquireLifecycleLock, isAlive, readState } from './state.js';
|
|
7
|
-
|
|
8
|
-
const MODIFIERS = {
|
|
9
|
-
none: { prefixes: [], labels: [] },
|
|
10
|
-
ctrl: { prefixes: ['C-'], labels: ['Ctrl'] },
|
|
11
|
-
shift: { prefixes: ['S-'], labels: ['Shift'] },
|
|
12
|
-
alt: { prefixes: ['M-'], labels: ['Alt'] },
|
|
13
|
-
'ctrl-shift': { prefixes: ['C-', 'S-'], labels: ['Ctrl', 'Shift'] },
|
|
14
|
-
'ctrl-alt': { prefixes: ['C-', 'M-'], labels: ['Ctrl', 'Alt'] },
|
|
15
|
-
};
|
|
16
|
-
const NAMED_BASES = ['Up', 'Down', 'Left', 'Right', 'Tab', 'Enter', 'Escape', 'Space', 'BSpace', 'Home', 'End', 'PageUp', 'PageDown'];
|
|
17
|
-
const CHAR_BASES = [...'abcdefghijklmnopqrstuvwxyz', ...'0123456789'];
|
|
18
|
-
const DISPLAY = { Escape: 'Esc', BSpace: '⌫', PageUp: 'PgUp', PageDown: 'PgDn' };
|
|
19
|
-
|
|
20
|
-
export function buildShortcutKey(modifier, base) {
|
|
21
|
-
const mod = MODIFIERS[modifier];
|
|
22
|
-
if (!mod) throw new Error(`unknown modifier: ${modifier}`);
|
|
23
|
-
if (!NAMED_BASES.includes(base) && !CHAR_BASES.includes(base)) throw new Error(`unsupported base key: ${base}`);
|
|
24
|
-
if (CHAR_BASES.includes(base) && modifier === 'none') throw new Error('a character key needs a modifier');
|
|
25
|
-
if (CHAR_BASES.includes(base) && mod.prefixes.includes('S-')) throw new Error('Shift + character is not a distinct tmux key');
|
|
26
|
-
const key = base === 'Tab' && modifier === 'shift'
|
|
27
|
-
? 'BTab'
|
|
28
|
-
: `${mod.prefixes.join('')}${CHAR_BASES.includes(base) ? base.toLowerCase() : base}`;
|
|
29
|
-
const shownBase = CHAR_BASES.includes(base) ? base.toUpperCase() : (DISPLAY[base] || base);
|
|
30
|
-
return { type: 'key', key, label: [...mod.labels, shownBase].join('+') };
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function moveShortcut(items, index, target) {
|
|
34
|
-
if (index < 0 || target < 0 || index >= items.length || target >= items.length || target === index) return items;
|
|
35
|
-
const next = items.slice();
|
|
36
|
-
const [item] = next.splice(index, 1);
|
|
37
|
-
next.splice(target, 0, item);
|
|
38
|
-
return next;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function readExisting(target) {
|
|
42
|
-
try { return JSON.parse(fs.readFileSync(target, 'utf8')); }
|
|
43
|
-
catch (error) {
|
|
44
|
-
if (error.code === 'ENOENT') return {};
|
|
45
|
-
throw error;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function saveShortcutConfig(target, shortcuts) {
|
|
50
|
-
const existing = readExisting(target);
|
|
51
|
-
const cfg = { ...existing, shortcuts: normalizeShortcuts(shortcuts) };
|
|
52
|
-
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
53
|
-
const tmp = `${target}.tmp`;
|
|
54
|
-
fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
|
|
55
|
-
fs.renameSync(tmp, target);
|
|
56
|
-
fs.chmodSync(target, 0o600);
|
|
57
|
-
return cfg;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const defaultUi = {
|
|
61
|
-
intro: prompts.intro,
|
|
62
|
-
outro: prompts.outro,
|
|
63
|
-
cancel: prompts.cancel,
|
|
64
|
-
select: prompts.select,
|
|
65
|
-
text: prompts.text,
|
|
66
|
-
confirm: prompts.confirm,
|
|
67
|
-
ask: prompts.ask,
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
const modeLabel = (mode) => t(mode === 'command' ? 'shortcuts.command' : 'shortcuts.chat');
|
|
71
|
-
const itemLabel = (item) => item.type === 'key' ? item.label : item.text;
|
|
72
|
-
const itemHint = (item) => item.type === 'key'
|
|
73
|
-
? t('shortcuts.key')
|
|
74
|
-
: t(item.enter ? 'shortcuts.textEnter' : 'shortcuts.textOnly');
|
|
75
|
-
const positionOptions = (items, index = null) => {
|
|
76
|
-
const remaining = index === null ? items : items.filter((_item, i) => i !== index);
|
|
77
|
-
const count = remaining.length + 1;
|
|
78
|
-
if (remaining.length === 0) return [{ value: 0, label: t('shortcuts.positionLast', { n: 1 }) }];
|
|
79
|
-
return Array.from({ length: count }, (_item, target) => {
|
|
80
|
-
if (target === 0) return { value: target, label: t('shortcuts.positionFirst') };
|
|
81
|
-
if (target === count - 1) return { value: target, label: t('shortcuts.positionLast', { n: count }) };
|
|
82
|
-
return {
|
|
83
|
-
value: target,
|
|
84
|
-
label: t('shortcuts.positionAfter', { n: target + 1, item: itemLabel(remaining[target - 1]) }),
|
|
85
|
-
};
|
|
86
|
-
});
|
|
87
|
-
};
|
|
88
|
-
const validateText = (value) => {
|
|
89
|
-
if (typeof value !== 'string' || !value.length || /[\r\n\x00-\x1f\x7f]/.test(value)) return t('shortcuts.badText');
|
|
90
|
-
return undefined;
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
function parseShortcutKey(item) {
|
|
94
|
-
if (!item || item.type !== 'key') return { modifier: 'none', base: 'Escape' };
|
|
95
|
-
if (item.key === 'BTab') return { modifier: 'shift', base: 'Tab' };
|
|
96
|
-
let key = item.key;
|
|
97
|
-
const ctrl = key.startsWith('C-'); if (ctrl) key = key.slice(2);
|
|
98
|
-
const alt = key.startsWith('M-'); if (alt) key = key.slice(2);
|
|
99
|
-
const shift = key.startsWith('S-'); if (shift) key = key.slice(2);
|
|
100
|
-
const modifier = ctrl && alt ? 'ctrl-alt' : ctrl && shift ? 'ctrl-shift'
|
|
101
|
-
: ctrl ? 'ctrl' : alt ? 'alt' : shift ? 'shift' : 'none';
|
|
102
|
-
return { modifier, base: key };
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const modifierOptions = () => Object.entries(MODIFIERS).map(([value, item]) => ({
|
|
106
|
-
value, label: item.labels.length ? item.labels.join('+') : t('shortcuts.noModifier'),
|
|
107
|
-
}));
|
|
108
|
-
const baseOptions = (modifier) => {
|
|
109
|
-
const allowChars = modifier !== 'none' && !MODIFIERS[modifier].prefixes.includes('S-');
|
|
110
|
-
return [...NAMED_BASES, ...(allowChars ? CHAR_BASES : [])].map((value) => ({
|
|
111
|
-
value, label: DISPLAY[value] || value.toUpperCase?.() || value,
|
|
112
|
-
}));
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
async function editItem(mode, seed, ui, forcedType = null) {
|
|
116
|
-
const type = forcedType || await ui.ask(ui.select({
|
|
117
|
-
message: t('shortcuts.type'), initialValue: seed?.type || 'text',
|
|
118
|
-
options: [
|
|
119
|
-
{ value: 'text', label: t('shortcuts.text') },
|
|
120
|
-
{ value: 'key', label: t('shortcuts.key') },
|
|
121
|
-
],
|
|
122
|
-
}));
|
|
123
|
-
if (type === 'text') {
|
|
124
|
-
const text = await ui.ask(ui.text({
|
|
125
|
-
message: t('shortcuts.textPrompt'), initialValue: seed?.type === 'text' ? seed.text : '', validate: validateText,
|
|
126
|
-
}));
|
|
127
|
-
const enter = await ui.ask(ui.confirm({
|
|
128
|
-
message: t('shortcuts.enter'), initialValue: seed?.type === 'text' ? seed.enter : mode === 'chat',
|
|
129
|
-
}));
|
|
130
|
-
return { type: 'text', text, enter };
|
|
131
|
-
}
|
|
132
|
-
const parsed = parseShortcutKey(seed);
|
|
133
|
-
const modifier = await ui.ask(ui.select({
|
|
134
|
-
message: t('shortcuts.modifier'), initialValue: parsed.modifier, options: modifierOptions(),
|
|
135
|
-
}));
|
|
136
|
-
const available = baseOptions(modifier);
|
|
137
|
-
const base = await ui.ask(ui.select({
|
|
138
|
-
message: t('shortcuts.base'),
|
|
139
|
-
initialValue: available.some((option) => option.value === parsed.base) ? parsed.base : available[0].value,
|
|
140
|
-
options: available,
|
|
141
|
-
}));
|
|
142
|
-
return buildShortcutKey(modifier, base);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
async function editMode(mode, initial, ui) {
|
|
146
|
-
let items = initial.slice();
|
|
147
|
-
for (;;) {
|
|
148
|
-
const choice = await ui.ask(ui.select({
|
|
149
|
-
message: modeLabel(mode),
|
|
150
|
-
options: [
|
|
151
|
-
...items.map((item, index) => ({ value: `item:${index}`, label: itemLabel(item), hint: itemHint(item) })),
|
|
152
|
-
{ value: 'add-key', label: t('shortcuts.addKey') },
|
|
153
|
-
{ value: 'add-text', label: t('shortcuts.addText') },
|
|
154
|
-
{ value: 'back', label: t('shortcuts.back') },
|
|
155
|
-
],
|
|
156
|
-
}));
|
|
157
|
-
if (choice === 'back') return items;
|
|
158
|
-
if (choice === 'add-key' || choice === 'add-text') {
|
|
159
|
-
const item = await editItem(mode, null, ui, choice === 'add-key' ? 'key' : 'text');
|
|
160
|
-
if (!items.some((existing) => shortcutIdentity(existing) === shortcutIdentity(item))) {
|
|
161
|
-
const target = await ui.ask(ui.select({
|
|
162
|
-
message: t('shortcuts.addPositionPrompt'), options: positionOptions(items),
|
|
163
|
-
}));
|
|
164
|
-
items = moveShortcut([...items, item], items.length, target);
|
|
165
|
-
}
|
|
166
|
-
continue;
|
|
167
|
-
}
|
|
168
|
-
const index = Number(choice.slice(5));
|
|
169
|
-
const action = await ui.ask(ui.select({
|
|
170
|
-
message: itemLabel(items[index]),
|
|
171
|
-
options: [
|
|
172
|
-
{ value: 'edit', label: t('shortcuts.edit') },
|
|
173
|
-
...(items.length > 1 ? [{ value: 'move', label: t('shortcuts.move') }] : []),
|
|
174
|
-
{ value: 'delete', label: t('shortcuts.delete') },
|
|
175
|
-
{ value: 'back', label: t('shortcuts.back') },
|
|
176
|
-
],
|
|
177
|
-
}));
|
|
178
|
-
if (action === 'edit') {
|
|
179
|
-
const edited = await editItem(mode, items[index], ui);
|
|
180
|
-
if (!items.some((item, i) => i !== index && shortcutIdentity(item) === shortcutIdentity(edited))) {
|
|
181
|
-
items = items.map((item, i) => i === index ? edited : item);
|
|
182
|
-
}
|
|
183
|
-
} else if (action === 'move') {
|
|
184
|
-
const target = await ui.ask(ui.select({
|
|
185
|
-
message: t('shortcuts.movePrompt', { n: index + 1 }), options: positionOptions(items, index),
|
|
186
|
-
}));
|
|
187
|
-
items = moveShortcut(items, index, target);
|
|
188
|
-
} else if (action === 'delete') items = items.filter((_item, i) => i !== index);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
export async function runShortcutEditor({
|
|
193
|
-
target, log = console, isTTY = process.stdin.isTTY, ui = defaultUi,
|
|
194
|
-
commit = async (file, shortcuts) => ({ cfg: saveShortcutConfig(file, shortcuts) }),
|
|
195
|
-
} = {}) {
|
|
196
|
-
if (!isTTY) { log.error(t('shortcuts.needTty')); return { error: 'non-tty' }; }
|
|
197
|
-
const existing = readExisting(target);
|
|
198
|
-
let shortcuts = normalizeShortcuts(existing.shortcuts);
|
|
199
|
-
ui.intro('handmux shortcuts');
|
|
200
|
-
try {
|
|
201
|
-
for (;;) {
|
|
202
|
-
const choice = await ui.ask(ui.select({
|
|
203
|
-
message: t('shortcuts.title'),
|
|
204
|
-
options: [
|
|
205
|
-
{ value: 'command', label: modeLabel('command'), hint: t('shortcuts.count', { n: shortcuts.command.length }) },
|
|
206
|
-
{ value: 'chat', label: modeLabel('chat'), hint: t('shortcuts.count', { n: shortcuts.chat.length }) },
|
|
207
|
-
{ value: 'save', label: t('shortcuts.save') },
|
|
208
|
-
{ value: 'exit', label: t('shortcuts.exit') },
|
|
209
|
-
],
|
|
210
|
-
}));
|
|
211
|
-
if (choice === 'exit') { ui.cancel(t('shortcuts.exited')); return null; }
|
|
212
|
-
if (choice === 'command' || choice === 'chat') {
|
|
213
|
-
shortcuts = { ...shortcuts, [choice]: await editMode(choice, shortcuts[choice], ui) };
|
|
214
|
-
continue;
|
|
215
|
-
}
|
|
216
|
-
const result = await commit(target, shortcuts);
|
|
217
|
-
ui.outro(t('shortcuts.wrote', { path: target }));
|
|
218
|
-
return result;
|
|
219
|
-
}
|
|
220
|
-
} catch (error) {
|
|
221
|
-
if (error === prompts.CANCELLED) { ui.cancel(t('shortcuts.exited')); return null; }
|
|
222
|
-
throw error;
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
export async function applyShortcutsLive({
|
|
227
|
-
state, shortcuts, fetchImpl = globalThis.fetch, timeoutMs = 8000,
|
|
228
|
-
}) {
|
|
229
|
-
if (!state?.localUrl || !state?.token) throw new Error('running server state is incomplete');
|
|
230
|
-
const base = state.localUrl.replace(/\/$/, '');
|
|
231
|
-
const controller = new AbortController();
|
|
232
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
233
|
-
try {
|
|
234
|
-
const response = await fetchImpl(`${base}/api/config/shortcuts`, {
|
|
235
|
-
method: 'PUT',
|
|
236
|
-
redirect: 'manual',
|
|
237
|
-
signal: controller.signal,
|
|
238
|
-
headers: { Authorization: `Bearer ${state.token}`, 'Content-Type': 'application/json' },
|
|
239
|
-
body: JSON.stringify({ shortcuts }),
|
|
240
|
-
});
|
|
241
|
-
if (!response.ok) throw new Error(`server returned HTTP ${response.status}`);
|
|
242
|
-
let acknowledgment;
|
|
243
|
-
try { acknowledgment = await response.json(); } catch { /* validated below */ }
|
|
244
|
-
if (acknowledgment?.ok !== true) throw new Error('invalid server response');
|
|
245
|
-
} catch (error) {
|
|
246
|
-
if (controller.signal.aborted) throw new Error(`server request timed out after ${timeoutMs}ms`);
|
|
247
|
-
throw error;
|
|
248
|
-
} finally {
|
|
249
|
-
clearTimeout(timer);
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// Keep the durable write and the runtime replacement in one short cross-process critical section.
|
|
254
|
-
// Otherwise two editors can interleave their save/PUT calls and leave disk and memory disagreeing.
|
|
255
|
-
export async function commitShortcuts({
|
|
256
|
-
home, target, shortcuts,
|
|
257
|
-
acquireLock = acquireLifecycleLock,
|
|
258
|
-
readStateImpl = readState,
|
|
259
|
-
isAliveImpl = isAlive,
|
|
260
|
-
saveImpl = saveShortcutConfig,
|
|
261
|
-
fetchImpl = globalThis.fetch,
|
|
262
|
-
timeoutMs = 8000,
|
|
263
|
-
}) {
|
|
264
|
-
const release = acquireLock(home);
|
|
265
|
-
try {
|
|
266
|
-
const cfg = saveImpl(target, shortcuts);
|
|
267
|
-
const state = readStateImpl(home);
|
|
268
|
-
if (!state || !isAliveImpl(state.supervisorPid)) return { cfg, running: false, applied: false };
|
|
269
|
-
try {
|
|
270
|
-
await applyShortcutsLive({ state, shortcuts: cfg.shortcuts, fetchImpl, timeoutMs });
|
|
271
|
-
return { cfg, running: true, applied: true };
|
|
272
|
-
} catch (error) {
|
|
273
|
-
return { cfg, running: true, applied: false, error };
|
|
274
|
-
}
|
|
275
|
-
} finally {
|
|
276
|
-
release();
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
export function reportShortcutCommit(result, output = console) {
|
|
281
|
-
if (!result.running) return 0;
|
|
282
|
-
if (result.applied) {
|
|
283
|
-
output.log(t('shortcuts.applied'));
|
|
284
|
-
return 0;
|
|
285
|
-
}
|
|
286
|
-
output.error(t('shortcuts.applyFailed', { msg: result.error?.message || 'unknown error' }));
|
|
287
|
-
return 1;
|
|
288
|
-
}
|
package/src/cli/sshTunnel.js
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
// Pure: detect tunlite `run --json` reaching the `connected` state from an NDJSON log chunk. tunlite
|
|
2
|
-
// self-reconnects, so we only flip to "live" on a real connected event (mirrors cloudflare URL-scrape:
|
|
3
|
-
// don't surface the public URL until the tunnel is actually up). Side-effect-free → unit-tested against
|
|
4
|
-
// real captured NDJSON lines.
|
|
5
|
-
export function isTunnelConnected(text) {
|
|
6
|
-
for (const line of String(text || '').split('\n')) {
|
|
7
|
-
const s = line.trim();
|
|
8
|
-
if (!s) continue;
|
|
9
|
-
try { if (JSON.parse(s).state === 'connected') return true; } catch { /* partial / non-json line */ }
|
|
10
|
-
}
|
|
11
|
-
return false;
|
|
12
|
-
}
|
package/src/cli/state.js
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
// Runtime state lives in ~/.handmux/ — a single state.json (pids + the live public URL) plus a log
|
|
2
|
-
// file the detached supervisor writes to. `home` is injectable so this unit-tests in a temp dir.
|
|
3
|
-
import fs from 'node:fs';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
import { homedir } from 'node:os';
|
|
6
|
-
|
|
7
|
-
export function pocketHome(home = homedir()) { return path.join(home, '.handmux'); }
|
|
8
|
-
export function statePath(home) { return path.join(pocketHome(home), 'state.json'); }
|
|
9
|
-
export function lifecycleLockPath(home) { return path.join(pocketHome(home), 'lifecycle.lock'); }
|
|
10
|
-
export function logPath(home) { return path.join(pocketHome(home), 'handmux.log'); }
|
|
11
|
-
export function configPath(home) { return path.join(pocketHome(home), 'config.json'); }
|
|
12
|
-
|
|
13
|
-
// The hook-maintained Claude state file, on a stable per-user path (survives a global reinstall, unlike
|
|
14
|
-
// the package-internal server/data default). The CLI sets this as CLAUDE_STATE_FILE for the server child
|
|
15
|
-
// and writes the same path into the hook's env, so both ends read/write one file.
|
|
16
|
-
export function claudeStatePath(home) { return path.join(pocketHome(home), 'claude-state.json'); }
|
|
17
|
-
|
|
18
|
-
// Push subscriptions and the dynamic-preview registry are mutable runtime data too, so they belong on the
|
|
19
|
-
// same stable per-user path — NOT the package-internal server/data default, which a `npm i -g handmux`
|
|
20
|
-
// reinstall replaces (silently dropping every saved subscription). The CLI injects these as PUSH_STORE /
|
|
21
|
-
// PREVIEW_STORE for the server child.
|
|
22
|
-
export function pushStorePath(home) { return path.join(pocketHome(home), 'push-subs.json'); }
|
|
23
|
-
export function previewStorePath(home) { return path.join(pocketHome(home), 'previews.json'); }
|
|
24
|
-
export function notificationsDirPath(home) { return path.join(pocketHome(home), 'notifications'); }
|
|
25
|
-
|
|
26
|
-
export function readState(home) {
|
|
27
|
-
try { return JSON.parse(fs.readFileSync(statePath(home), 'utf8')); } catch { return null; }
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// Atomic write (tmp + rename): the supervisor persist()s frequently while the CLI concurrently readState()s
|
|
31
|
-
// (waitAndPrint/status), so a plain writeFileSync could be caught mid-write and JSON.parse-fail — silently
|
|
32
|
-
// degrading to "not running". rename is atomic on the same filesystem, so a reader sees old or new, never torn.
|
|
33
|
-
export function writeState(state, home) {
|
|
34
|
-
fs.mkdirSync(pocketHome(home), { recursive: true });
|
|
35
|
-
const file = statePath(home);
|
|
36
|
-
const tmp = `${file}.tmp`;
|
|
37
|
-
fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
|
|
38
|
-
fs.renameSync(tmp, file);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function clearState(home) {
|
|
42
|
-
try { fs.unlinkSync(statePath(home)); } catch { /* already gone */ }
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// pid liveness without sending a real signal: kill(pid, 0) throws ESRCH if dead, EPERM if alive but
|
|
46
|
-
// not ours (still counts as running).
|
|
47
|
-
export function isAlive(pid) {
|
|
48
|
-
if (!pid) return false;
|
|
49
|
-
try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const LIFECYCLE_LOCK_STALE_MS = 10 * 60 * 1000;
|
|
53
|
-
|
|
54
|
-
// Cross-process mutex for start/stop/restart/service operations. `wx` is atomic even when two shells run
|
|
55
|
-
// start at exactly the same time. A killed CLI leaves a tiny stale file; its dead owner is detected and
|
|
56
|
-
// reclaimed on the next operation. The release closure only removes a lock still owned by this process.
|
|
57
|
-
export function acquireLifecycleLock(home, pid = process.pid) {
|
|
58
|
-
fs.mkdirSync(pocketHome(home), { recursive: true });
|
|
59
|
-
const file = lifecycleLockPath(home);
|
|
60
|
-
const stamp = JSON.stringify({ pid, createdAt: Date.now() });
|
|
61
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
62
|
-
try {
|
|
63
|
-
fs.writeFileSync(file, stamp, { flag: 'wx' });
|
|
64
|
-
return () => {
|
|
65
|
-
try {
|
|
66
|
-
if (fs.readFileSync(file, 'utf8') === stamp) fs.unlinkSync(file);
|
|
67
|
-
} catch { /* already gone/replaced */ }
|
|
68
|
-
};
|
|
69
|
-
} catch (e) {
|
|
70
|
-
if (e.code !== 'EEXIST') throw e;
|
|
71
|
-
let owner = 0, createdAt = 0;
|
|
72
|
-
try {
|
|
73
|
-
const raw = fs.readFileSync(file, 'utf8').trim();
|
|
74
|
-
const parsed = JSON.parse(raw);
|
|
75
|
-
owner = Number(parsed.pid);
|
|
76
|
-
createdAt = Number(parsed.createdAt);
|
|
77
|
-
} catch { /* malformed/legacy → stale unless its plain pid is live */
|
|
78
|
-
try { owner = Number(fs.readFileSync(file, 'utf8').trim()); } catch { /* stale */ }
|
|
79
|
-
}
|
|
80
|
-
const fresh = !createdAt || (Date.now() - createdAt) < LIFECYCLE_LOCK_STALE_MS;
|
|
81
|
-
if (owner && fresh && isAlive(owner)) {
|
|
82
|
-
const err = new Error(`lifecycle operation already running (pid ${owner})`);
|
|
83
|
-
err.ownerPid = owner;
|
|
84
|
-
throw err;
|
|
85
|
-
}
|
|
86
|
-
try { fs.unlinkSync(file); } catch { /* raced with another contender */ }
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
throw new Error('could not acquire lifecycle lock');
|
|
90
|
-
}
|
package/src/cli/statusLine.js
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
// Install/uninstall the handmux Claude statusLine — the capturer that snapshots the 5h/weekly rate-limit %
|
|
2
|
-
// (from Claude Code's statusLine stdin, the only documented local source) to ~/.handmux/claude-usage.json
|
|
3
|
-
// for the phone's Usage page. Opt-in, and NON-DESTRUCTIVE by design: Claude allows exactly one statusLine,
|
|
4
|
-
// so if the user already has their OWN we NEVER clobber it — we report 'foreign' and the CLI prints a
|
|
5
|
-
// one-line compose snippet instead. We only ever write settings.statusLine when it's absent or already ours.
|
|
6
|
-
//
|
|
7
|
-
// Iron rule (same as claudeHooks): only ever touch ~/.handmux/ and — after opt-in — ~/.claude/. Never
|
|
8
|
-
// create ~/.claude.
|
|
9
|
-
import fs from 'node:fs';
|
|
10
|
-
import path from 'node:path';
|
|
11
|
-
import { homedir } from 'node:os';
|
|
12
|
-
import { writeJsonAtomic } from './hookScaffold.js';
|
|
13
|
-
|
|
14
|
-
const STATUS_MARK = 'handmux-statusline.cjs'; // identifies our statusLine command among the user's own
|
|
15
|
-
const SCRIPT = 'handmux-statusline.cjs';
|
|
16
|
-
|
|
17
|
-
function claudeDir(home = homedir()) { return path.join(home, '.claude'); }
|
|
18
|
-
function settingsPath(home = homedir()) { return path.join(claudeDir(home), 'settings.json'); }
|
|
19
|
-
|
|
20
|
-
function readSettings(home) {
|
|
21
|
-
try { return JSON.parse(fs.readFileSync(settingsPath(home), 'utf8')); } catch { return {}; }
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function isOurs(sl) {
|
|
25
|
-
return !!(sl && typeof sl.command === 'string' && sl.command.includes(STATUS_MARK));
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// 'no-claude' → ~/.claude absent. 'ours' → our statusLine is installed. 'foreign' → the user has their own
|
|
29
|
-
// statusLine (we must not touch it). 'absent' → Claude Code is here but no statusLine configured.
|
|
30
|
-
export function statusLineStatus(home = homedir()) {
|
|
31
|
-
if (!fs.existsSync(claudeDir(home))) return 'no-claude';
|
|
32
|
-
const sl = readSettings(home).statusLine;
|
|
33
|
-
if (isOurs(sl)) return 'ours';
|
|
34
|
-
if (sl && (sl.command || sl.type)) return 'foreign';
|
|
35
|
-
return 'absent';
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// The exact command a user with an EXISTING statusline appends to capture without changing their display:
|
|
39
|
-
// pipe their statusline's stdin through our capturer in TEE mode first. Returned so the CLI can print it.
|
|
40
|
-
export function composeHint(home = homedir(), { usageFile } = {}) {
|
|
41
|
-
const dest = path.join(claudeDir(home), 'hooks', SCRIPT);
|
|
42
|
-
return `HANDMUX_STATUS_TEE=1 node ${dest} ${usageFile} | <your existing statusline>`;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Install (opt-in): copy the capturer to ~/.claude/hooks/ and point settings.statusLine at it — but ONLY
|
|
46
|
-
// when it's safe (absent or already ours). A 'foreign' statusLine is left untouched. Returns { status }.
|
|
47
|
-
// srcDir = bundled hooks dir (server/hooks)
|
|
48
|
-
// usageFile = ~/.handmux/claude-usage.json (the snapshot the server reads)
|
|
49
|
-
export function installStatusLine(home = homedir(), { srcDir, usageFile } = {}) {
|
|
50
|
-
if (!fs.existsSync(claudeDir(home))) return { status: 'no-claude' };
|
|
51
|
-
const status = statusLineStatus(home);
|
|
52
|
-
// Always deploy the capturer script (it's ours, inert until invoked) so the compose one-liner works even
|
|
53
|
-
// in the foreign case. Only the settings.statusLine write is gated on not clobbering the user's own.
|
|
54
|
-
const hooksDir = path.join(claudeDir(home), 'hooks');
|
|
55
|
-
fs.mkdirSync(hooksDir, { recursive: true });
|
|
56
|
-
const dest = path.join(hooksDir, SCRIPT);
|
|
57
|
-
fs.copyFileSync(path.join(srcDir, SCRIPT), dest);
|
|
58
|
-
if (status === 'foreign') return { status: 'foreign', script: dest }; // never touch their statusLine
|
|
59
|
-
const settings = readSettings(home);
|
|
60
|
-
settings.statusLine = { type: 'command', command: `node ${dest} ${usageFile}` };
|
|
61
|
-
writeJsonAtomic(settingsPath(home), settings);
|
|
62
|
-
return { status: 'installed' };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Refresh the on-disk capturer script to the BUNDLED version without touching settings.statusLine — so an
|
|
66
|
-
// npm upgrade actually reaches a user who already opted in. Critically settings-safe: a user who composed us
|
|
67
|
-
// into their OWN statusline (the TEE form) still reads as 'ours' (the command contains our mark), and we must
|
|
68
|
-
// NOT rewrite their command to the bare form (that would drop their downstream renderer). So this only ever
|
|
69
|
-
// copies the script. No-op (returns false) when we're not installed. Idempotent; safe to call every start.
|
|
70
|
-
export function refreshStatusLineScript(home = homedir(), { srcDir } = {}) {
|
|
71
|
-
if (statusLineStatus(home) !== 'ours') return false;
|
|
72
|
-
try {
|
|
73
|
-
const dest = path.join(claudeDir(home), 'hooks', SCRIPT);
|
|
74
|
-
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
75
|
-
fs.copyFileSync(path.join(srcDir, SCRIPT), dest);
|
|
76
|
-
return true;
|
|
77
|
-
} catch { return false; }
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Uninstall: drop settings.statusLine only if it's ours, and remove the copied script. Leaves a foreign
|
|
81
|
-
// statusLine and everything else intact.
|
|
82
|
-
export function uninstallStatusLine(home = homedir()) {
|
|
83
|
-
const settings = readSettings(home);
|
|
84
|
-
if (isOurs(settings.statusLine)) {
|
|
85
|
-
delete settings.statusLine;
|
|
86
|
-
if (fs.existsSync(settingsPath(home))) writeJsonAtomic(settingsPath(home), settings);
|
|
87
|
-
}
|
|
88
|
-
try { fs.unlinkSync(path.join(claudeDir(home), 'hooks', SCRIPT)); } catch { /* already gone */ }
|
|
89
|
-
return { status: 'absent' };
|
|
90
|
-
}
|
package/src/cli/supervisor.js
DELETED
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
// The single long-running supervisor. It owns the node server (and, for a process-backed tunnel like
|
|
2
|
-
// cloudflare, the tunnel) as CHILD processes, restarts them with a small backoff on exit, and records
|
|
3
|
-
// the live public URL into state.json. There is only ever one daemon here — cloudflared (and, later,
|
|
4
|
-
// `tunlite run`) are just its children, exactly like the server is.
|
|
5
|
-
import { spawn } from 'node:child_process';
|
|
6
|
-
import net from 'node:net';
|
|
7
|
-
import path from 'node:path';
|
|
8
|
-
import os from 'node:os';
|
|
9
|
-
import { fileURLToPath } from 'node:url';
|
|
10
|
-
import { createRequire } from 'node:module';
|
|
11
|
-
import { getDriver } from './drivers.js';
|
|
12
|
-
import { writeState, clearState, claudeStatePath, pushStorePath, previewStorePath, notificationsDirPath } from './state.js';
|
|
13
|
-
|
|
14
|
-
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
-
const SERVER = path.resolve(here, '../server.js');
|
|
16
|
-
const require = createRequire(import.meta.url);
|
|
17
|
-
const VERSION = require('../../package.json').version;
|
|
18
|
-
|
|
19
|
-
// First non-internal IPv4 — the address a phone on the same wifi uses when there's no tunnel.
|
|
20
|
-
export function lanUrl(port, ifaces = os.networkInterfaces()) {
|
|
21
|
-
for (const list of Object.values(ifaces)) {
|
|
22
|
-
for (const ni of list || []) {
|
|
23
|
-
if (ni.family === 'IPv4' && !ni.internal) return `http://${ni.address}:${port}`;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
return null;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// The token rides in the query string so the first navigation (or a QR scan) authenticates in one shot.
|
|
30
|
-
export function publicUrlWithToken(base, token) {
|
|
31
|
-
if (!base) return base;
|
|
32
|
-
return `${base.replace(/\/$/, '')}/?token=${encodeURIComponent(token)}`;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// Bare address with no token in it — printed/QR-encoded so a link can be shared or screenshotted without
|
|
36
|
-
// leaking the secret; the token is shown separately for the user to paste in.
|
|
37
|
-
export function bareUrl(base) {
|
|
38
|
-
if (!base) return base;
|
|
39
|
-
return `${base.replace(/\/$/, '')}/`;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function browserPublicOriginEnv(cfg) {
|
|
43
|
-
return {
|
|
44
|
-
...(cfg.previewDomain ? { HANDMUX_PREVIEW_DOMAIN: cfg.previewDomain } : {}),
|
|
45
|
-
...(cfg.publicUrl ? { HANDMUX_PUBLIC_URL: cfg.publicUrl } : {}),
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function supervise(cfg, { home, log = console } = {}) {
|
|
50
|
-
const driver = getDriver(cfg.tunnel);
|
|
51
|
-
const children = {};
|
|
52
|
-
let stopping = false;
|
|
53
|
-
let urlBuf = '';
|
|
54
|
-
let backoff = 500;
|
|
55
|
-
|
|
56
|
-
const state = {
|
|
57
|
-
supervisorPid: process.pid,
|
|
58
|
-
version: VERSION,
|
|
59
|
-
startedAt: Date.now(),
|
|
60
|
-
tunnel: cfg.tunnel,
|
|
61
|
-
port: cfg.port,
|
|
62
|
-
host: cfg.host,
|
|
63
|
-
token: cfg.token,
|
|
64
|
-
localUrl: `http://localhost:${cfg.port}`,
|
|
65
|
-
lanUrl: lanUrl(cfg.port),
|
|
66
|
-
publicUrl: null,
|
|
67
|
-
ready: false,
|
|
68
|
-
error: null,
|
|
69
|
-
};
|
|
70
|
-
const persist = () => writeState(state, home);
|
|
71
|
-
persist();
|
|
72
|
-
|
|
73
|
-
// The server socket comes up a beat after spawn; don't report "ready" (and don't let the CLI print an
|
|
74
|
-
// access URL) until a TCP connect to it actually succeeds, or callers race an unbound port.
|
|
75
|
-
const waitListening = () => {
|
|
76
|
-
if (stopping || state.ready) return;
|
|
77
|
-
const s = net.connect({ port: cfg.port, host: '127.0.0.1' });
|
|
78
|
-
const retry = () => { s.destroy(); if (!stopping) setTimeout(waitListening, 200); };
|
|
79
|
-
s.setTimeout(500, retry);
|
|
80
|
-
s.once('error', retry);
|
|
81
|
-
s.once('connect', () => { s.destroy(); state.ready = true; persist(); });
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
const startServer = () => {
|
|
85
|
-
// The server reads only process.env (no .env files) — the CLI resolved the one config file and we
|
|
86
|
-
// hand the server everything it needs here. This is the single injection point: config.json fields →
|
|
87
|
-
// the env names the server already reads (HANDMUX_* / VAPID_* / XFYUN_*).
|
|
88
|
-
const env = {
|
|
89
|
-
...process.env,
|
|
90
|
-
NODE_ENV: 'handmux',
|
|
91
|
-
HANDMUX_PORT: String(cfg.port),
|
|
92
|
-
HANDMUX_HOST: cfg.host,
|
|
93
|
-
HANDMUX_TOKEN: cfg.token,
|
|
94
|
-
CLAUDE_STATE_FILE: claudeStatePath(home),
|
|
95
|
-
PUSH_STORE: pushStorePath(home),
|
|
96
|
-
PREVIEW_STORE: previewStorePath(home),
|
|
97
|
-
NOTIF_DIR: notificationsDirPath(home),
|
|
98
|
-
...browserPublicOriginEnv(cfg),
|
|
99
|
-
};
|
|
100
|
-
if (cfg.name) env.HANDMUX_APP_NAME = cfg.name;
|
|
101
|
-
if (cfg.staticDir) env.HANDMUX_STATIC_DIR = cfg.staticDir;
|
|
102
|
-
if (cfg.uploadExts) env.HANDMUX_UPLOAD_EXTS = cfg.uploadExts;
|
|
103
|
-
env.HANDMUX_SHORTCUTS = JSON.stringify(cfg.shortcuts);
|
|
104
|
-
if (cfg.vapid) {
|
|
105
|
-
if (cfg.vapid.public) env.VAPID_PUBLIC = cfg.vapid.public;
|
|
106
|
-
if (cfg.vapid.private) env.VAPID_PRIVATE = cfg.vapid.private;
|
|
107
|
-
if (cfg.vapid.subject) env.VAPID_SUBJECT = cfg.vapid.subject;
|
|
108
|
-
}
|
|
109
|
-
if (cfg.xfyun) {
|
|
110
|
-
if (cfg.xfyun.appId) env.XFYUN_APPID = cfg.xfyun.appId;
|
|
111
|
-
if (cfg.xfyun.apiKey) env.XFYUN_APIKEY = cfg.xfyun.apiKey;
|
|
112
|
-
if (cfg.xfyun.apiSecret) env.XFYUN_APISECRET = cfg.xfyun.apiSecret;
|
|
113
|
-
}
|
|
114
|
-
const c = spawn(process.execPath, [SERVER], { env, stdio: ['ignore', 'inherit', 'inherit'] });
|
|
115
|
-
children.server = c;
|
|
116
|
-
state.serverPid = c.pid; persist();
|
|
117
|
-
c.on('exit', () => { if (!stopping) backoffRestart('server', startServer); });
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
const startTunnel = () => {
|
|
121
|
-
if (!driver.needsProcess) { // 'none' — reachable directly on LAN/localhost (or a tunnel you run yourself)
|
|
122
|
-
state.publicUrl = cfg.publicUrl || state.lanUrl || state.localUrl; persist(); return;
|
|
123
|
-
}
|
|
124
|
-
const spec = driver.proc(cfg);
|
|
125
|
-
const c = spawn(spec.cmd, spec.args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
126
|
-
children.tunnel = c;
|
|
127
|
-
state.tunnelPid = c.pid; persist();
|
|
128
|
-
const onData = (b) => {
|
|
129
|
-
const s = b.toString();
|
|
130
|
-
process.stdout.write(s);
|
|
131
|
-
if (state.publicUrl) return;
|
|
132
|
-
urlBuf = (urlBuf + s).slice(-4000);
|
|
133
|
-
const url = driver.matchUrl(urlBuf, cfg);
|
|
134
|
-
if (url) { state.publicUrl = url; state.error = null; backoff = 500; persist(); }
|
|
135
|
-
};
|
|
136
|
-
c.stdout.on('data', onData);
|
|
137
|
-
c.stderr.on('data', onData);
|
|
138
|
-
c.on('error', (e) => {
|
|
139
|
-
state.error = e.code === 'ENOENT'
|
|
140
|
-
? (driver.notFoundHint || `${spec.cmd} not found`)
|
|
141
|
-
: String(e);
|
|
142
|
-
persist();
|
|
143
|
-
});
|
|
144
|
-
c.on('exit', () => { if (!stopping) { state.publicUrl = null; persist(); backoffRestart('tunnel', startTunnel); } });
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
const backoffRestart = (what, fn) => {
|
|
148
|
-
const d = Math.min(backoff, 15000);
|
|
149
|
-
backoff = Math.min(backoff * 2, 15000);
|
|
150
|
-
log.warn?.(`[handmux] ${what} exited; restarting in ${d}ms`);
|
|
151
|
-
setTimeout(() => { if (!stopping) fn(); }, d);
|
|
152
|
-
};
|
|
153
|
-
|
|
154
|
-
const shutdown = () => {
|
|
155
|
-
stopping = true;
|
|
156
|
-
const kids = Object.values(children).filter(Boolean);
|
|
157
|
-
for (const c of kids) { try { c.kill('SIGTERM'); } catch { /* already dead */ } }
|
|
158
|
-
clearState(home);
|
|
159
|
-
// Don't exit on a fixed timer — that orphans any child still shutting down (a SIGKILL'd or crashed
|
|
160
|
-
// supervisor was how a stray cloudflared kept running after `stop`). Poll until the children are gone,
|
|
161
|
-
// then SIGKILL any straggler so we NEVER leak a tunnel process. With --grace-period 0s, cloudflared
|
|
162
|
-
// exits near-instantly; the 3s ceiling is just a backstop.
|
|
163
|
-
const alive = () => kids.filter((c) => { try { process.kill(c.pid, 0); return true; } catch { return false; } });
|
|
164
|
-
let waited = 0;
|
|
165
|
-
const tick = () => {
|
|
166
|
-
const left = alive();
|
|
167
|
-
if (left.length === 0 || waited >= 3000) {
|
|
168
|
-
for (const c of left) { try { c.kill('SIGKILL'); } catch { /* already dead */ } }
|
|
169
|
-
process.exit(0);
|
|
170
|
-
}
|
|
171
|
-
waited += 150;
|
|
172
|
-
setTimeout(tick, 150);
|
|
173
|
-
};
|
|
174
|
-
tick();
|
|
175
|
-
};
|
|
176
|
-
process.on('SIGTERM', shutdown);
|
|
177
|
-
process.on('SIGINT', shutdown);
|
|
178
|
-
|
|
179
|
-
startServer();
|
|
180
|
-
startTunnel();
|
|
181
|
-
waitListening();
|
|
182
|
-
return { state };
|
|
183
|
-
}
|