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
|
@@ -0,0 +1,278 @@
|
|
|
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 path from 'node:path';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import { getDriver } from './drivers.js';
|
|
11
|
+
import { writeState, clearState, claudeStatePath, pushStorePath, previewStorePath, notificationsDirPath } from './state.js';
|
|
12
|
+
import { initialSupervisorComponentState, reduceSupervisorComponent, } from './supervisorState.js';
|
|
13
|
+
import { probeServerReadiness } from './supervisorHealth.js';
|
|
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 packageInfo = require('../../package.json');
|
|
18
|
+
const VERSION = typeof packageInfo === 'object' && packageInfo !== null && 'version' in packageInfo
|
|
19
|
+
&& typeof packageInfo.version === 'string' ? packageInfo.version : 'unknown';
|
|
20
|
+
const defaultSpawnChild = (command, args, options) => spawn(command, [...args], options);
|
|
21
|
+
const errorCode = (error) => {
|
|
22
|
+
if (typeof error !== 'object' || error === null || !('code' in error))
|
|
23
|
+
return null;
|
|
24
|
+
return typeof error.code === 'string' ? error.code : null;
|
|
25
|
+
};
|
|
26
|
+
// First non-internal IPv4 — the address a phone on the same wifi uses when there's no tunnel.
|
|
27
|
+
export function lanUrl(port, ifaces = os.networkInterfaces()) {
|
|
28
|
+
for (const list of Object.values(ifaces)) {
|
|
29
|
+
for (const ni of list || []) {
|
|
30
|
+
if (ni.family === 'IPv4' && !ni.internal)
|
|
31
|
+
return `http://${ni.address}:${port}`;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
// The token rides in the query string so the first navigation (or a QR scan) authenticates in one shot.
|
|
37
|
+
export function publicUrlWithToken(base, token) {
|
|
38
|
+
if (!base)
|
|
39
|
+
return base;
|
|
40
|
+
return `${base.replace(/\/$/, '')}/?token=${encodeURIComponent(token)}`;
|
|
41
|
+
}
|
|
42
|
+
// Bare address with no token in it — printed/QR-encoded so a link can be shared or screenshotted without
|
|
43
|
+
// leaking the secret; the token is shown separately for the user to paste in.
|
|
44
|
+
export function bareUrl(base) {
|
|
45
|
+
if (!base)
|
|
46
|
+
return base;
|
|
47
|
+
return `${base.replace(/\/$/, '')}/`;
|
|
48
|
+
}
|
|
49
|
+
export function browserPublicOriginEnv(cfg) {
|
|
50
|
+
return {
|
|
51
|
+
...(cfg.previewDomain ? { HANDMUX_PREVIEW_DOMAIN: cfg.previewDomain } : {}),
|
|
52
|
+
...(cfg.publicUrl ? { HANDMUX_PUBLIC_URL: cfg.publicUrl } : {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export function supervise(cfg, { home, log = console, spawnChild = defaultSpawnChild, probeServerReady = () => probeServerReadiness(cfg.port), setTimer = setTimeout, now = Date.now, processRef = process, }) {
|
|
56
|
+
const driver = getDriver(cfg.tunnel);
|
|
57
|
+
const children = {};
|
|
58
|
+
let stopping = false;
|
|
59
|
+
let urlBuf = '';
|
|
60
|
+
const components = {
|
|
61
|
+
server: initialSupervisorComponentState('server'),
|
|
62
|
+
tunnel: initialSupervisorComponentState('tunnel'),
|
|
63
|
+
};
|
|
64
|
+
const state = {
|
|
65
|
+
supervisorPid: processRef.pid,
|
|
66
|
+
version: VERSION,
|
|
67
|
+
startedAt: now(),
|
|
68
|
+
tunnel: cfg.tunnel,
|
|
69
|
+
port: cfg.port,
|
|
70
|
+
host: cfg.host,
|
|
71
|
+
token: cfg.token,
|
|
72
|
+
localUrl: `http://localhost:${cfg.port}`,
|
|
73
|
+
lanUrl: lanUrl(cfg.port),
|
|
74
|
+
publicUrl: null,
|
|
75
|
+
ready: false,
|
|
76
|
+
serverPid: null,
|
|
77
|
+
tunnelPid: null,
|
|
78
|
+
components,
|
|
79
|
+
error: null,
|
|
80
|
+
};
|
|
81
|
+
const persist = () => {
|
|
82
|
+
// Keep the legacy top-level fields during migration, but derive them from the explicit component
|
|
83
|
+
// machines so `ready` can never outlive the Server process that earned it.
|
|
84
|
+
state.serverPid = components.server.pid;
|
|
85
|
+
state.tunnelPid = components.tunnel.pid;
|
|
86
|
+
state.ready = components.server.phase === 'ready';
|
|
87
|
+
writeState(state, home);
|
|
88
|
+
};
|
|
89
|
+
const transition = (name, event) => {
|
|
90
|
+
components[name] = reduceSupervisorComponent(components[name], event);
|
|
91
|
+
persist();
|
|
92
|
+
};
|
|
93
|
+
persist();
|
|
94
|
+
// Readiness belongs to the Server generation that answered the structured health probe. A bare TCP
|
|
95
|
+
// listener could be an old process or a half-initialized API with Workspace/Browser already degraded.
|
|
96
|
+
const waitReady = (serverChild) => {
|
|
97
|
+
if (stopping || children.server !== serverChild || components.server.phase === 'ready')
|
|
98
|
+
return;
|
|
99
|
+
const retry = () => {
|
|
100
|
+
if (!stopping && children.server === serverChild) {
|
|
101
|
+
setTimer(() => waitReady(serverChild), 200);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
Promise.resolve(probeServerReady()).then((ready) => {
|
|
105
|
+
if (!ready) {
|
|
106
|
+
retry();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (!stopping && children.server === serverChild) {
|
|
110
|
+
transition('server', { type: 'ready', at: now() });
|
|
111
|
+
}
|
|
112
|
+
}, retry);
|
|
113
|
+
};
|
|
114
|
+
const startServer = () => {
|
|
115
|
+
// The server reads only process.env (no .env files) — the CLI resolved the one config file and we
|
|
116
|
+
// hand the server everything it needs here. This is the single injection point: config.json fields →
|
|
117
|
+
// the env names the server already reads (HANDMUX_* / VAPID_* / XFYUN_*).
|
|
118
|
+
const env = {
|
|
119
|
+
...processRef.env,
|
|
120
|
+
NODE_ENV: 'handmux',
|
|
121
|
+
HANDMUX_PORT: String(cfg.port),
|
|
122
|
+
HANDMUX_HOST: cfg.host,
|
|
123
|
+
HANDMUX_TOKEN: cfg.token,
|
|
124
|
+
CLAUDE_STATE_FILE: claudeStatePath(home),
|
|
125
|
+
PUSH_STORE: pushStorePath(home),
|
|
126
|
+
PREVIEW_STORE: previewStorePath(home),
|
|
127
|
+
NOTIF_DIR: notificationsDirPath(home),
|
|
128
|
+
...browserPublicOriginEnv(cfg),
|
|
129
|
+
};
|
|
130
|
+
if (cfg.name)
|
|
131
|
+
env.HANDMUX_APP_NAME = cfg.name;
|
|
132
|
+
if (cfg.staticDir)
|
|
133
|
+
env.HANDMUX_STATIC_DIR = cfg.staticDir;
|
|
134
|
+
if (cfg.uploadExts)
|
|
135
|
+
env.HANDMUX_UPLOAD_EXTS = cfg.uploadExts;
|
|
136
|
+
env.HANDMUX_SHORTCUTS = JSON.stringify(cfg.shortcuts);
|
|
137
|
+
if (cfg.vapid) {
|
|
138
|
+
if (cfg.vapid.public)
|
|
139
|
+
env.VAPID_PUBLIC = cfg.vapid.public;
|
|
140
|
+
if (cfg.vapid.private)
|
|
141
|
+
env.VAPID_PRIVATE = cfg.vapid.private;
|
|
142
|
+
if (cfg.vapid.subject)
|
|
143
|
+
env.VAPID_SUBJECT = cfg.vapid.subject;
|
|
144
|
+
}
|
|
145
|
+
if (cfg.xfyun) {
|
|
146
|
+
if (cfg.xfyun.appId)
|
|
147
|
+
env.XFYUN_APPID = cfg.xfyun.appId;
|
|
148
|
+
if (cfg.xfyun.apiKey)
|
|
149
|
+
env.XFYUN_APIKEY = cfg.xfyun.apiKey;
|
|
150
|
+
if (cfg.xfyun.apiSecret)
|
|
151
|
+
env.XFYUN_APISECRET = cfg.xfyun.apiSecret;
|
|
152
|
+
}
|
|
153
|
+
const c = spawnChild(processRef.execPath, [SERVER], { env, stdio: ['ignore', 'inherit', 'inherit'] });
|
|
154
|
+
children.server = c;
|
|
155
|
+
transition('server', { type: 'spawned', pid: c.pid ?? null, at: now() });
|
|
156
|
+
waitReady(c);
|
|
157
|
+
let finalized = false;
|
|
158
|
+
const finalize = (error = null) => {
|
|
159
|
+
if (finalized || children.server !== c)
|
|
160
|
+
return;
|
|
161
|
+
finalized = true;
|
|
162
|
+
delete children.server;
|
|
163
|
+
if (stopping) {
|
|
164
|
+
components.server = reduceSupervisorComponent(components.server, { type: 'stopped', at: now() });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
transition('server', { type: 'exited', at: now(), error });
|
|
168
|
+
backoffRestart('server', startServer);
|
|
169
|
+
};
|
|
170
|
+
c.once('error', (error) => finalize(String(error)));
|
|
171
|
+
c.once('exit', (code, signal) => finalize(code || signal ? `exit ${code ?? signal}` : null));
|
|
172
|
+
};
|
|
173
|
+
const startTunnel = () => {
|
|
174
|
+
if (!driver.needsProcess) { // 'none' — reachable directly on LAN/localhost (or a tunnel you run yourself)
|
|
175
|
+
state.publicUrl = cfg.publicUrl || state.lanUrl || state.localUrl;
|
|
176
|
+
transition('tunnel', { type: 'spawned', pid: null, at: now() });
|
|
177
|
+
transition('tunnel', { type: 'ready', at: now() });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
urlBuf = '';
|
|
181
|
+
const spec = driver.proc(cfg);
|
|
182
|
+
if (!spec)
|
|
183
|
+
throw new Error(`tunnel driver ${driver.name} did not provide a process`);
|
|
184
|
+
const c = spawnChild(spec.cmd, spec.args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
185
|
+
children.tunnel = c;
|
|
186
|
+
transition('tunnel', { type: 'spawned', pid: c.pid ?? null, at: now() });
|
|
187
|
+
const onData = (chunk) => {
|
|
188
|
+
const s = String(chunk);
|
|
189
|
+
processRef.stdout.write(s);
|
|
190
|
+
if (state.publicUrl)
|
|
191
|
+
return;
|
|
192
|
+
urlBuf = (urlBuf + s).slice(-4000);
|
|
193
|
+
const url = driver.matchUrl(urlBuf, cfg);
|
|
194
|
+
if (url) {
|
|
195
|
+
state.publicUrl = url;
|
|
196
|
+
state.error = null;
|
|
197
|
+
transition('tunnel', { type: 'ready', at: now() });
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
c.stdout.on('data', onData);
|
|
201
|
+
c.stderr.on('data', onData);
|
|
202
|
+
let finalized = false;
|
|
203
|
+
const finalize = (error = null) => {
|
|
204
|
+
if (finalized || children.tunnel !== c)
|
|
205
|
+
return;
|
|
206
|
+
finalized = true;
|
|
207
|
+
delete children.tunnel;
|
|
208
|
+
state.publicUrl = null;
|
|
209
|
+
if (stopping) {
|
|
210
|
+
components.tunnel = reduceSupervisorComponent(components.tunnel, { type: 'stopped', at: now() });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
transition('tunnel', { type: 'exited', at: now(), error });
|
|
214
|
+
backoffRestart('tunnel', startTunnel);
|
|
215
|
+
};
|
|
216
|
+
c.once('error', (e) => {
|
|
217
|
+
const message = errorCode(e) === 'ENOENT'
|
|
218
|
+
? (driver.notFoundHint || `${spec.cmd} not found`)
|
|
219
|
+
: String(e);
|
|
220
|
+
state.error = message;
|
|
221
|
+
finalize(message);
|
|
222
|
+
});
|
|
223
|
+
c.once('exit', (code, signal) => finalize(code || signal ? `exit ${code ?? signal}` : null));
|
|
224
|
+
};
|
|
225
|
+
const backoffRestart = (what, fn) => {
|
|
226
|
+
const d = Math.max(0, (components[what].restartAt ?? now()) - now());
|
|
227
|
+
log.warn?.(`[handmux] ${what} exited; restarting in ${d}ms`);
|
|
228
|
+
setTimer(() => { if (!stopping)
|
|
229
|
+
fn(); }, d);
|
|
230
|
+
};
|
|
231
|
+
const shutdown = () => {
|
|
232
|
+
stopping = true;
|
|
233
|
+
const kids = Object.values(children).filter(Boolean);
|
|
234
|
+
for (const c of kids) {
|
|
235
|
+
try {
|
|
236
|
+
c.kill('SIGTERM');
|
|
237
|
+
}
|
|
238
|
+
catch { /* already dead */ }
|
|
239
|
+
}
|
|
240
|
+
clearState(home);
|
|
241
|
+
// Don't exit on a fixed timer — that orphans any child still shutting down (a SIGKILL'd or crashed
|
|
242
|
+
// supervisor was how a stray cloudflared kept running after `stop`). Poll until the children are gone,
|
|
243
|
+
// then SIGKILL any straggler so we NEVER leak a tunnel process. With --grace-period 0s, cloudflared
|
|
244
|
+
// exits near-instantly; the 3s ceiling is just a backstop.
|
|
245
|
+
const alive = () => kids.filter((child) => {
|
|
246
|
+
if (child.pid == null)
|
|
247
|
+
return false;
|
|
248
|
+
try {
|
|
249
|
+
processRef.kill(child.pid, 0);
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
let waited = 0;
|
|
257
|
+
const tick = () => {
|
|
258
|
+
const left = alive();
|
|
259
|
+
if (left.length === 0 || waited >= 3000) {
|
|
260
|
+
for (const c of left) {
|
|
261
|
+
try {
|
|
262
|
+
c.kill('SIGKILL');
|
|
263
|
+
}
|
|
264
|
+
catch { /* already dead */ }
|
|
265
|
+
}
|
|
266
|
+
processRef.exit(0);
|
|
267
|
+
}
|
|
268
|
+
waited += 150;
|
|
269
|
+
setTimer(tick, 150);
|
|
270
|
+
};
|
|
271
|
+
tick();
|
|
272
|
+
};
|
|
273
|
+
processRef.on('SIGTERM', shutdown);
|
|
274
|
+
processRef.on('SIGINT', shutdown);
|
|
275
|
+
startServer();
|
|
276
|
+
startTunnel();
|
|
277
|
+
return { state };
|
|
278
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { parseHealthReadySnapshot } from '../healthProtocol.js';
|
|
2
|
+
export async function probeServerReadiness(port, { fetchImpl = fetch, timeoutMs = 500 } = {}) {
|
|
3
|
+
const controller = new AbortController();
|
|
4
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
5
|
+
try {
|
|
6
|
+
const response = await fetchImpl(`http://127.0.0.1:${port}/health/ready`, {
|
|
7
|
+
method: 'GET',
|
|
8
|
+
headers: { accept: 'application/json' },
|
|
9
|
+
signal: controller.signal,
|
|
10
|
+
});
|
|
11
|
+
if (!response.ok)
|
|
12
|
+
return false;
|
|
13
|
+
return parseHealthReadySnapshot(await response.json())?.ready === true;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
clearTimeout(timer);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { readSupervisorConfig, supervisorConfigPath, writeSupervisorConfig, } from './state.js';
|
|
3
|
+
import { TUNNELS } from './options.js';
|
|
4
|
+
import { normalizeShortcuts } from '../shortcutConfig.js';
|
|
5
|
+
const recordOf = (value) => typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
6
|
+
? value : null;
|
|
7
|
+
const isTunnel = (value) => typeof value === 'string' && TUNNELS.some((candidate) => candidate === value);
|
|
8
|
+
const optionalString = (value, key) => {
|
|
9
|
+
if (value === undefined)
|
|
10
|
+
return undefined;
|
|
11
|
+
if (value === null)
|
|
12
|
+
return null;
|
|
13
|
+
if (typeof value !== 'string')
|
|
14
|
+
throw new Error(`invalid supervisor config: ${key} must be text`);
|
|
15
|
+
return value;
|
|
16
|
+
};
|
|
17
|
+
const optionalNumber = (value, key) => {
|
|
18
|
+
if (value === undefined)
|
|
19
|
+
return undefined;
|
|
20
|
+
if (value === null)
|
|
21
|
+
return null;
|
|
22
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65535) {
|
|
23
|
+
throw new Error(`invalid supervisor config: ${key} must be an integer from 1 to 65535`);
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
};
|
|
27
|
+
const stringFields = (value, keys, key) => {
|
|
28
|
+
if (value === undefined)
|
|
29
|
+
return undefined;
|
|
30
|
+
if (value === null)
|
|
31
|
+
return null;
|
|
32
|
+
const record = recordOf(value);
|
|
33
|
+
if (!record)
|
|
34
|
+
throw new Error(`invalid supervisor config: ${key} must be an object`);
|
|
35
|
+
const result = {};
|
|
36
|
+
for (const field of keys) {
|
|
37
|
+
const item = record[field];
|
|
38
|
+
if (item !== undefined) {
|
|
39
|
+
if (typeof item !== 'string')
|
|
40
|
+
throw new Error(`invalid supervisor config: ${key}.${field} must be text`);
|
|
41
|
+
result[field] = item;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
};
|
|
46
|
+
// The persisted file and the legacy base64 argv are untrusted process boundaries. Parse them before the
|
|
47
|
+
// supervisor starts so malformed or stale data fails as one explicit configuration error instead of being
|
|
48
|
+
// used as partially-typed process arguments.
|
|
49
|
+
export function parseSupervisorConfig(value) {
|
|
50
|
+
const record = recordOf(value);
|
|
51
|
+
if (!record)
|
|
52
|
+
throw new Error('invalid supervisor config: expected an object');
|
|
53
|
+
const tunnel = record.tunnel;
|
|
54
|
+
if (!isTunnel(tunnel)) {
|
|
55
|
+
throw new Error('invalid supervisor config: unknown tunnel');
|
|
56
|
+
}
|
|
57
|
+
const port = record.port;
|
|
58
|
+
if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535) {
|
|
59
|
+
throw new Error('invalid supervisor config: port must be an integer from 1 to 65535');
|
|
60
|
+
}
|
|
61
|
+
if (typeof record.host !== 'string' || !record.host) {
|
|
62
|
+
throw new Error('invalid supervisor config: host is required');
|
|
63
|
+
}
|
|
64
|
+
if (typeof record.token !== 'string' || !record.token) {
|
|
65
|
+
throw new Error('invalid supervisor config: token is required');
|
|
66
|
+
}
|
|
67
|
+
const config = {
|
|
68
|
+
tunnel,
|
|
69
|
+
port,
|
|
70
|
+
host: record.host,
|
|
71
|
+
token: record.token,
|
|
72
|
+
shortcuts: normalizeShortcuts(record.shortcuts),
|
|
73
|
+
};
|
|
74
|
+
const name = optionalString(record.name, 'name');
|
|
75
|
+
const staticDir = optionalString(record.staticDir, 'staticDir');
|
|
76
|
+
const uploadExts = optionalString(record.uploadExts, 'uploadExts');
|
|
77
|
+
const previewDomain = optionalString(record.previewDomain, 'previewDomain');
|
|
78
|
+
const publicUrl = optionalString(record.publicUrl, 'publicUrl');
|
|
79
|
+
const cloudflaredBin = optionalString(record.cloudflaredBin, 'cloudflaredBin');
|
|
80
|
+
const cfTunnelName = optionalString(record.cfTunnelName, 'cfTunnelName');
|
|
81
|
+
const tunliteBin = optionalString(record.tunliteBin, 'tunliteBin');
|
|
82
|
+
const sshHost = optionalString(record.sshHost, 'sshHost');
|
|
83
|
+
const remotePort = optionalNumber(record.remotePort, 'remotePort');
|
|
84
|
+
const sshJump = optionalString(record.sshJump, 'sshJump');
|
|
85
|
+
const natappBin = optionalString(record.natappBin, 'natappBin');
|
|
86
|
+
const authtoken = optionalString(record.authtoken, 'authtoken');
|
|
87
|
+
const cpolarBin = optionalString(record.cpolarBin, 'cpolarBin');
|
|
88
|
+
const cpolarRegion = optionalString(record.cpolarRegion, 'cpolarRegion');
|
|
89
|
+
const vapid = stringFields(record.vapid, ['public', 'private', 'subject'], 'vapid');
|
|
90
|
+
const xfyun = stringFields(record.xfyun, ['appId', 'apiKey', 'apiSecret'], 'xfyun');
|
|
91
|
+
if (name !== undefined)
|
|
92
|
+
config.name = name;
|
|
93
|
+
if (staticDir !== undefined)
|
|
94
|
+
config.staticDir = staticDir;
|
|
95
|
+
if (uploadExts !== undefined)
|
|
96
|
+
config.uploadExts = uploadExts;
|
|
97
|
+
if (previewDomain !== undefined)
|
|
98
|
+
config.previewDomain = previewDomain;
|
|
99
|
+
if (publicUrl !== undefined)
|
|
100
|
+
config.publicUrl = publicUrl;
|
|
101
|
+
if (typeof cloudflaredBin === 'string')
|
|
102
|
+
config.cloudflaredBin = cloudflaredBin;
|
|
103
|
+
if (cfTunnelName !== undefined)
|
|
104
|
+
config.cfTunnelName = cfTunnelName;
|
|
105
|
+
if (typeof tunliteBin === 'string')
|
|
106
|
+
config.tunliteBin = tunliteBin;
|
|
107
|
+
if (sshHost !== undefined)
|
|
108
|
+
config.sshHost = sshHost;
|
|
109
|
+
if (remotePort !== undefined)
|
|
110
|
+
config.remotePort = remotePort;
|
|
111
|
+
if (sshJump !== undefined)
|
|
112
|
+
config.sshJump = sshJump;
|
|
113
|
+
if (typeof natappBin === 'string')
|
|
114
|
+
config.natappBin = natappBin;
|
|
115
|
+
if (authtoken !== undefined)
|
|
116
|
+
config.authtoken = authtoken;
|
|
117
|
+
if (typeof cpolarBin === 'string')
|
|
118
|
+
config.cpolarBin = cpolarBin;
|
|
119
|
+
if (cpolarRegion !== undefined)
|
|
120
|
+
config.cpolarRegion = cpolarRegion;
|
|
121
|
+
if (vapid !== undefined)
|
|
122
|
+
config.vapid = vapid;
|
|
123
|
+
if (xfyun !== undefined)
|
|
124
|
+
config.xfyun = xfyun;
|
|
125
|
+
return config;
|
|
126
|
+
}
|
|
127
|
+
export function supervisorLaunchArgs(config, { home, entry, executable = process.execPath }) {
|
|
128
|
+
writeSupervisorConfig(config, home);
|
|
129
|
+
return [executable, entry, '__supervise', '--payload-file', supervisorConfigPath(home)];
|
|
130
|
+
}
|
|
131
|
+
export function readSupervisorLaunchConfig({ payloadFile, legacyPayload }, home) {
|
|
132
|
+
if (payloadFile != null) {
|
|
133
|
+
const expected = path.resolve(supervisorConfigPath(home));
|
|
134
|
+
if (typeof payloadFile !== 'string' || path.resolve(payloadFile) !== expected) {
|
|
135
|
+
throw new Error('invalid supervisor config path');
|
|
136
|
+
}
|
|
137
|
+
const config = readSupervisorConfig(home);
|
|
138
|
+
if (!config)
|
|
139
|
+
throw new Error('supervisor config is missing or invalid');
|
|
140
|
+
return config;
|
|
141
|
+
}
|
|
142
|
+
if (typeof legacyPayload === 'string') {
|
|
143
|
+
// A still-loaded pre-migration service may invoke the upgraded CLI once with its old argv. The next
|
|
144
|
+
// start/service install rewrites the definition to the private config-file contract.
|
|
145
|
+
return JSON.parse(Buffer.from(legacyPayload, 'base64').toString('utf8'));
|
|
146
|
+
}
|
|
147
|
+
throw new Error('supervisor config is missing');
|
|
148
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Process-table backstop for the single-instance invariant. state.json is deliberately only one record,
|
|
2
|
+
// so an older restart race can leave a live supervisor that is no longer referenced there. Scan the real
|
|
3
|
+
// argv instead: every packaged/manual/service supervisor has this stable signature across install paths.
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
export function parseSupervisorPids(psOut) {
|
|
6
|
+
const pids = [];
|
|
7
|
+
for (const line of String(psOut).split('\n')) {
|
|
8
|
+
const m = line.match(/^\s*(\d+)\s+(\S+)\s+(.*)$/);
|
|
9
|
+
const pidText = m?.[1];
|
|
10
|
+
const state = m?.[2];
|
|
11
|
+
const args = m?.[3];
|
|
12
|
+
if (!pidText || !state || !args || state[0] === 'Z')
|
|
13
|
+
continue;
|
|
14
|
+
// Anchor the executable as Node and handmux.js as its direct script argument. A shell command or test
|
|
15
|
+
// runner can contain the same words in its own command text; it is not a supervisor and must not match.
|
|
16
|
+
if (/^(?:\S*\/)?node(?:js)?\s+(?:"[^"]*(?:handmux\.js|\/handmux)"|'[^']*(?:handmux\.js|\/handmux)'|\S*(?:handmux\.js|\/handmux))\s+__supervise\s+--payload(?:-file)?(?:\s|$)/.test(args)) {
|
|
17
|
+
pids.push(Number(pidText));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return [...new Set(pids)].sort((a, b) => a - b);
|
|
21
|
+
}
|
|
22
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
23
|
+
// Terminate an initial snapshot and keep rescanning: a duplicate that was not in state.json, or one that
|
|
24
|
+
// appears during cleanup, is folded into `seen` and signalled too. Pure dependencies make the race/timeout
|
|
25
|
+
// behavior unit-testable without touching real processes.
|
|
26
|
+
export async function terminateSupervisorPids(initialPids, { scan = scanSupervisorPids, kill = (pid) => process.kill(pid, 'SIGTERM'), waitFn = wait, now = Date.now, timeoutMs = 4500, } = {}) {
|
|
27
|
+
const seen = new Set(initialPids);
|
|
28
|
+
const signal = (pid) => { try {
|
|
29
|
+
kill(pid);
|
|
30
|
+
}
|
|
31
|
+
catch { /* raced away / not ours */ } };
|
|
32
|
+
for (const pid of seen)
|
|
33
|
+
signal(pid);
|
|
34
|
+
const deadline = now() + timeoutMs;
|
|
35
|
+
for (;;) {
|
|
36
|
+
const current = scan();
|
|
37
|
+
if (!current.ok)
|
|
38
|
+
return { ok: false, reason: 'scan', pids: [...seen], remaining: [] };
|
|
39
|
+
if (!current.pids.length)
|
|
40
|
+
return { ok: true, pids: [...seen].sort((a, b) => a - b), remaining: [] };
|
|
41
|
+
for (const pid of current.pids) {
|
|
42
|
+
seen.add(pid);
|
|
43
|
+
signal(pid);
|
|
44
|
+
}
|
|
45
|
+
if (now() >= deadline) {
|
|
46
|
+
return { ok: false, reason: 'timeout', pids: [...seen].sort((a, b) => a - b), remaining: current.pids };
|
|
47
|
+
}
|
|
48
|
+
await waitFn(100);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function scanSupervisorPids({ run = spawnSync } = {}) {
|
|
52
|
+
try {
|
|
53
|
+
// `-x` includes this user's detached/no-TTY daemons without crossing into other users' processes.
|
|
54
|
+
const r = run('ps', ['-x', '-o', 'pid=,stat=,args='], { encoding: 'utf8' });
|
|
55
|
+
if (!r || r.status !== 0)
|
|
56
|
+
return { ok: false, pids: [] };
|
|
57
|
+
return { ok: true, pids: parseSupervisorPids(r.stdout) };
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return { ok: false, pids: [] };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export function supervisorRestartDelay(restartAttempt, { baseMs = 500, maxMs = 15_000 } = {}) {
|
|
2
|
+
if (!Number.isSafeInteger(restartAttempt) || restartAttempt <= 0)
|
|
3
|
+
return 0;
|
|
4
|
+
return Math.min(baseMs * (2 ** Math.min(restartAttempt - 1, 30)), maxMs);
|
|
5
|
+
}
|
|
6
|
+
export function initialSupervisorComponentState(name) {
|
|
7
|
+
return {
|
|
8
|
+
name,
|
|
9
|
+
phase: 'stopped',
|
|
10
|
+
pid: null,
|
|
11
|
+
restartAttempt: 0,
|
|
12
|
+
restartAt: null,
|
|
13
|
+
startedAt: null,
|
|
14
|
+
readyAt: null,
|
|
15
|
+
lastExitAt: null,
|
|
16
|
+
error: null,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function reduceSupervisorComponent(state, event, backoff) {
|
|
20
|
+
if (event.type === 'spawned') {
|
|
21
|
+
return {
|
|
22
|
+
...state,
|
|
23
|
+
phase: 'starting',
|
|
24
|
+
pid: event.pid,
|
|
25
|
+
restartAt: null,
|
|
26
|
+
startedAt: event.at,
|
|
27
|
+
readyAt: null,
|
|
28
|
+
error: null,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
if (event.type === 'ready') {
|
|
32
|
+
return {
|
|
33
|
+
...state,
|
|
34
|
+
phase: 'ready',
|
|
35
|
+
restartAttempt: 0,
|
|
36
|
+
restartAt: null,
|
|
37
|
+
readyAt: event.at,
|
|
38
|
+
error: null,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (event.type === 'exited') {
|
|
42
|
+
const restartAttempt = state.restartAttempt + 1;
|
|
43
|
+
return {
|
|
44
|
+
...state,
|
|
45
|
+
phase: 'backoff',
|
|
46
|
+
pid: null,
|
|
47
|
+
restartAttempt,
|
|
48
|
+
restartAt: event.at + supervisorRestartDelay(restartAttempt, backoff),
|
|
49
|
+
readyAt: null,
|
|
50
|
+
lastExitAt: event.at,
|
|
51
|
+
error: event.error ?? null,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
...state,
|
|
56
|
+
phase: 'stopped',
|
|
57
|
+
pid: null,
|
|
58
|
+
restartAt: null,
|
|
59
|
+
readyAt: null,
|
|
60
|
+
error: null,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// handmux's terminal rendering depends on `capture-pane -e -N` semantics, and those have drifted
|
|
2
|
+
// across tmux versions before (e.g. how -N pads trailing whitespace). So we check the host's tmux at
|
|
3
|
+
// start: absent → hard error; below the version we've validated → warn (don't block). exec injectable.
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
// Lowest tmux handmux has been validated against. Bump as the CI matrix (see release plan) widens.
|
|
6
|
+
export const MIN_TMUX = '3.0';
|
|
7
|
+
// "tmux 3.6a" / "tmux 3.4" / "tmux next-3.5" / "tmux openbsd-7.4" → {major,minor,suffix,raw}.
|
|
8
|
+
export function parseTmuxVersion(out) {
|
|
9
|
+
const m = /tmux\s+(?:next-|openbsd-)?(\d+)\.(\d+)([a-z]?)/i.exec(String(out || ''));
|
|
10
|
+
if (!m)
|
|
11
|
+
return null;
|
|
12
|
+
const major = Number(m[1] ?? 0);
|
|
13
|
+
const minor = Number(m[2] ?? 0);
|
|
14
|
+
const suffix = m[3] || '';
|
|
15
|
+
return { major, minor, suffix, raw: `${major}.${minor}${suffix}` };
|
|
16
|
+
}
|
|
17
|
+
// v >= min, comparing major.minor only (patch letters don't change the capture behaviour we rely on).
|
|
18
|
+
export function versionAtLeast(v, minStr = MIN_TMUX) {
|
|
19
|
+
if (!v)
|
|
20
|
+
return false;
|
|
21
|
+
const [maj = 0, min = 0] = minStr.split('.').map(Number);
|
|
22
|
+
return v.major > maj || (v.major === maj && v.minor >= min);
|
|
23
|
+
}
|
|
24
|
+
export function checkTmux(exec = spawnSync) {
|
|
25
|
+
const r = exec('tmux', ['-V'], { encoding: 'utf8' });
|
|
26
|
+
if (!r || r.status !== 0 || !r.stdout)
|
|
27
|
+
return { present: false };
|
|
28
|
+
const version = parseTmuxVersion(r.stdout);
|
|
29
|
+
return { present: true, version, ok: versionAtLeast(version), raw: version ? version.raw : String(r.stdout).trim() };
|
|
30
|
+
}
|
|
31
|
+
// In install-command order: probe for the package manager that's actually on this Linux box.
|
|
32
|
+
const LINUX_PKG_MANAGERS = [
|
|
33
|
+
['apt-get', 'sudo apt install tmux'],
|
|
34
|
+
['dnf', 'sudo dnf install tmux'],
|
|
35
|
+
['pacman', 'sudo pacman -S tmux'],
|
|
36
|
+
['zypper', 'sudo zypper install tmux'],
|
|
37
|
+
['apk', 'sudo apk add tmux'],
|
|
38
|
+
['yum', 'sudo yum install tmux'],
|
|
39
|
+
];
|
|
40
|
+
// The exact "install tmux" command for THIS host, so a tmux-less newcomer gets a copy-paste line instead
|
|
41
|
+
// of a dead end. macOS → Homebrew; Linux → whichever package manager is present; Windows → tmux is a
|
|
42
|
+
// Unix tool, so point at WSL. exec/platform injectable for tests.
|
|
43
|
+
export function tmuxInstallHint(exec = spawnSync, platform = process.platform) {
|
|
44
|
+
if (platform === 'darwin')
|
|
45
|
+
return 'brew install tmux';
|
|
46
|
+
if (platform === 'win32')
|
|
47
|
+
return 'tmux is a Unix tool — install WSL (`wsl --install`), then inside it: sudo apt install tmux';
|
|
48
|
+
const has = (bin) => exec('which', [bin], { encoding: 'utf8' }).status === 0;
|
|
49
|
+
for (const [bin, cmd] of LINUX_PKG_MANAGERS)
|
|
50
|
+
if (has(bin))
|
|
51
|
+
return cmd;
|
|
52
|
+
return 'install tmux with your package manager (e.g. `sudo apt install tmux`)';
|
|
53
|
+
}
|
|
@@ -5,18 +5,15 @@ import fs from 'node:fs';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { spawnSync } from 'node:child_process';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
|
-
|
|
9
8
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
10
9
|
const BUNDLED = path.resolve(here, '../../node_modules/.bin/tunlite');
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
10
|
+
export function resolveTunlite({ run = spawnSync, exists = fs.existsSync, bundled = BUNDLED, } = {}) {
|
|
11
|
+
const candidate = exists(bundled) ? bundled : 'tunlite';
|
|
12
|
+
const r = run(candidate, ['--version'], { encoding: 'utf8' });
|
|
13
|
+
if (r && r.status === 0)
|
|
14
|
+
return candidate;
|
|
15
|
+
throw new Error('tunlite not found — install it (npm i -g tunlite / npx tunlite install)');
|
|
17
16
|
}
|
|
18
|
-
|
|
19
|
-
// 0 = passwordless SSH ready; non-zero (tunlite exit 4 = needs-auth) means key setup required.
|
|
20
17
|
export function checkSshAuth(sshHost, { run = spawnSync, bin = 'tunlite' } = {}) {
|
|
21
|
-
|
|
18
|
+
return run(bin, ['check', sshHost], { stdio: 'ignore' }).status;
|
|
22
19
|
}
|