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.
Files changed (327) hide show
  1. package/README.md +31 -8
  2. package/README.zh-CN.md +31 -8
  3. package/bin/handmux.js +14 -688
  4. package/bin/node-version.js +20 -0
  5. package/dist/bin/handmux.js +1015 -0
  6. package/dist/connectors/bridgeClient.js +385 -0
  7. package/dist/connectors/claude/index.js +476 -0
  8. package/dist/connectors/pi/bridgeClient.js +8 -0
  9. package/dist/connectors/pi/index.js +851 -0
  10. package/dist/hooks/handmux-notify.sh +64 -0
  11. package/{hooks → dist/hooks}/handmux-statusline.cjs +28 -6
  12. package/dist/hooks/handmux-write.cjs +264 -0
  13. package/dist/package.json +127 -0
  14. package/dist/public/assets/index-CTrqTqtT.css +32 -0
  15. package/dist/public/assets/index-DmjeMSQA.js +346 -0
  16. package/dist/public/assets/pcm-worklet-CxcDy7PB.js +1 -0
  17. package/{public → dist/public}/index.html +5 -4
  18. package/dist/src/agent-runtime/adapter.js +204 -0
  19. package/dist/src/agent-runtime/bridge.js +1006 -0
  20. package/dist/src/agent-runtime/bridgeStore.js +25 -0
  21. package/dist/src/agent-runtime/bridgeTransport.js +619 -0
  22. package/dist/src/agent-runtime/bridgeTypes.js +13 -0
  23. package/dist/src/agent-runtime/builtinRuntime.js +193 -0
  24. package/dist/src/agent-runtime/conversation.js +2179 -0
  25. package/dist/src/agent-runtime/conversationActivation.js +75 -0
  26. package/dist/src/agent-runtime/conversationActivity.js +69 -0
  27. package/dist/src/agent-runtime/conversationControls.js +206 -0
  28. package/dist/src/agent-runtime/conversationLiveHub.js +276 -0
  29. package/dist/src/agent-runtime/conversationStore.js +352 -0
  30. package/dist/src/agent-runtime/conversationTypes.js +1 -0
  31. package/dist/src/agent-runtime/conversationValidation.js +339 -0
  32. package/dist/src/agent-runtime/inbox.js +973 -0
  33. package/dist/src/agent-runtime/inboxPushProjection.js +69 -0
  34. package/dist/src/agent-runtime/inboxStore.js +25 -0
  35. package/dist/src/agent-runtime/inboxTypes.js +1 -0
  36. package/dist/src/agent-runtime/interaction.js +829 -0
  37. package/dist/src/agent-runtime/interactionLiveHub.js +225 -0
  38. package/dist/src/agent-runtime/interactionStore.js +18 -0
  39. package/dist/src/agent-runtime/interactionTypes.js +1 -0
  40. package/dist/src/agent-runtime/legacyInboxProjection.js +70 -0
  41. package/dist/src/agent-runtime/migrateLegacyCodexOutbox.js +336 -0
  42. package/dist/src/agent-runtime/resourceTypes.js +1 -0
  43. package/dist/src/agent-runtime/resources.js +245 -0
  44. package/dist/src/agent-runtime/run.js +328 -0
  45. package/dist/src/agent-runtime/runtime.js +793 -0
  46. package/dist/src/agent-runtime/sessionControl.js +164 -0
  47. package/dist/src/agent-runtime/subscriptionUsage.js +190 -0
  48. package/dist/src/agent-runtime/subscriptionUsageLegacy.js +18 -0
  49. package/dist/src/agent-runtime/tmuxRuntime.js +156 -0
  50. package/dist/src/agents/claude.js +203 -0
  51. package/dist/src/agents/claudeConversation.js +362 -0
  52. package/dist/src/agents/claudeInteraction.js +134 -0
  53. package/dist/src/agents/claudeSubscriptionUsage.js +61 -0
  54. package/dist/src/agents/codex.js +262 -0
  55. package/dist/src/agents/codexConversation.js +1103 -0
  56. package/dist/src/agents/codexConversationActivation.js +121 -0
  57. package/dist/src/agents/codexConversationControls.js +256 -0
  58. package/dist/src/agents/codexInteraction.js +306 -0
  59. package/dist/src/agents/codexSessionControl.js +118 -0
  60. package/dist/src/agents/codexSubscriptionUsage.js +313 -0
  61. package/dist/src/agents/codexTerminalControl.js +23 -0
  62. package/dist/src/agents/conversationProjectionSafety.js +416 -0
  63. package/dist/src/agents/index.js +39 -0
  64. package/dist/src/agents/nativeInbox.js +264 -0
  65. package/dist/src/agents/nativeInboxSources.js +115 -0
  66. package/dist/src/agents/pi.js +28 -0
  67. package/dist/src/agents/piConversation.js +523 -0
  68. package/dist/src/agents/piConversationHistory.js +502 -0
  69. package/dist/src/agents/piInboxBridge.js +368 -0
  70. package/dist/src/agents/piSessionControl.js +32 -0
  71. package/dist/src/agents/processIdentity.js +94 -0
  72. package/dist/src/agents/scanUtils.js +262 -0
  73. package/dist/src/apiAccountProviders.js +194 -0
  74. package/dist/src/apiAccounts.js +592 -0
  75. package/dist/src/apiErrors.js +86 -0
  76. package/{src → dist/src}/appName.js +12 -13
  77. package/dist/src/asr/iflyConfig.js +7 -0
  78. package/dist/src/asr/iflySign.js +14 -0
  79. package/dist/src/auth.js +27 -0
  80. package/dist/src/browser/bootstrap.js +72 -0
  81. package/dist/src/browser/cookieProfiles.js +413 -0
  82. package/dist/src/browser/coordinator.js +51 -0
  83. package/dist/src/browser/hammerheadCompat.js +104 -0
  84. package/dist/src/browser/hammerheadRedirectCompat.js +37 -0
  85. package/dist/src/browser/manager.js +560 -0
  86. package/dist/src/browser/originLabel.js +15 -0
  87. package/dist/src/browser/profilePersistence.js +235 -0
  88. package/dist/src/browser/publicProxy.js +176 -0
  89. package/dist/src/browser/routes.js +159 -0
  90. package/dist/src/browser/sessionStore.js +72 -0
  91. package/dist/src/browser/siteVersion.js +121 -0
  92. package/dist/src/browser/targetPolicy.js +163 -0
  93. package/{src → dist/src}/browser/worker.js +14 -18
  94. package/dist/src/browser/workerClient.js +286 -0
  95. package/dist/src/browser/workerServer.js +91 -0
  96. package/dist/src/captureBackground.js +95 -0
  97. package/dist/src/claudeEvents.js +525 -0
  98. package/dist/src/cli/agentIntegration.js +131 -0
  99. package/dist/src/cli/agentLauncher.js +26 -0
  100. package/dist/src/cli/claudeHooks.js +290 -0
  101. package/{src → dist/src}/cli/cloudflareUrl.js +2 -3
  102. package/dist/src/cli/cloudflared.js +106 -0
  103. package/dist/src/cli/codexManaged.js +80 -0
  104. package/{src → dist/src}/cli/cpolarUrl.js +8 -9
  105. package/dist/src/cli/drivers.js +98 -0
  106. package/dist/src/cli/hookScaffold.js +39 -0
  107. package/dist/src/cli/i18n/en.js +392 -0
  108. package/{src → dist/src}/cli/i18n/index.js +27 -25
  109. package/dist/src/cli/i18n/zh.js +391 -0
  110. package/dist/src/cli/legacyCodexHooks.js +52 -0
  111. package/{src → dist/src}/cli/natappUrl.js +2 -3
  112. package/dist/src/cli/options.js +245 -0
  113. package/dist/src/cli/piExtension.js +155 -0
  114. package/dist/src/cli/probe.js +16 -0
  115. package/{src → dist/src}/cli/prompt.js +4 -6
  116. package/dist/src/cli/pushCmd.js +102 -0
  117. package/{src → dist/src}/cli/qr.js +19 -19
  118. package/dist/src/cli/service.js +154 -0
  119. package/dist/src/cli/setupModel.js +194 -0
  120. package/dist/src/cli/setupWizard.js +647 -0
  121. package/dist/src/cli/shortcutEditor.js +351 -0
  122. package/dist/src/cli/sshTunnel.js +18 -0
  123. package/dist/src/cli/state.js +117 -0
  124. package/dist/src/cli/statusLine.js +115 -0
  125. package/dist/src/cli/supervisor.js +278 -0
  126. package/dist/src/cli/supervisorHealth.js +21 -0
  127. package/dist/src/cli/supervisorLaunch.js +148 -0
  128. package/dist/src/cli/supervisorProcesses.js +62 -0
  129. package/dist/src/cli/supervisorState.js +62 -0
  130. package/dist/src/cli/tmuxVersion.js +53 -0
  131. package/{src → dist/src}/cli/tunlite.js +7 -10
  132. package/dist/src/cli/tunnelClients.js +72 -0
  133. package/{src → dist/src}/cli/updateCheck.js +98 -69
  134. package/{src → dist/src}/cli/urlHost.js +8 -4
  135. package/dist/src/cli/workspaceCmd.js +347 -0
  136. package/dist/src/codexAppServer.js +3436 -0
  137. package/dist/src/codexConversationProjection.js +199 -0
  138. package/dist/src/codexDiff.js +118 -0
  139. package/dist/src/codexMessageIdentity.js +18 -0
  140. package/dist/src/codexPlan.js +30 -0
  141. package/dist/src/codexQueueProtocol.js +137 -0
  142. package/dist/src/codexStreamProtocol.js +207 -0
  143. package/dist/src/codexToolProtocol.js +68 -0
  144. package/dist/src/codexTranscriptParse.js +835 -0
  145. package/dist/src/codexUsageSnapshot.js +228 -0
  146. package/dist/src/config.js +8 -0
  147. package/{src → dist/src}/docPath.js +22 -24
  148. package/dist/src/docs.js +337 -0
  149. package/dist/src/git.js +284 -0
  150. package/dist/src/healthProtocol.js +97 -0
  151. package/dist/src/httpApi.js +96 -0
  152. package/dist/src/jsonStore.js +15 -0
  153. package/{src → dist/src}/keyNames.js +3 -3
  154. package/dist/src/notifications.js +111 -0
  155. package/dist/src/orphans.js +167 -0
  156. package/dist/src/paneInput.js +42 -0
  157. package/dist/src/pendingPrompt.js +194 -0
  158. package/dist/src/previewServer.js +202 -0
  159. package/dist/src/previews.js +179 -0
  160. package/dist/src/privateStateStore.js +124 -0
  161. package/dist/src/projectTask/backup.js +224 -0
  162. package/dist/src/projectTask/lock.js +108 -0
  163. package/dist/src/projectTask/migrations.js +90 -0
  164. package/dist/src/projectTask/routes.js +155 -0
  165. package/dist/src/projectTask/runtime.js +182 -0
  166. package/dist/src/projectTask/schema.js +56 -0
  167. package/dist/src/projectTask/store.js +457 -0
  168. package/dist/src/push.js +224 -0
  169. package/dist/src/routes/agents.js +631 -0
  170. package/dist/src/routes/apiAccounts.js +82 -0
  171. package/dist/src/routes/files.js +230 -0
  172. package/dist/src/routes/git.js +89 -0
  173. package/dist/src/routes/health.js +16 -0
  174. package/dist/src/routes/notifications.js +16 -0
  175. package/dist/src/routes/previews.js +43 -0
  176. package/dist/src/routes/push.js +145 -0
  177. package/dist/src/routes/sessions.js +244 -0
  178. package/dist/src/routes/system.js +220 -0
  179. package/dist/src/routes/terminal.js +244 -0
  180. package/dist/src/routes/workspace.js +267 -0
  181. package/dist/src/server.js +315 -0
  182. package/dist/src/shortcutConfig.js +83 -0
  183. package/{src → dist/src}/staticCache.js +5 -3
  184. package/{src → dist/src}/staticCompression.js +5 -6
  185. package/dist/src/terminalStream.js +441 -0
  186. package/{src → dist/src}/tmux/commands.js +185 -172
  187. package/dist/src/tmux/format.js +47 -0
  188. package/dist/src/transcriptParse.js +261 -0
  189. package/dist/src/transcriptReader.js +209 -0
  190. package/{src → dist/src}/trimCapture.js +11 -11
  191. package/dist/src/uploadTypes.js +31 -0
  192. package/dist/src/urlPolicy.js +18 -0
  193. package/dist/src/usagePaths.js +15 -0
  194. package/dist/src/workspace/agentRunner.js +301 -0
  195. package/dist/src/workspace/atomicJson.js +31 -0
  196. package/dist/src/workspace/capture.js +139 -0
  197. package/dist/src/workspace/checkpointer.js +228 -0
  198. package/dist/src/workspace/environment.js +71 -0
  199. package/dist/src/workspace/lock.js +137 -0
  200. package/dist/src/workspace/mapping.js +128 -0
  201. package/dist/src/workspace/operations.js +271 -0
  202. package/dist/src/workspace/paths.js +15 -0
  203. package/dist/src/workspace/planner.js +292 -0
  204. package/dist/src/workspace/restore.js +363 -0
  205. package/dist/src/workspace/runtime.js +318 -0
  206. package/dist/src/workspace/schema.js +156 -0
  207. package/dist/src/workspace/store.js +464 -0
  208. package/dist/src/workspace/tmuxAdapter.js +704 -0
  209. package/dist/src/workspaceProtocol.js +158 -0
  210. package/package.json +30 -17
  211. package/hooks/handmux-notify.sh +0 -22
  212. package/hooks/handmux-write.cjs +0 -78
  213. package/public/assets/index-C3N7OkyP.css +0 -32
  214. package/public/assets/index-D1DWcC5u.js +0 -337
  215. package/src/.gitkeep +0 -0
  216. package/src/agents/claude.js +0 -159
  217. package/src/agents/codex.js +0 -168
  218. package/src/agents/index.js +0 -21
  219. package/src/agents/processIdentity.js +0 -72
  220. package/src/agents/scanUtils.js +0 -225
  221. package/src/asr/iflyConfig.js +0 -10
  222. package/src/asr/iflySign.js +0 -16
  223. package/src/auth.js +0 -30
  224. package/src/browser/bootstrap.js +0 -68
  225. package/src/browser/cookieProfiles.js +0 -403
  226. package/src/browser/coordinator.js +0 -47
  227. package/src/browser/hammerheadCompat.js +0 -86
  228. package/src/browser/hammerheadRedirectCompat.js +0 -38
  229. package/src/browser/manager.js +0 -556
  230. package/src/browser/originLabel.js +0 -17
  231. package/src/browser/profilePersistence.js +0 -241
  232. package/src/browser/publicProxy.js +0 -175
  233. package/src/browser/routes.js +0 -154
  234. package/src/browser/sessionStore.js +0 -78
  235. package/src/browser/siteVersion.js +0 -124
  236. package/src/browser/targetPolicy.js +0 -137
  237. package/src/browser/workerClient.js +0 -253
  238. package/src/browser/workerServer.js +0 -98
  239. package/src/captureBackground.js +0 -93
  240. package/src/claudeEvents.js +0 -399
  241. package/src/cli/claudeHooks.js +0 -206
  242. package/src/cli/cloudflared.js +0 -99
  243. package/src/cli/codexManaged.js +0 -77
  244. package/src/cli/drivers.js +0 -97
  245. package/src/cli/hookScaffold.js +0 -33
  246. package/src/cli/i18n/en.js +0 -385
  247. package/src/cli/i18n/zh.js +0 -384
  248. package/src/cli/legacyCodexHooks.js +0 -49
  249. package/src/cli/options.js +0 -211
  250. package/src/cli/probe.js +0 -16
  251. package/src/cli/pushCmd.js +0 -60
  252. package/src/cli/service.js +0 -126
  253. package/src/cli/setupModel.js +0 -154
  254. package/src/cli/setupWizard.js +0 -506
  255. package/src/cli/shortcutEditor.js +0 -288
  256. package/src/cli/sshTunnel.js +0 -12
  257. package/src/cli/state.js +0 -90
  258. package/src/cli/statusLine.js +0 -90
  259. package/src/cli/supervisor.js +0 -183
  260. package/src/cli/supervisorProcesses.js +0 -57
  261. package/src/cli/tmuxVersion.js +0 -49
  262. package/src/cli/tunnelClients.js +0 -62
  263. package/src/cli/workspaceCmd.js +0 -300
  264. package/src/codexAppServer.js +0 -1620
  265. package/src/codexDiff.js +0 -108
  266. package/src/codexTranscriptParse.js +0 -413
  267. package/src/codexUsageSnapshot.cjs +0 -129
  268. package/src/config.js +0 -9
  269. package/src/docs.js +0 -228
  270. package/src/git.js +0 -208
  271. package/src/httpApi.js +0 -72
  272. package/src/jsonStore.js +0 -24
  273. package/src/notifications.js +0 -68
  274. package/src/orphans.js +0 -152
  275. package/src/pendingPrompt.js +0 -163
  276. package/src/previewServer.js +0 -168
  277. package/src/previews.js +0 -116
  278. package/src/push.js +0 -183
  279. package/src/routes/codex.js +0 -473
  280. package/src/routes/files.js +0 -172
  281. package/src/routes/git.js +0 -57
  282. package/src/routes/notifications.js +0 -19
  283. package/src/routes/previews.js +0 -35
  284. package/src/routes/push.js +0 -119
  285. package/src/routes/sessions.js +0 -168
  286. package/src/routes/system.js +0 -132
  287. package/src/routes/terminal.js +0 -210
  288. package/src/routes/transcript.js +0 -171
  289. package/src/routes/workspace.js +0 -267
  290. package/src/server.js +0 -186
  291. package/src/shortcutConfig.js +0 -75
  292. package/src/terminalStream.js +0 -391
  293. package/src/tmux/format.js +0 -45
  294. package/src/transcriptParse.js +0 -180
  295. package/src/transcriptReader.js +0 -101
  296. package/src/uploadTypes.js +0 -30
  297. package/src/urlPolicy.js +0 -14
  298. package/src/usage.js +0 -260
  299. package/src/workspace/agentRunner.js +0 -188
  300. package/src/workspace/atomicJson.js +0 -32
  301. package/src/workspace/capture.js +0 -139
  302. package/src/workspace/checkpointer.js +0 -187
  303. package/src/workspace/environment.js +0 -75
  304. package/src/workspace/lock.js +0 -128
  305. package/src/workspace/mapping.js +0 -124
  306. package/src/workspace/operations.js +0 -252
  307. package/src/workspace/paths.js +0 -16
  308. package/src/workspace/planner.js +0 -247
  309. package/src/workspace/restore.js +0 -279
  310. package/src/workspace/runtime.js +0 -314
  311. package/src/workspace/schema.js +0 -136
  312. package/src/workspace/store.js +0 -382
  313. package/src/workspace/tmuxAdapter.js +0 -554
  314. /package/{public → dist/public}/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
  315. /package/{public → dist/public}/fonts/TWUnifont.woff2 +0 -0
  316. /package/{public → dist/public}/icons/apple-touch-icon.png +0 -0
  317. /package/{public → dist/public}/icons/badge-96.png +0 -0
  318. /package/{public → dist/public}/icons/icon-192-maskable.png +0 -0
  319. /package/{public → dist/public}/icons/icon-192.png +0 -0
  320. /package/{public → dist/public}/icons/icon-512-maskable.png +0 -0
  321. /package/{public → dist/public}/icons/icon-512.png +0 -0
  322. /package/{public → dist/public}/icons/logo.svg +0 -0
  323. /package/{public → dist/public}/manifest.webmanifest +0 -0
  324. /package/{public → dist/public}/offline.html +0 -0
  325. /package/{public → dist/public}/sw.js +0 -0
  326. /package/{src → dist/src}/browser/protocol.js +0 -0
  327. /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
- export function resolveTunlite({ run = spawnSync, exists = fs.existsSync, bundled = BUNDLED } = {}) {
13
- const candidate = exists(bundled) ? bundled : 'tunlite';
14
- const r = run(candidate, ['--version'], { encoding: 'utf8' });
15
- if (r && r.status === 0) return candidate;
16
- throw new Error('tunlite not found — install it (npm i -g tunlite / npx tunlite install)');
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
- return run(bin, ['check', sshHost], { stdio: 'ignore' }).status;
18
+ return run(bin, ['check', sshHost], { stdio: 'ignore' }).status;
22
19
  }