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,72 @@
1
+ // Resolve the natapp / cpolar client binaries, mirroring cloudflared.js. The two providers differ in one
2
+ // hard way: cpolar publishes stable per-OS/arch zips, so we can AUTO-DOWNLOAD it (PATH → ~/.handmux/bin →
3
+ // fetch+unzip); natapp gates its downloads behind a login, so there's no stable URL to fetch — we resolve an
4
+ // already-installed binary and otherwise throw a clear "download it yourself into ~/.handmux/bin" message.
5
+ // Pure mappers (assetForCpolar) are unit-tested offline; `which`/`fetchImpl` are injectable.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { spawnSync } from 'node:child_process';
9
+ import { pocketHome } from './state.js';
10
+ import { onPath, drain, defaultProgress } from './cloudflared.js';
11
+ import { t } from './i18n/index.js';
12
+ // cpolar release asset for this OS/arch. Linux/macOS ship a bare binary inside a .zip; Windows ships a
13
+ // `-setup.zip` INSTALLER (not a bare binary), so we don't auto-extract it — Windows users install manually.
14
+ const CPOLAR_VERSION = '3.3.12';
15
+ export function assetForCpolar(platform = process.platform, arch = process.arch) {
16
+ const a = { x64: 'amd64', arm64: 'arm64', arm: 'arm', ia32: '386' }[arch] || arch;
17
+ const base = `https://www.cpolar.com/static/downloads/releases/${CPOLAR_VERSION}`;
18
+ if (platform === 'win32')
19
+ return { bin: 'cpolar.exe', url: null }; // installer-only → manual
20
+ const file = `cpolar-stable-${platform === 'darwin' ? 'darwin' : 'linux'}-${a}.zip`;
21
+ return { bin: 'cpolar', file, url: `${base}/${file}` };
22
+ }
23
+ // PATH → ~/.handmux/bin/cpolar → download+unzip. Any download/extract failure surfaces the friendly manual
24
+ // hint (t('client.cpolarManual')) so the worst case is "install it yourself", never a silent dead child.
25
+ export async function resolveCpolar(home, { which = onPath, fetchImpl, log = console, progress } = {}) {
26
+ const found = which('cpolar');
27
+ if (found)
28
+ return found;
29
+ const dir = path.join(pocketHome(home), 'bin');
30
+ const asset = assetForCpolar();
31
+ const dest = path.join(dir, asset.bin);
32
+ if (fs.existsSync(dest))
33
+ return dest;
34
+ if (!asset.url)
35
+ throw new Error(t('client.cpolarManual')); // windows / unknown platform
36
+ const doFetch = fetchImpl || globalThis.fetch;
37
+ if (!doFetch)
38
+ throw new Error(t('client.cpolarManual'));
39
+ fs.mkdirSync(dir, { recursive: true });
40
+ log.log?.(t('client.downloading', { name: 'cpolar', file: asset.file }));
41
+ let res;
42
+ try {
43
+ res = await doFetch(asset.url, { redirect: 'follow' });
44
+ }
45
+ catch {
46
+ throw new Error(t('client.cpolarManual'));
47
+ }
48
+ if (!res.ok)
49
+ throw new Error(t('client.cpolarManual'));
50
+ const buf = await drain(res, progress || defaultProgress(process.stdout, 'cpolar'));
51
+ const tmp = path.join(dir, asset.file);
52
+ fs.writeFileSync(tmp, buf);
53
+ const r = spawnSync('unzip', ['-o', tmp, '-d', dir], { encoding: 'utf8' });
54
+ try {
55
+ fs.unlinkSync(tmp);
56
+ }
57
+ catch { /* best-effort cleanup */ }
58
+ if (r.status !== 0 || !fs.existsSync(dest))
59
+ throw new Error(t('client.cpolarManual'));
60
+ fs.chmodSync(dest, 0o755);
61
+ return dest;
62
+ }
63
+ // natapp: PATH → ~/.handmux/bin/natapp, else a clear manual-download hint (no auto-download — login-gated).
64
+ export function resolveNatapp(home, { which = onPath } = {}) {
65
+ const found = which('natapp');
66
+ if (found)
67
+ return found;
68
+ const dest = path.join(pocketHome(home), 'bin', process.platform === 'win32' ? 'natapp.exe' : 'natapp');
69
+ if (fs.existsSync(dest))
70
+ return dest;
71
+ throw new Error(t('client.natappManual', { dir: path.join(pocketHome(home), 'bin') }));
72
+ }
@@ -7,127 +7,156 @@
7
7
  // 2. The version query goes through the user's own npm (`npm view handmux version`), so it honours a
