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
@@ -1,206 +0,0 @@
1
- // Install/uninstall the Claude Code lifecycle hooks that feed the handmux inbox. This ports the idempotent
2
- // merge from scripts/install-hooks.sh into testable JS so the OSS CLI (and the phone, via the server) can
3
- // turn the inbox on — opt-in, since writing ~/.claude/settings.json edits another tool's config.
4
- //
5
- // Iron rule: only ever touch ~/.handmux/ and — after explicit opt-in — ~/.claude/. If ~/.claude is absent
6
- // (no Claude Code), skip and report 'no-claude'; never create it.
7
- import fs from 'node:fs';
8
- import path from 'node:path';
9
- import { homedir } from 'node:os';
10
- import { spawnSync } from 'node:child_process';
11
- import { writeJsonAtomic, deployHookScripts, removeHookScripts } from './hookScaffold.js';
12
-
13
- // The seven events the inbox reads. src is the arg passed to handmux-notify.sh; only PostToolUse is scoped to
14
- // a matcher (the two "需要你" interaction tools) — every other Read/Bash/Edit must NOT wake the hook, or
15
- // many concurrent Claude panes would each spawn the hook on every tool call. Keep this table in sync with
16
- // the hook scripts in ../../hooks.
17
- // SessionStart binds the pane→session mapping the instant a session begins — critically after /clear, which
18
- // starts a NEW transcript file. Without it, /clear's SessionEnd(old) drops the pane and nothing rebinds it
19
- // until the next prompt, so the 对话 lens goes blank / falls back to the ambiguous cwd→newest-jsonl guess.
20
- // Fires only at session boundaries (startup/clear/resume), never per tool call, so it adds no hot-path load;
21
- // classified neutral (no push, no roster entry — see agents/claude.js). Base, not version-gated: SessionStart
22
- // is a long-standing lifecycle hook every supported Claude recognises.
23
- export const HOOK_EVENTS = [
24
- { event: 'Stop', src: 'stop' },
25
- { event: 'Notification', src: 'notify' },
26
- { event: 'UserPromptSubmit', src: 'prompt' },
27
- { event: 'SessionStart', src: 'start' },
28
- { event: 'SessionEnd', src: 'end' },
29
- { event: 'PostToolUse', src: 'resume', matcher: 'AskUserQuestion|ExitPlanMode' },
30
- { event: 'PermissionRequest', src: 'permreq' },
31
- ];
32
-
33
- // Version-gated events, only registered on a Claude Code new enough to EMIT them. Older Claude does not
34
- // recognise these event names — and the docs give NO guarantee it ignores unknown ones (it "likely" does,
35
- // but might reject the settings file) — so we NEVER write an event a version can't handle. Each carries a
36
- // minVersion; below it (or when the version can't be detected) the event isn't written at all → pure
37
- // downgrade, never an error. The pane instead self-heals via the existing idle_prompt(~60s) fallback.
38
- // PreCompact → 压缩中 shown while compaction runs (honest progress for a slow op).
39
- // PostCompact → clears the 压缩中/进行中 state the instant compaction finishes.
40
- // StopFailure → a turn that died on an API error (rate limit / overload / …) fires NO Stop, so without
41
- // this the pane sticks at 进行中 forever; maps to an 'error' state.
42
- // `pairWith` welds an invariant (learned the hard way — never light a state you can't turn off): PreCompact
43
- // is installed ONLY when its clearer PostCompact is also installed. minVersion is set conservatively to the
44
- // version we've actually verified emits these (2.1.207); lower it only after test-firing an older build.
45
- const COMPACT_MIN = '2.1.207';
46
- export const HOOK_EVENTS_EXT = [
47
- { event: 'PostCompact', src: 'compact', minVersion: COMPACT_MIN },
48
- { event: 'PreCompact', src: 'compacting', minVersion: COMPACT_MIN, pairWith: 'PostCompact' },
49
- { event: 'StopFailure', src: 'stopfail', minVersion: COMPACT_MIN },
50
- ];
51
-
52
- const HOOK_MARK = 'handmux-notify.sh'; // identifies our hooks among the user's own
53
-
54
- // Parse `claude --version` output ("2.1.207 (Claude Code)") → { major, minor, patch } | null.
55
- export function parseClaudeVersion(out) {
56
- const m = /(\d+)\.(\d+)\.(\d+)/.exec(String(out || ''));
57
- return m ? { major: +m[1], minor: +m[2], patch: +m[3] } : null;
58
- }
59
-
60
- // v >= min ("X.Y.Z"), full major.minor.patch compare. A null/undefined version is always below → fail-closed.
61
- export function claudeVersionAtLeast(v, minStr) {
62
- if (!v) return false;
63
- const [a, b, c] = String(minStr).split('.').map(Number);
64
- if (v.major !== a) return v.major > a;
65
- if (v.minor !== b) return v.minor > b;
66
- return v.patch >= c;
67
- }
68
-
69
- // Detect the installed Claude Code version, or null if `claude` can't be run/parsed (→ ext hooks skipped).
70
- export function detectClaudeVersion(exec = spawnSync) {
71
- try {
72
- const r = exec('claude', ['--version'], { encoding: 'utf8', timeout: 4000 });
73
- if (!r || r.status !== 0 || !r.stdout) return null;
74
- return parseClaudeVersion(r.stdout);
75
- } catch { return null; }
76
- }
77
-
78
- // Drop OUR hook from settings.hooks[event] (used to prune an ext event that no longer passes the version
79
- // gate — e.g. Claude was downgraded after a newer install). Mutates the passed hooks object.
80
- function dropOurHook(hooks, event) {
81
- if (!hooks[event]) return;
82
- const kept = hooks[event]
83
- .map((g) => ({ ...g, hooks: (g.hooks || []).filter((h) => !(typeof h.command === 'string' && h.command.includes(HOOK_MARK))) }))
84
- .filter((g) => (g.hooks || []).length > 0);
85
- if (kept.length) hooks[event] = kept; else delete hooks[event];
86
- }
87
-
88
- // True if `settings.hooks[event]` already has one of our hooks (command references the dest script).
89
- function alreadyHas(hooks, event) {
90
- return (hooks[event] || []).some((g) => (g.hooks || []).some(
91
- (h) => typeof h.command === 'string' && h.command.includes(HOOK_MARK)));
92
- }
93
-
94
- // Merge one event's hook group into `hooks` (mutates), idempotently — a no-op if one of ours is already
95
- // registered for that event, so it's safe to call on every install/sync. Shared by mergeHooks and syncHooks.
96
- function addHook(hooks, e, dest) {
97
- if (alreadyHas(hooks, e.event)) return;
98
- const groups = hooks[e.event] = [...(hooks[e.event] || [])];
99
- groups.push({ matcher: e.matcher || '', hooks: [{ type: 'command', command: `${dest} ${e.src}`, async: true, timeout: 5 }] });
100
- }
101
-
102
- // Pure: return a NEW settings object with our six hooks merged into settings.hooks, idempotently, leaving
103
- // the user's own hooks and other keys untouched. `dest` is the absolute path to the copied notify script.
104
- export function mergeHooks(settings, dest, claudeVersion = null) {
105
- const s = { ...(settings || {}) };
106
- const hooks = (s.hooks && typeof s.hooks === 'object' && !Array.isArray(s.hooks)) ? { ...s.hooks } : {};
107
- for (const e of HOOK_EVENTS) addHook(hooks, e, dest);
108
- // Version-gated ext events: install only when the detected Claude is new enough AND (for a paired event)
109
- // its clearer is also being installed. Anything that fails the gate is actively PRUNED, so a Claude
110
- // downgrade after a newer install can't leave an unrecognised event name lingering in settings.json.
111
- const enabled = new Set();
112
- for (const e of HOOK_EVENTS_EXT) {
113
- const ok = claudeVersionAtLeast(claudeVersion, e.minVersion) && (!e.pairWith || enabled.has(e.pairWith));
114
- if (ok) { enabled.add(e.event); addHook(hooks, e, dest); } else dropOurHook(hooks, e.event);
115
- }
116
- s.hooks = hooks;
117
- return s;
118
- }
119
-
120
- // Pure: return a NEW settings object with all of OUR hooks removed (uninstall). An event group that ends up
121
- // empty is dropped; the user's own hooks and other keys are untouched.
122
- export function stripHooks(settings) {
123
- const s = { ...(settings || {}) };
124
- if (!s.hooks || typeof s.hooks !== 'object' || Array.isArray(s.hooks)) return s;
125
- const hooks = {};
126
- for (const [event, groups] of Object.entries(s.hooks)) {
127
- const kept = (groups || [])
128
- .map((g) => ({ ...g, hooks: (g.hooks || []).filter((h) => !(typeof h.command === 'string' && h.command.includes(HOOK_MARK))) }))
129
- .filter((g) => (g.hooks || []).length > 0);
130
- if (kept.length) hooks[event] = kept;
131
- }
132
- s.hooks = hooks;
133
- return s;
134
- }
135
-
136
- // Path helpers (also used by the IO shell in the next task).
137
- function claudeDir(home = homedir()) { return path.join(home, '.claude'); }
138
- function settingsPath(home = homedir()) { return path.join(claudeDir(home), 'settings.json'); }
139
-
140
- // 'no-claude' → ~/.claude absent (don't prompt to enable). 'installed' → our hooks present. 'absent' →
141
- // Claude Code is here but our hooks aren't (offer to enable).
142
- export function hooksStatus(home = homedir()) {
143
- if (!fs.existsSync(claudeDir(home))) return 'no-claude';
144
- let settings = {};
145
- try { settings = JSON.parse(fs.readFileSync(settingsPath(home), 'utf8')); } catch { /* missing/corrupt → {} */ }
146
- const hooks = (settings && settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks)) ? settings.hooks : {};
147
- return Object.keys(hooks).some((ev) => alreadyHas(hooks, ev)) ? 'installed' : 'absent';
148
- }
149
-
150
- // Install (opt-in): copy the bundled hook scripts to ~/.claude/hooks/, write the env pointing at the state
151
- // file, and merge our six hooks into settings.json. Returns { status }. NEVER creates ~/.claude — if it's
152
- // absent the user doesn't run Claude Code, so we report 'no-claude' and do nothing.
153
- // srcDir = the bundled hooks dir (server/hooks)
154
- // stateFile = the unified ~/.handmux/claude-state.json path the hook writes and the server reads
155
- export function installHooks(home = homedir(), { srcDir, stateFile, claudeVersion } = {}) {
156
- if (!fs.existsSync(claudeDir(home))) return { status: 'no-claude' };
157
- const hooksDir = path.join(claudeDir(home), 'hooks');
158
- deployHookScripts(hooksDir, srcDir, stateFile);
159
-
160
- const dest = path.join(hooksDir, 'handmux-notify.sh');
161
- let settings = {};
162
- try { settings = JSON.parse(fs.readFileSync(settingsPath(home), 'utf8')); } catch { /* missing/corrupt → {} */ }
163
- // Gate the version-specific events (compact pair, StopFailure) on the installed Claude. Detect when the
164
- // caller didn't inject a version; a null result (no `claude` / unparseable) is fail-closed = base 6 only.
165
- const version = claudeVersion !== undefined ? claudeVersion : detectClaudeVersion();
166
- writeJsonAtomic(settingsPath(home), mergeHooks(settings, dest, version));
167
- return { status: 'installed' };
168
- }
169
-
170
- // Keep an ALREADY-installed user's hooks in step with this handmux version on every server start, so a plain
171
- // `./deploy.sh` (restart) rolls out newly-added lifecycle events (e.g. SessionStart) and refreshed hook
172
- // scripts — no phone re-enable needed. Two moves: (1) re-deploy the bundled hook scripts (idempotent — picks
173
- // up a fixed handmux-write.cjs), and (2) add any of our BASE events a prior install predates.
174
- //
175
- // Strictly opt-in-preserving: a NO-OP unless our hooks are already present ('installed'). It never enables
176
- // hooks for a user who hasn't opted in ('absent') and never creates ~/.claude ('no-claude'). BASE events
177
- // only — it deliberately does NOT touch the version-gated ext events (compact pair / StopFailure): reconciling
178
- // those needs the Claude version (a null read would PRUNE the ext hooks a user already has), and detecting it
179
- // means spawning `claude --version` on the hot startup path. So sync stays pure-fs and non-pruning; ext-event
180
- // rollout stays with the explicit installHooks (phone re-enable) path. settings.json is rewritten only when
181
- // the merge actually changes it, so a steady state writes nothing.
182
- export function syncHooks(home = homedir(), { srcDir, stateFile } = {}) {
183
- const status = hooksStatus(home);
184
- if (status !== 'installed') return { status, changed: false };
185
- const hooksDir = path.join(claudeDir(home), 'hooks');
186
- deployHookScripts(hooksDir, srcDir, stateFile); // refresh the deployed scripts (idempotent)
187
- const dest = path.join(hooksDir, 'handmux-notify.sh');
188
- let settings = {};
189
- try { settings = JSON.parse(fs.readFileSync(settingsPath(home), 'utf8')); } catch { /* missing/corrupt → {} */ }
190
- const hooks = (settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks)) ? { ...settings.hooks } : {};
191
- const before = JSON.stringify(hooks);
192
- for (const e of HOOK_EVENTS) addHook(hooks, e, dest); // add only MISSING base events; never add/prune ext
193
- const changed = JSON.stringify(hooks) !== before;
194
- if (changed) writeJsonAtomic(settingsPath(home), { ...settings, hooks });
195
- return { status: 'installed', changed };
196
- }
197
-
198
- // Uninstall: strip our hooks from settings.json and remove the copied scripts/env. Best-effort on the file
199
- // deletes (a missing file is fine). Leaves ~/.claude and the user's own hooks intact.
200
- export function uninstallHooks(home = homedir()) {
201
- let settings = {};
202
- try { settings = JSON.parse(fs.readFileSync(settingsPath(home), 'utf8')); } catch { /* nothing to strip */ }
203
- if (fs.existsSync(settingsPath(home))) writeJsonAtomic(settingsPath(home), stripHooks(settings));
204
- removeHookScripts(path.join(claudeDir(home), 'hooks'));
205
- return { status: 'absent' };
206
- }
@@ -1,99 +0,0 @@
1
- // Resolve a usable `cloudflared` binary so `--tunnel cloudflare` is truly one-click (no brew/manual
2
- // install). Order: $PATH → ~/.handmux/bin/ → download the latest release for this OS/arch from GitHub.
3
- // `which`/`fetchImpl` are injectable so the pure mapping (assetFor) unit-tests offline.
4
- import fs from 'node:fs';
5
- import path from 'node:path';
6
- import { spawnSync } from 'node:child_process';
7
- import { pocketHome } from './state.js';
8
- import { t } from './i18n/index.js';
9
-
10
- const fmtMB = (n) => (n / 1048576).toFixed(1);
11
-
12
- // Stream a fetch response into a Buffer, reporting progress per chunk as onProgress(received, total).
13
- // `total` is the content-length (0 if the server didn't send one). Falls back to arrayBuffer() when the
14
- // response isn't a readable stream (older/mock fetch), so callers still get the bytes.
15
- export async function drain(res, onProgress) {
16
- const total = Number(res.headers?.get?.('content-length')) || 0;
17
- if (!res.body?.getReader) {
18
- const b = Buffer.from(await res.arrayBuffer());
19
- onProgress?.(b.length, total || b.length);
20
- return b;
21
- }
22
- const reader = res.body.getReader();
23
- const chunks = [];
24
- let received = 0;
25
- for (;;) {
26
- const { done, value } = await reader.read();
27
- if (done) break;
28
- const c = Buffer.from(value);
29
- chunks.push(c);
30
- received += c.length;
31
- onProgress?.(received, total);
32
- }
33
- return Buffer.concat(chunks);
34
- }
35
-
36
- // Default progress renderer: a single \r-updated line on a TTY (throttled to ~10/s), e.g.
37
- // ` cloudflared 45% (9.2/20.4 MB)`. Non-TTY (piped/logged) gets nothing — the start line already
38
- // announced the download and a spammy animation would just fill the log with control chars.
39
- export function defaultProgress(out = process.stdout, name = 'cloudflared') {
40
- if (!out.isTTY) return () => {};
41
- let last = 0;
42
- return (received, total) => {
43
- const done = total && received >= total;
44
- const now = Date.now();
45
- if (!done && now - last < 100) return;
46
- last = now;
47
- const body = total
48
- ? `${Math.floor((received / total) * 100)}% (${fmtMB(received)}/${fmtMB(total)} MB)`
49
- : `${fmtMB(received)} MB`;
50
- out.write(`\r ${name} ${body} `);
51
- if (done) out.write('\n');
52
- };
53
- }
54
-
55
- // Map Node's platform/arch to cloudflared's release asset. Linux/Windows ship a bare binary; macOS
56
- // ships a .tgz that contains a `cloudflared` executable.
57
- export function assetFor(platform = process.platform, arch = process.arch) {
58
- const a = { x64: 'amd64', arm64: 'arm64', arm: 'arm', ia32: '386' }[arch] || arch;
59
- if (platform === 'darwin') return { file: `cloudflared-darwin-${a}.tgz`, archive: 'tgz', bin: 'cloudflared' };
60
- if (platform === 'win32') return { file: `cloudflared-windows-${a}.exe`, archive: null, bin: 'cloudflared.exe' };
61
- return { file: `cloudflared-linux-${a}`, archive: null, bin: 'cloudflared' };
62
- }
63
-
64
- export function onPath(exec = 'cloudflared') {
65
- const finder = process.platform === 'win32' ? 'where' : 'which';
66
- const r = spawnSync(finder, [exec], { encoding: 'utf8' });
67
- return r.status === 0 ? String(r.stdout).trim().split(/\r?\n/)[0] : null;
68
- }
69
-
70
- export async function resolveCloudflared(home, { which = onPath, fetchImpl, log = console, progress } = {}) {
71
- const found = which('cloudflared');
72
- if (found) return found;
73
-
74
- const dir = path.join(pocketHome(home), 'bin');
75
- const asset = assetFor();
76
- const dest = path.join(dir, asset.bin);
77
- if (fs.existsSync(dest)) return dest;
78
-
79
- const doFetch = fetchImpl || globalThis.fetch;
80
- if (!doFetch) throw new Error('no fetch available to download cloudflared (Node 18+ required)');
81
- fs.mkdirSync(dir, { recursive: true });
82
- const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/${asset.file}`;
83
- log.log?.(t('cf.downloading', { file: asset.file }));
84
- const res = await doFetch(url, { redirect: 'follow' });
85
- if (!res.ok) throw new Error(`cloudflared download failed: HTTP ${res.status} (${url})`);
86
- const buf = await drain(res, progress || defaultProgress(process.stdout));
87
-
88
- if (asset.archive === 'tgz') {
89
- const tmp = path.join(dir, asset.file);
90
- fs.writeFileSync(tmp, buf);
91
- const r = spawnSync('tar', ['xzf', tmp, '-C', dir], { encoding: 'utf8' });
92
- fs.unlinkSync(tmp);
93
- if (r.status !== 0) throw new Error(`failed to extract cloudflared: ${r.stderr || r.status}`);
94
- } else {
95
- fs.writeFileSync(dest, buf);
96
- }
97
- fs.chmodSync(dest, 0o755);
98
- return dest;
99
- }
@@ -1,77 +0,0 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import os from 'node:os';
4
- import { spawn as spawnChild } from 'node:child_process';
5
-
6
- const PANE_RE = /^%\d+$/;
7
-
8
- export function codexAppSocketPath(pane, home = os.homedir()) {
9
- if (!PANE_RE.test(pane || '')) throw new Error('managed Codex requires a tmux pane');
10
- return path.join(home, '.handmux', 'codex-app', `${pane.slice(1)}.sock`);
11
- }
12
-
13
- function waitForSocket(socketPath, child, {
14
- exists = fs.existsSync,
15
- wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
16
- timeoutMs = 5_000,
17
- now = Date.now,
18
- } = {}) {
19
- return new Promise((resolve, reject) => {
20
- let exited = false;
21
- let exitCode = null;
22
- child.once('exit', (code) => { exited = true; exitCode = code; });
23
- child.once('error', reject);
24
- (async () => {
25
- const started = now();
26
- while (!exists(socketPath)) {
27
- if (exited) throw new Error(`Codex App Server exited before startup (${exitCode ?? 'unknown'})`);
28
- if (now() - started >= timeoutMs) throw new Error('Codex App Server startup timed out');
29
- await wait(25);
30
- }
31
- resolve();
32
- })().catch(reject);
33
- });
34
- }
35
-
36
- function finish(child) {
37
- return new Promise((resolve, reject) => {
38
- child.once('error', reject);
39
- child.once('exit', (code, signal) => resolve({ code, signal }));
40
- });
41
- }
42
-
43
- // One App Server belongs to one tmux pane. The TUI and Handmux connect to that pane-owned Unix socket,
44
- // so thread identity, messages, approvals, settings, interrupts and inbox state share one runtime.
45
- export async function runManagedCodex(args = [], {
46
- home = os.homedir(),
47
- env = process.env,
48
- spawn = spawnChild,
49
- mkdir = (dir) => fs.mkdirSync(dir, { recursive: true, mode: 0o700 }),
50
- unlink = (file) => { try { fs.unlinkSync(file); } catch (error) { if (error?.code !== 'ENOENT') throw error; } },
51
- waitOptions,
52
- } = {}) {
53
- const pane = env.TMUX_PANE;
54
- const socketPath = codexAppSocketPath(pane, home);
55
- mkdir(path.dirname(socketPath));
56
- unlink(socketPath);
57
-
58
- const managedEnv = { ...env, HANDMUX_CODEX_MANAGED: '1' };
59
- const appServer = spawn('codex', ['app-server', '--listen', `unix://${socketPath}`], {
60
- env: managedEnv,
61
- stdio: ['ignore', 'ignore', 'ignore'],
62
- });
63
- let tui = null;
64
- try {
65
- await waitForSocket(socketPath, appServer, waitOptions);
66
- tui = spawn('codex', ['--remote', `unix://${socketPath}`, ...args], {
67
- env: managedEnv,
68
- stdio: 'inherit',
69
- });
70
- const result = await finish(tui);
71
- return Number.isInteger(result.code) ? result.code : 1;
72
- } finally {
73
- if (tui && tui.exitCode == null && tui.signalCode == null) tui.kill('SIGTERM');
74
- if (appServer.exitCode == null && appServer.signalCode == null) appServer.kill('SIGTERM');
75
- unlink(socketPath);
76
- }
77
- }
@@ -1,97 +0,0 @@
1
- // Tunnel driver registry. A driver only DESCRIBES how to expose the local server (what process to spawn,
2
- // how to read the public URL out of its output); the supervisor owns process lifecycle (spawn, restart,
3
- // kill). Declarative → unit-testable without spawning. matchUrl receives (chunk, cfg): cloudflare scrapes
4
- // a random URL from logs; ssh / cloudflare-named already KNOW the URL (cfg.publicUrl) and only gate it on
5
- // a readiness signal so the QR isn't shown before the tunnel is live.
6
- import { extractCloudflareUrl } from './cloudflareUrl.js';
7
- import { isTunnelConnected } from './sshTunnel.js';
8
- import { cfNamedReady } from './cfNamed.js';
9
- import { extractNatappUrl } from './natappUrl.js';
10
- import { extractCpolarUrl, cpolarNamedArgs } from './cpolarUrl.js';
11
- import { hostIn } from './urlHost.js';
12
-
13
- export const DRIVERS = {
14
- none: {
15
- name: 'none',
16
- needsProcess: false,
17
- proc: () => null,
18
- matchUrl: () => null,
19
- },
20
- cloudflare: {
21
- name: 'cloudflare',
22
- needsProcess: true,
23
- notFoundHint: 'cloudflared not found — install it (brew install cloudflared)',
24
- proc: (cfg) => ({
25
- cmd: cfg.cloudflaredBin || 'cloudflared',
26
- // --grace-period 0s: drop the edge connection immediately on SIGTERM instead of draining for the 30s
27
- // default, so `stop`/`restart` don't leave the tunnel lingering on Cloudflare's side (it would look
28
- // "still running" remotely and overlap a restart). See supervisor.shutdown for the local-process side.
29
- args: ['tunnel', '--grace-period', '0s', '--url', `http://localhost:${cfg.port}`],
30
- }),
31
- matchUrl: (chunk) => extractCloudflareUrl(chunk),
32
- },
33
- 'cloudflare-named': {
34
- name: 'cloudflare-named',
35
- needsProcess: true,
36
- notFoundHint: 'cloudflared not found — run `handmux setup` to provision the named tunnel',
37
- proc: (cfg) => ({
38
- cmd: cfg.cloudflaredBin || 'cloudflared',
39
- // --grace-period 0s goes BEFORE the `run` subcommand (it's a `cloudflared tunnel` flag). Same reason as
40
- // the quick tunnel: disconnect immediately on stop so the named tunnel doesn't linger on the edge.
41
- args: ['tunnel', '--grace-period', '0s', 'run', cfg.cfTunnelName],
42
- }),
43
- matchUrl: (chunk, cfg) => (cfNamedReady(chunk) ? cfg.publicUrl : null),
44
- },
45
- ssh: {
46
- name: 'ssh',
47
- needsProcess: true,
48
- notFoundHint: 'tunlite not found — install it (npm i -g tunlite / npx tunlite install)',
49
- proc: (cfg) => ({
50
- cmd: cfg.tunliteBin || 'tunlite',
51
- args: ['run', '--to', cfg.sshHost, '-R', `${cfg.remotePort}:localhost:${cfg.port}`,
52
- '--name', 'handmux', '--json', ...(cfg.sshJump ? ['--jump', cfg.sshJump] : [])],
53
- }),
54
- matchUrl: (chunk, cfg) => (isTunnelConnected(chunk) ? cfg.publicUrl : null),
55
- },
56
- // natapp / cpolar — ngrok-derived domestic tunnels (China-usable when cloudflare's edge isn't). Both take a
57
- // stable `authtoken`; both default to a full-screen TUI, so we force `-log=stdout` to get scrapeable text.
58
- // FREE tier = random domain we scrape; a fixed/reserved domain arrives via publicUrl (see cfg.publicUrl).
59
- natapp: {
60
- name: 'natapp',
61
- needsProcess: true,
62
- notFoundHint: 'natapp not found — download it from https://natapp.cn (login required) into ~/.handmux/bin/',
63
- proc: (cfg) => ({
64
- cmd: cfg.natappBin || 'natapp',
65
- args: [`-authtoken=${cfg.authtoken}`, '-log=stdout'],
66
- }),
67
- // The reserved/paid zone is arbitrary (natapp1.cc / your own) so we can't scrape it — gate on the known
68
- // host echoing in the log. Free tier has no publicUrl → scrape the natappfree.cc URL.
69
- matchUrl: (chunk, cfg) => (cfg.publicUrl
70
- ? (hostIn(chunk, cfg.publicUrl) ? cfg.publicUrl : null)
71
- : extractNatappUrl(chunk)),
72
- },
73
- cpolar: {
74
- name: 'cpolar',
75
- needsProcess: true,
76
- notFoundHint: 'cpolar not found — install it (or run `handmux setup`, which auto-downloads it)',
77
- proc: (cfg) => ({
78
- cmd: cfg.cpolarBin || 'cpolar',
79
- // authtoken is seeded into cpolar's own config beforehand (handmux.js / setup); a reserved subdomain or
80
- // bound domain becomes -subdomain/-hostname; -region selects the edge (cn = mainland China).
81
- args: ['http', '-log=stdout',
82
- ...cpolarNamedArgs(cfg.publicUrl),
83
- ...(cfg.cpolarRegion ? [`-region=${cfg.cpolarRegion}`] : []),
84
- String(cfg.port)],
85
- }),
86
- // A reserved subdomain is still on the cpolar zone, so scrape it (learns the region-qualified host cpolar
87
- // serves); a bound custom domain is off-zone → gate on its known host from publicUrl.
88
- matchUrl: (chunk, cfg) => extractCpolarUrl(chunk)
89
- || (cfg.publicUrl && hostIn(chunk, cfg.publicUrl) ? cfg.publicUrl : null),
90
- },
91
- };
92
-
93
- export function getDriver(name) {
94
- const d = DRIVERS[name];
95
- if (!d) throw new Error(`unknown tunnel: ${name}`);
96
- return d;
97
- }
@@ -1,33 +0,0 @@
1
- // Shared IO scaffolding for the Claude Code hook installer and statusLine capturer.
2
- import fs from 'node:fs';
3
- import path from 'node:path';
4
-
5
- export const HOOK_SCRIPTS = ['handmux-notify.sh', 'handmux-write.cjs'];
6
- const LEGACY_HOOK_SCRIPTS = ['handmux-codex-usage.cjs'];
7
-
8
- // Atomic write (tmp + rename) so a crash can't leave a half-written config file. Text in, text out — callers
9
- // pass raw TOML for config.toml, or use writeJsonAtomic for pretty-printed settings.json.
10
- export function writeFileAtomic(file, text) {
11
- const tmp = `${file}.tmp`;
12
- fs.writeFileSync(tmp, text);
13
- fs.renameSync(tmp, file);
14
- }
15
- export const writeJsonAtomic = (file, obj) => writeFileAtomic(file, JSON.stringify(obj, null, 2));
16
-
17
- // Deploy the Claude notify/write scripts into `hooksDir` and point their env at the shared state file.
18
- export function deployHookScripts(hooksDir, srcDir, stateFile) {
19
- fs.mkdirSync(hooksDir, { recursive: true });
20
- for (const f of HOOK_SCRIPTS) fs.copyFileSync(path.join(srcDir, f), path.join(hooksDir, f));
21
- for (const f of LEGACY_HOOK_SCRIPTS) {
22
- try { fs.unlinkSync(path.join(hooksDir, f)); } catch { /* absent */ }
23
- }
24
- fs.chmodSync(path.join(hooksDir, 'handmux-notify.sh'), 0o755);
25
- fs.writeFileSync(path.join(hooksDir, 'handmux-notify.env'), `HANDMUX_STATE=${stateFile}\n`, { mode: 0o600 });
26
- }
27
-
28
- // Remove the deployed scripts + env (uninstall). Best-effort: a missing file is fine.
29
- export function removeHookScripts(hooksDir) {
30
- for (const f of [...HOOK_SCRIPTS, ...LEGACY_HOOK_SCRIPTS, 'handmux-notify.env']) {
31
- try { fs.unlinkSync(path.join(hooksDir, f)); } catch { /* already gone */ }
32
- }
33
- }