8
8
  // configured China mirror / private registry instead of hard-coding registry.npmjs.org. Any failure
9
9
  // (offline, blocked, npm missing) is swallowed — the notifier is best-effort, never an error.
10
- import fs from 'node:fs';
11
10
  import path from 'node:path';
12
11
  import { spawn, spawnSync } from 'node:child_process';
13
12
  import { pocketHome } from './state.js';
14
13
  import { t } from './i18n/index.js';
15
-
14
+ import { PrivateStateStore } from '../privateStateStore.js';
16
15
  export const PKG_NAME = 'handmux';
17
16
  export const CHECK_INTERVAL_MS = 60 * 60 * 1000; // refresh the cached "latest" at most once an hour
18
-
17
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
19
18
  // A Homebrew-installed handmux lives under the Cellar (…/Cellar/handmux/<ver>/…, symlinked from the brew
20
19
  // prefix's bin), or under a linuxbrew prefix. For such installs `handmux update` must NOT `npm i -g` over
21
20
  // itself — that plants a second, conflicting copy that brew can't see or upgrade — and the "how to upgrade"
22
21
  // hint must point at `brew upgrade` instead. Detect the source from the (real, symlink-resolved) entry path.
23
- export function isBrewInstall(selfPath = '') { return /\/(Cellar|homebrew)\//.test(selfPath); }
24
-
22
+ export function isBrewInstall(selfPath = '') { return /\/(Cellar|homebrew)\//.test(String(selfPath)); }
25
23
  export function updateCachePath(home) { return path.join(pocketHome(home), 'update-check.json'); }
26
-
24
+ function parseHighlights(value) {
25
+ if (!Array.isArray(value))
26
+ return null;
27
+ return value.flatMap((item) => {
28
+ if (!isRecord(item)
29
+ || typeof item.version !== 'string'
30
+ || typeof item.date !== 'string'
31
+ || typeof item.zh !== 'string'
32
+ || typeof item.en !== 'string')
33
+ return [];
34
+ return [{ version: item.version, date: item.date, zh: item.zh, en: item.en }];
35
+ });
36
+ }
27
37
  export function readCache(home) {
28
- try { return JSON.parse(fs.readFileSync(updateCachePath(home), 'utf8')); } catch { return null; }
38
+ const value = new PrivateStateStore(updateCachePath(home)).read();
39
+ if (!isRecord(value))
40
+ return null;
41
+ const cache = {};
42
+ if (typeof value.checkedAt === 'number' && Number.isFinite(value.checkedAt))
43
+ cache.checkedAt = value.checkedAt;
44
+ if (typeof value.latest === 'string' && parts(value.latest))
45
+ cache.latest = value.latest;
46
+ else if (value.latest === null)
47
+ cache.latest = null;
48
+ if (Array.isArray(value.whatsNew))
49
+ cache.whatsNew = parseHighlights(value.whatsNew);
50
+ else if (value.whatsNew === null)
51
+ cache.whatsNew = null;
52
+ return cache;
29
53
  }
30
-
31
54
  export function writeCache(home, obj) {
32
- try {
33
- fs.mkdirSync(pocketHome(home), { recursive: true });
34
- fs.writeFileSync(updateCachePath(home), JSON.stringify(obj));
35
- } catch { /* best effort — a missing cache just means we re-check next time */ }
55
+ try {
56
+ new PrivateStateStore(updateCachePath(home)).write(obj);
57
+ }
58
+ catch { /* best effort — a missing cache just means we re-check next time */ }
36
59
  }
37
-
38
60
  // "1.2.3" → [1,2,3]; a prerelease/build tail (`-rc.1`, `+meta`) is ignored. null if unparseable.
39
61
  function parts(v) {
40
- const m = String(v || '').trim().match(/^v?(\d+)\.(\d+)\.(\d+)/);
41
- return m ? [+m[1], +m[2], +m[3]] : null;
62
+ const m = String(v || '').trim().match(/^v?(\d+)\.(\d+)\.(\d+)/);
63
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
42
64
  }
43
-
44
65
  // -1 / 0 / 1 by numeric major.minor.patch. Unparseable inputs compare equal (→ no false "upgrade").
45
66
  export function compareVersions(a, b) {
46
- const pa = parts(a), pb = parts(b);
47
- if (!pa || !pb) return 0;
48
- for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
49
- return 0;
67
+ const pa = parts(a), pb = parts(b);
68
+ if (!pa || !pb)
69
+ return 0;
70
+ for (let i = 0; i < 3; i++) {
71
+ const left = pa[i] ?? 0;
72
+ const right = pb[i] ?? 0;
73
+ if (left !== right)
74
+ return left < right ? -1 : 1;
75
+ }
76
+ return 0;
50
77
  }
51
-
52
78
  export function isNewer(latest, current) { return compareVersions(latest, current) > 0; }
53
-
54
79
  export function shouldRefresh(cache, now = Date.now(), interval = CHECK_INTERVAL_MS) {
55
- return !cache || typeof cache.checkedAt !== 'number' || (now - cache.checkedAt) > interval;
80
+ return !isRecord(cache) || typeof cache.checkedAt !== 'number' || (now - cache.checkedAt) > interval;
56
81
  }
57
-
58
82
  // ONE npm call fetches both the latest version AND its concise `whatsNew` highlights (the published
59
83
  // package.json field release.sh mirrors from the changelog), so the phone can show "what's new" BEFORE
60
84
  // upgrading — sourced through the user's own npm, so it stays China-mirror-friendly. `@latest` + `--json`
61
85
  // makes npm return an object keyed by field: {"version":"0.9.1","whatsNew":[{version,date,zh,en},…]}.
62
86
  export const VIEW_ARGS = ['view', `${PKG_NAME}@latest`, 'version', 'whatsNew', '--json'];
63
-
64
87
  // Parse that JSON into { latest, whatsNew }. `latest` is null unless it's a real version; `whatsNew` is
65
88
  // null unless it's an array (old published versions predate the field). Never throws.
66
89
  export function parseView(stdout) {
67
- try {
68
- const o = JSON.parse(String(stdout));
69
- const v = String(o.version || '').trim();
70
- return { latest: parts(v) ? v : null, whatsNew: Array.isArray(o.whatsNew) ? o.whatsNew : null };
71
- } catch { return { latest: null, whatsNew: null }; }
90
+ try {
91
+ const value = JSON.parse(String(stdout));
92
+ if (!isRecord(value))
93
+ return { latest: null, whatsNew: null };
94
+ const version = typeof value.version === 'string' ? value.version.trim() : '';
95
+ return { latest: parts(version) ? version : null, whatsNew: parseHighlights(value.whatsNew) };
96
+ }
97
+ catch {
98
+ return { latest: null, whatsNew: null };
99
+ }
72
100
  }
73
-
74
101
  // Query the latest version + highlights via the user's own npm (honours their registry/mirror). Hard
75
102
  // timeout; any non-zero exit, empty/garbled output, or thrown error → { latest:null, whatsNew:null }.
76
103
  export function fetchLatest({ timeoutMs = 4000, run = spawnSync } = {}) {
77
- try {
78
- const r = run('npm', VIEW_ARGS, { timeout: timeoutMs, encoding: 'utf8' });
79
- if (!r || r.status !== 0 || !r.stdout) return { latest: null, whatsNew: null };
80
- return parseView(r.stdout);
81
- } catch { return { latest: null, whatsNew: null }; }
104
+ try {
105
+ const r = run('npm', VIEW_ARGS, { timeout: timeoutMs, encoding: 'utf8' });
106
+ if (!r || r.status !== 0 || !r.stdout)
107
+ return { latest: null, whatsNew: null };
108
+ return parseView(r.stdout);
109
+ }
110
+ catch {
111
+ return { latest: null, whatsNew: null };
112
+ }
82
113
  }
83
-
84
114
  // Merge a fetch result over the prior cache: on a failed field keep the previously-known value, so a flaky
85
115
  // network never blanks out a good `latest`/`whatsNew`. Always stamps `checkedAt`.
86
116
  function mergedCache(home, now, { latest, whatsNew }) {
87
- const prev = readCache(home);
88
- return { checkedAt: now, latest: latest ?? prev?.latest ?? null, whatsNew: whatsNew ?? prev?.whatsNew ?? null };
117
+ const prev = readCache(home);
118
+ return { checkedAt: now, latest: latest ?? prev?.latest ?? null, whatsNew: whatsNew ?? prev?.whatsNew ?? null };
89
119
  }
90
-
91
120
  // Non-blocking refresh for the long-running server: query npm asynchronously (never stalls the event loop
92
121
  // the way the CLI's spawnSync path would) and persist the same cache the CLI reads. The /api/version route
93
122
  // calls this when the cache is stale, so the phone opening the app keeps `latest`/`whatsNew` current without
94
123
  // the user re-running the CLI. Best-effort: npm missing/offline/blocked leaves the prior values.
95
- export function refreshLatestAsync(home, { now = Date.now(), spawnFn = spawn, timeoutMs = 4000 } = {}) {
96
- try {
97
- const child = spawnFn('npm', VIEW_ARGS, { timeout: timeoutMs });
98
- let out = '';
99
- child.stdout?.on('data', (d) => { out += d; });
100
- child.on('close', (code) => {
101
- writeCache(home, mergedCache(home, now, code === 0 ? parseView(out) : { latest: null, whatsNew: null }));
102
- });
103
- child.on('error', () => { /* npm missing/offline — leave the cache untouched */ });
104
- } catch { /* best effort */ }
124
+ export function refreshLatestAsync(home, { now = Date.now(), spawnFn = spawn, timeoutMs = 4000, } = {}) {
125
+ try {
126
+ const child = spawnFn('npm', VIEW_ARGS, { timeout: timeoutMs });
127
+ let out = '';
128
+ child.stdout?.on('data', (data) => { out += String(data); });
129
+ child.on('close', (code) => {
130
+ writeCache(home, mergedCache(home, now, code === 0 ? parseView(out) : { latest: null, whatsNew: null }));
131
+ });
132
+ child.on('error', () => { });
133
+ }
134
+ catch { /* best effort */ }
105
135
  }
106
-
107
136
  // The hidden `__update-check` worker (runs detached, prints nothing): refresh the cache. On a failed fetch
108
137
  // keep the previously-known values but still stamp checkedAt, so a flaky network doesn't re-spawn every run.
109
138
  export function runUpdateCheck(home, { now = Date.now(), ...opts } = {}) {
110
- writeCache(home, mergedCache(home, now, fetchLatest(opts)));
139
+ writeCache(home, mergedCache(home, now, fetchLatest(opts)));
111
140
  }
112
-
113
141
  // Fire-and-forget notifier for a foreground command. Prints an upgrade line straight from the cache (no
114
142
  // network on this path), then — if the cache is stale — kicks off a detached refresh so the NEXT run is
115
143
  // current. Returns true if a notice was printed (handy for tests). `selfPath` is the CLI entry so the
116
144
  // background worker re-invokes this same binary.
117
- export function notifyUpdate(home, { version, selfPath, now = Date.now(), log = console.log, spawnFn = spawn } = {}) {
118
- const cache = readCache(home);
119
- let shown = false;
120
- if (cache && cache.latest && isNewer(cache.latest, version)) {
121
- log('');
122
- log(t('update.available', { current: version, latest: cache.latest }));
123
- log(t(isBrewInstall(selfPath) ? 'update.howBrew' : 'update.how'));
124
- shown = true;
125
- }
126
- if (selfPath && shouldRefresh(cache, now)) {
127
- try {
128
- const child = spawnFn(process.execPath, [selfPath, '__update-check'], { detached: true, stdio: 'ignore' });
129
- child.unref?.();
130
- } catch { /* best effort */ }
131
- }
132
- return shown;
145
+ export function notifyUpdate(home, { version, selfPath, now = Date.now(), log = console.log, spawnFn = spawn, } = {}) {
146
+ const cache = readCache(home);
147
+ let shown = false;
148
+ if (cache && cache.latest && isNewer(cache.latest, version)) {
149
+ log('');
150
+ log(t('update.available', { current: version, latest: cache.latest }));
151
+ log(t(isBrewInstall(selfPath) ? 'update.howBrew' : 'update.how'));
152
+ shown = true;
153
+ }
154
+ if (selfPath && shouldRefresh(cache, now)) {
155
+ try {
156
+ const child = spawnFn(process.execPath, [selfPath, '__update-check'], { detached: true, stdio: 'ignore' });
157
+ child.unref?.();
158
+ }
159
+ catch { /* best effort */ }
160
+ }
161
+ return shown;
133
162
  }
@@ -2,11 +2,15 @@
2
2
  // host up front, so instead of scraping a random URL we gate readiness on the tunnel process echoing that
3
3
  // exact host in its own log — and cpolar derives its -subdomain/-hostname flag from the same host. Pure.
4
4
  export function hostOf(url) {
5
- try { return new URL(String(url)).host; } catch { return null; }
5
+ try {
6
+ return new URL(String(url)).host;
7
+ }
8
+ catch {
9
+ return null;
10
+ }
6
11
  }
7
-
8
12
  // Has the process output mentioned this URL's host yet? (i.e. the named tunnel is up on its known domain.)
9
13
  export function hostIn(text, url) {
10
- const h = hostOf(url);
11
- return !!h && String(text || '').includes(h);
14
+ const h = hostOf(url);
15
+ return !!h && String(text || '').includes(h);
12
16
  }
@@ -0,0 +1,347 @@
1
+ import { homedir } from 'node:os';
2
+ import { runTmux as defaultRunTmux } from '../tmux/commands.js';
3
+ import { createWorkspaceStore } from '../workspace/store.js';
4
+ import { createWorkspaceTmux } from '../workspace/tmuxAdapter.js';
5
+ import { createWorkspaceLock } from '../workspace/lock.js';
6
+ import { createWorkspaceBackground } from '../workspace/checkpointer.js';
7
+ import { createEnvironmentProvider } from '../workspace/environment.js';
8
+ import { createWorkspaceRuntime } from '../workspace/runtime.js';
9
+ import { claudeStatePath } from './state.js';
10
+ import { ask, select, CANCELLED } from './prompt.js';
11
+ import { t } from './i18n/index.js';
12
+ const RESTORE_FLAGS = new Set(['list', 'dryRun', 'checkpoint', 'session', 'lang', 'config']);
13
+ const SAFE_CHECKPOINT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
14
+ const INVALID_TOPOLOGY_REASONS = new Set([
15
+ 'invalid-session', 'invalid-session-id', 'invalid-session-name', 'invalid-active-window-id',
16
+ 'invalid-window-links', 'missing-window-links', 'invalid-window-link', 'invalid-window-link-index',
17
+ 'duplicate-window-link', 'duplicate-window-link-index', 'dangling-window-link', 'dangling-active-window',
18
+ 'missing-window-panes', 'invalid-pane', 'duplicate-pane', 'dangling-active-pane',
19
+ ]);
20
+ const recordOf = (value) => value && typeof value === 'object' && !Array.isArray(value)
21
+ ? value : null;
22
+ const arrayOfRecords = (value) => Array.isArray(value) ? value.flatMap((item) => {
23
+ const record = recordOf(item);
24
+ return record ? [record] : [];
25
+ }) : [];
26
+ const strings = (value) => Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
27
+ function line(stream, value = '') {
28
+ if (typeof stream === 'function')
29
+ stream(value);
30
+ else
31
+ stream.write(`${value}\n`);
32
+ }
33
+ function countAgents(checkpoint) {
34
+ return arrayOfRecords(checkpoint.windows)
35
+ .flatMap((window) => arrayOfRecords(window.panes))
36
+ .filter((pane) => Boolean(pane.agent)).length;
37
+ }
38
+ function checkpointCounts(checkpoint) {
39
+ const windows = arrayOfRecords(checkpoint.windows);
40
+ return {
41
+ sessions: Array.isArray(checkpoint.sessions) ? checkpoint.sessions.length : 0,
42
+ windows: windows.length,
43
+ panes: windows.reduce((sum, window) => sum + (Array.isArray(window.panes) ? window.panes.length : 0), 0),
44
+ agents: countAgents(checkpoint),
45
+ };
46
+ }
47
+ function unsupportedReason(reason) {
48
+ if (reason === 'linked-windows-unsupported')
49
+ return t('restore.reason.linkedWindows');
50
+ if (typeof reason === 'string' && INVALID_TOPOLOGY_REASONS.has(reason))
51
+ return t('restore.reason.invalidTopology');
52
+ return t('restore.reason.unknown', { reason: reason || 'unknown' });
53
+ }
54
+ function posixQuote(value) {
55
+ return `'${String(value).replaceAll("'", `'"'"'`)}'`;
56
+ }
57
+ function restoreCommand(checkpoint, sessions = []) {
58
+ const selectedSessions = Array.isArray(sessions) ? sessions : [sessions];
59
+ return selectedSessions.reduce((command, session) => `${command} --session ${posixQuote(session)}`, `handmux restore --checkpoint ${posixQuote(checkpoint)}`);
60
+ }
61
+ function manualSessionCommand(session) {
62
+ return `tmux new-session -s ${posixQuote(session)}`;
63
+ }
64
+ function writeManualRecovery(stream, session) {
65
+ line(stream, t('restore.manualRecovery', { command: manualSessionCommand(session) }));
66
+ }
67
+ function normalizeFlags(flags) {
68
+ const unknown = Object.keys(flags).find((key) => !RESTORE_FLAGS.has(key));
69
+ if (unknown)
70
+ return { error: t('restore.badFlag', { flag: `--${unknown}` }) };
71
+ if (flags.list !== undefined && typeof flags.list !== 'boolean')
72
+ return { error: t('restore.flagBoolean', { flag: '--list' }) };
73
+ if (flags.dryRun !== undefined && typeof flags.dryRun !== 'boolean')
74
+ return { error: t('restore.flagBoolean', { flag: '--dry-run' }) };
75
+ if (flags.checkpoint !== undefined && (typeof flags.checkpoint !== 'string' || !flags.checkpoint.trim())) {
76
+ return { error: t('restore.checkpointValue') };
77
+ }
78
+ if (typeof flags.checkpoint === 'string'
79
+ && (!SAFE_CHECKPOINT_ID.test(flags.checkpoint.trim()) || flags.checkpoint.trim() === '.' || flags.checkpoint.trim() === '..')) {
80
+ return { error: t('restore.checkpointId') };
81
+ }
82
+ const rawSessions = flags.session === undefined ? [] : (Array.isArray(flags.session) ? flags.session : [flags.session]);
83
+ if (rawSessions.some((name) => typeof name !== 'string' || !name.trim()))
84
+ return { error: t('restore.sessionValue') };
85
+ if (flags.list && (flags.dryRun || flags.checkpoint !== undefined || flags.session !== undefined)) {
86
+ return { error: t('restore.listExclusive') };
87
+ }
88
+ const checkpoint = typeof flags.checkpoint === 'string' ? flags.checkpoint.trim() : undefined;
89
+ return {
90
+ list: flags.list === true,
91
+ dryRun: flags.dryRun === true,
92
+ ...(checkpoint ? { checkpoint } : {}),
93
+ sessions: rawSessions.map((name) => name.trim()),
94
+ };
95
+ }
96
+ function checkpointRows(value) {
97
+ if (!Array.isArray(value))
98
+ throw new Error('invalid checkpoint list');
99
+ return value.map((candidate) => {
100
+ const row = recordOf(candidate);
101
+ if (row?.status === 'ok' && typeof row.id === 'string' && row.id && recordOf(row.value)) {
102
+ return candidate;
103
+ }
104
+ if (typeof row?.status === 'string' && typeof row.id === 'string' && row.id
105
+ && typeof row.error === 'string') {
106
+ return candidate;
107
+ }
108
+ return {
109
+ status: typeof row?.status === 'string' ? row.status : 'invalid',
110
+ id: typeof row?.id === 'string' && row.id ? row.id : '?',
111
+ error: typeof row?.error === 'string' ? row.error : 'invalid checkpoint record',
112
+ };
113
+ });
114
+ }
115
+ function validRows(rows) {
116
+ return rows.filter((row) => 'value' in row);
117
+ }
118
+ function writeCheckpointRow(stdout, row) {
119
+ if (!('value' in row)) {
120
+ line(stdout, t('restore.listUnavailable', { id: row.id || '?', error: row.error || row.status || 'unknown' }));
121
+ return;
122
+ }
123
+ const counts = checkpointCounts(row.value);
124
+ line(stdout, t('restore.listRow', {
125
+ id: row.id,
126
+ time: row.value.archivedAt || row.value.capturedAt || '?',
127
+ ...counts,
128
+ }));
129
+ }
130
+ async function defaultSelectCheckpoint(rows) {
131
+ const selected = await ask(select({
132
+ message: t('restore.selectCheckpoint'),
133
+ options: rows.map((row) => {
134
+ const counts = checkpointCounts(row.value);
135
+ return {
136
+ value: row.id,
137
+ label: row.id,
138
+ hint: t('restore.selectHint', { time: row.value.archivedAt || row.value.capturedAt || '?', ...counts }),
139
+ };
140
+ }),
141
+ initialValue: rows[0]?.id,
142
+ }));
143
+ if (typeof selected !== 'string')
144
+ throw new Error('invalid checkpoint selection');
145
+ return selected;
146
+ }
147
+ function writePlan(stdout, planInput, dryRun, continuationCommand) {
148
+ const plan = recordOf(planInput);
149
+ if (!plan || typeof plan.checkpointId !== 'string')
150
+ throw new Error('invalid restore plan');
151
+ const summary = recordOf(plan.summary);
152
+ const preExisting = recordOf(plan.preExistingRuntimeIds);
153
+ const planSessions = arrayOfRecords(plan.sessions);
154
+ line(stdout, t('restore.planCheckpoint', {
155
+ id: plan.checkpointId,
156
+ time: plan.capturedAt || plan.archivedAt || '?',
157
+ sessions: summary?.sessions ?? 0,
158
+ windows: summary?.windows ?? 0,
159
+ panes: summary?.panes ?? 0,
160
+ }));
161
+ line(stdout, t('restore.planCurrent', {
162
+ sessions: Array.isArray(preExisting?.sessions) ? preExisting.sessions.length : 0,
163
+ }));
164
+ line(stdout);
165
+ for (const item of planSessions) {
166
+ if (item.action === 'create')
167
+ line(stdout, t('restore.planCreate', { session: item.sourceName }));
168
+ else if (item.action === 'create-renamed')
169
+ line(stdout, t('restore.planRenamed', { session: item.sourceName, target: item.targetName }));
170
+ else if (item.action === 'already-present')
171
+ line(stdout, t('restore.planAlready', { session: item.sourceName }));
172
+ else {
173
+ const session = item.sourceName || item.logicalId || '?';
174
+ line(stdout, t('restore.planUnavailable', { session, reason: unsupportedReason(item.reason) }));
175
+ writeManualRecovery(stdout, session);
176
+ }
177
+ }
178
+ for (const warning of strings(plan.warnings))
179
+ line(stdout, t('restore.warning', { warning }));
180
+ line(stdout);
181
+ line(stdout, t('restore.nonDestructive'));
182
+ if (dryRun && !planSessions.some((item) => item.action === 'unsupported')) {
183
+ line(stdout, t('restore.dryRunHint', { command: continuationCommand }));
184
+ }
185
+ const unsupported = recordOf(plan.planSummary)?.unsupported;
186
+ return typeof unsupported === 'number' && Number.isFinite(unsupported) ? unsupported : 0;
187
+ }
188
+ function writeResult(stdout, stderr, checkpointId, resultInput) {
189
+ const result = recordOf(resultInput);
190
+ if (!result || typeof result.status !== 'string' || !Array.isArray(result.results)) {
191
+ throw new Error('invalid restore result');
192
+ }
193
+ const resultRows = arrayOfRecords(result.results);
194
+ for (const item of resultRows) {
195
+ const session = item.sourceName || item.logicalId || '?';
196
+ if (item.status === 'restored') {
197
+ line(stdout, item.targetName && item.targetName !== session
198
+ ? t('restore.resultRenamed', { session, target: item.targetName })
199
+ : t('restore.resultRestored', { session }));
200
+ for (const warning of strings(item.warnings))
201
+ line(stdout, t('restore.sessionWarning', { session, warning }));
202
+ }
203
+ else if (item.status === 'already-present') {
204
+ line(stdout, t('restore.resultAlready', { session }));
205
+ }
206
+ else {
207
+ const stage = item.stage || 'restore';
208
+ line(stderr, t('restore.sessionFailed', {
209
+ checkpoint: checkpointId,
210
+ session,
211
+ stage,
212
+ error: stage === 'plan' ? unsupportedReason(item.error) : (item.error || 'unknown error'),
213
+ }));
214
+ if (stage === 'plan')
215
+ writeManualRecovery(stderr, session);
216
+ else
217
+ line(stderr, t('restore.retrySession', { command: restoreCommand(checkpointId, session) }));
218
+ }
219
+ }
220
+ if (result.status !== 'succeeded' && result.error && !resultRows.some((item) => item.status === 'failed')) {
221
+ line(stderr, t('restore.operationFailed', { checkpoint: checkpointId, stage: 'restore', error: result.error }));
222
+ line(stderr, t('restore.retry', { command: restoreCommand(checkpointId) }));
223
+ }
224
+ for (const warning of strings(result.warnings))
225
+ line(stdout, t('restore.warning', { warning }));
226
+ line(stdout);
227
+ line(stdout, t('restore.resultSummary', {
228
+ restored: result.restored ?? 0,
229
+ already: result.alreadyPresent ?? 0,
230
+ failed: result.failed ?? 0,
231
+ }));
232
+ line(stdout, t('restore.nonDestructivePast'));
233
+ return result.status;
234
+ }
235
+ export function createStandaloneWorkspaceRuntime(options = {}) {
236
+ const home = options.home ?? homedir();
237
+ const runTmux = options.runTmux ?? defaultRunTmux;
238
+ const stateFile = options.stateFile ?? claudeStatePath(home);
239
+ const readOnly = options.readOnly ?? false;
240
+ const createStore = (options.createStore ?? createWorkspaceStore);
241
+ const createTmux = (options.createTmux ?? createWorkspaceTmux);
242
+ const createLock = (options.createLock ?? createWorkspaceLock);
243
+ const createCheckpointer = (options.createCheckpointer ?? createWorkspaceBackground);
244
+ const createRuntime = (options.createRuntime ?? createWorkspaceRuntime);
245
+ const store = createStore({ home });
246
+ const tmux = createTmux({ run: runTmux, readOnly });
247
+ const lock = createLock({ dir: store.paths.lockDir });
248
+ const observe = options.observeEnvironment || createEnvironmentProvider({
249
+ tmuxServerIdProvider: async () => {
250
+ const observeTmux = recordOf(tmux)?.observeEnvironment;
251
+ if (typeof observeTmux !== 'function')
252
+ throw new Error('tmux environment observer unavailable');
253
+ const observedInput = await observeTmux.call(tmux);
254
+ const observed = recordOf(observedInput);
255
+ if (!observed)
256
+ throw new Error('tmux environment unavailable');
257
+ if (observed.status === 'unknown')
258
+ throw new Error('tmux environment unavailable');
259
+ return observed.tmuxServerId ?? null;
260
+ },
261
+ });
262
+ const checkpointer = createCheckpointer({ store, tmux, observeEnvironment: observe, lock, stateFile });
263
+ return createRuntime({ store, tmux, lock, checkpointer, home });
264
+ }
265
+ export async function runWorkspaceCommand({ flags = {}, positionals = [], unknownShortFlags = [], home = homedir(), runtime, inputIsTTY = Boolean(process.stdin.isTTY), outputIsTTY = Boolean(process.stdout.isTTY), selectCheckpoint = defaultSelectCheckpoint, stdout = process.stdout, stderr = process.stderr, createRuntime = createStandaloneWorkspaceRuntime, } = {}) {
266
+ if (positionals.length > 0 || unknownShortFlags.length > 0) {
267
+ const error = positionals.length > 0
268
+ ? t('restore.unexpectedArgument', { value: positionals[0] })
269
+ : t('restore.unknownShortFlag', { flag: unknownShortFlags[0] });
270
+ line(stderr, error);
271
+ line(stderr, t('restore.usage'));
272
+ return 2;
273
+ }
274
+ const parsed = normalizeFlags(flags);
275
+ if ('error' in parsed) {
276
+ line(stderr, parsed.error);
277
+ line(stderr, t('restore.usage'));
278
+ return 2;
279
+ }
280
+ const workspace = runtime || createRuntime({ home, readOnly: parsed.dryRun });
281
+ let resolvedCheckpoint = parsed.checkpoint || 'latest';
282
+ try {
283
+ if (parsed.list) {
284
+ const rows = checkpointRows(await workspace.listCheckpoints());
285
+ if (rows.length === 0)
286
+ line(stdout, t('restore.listEmpty'));
287
+ else
288
+ rows.forEach((row) => writeCheckpointRow(stdout, row));
289
+ return 0;
290
+ }
291
+ let checkpointId;
292
+ let historical = false;
293
+ if (parsed.checkpoint && parsed.checkpoint !== 'latest') {
294
+ checkpointId = parsed.checkpoint;
295
+ historical = true;
296
+ }
297
+ else {
298
+ const rows = validRows(checkpointRows(await workspace.listCheckpoints()));
299
+ if (rows.length === 0) {
300
+ line(stderr, t('restore.noCheckpoint'));
301
+ return 1;
302
+ }
303
+ const firstRow = rows[0];
304
+ if (!firstRow)
305
+ return 1;
306
+ if (parsed.checkpoint === 'latest') {
307
+ checkpointId = firstRow.id;
308
+ historical = true;
309
+ }
310
+ else if (rows.length === 1) {
311
+ checkpointId = firstRow.id;
312
+ }
313
+ else if (inputIsTTY && outputIsTTY) {
314
+ checkpointId = await selectCheckpoint(rows);
315
+ historical = true;
316
+ }
317
+ else {
318
+ checkpointId = firstRow.id;
319
+ }
320
+ }
321
+ if (typeof checkpointId !== 'string' || !checkpointId) {
322
+ line(stderr, t('restore.selectionCancelled', { command: 'handmux restore' }));
323
+ return 1;
324
+ }
325
+ resolvedCheckpoint = checkpointId;
326
+ const request = { checkpointId, sessions: parsed.sessions, historical: historical || parsed.dryRun };
327
+ if (parsed.dryRun) {
328
+ const restorePlan = await workspace.getRestorePlan(request);
329
+ const unsupported = writePlan(stdout, restorePlan, true, restoreCommand(checkpointId, parsed.sessions));
330
+ return unsupported > 0 ? 1 : 0;
331
+ }
332
+ const result = await workspace.restoreNow(request);
333
+ return writeResult(stdout, stderr, checkpointId, result) === 'succeeded' ? 0 : 1;
334
+ }
335
+ catch (error) {
336
+ if (error === CANCELLED) {
337
+ line(stderr, t('restore.selectionCancelled', { command: 'handmux restore' }));
338
+ return 1;
339
+ }
340
+ line(stderr, t('restore.error', {
341
+ checkpoint: resolvedCheckpoint,
342
+ error: error instanceof Error ? error.message : String(error),
343
+ }));
344
+ line(stderr, t('restore.retry', { command: restoreCommand(resolvedCheckpoint) }));
345
+ return 1;
346
+ }
347
+ }