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,363 @@
1
+ import fsp from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import { AGENTS } from '../agents/index.js';
4
+ const KINDS = ['sessions', 'windows', 'panes'];
5
+ const errorStage = (error) => (error && typeof error === 'object' && 'stage' in error && typeof error.stage === 'string'
6
+ ? error.stage : undefined);
7
+ function message(error) {
8
+ return error instanceof Error ? error.message : String(error);
9
+ }
10
+ function windowMap(checkpoint) {
11
+ return new Map(checkpoint.windows.map((window) => [window.id, window]));
12
+ }
13
+ function sessionMap(checkpoint) {
14
+ return new Map(checkpoint.sessions.map((session) => [session.id, session]));
15
+ }
16
+ function emptyMapping() {
17
+ return {
18
+ names: {},
19
+ runtime: { sessions: {}, windows: {}, panes: {} },
20
+ logical: { sessions: {}, windows: {}, panes: {} },
21
+ };
22
+ }
23
+ function mergeMapping(target, source) {
24
+ Object.assign(target.names, source.names);
25
+ for (const kind of KINDS) {
26
+ Object.assign(target.runtime[kind], source.runtime[kind]);
27
+ Object.assign(target.logical[kind], source.logical[kind]);
28
+ }
29
+ return target;
30
+ }
31
+ function mapRuntime(mapping, kind, source, logical, actual) {
32
+ if (typeof source === 'string' && source)
33
+ mapping.runtime[kind][source] = actual;
34
+ if (typeof logical === 'string' && logical)
35
+ mapping.logical[kind][logical] = actual;
36
+ }
37
+ async function usableCwd(cwd, { access, home, warnings, }) {
38
+ if (typeof cwd === 'string' && cwd) {
39
+ try {
40
+ await access(cwd);
41
+ return cwd;
42
+ }
43
+ catch { /* fall through */ }
44
+ }
45
+ warnings.push(`cwd ${typeof cwd === 'string' && cwd ? cwd : '(missing)'} is unavailable; restored in ${home}`);
46
+ return home;
47
+ }
48
+ function sortedPanes(window) {
49
+ return [...window.panes].sort((a, b) => a.index - b.index);
50
+ }
51
+ function requiredWindow(windows, id) {
52
+ const window = windows.get(id);
53
+ if (!window)
54
+ throw new Error(`checkpoint window ${id} is missing`);
55
+ return window;
56
+ }
57
+ function firstPane(window) {
58
+ const pane = sortedPanes(window)[0];
59
+ if (!pane)
60
+ throw new Error(`checkpoint window ${window.id} has no panes`);
61
+ return pane;
62
+ }
63
+ function dispositionMap(plan) {
64
+ return new Map((plan.windows || []).map((window) => [window.logicalId, window]));
65
+ }
66
+ async function notify(onProgress, results, result, total) {
67
+ if (typeof onProgress !== 'function')
68
+ return;
69
+ await onProgress({ completed: results.length, total, result });
70
+ }
71
+ async function restoreOneSession({ item, checkpoint, tmux, agents, access, home, dispositions, restoredWindows, agentReadiness, }) {
72
+ const sessions = sessionMap(checkpoint);
73
+ const windows = windowMap(checkpoint);
74
+ const source = sessions.get(item.logicalId);
75
+ if (!source)
76
+ throw Object.assign(new Error('checkpoint session is missing'), { stage: 'plan' });
77
+ const warnings = [];
78
+ const mapping = emptyMapping();
79
+ const localWindows = new Map();
80
+ const localPanes = new Map();
81
+ const links = [...item.windowLinks].sort((a, b) => a.index - b.index);
82
+ const seedLink = links.find((link) => {
83
+ if (restoredWindows.has(link.windowId))
84
+ return false;
85
+ return dispositions.get(link.windowId)?.action !== 'reuse';
86
+ });
87
+ let temp = null;
88
+ let topologyComplete = false;
89
+ try {
90
+ if (seedLink) {
91
+ const seedWindow = requiredWindow(windows, seedLink.windowId);
92
+ const seedPane = firstPane(seedWindow);
93
+ const cwd = await usableCwd(seedPane.cwd, { access, home, warnings });
94
+ temp = await tmux.createTemporarySession({
95
+ cwd,
96
+ sessionLogicalId: item.logicalId,
97
+ windowLogicalId: seedWindow.id,
98
+ paneLogicalId: seedPane.id,
99
+ windowName: seedWindow.name,
100
+ windowIndex: seedLink.index,
101
+ });
102
+ localWindows.set(seedWindow.id, temp.windowId);
103
+ localPanes.set(seedPane.id, temp.paneId);
104
+ mapRuntime(mapping, 'windows', seedWindow.runtimeId, seedWindow.id, temp.windowId);
105
+ mapRuntime(mapping, 'panes', seedPane.runtimeId, seedPane.id, temp.paneId);
106
+ }
107
+ else {
108
+ const fallbackId = links[0]?.windowId;
109
+ if (!fallbackId)
110
+ throw new Error('checkpoint session has no windows');
111
+ const fallbackWindow = requiredWindow(windows, fallbackId);
112
+ const cwd = await usableCwd(firstPane(fallbackWindow).cwd, { access, home, warnings });
113
+ temp = await tmux.createTemporarySession({ cwd, sessionLogicalId: item.logicalId });
114
+ }
115
+ mapRuntime(mapping, 'sessions', source.runtimeId, source.id, temp.sessionId);
116
+ for (const link of links) {
117
+ const window = windows.get(link.windowId);
118
+ if (!window)
119
+ throw new Error(`checkpoint window ${link.windowId} is missing`);
120
+ if (localWindows.has(window.id))
121
+ continue;
122
+ const sharedRuntime = restoredWindows.get(window.id);
123
+ const disposition = dispositions.get(window.id);
124
+ const dispositionRuntime = disposition?.action === 'reuse' ? disposition.runtimeId : undefined;
125
+ if (sharedRuntime || dispositionRuntime) {
126
+ const windowId = sharedRuntime || dispositionRuntime;
127
+ if (!windowId)
128
+ throw new Error(`reused window ${window.id} has no runtime id`);
129
+ await tmux.linkWindow(windowId, temp.sessionId, link.index, { existing: !sharedRuntime });
130
+ if (!sharedRuntime)
131
+ mapRuntime(mapping, 'windows', window.runtimeId, window.id, windowId);
132
+ continue;
133
+ }
134
+ const initialPane = firstPane(window);
135
+ const cwd = await usableCwd(initialPane.cwd, { access, home, warnings });
136
+ const created = await tmux.createWindow(temp.sessionId, {
137
+ name: window.name,
138
+ index: link.index,
139
+ cwd,
140
+ windowLogicalId: window.id,
141
+ paneLogicalId: initialPane.id,
142
+ });
143
+ localWindows.set(window.id, created.windowId);
144
+ localPanes.set(initialPane.id, created.paneId);
145
+ mapRuntime(mapping, 'windows', window.runtimeId, window.id, created.windowId);
146
+ mapRuntime(mapping, 'panes', initialPane.runtimeId, initialPane.id, created.paneId);
147
+ }
148
+ for (const [logicalId, runtimeId] of localWindows) {
149
+ const window = requiredWindow(windows, logicalId);
150
+ const panes = sortedPanes(window);
151
+ const firstPane = panes[0];
152
+ if (!firstPane)
153
+ throw new Error(`window ${logicalId} has no panes`);
154
+ const seedPaneId = localPanes.get(firstPane.id);
155
+ for (const pane of panes.slice(1)) {
156
+ const cwd = await usableCwd(pane.cwd, { access, home, warnings });
157
+ const paneId = await tmux.splitPane(seedPaneId, { cwd, paneLogicalId: pane.id });
158
+ localPanes.set(pane.id, paneId);
159
+ mapRuntime(mapping, 'panes', pane.runtimeId, pane.id, paneId);
160
+ }
161
+ try {
162
+ await tmux.applyLayout(runtimeId, window.layout);
163
+ }
164
+ catch (error) {
165
+ warnings.push(`layout for ${window.name} was unavailable; kept the default layout (${message(error)})`);
166
+ }
167
+ const activePane = localPanes.get(window.activePaneId);
168
+ if (activePane)
169
+ await tmux.selectPane(activePane);
170
+ }
171
+ const activeLink = links.find((link) => link.windowId === item.activeWindowId);
172
+ if (activeLink) {
173
+ if (typeof tmux.selectWindowInSession === 'function')
174
+ await tmux.selectWindowInSession(temp.sessionId, activeLink.index);
175
+ else {
176
+ const activeWindow = localWindows.get(activeLink.windowId) || restoredWindows.get(activeLink.windowId);
177
+ if (activeWindow)
178
+ await tmux.selectWindow(activeWindow);
179
+ }
180
+ }
181
+ if (!seedLink && temp.windowId)
182
+ await tmux.killCreatedWindow(temp.windowId);
183
+ await tmux.renameCreatedSession(temp.sessionId, item.targetName);
184
+ topologyComplete = true;
185
+ mapping.names[item.sourceName] = item.targetName;
186
+ for (const [logicalId, runtimeId] of localWindows)
187
+ restoredWindows.set(logicalId, runtimeId);
188
+ for (const [logicalId, paneId] of localPanes) {
189
+ const pane = [...windows.values()].flatMap((window) => window.panes).find((candidate) => candidate.id === logicalId);
190
+ if (!pane?.agent)
191
+ continue;
192
+ const binding = pane.agent;
193
+ const agentId = typeof binding.id === 'string' ? binding.id : '';
194
+ const sessionId = typeof binding.sessionId === 'string' ? binding.sessionId : '';
195
+ const transcriptPath = typeof binding.transcriptPath === 'string' ? binding.transcriptPath : '';
196
+ const driver = agents.find((candidate) => candidate.id === agentId);
197
+ if (!driver) {
198
+ warnings.push(`agent ${agentId || '(missing)'} is unsupported; shell was restored`);
199
+ continue;
200
+ }
201
+ if (!driver.sessions.isId(sessionId)) {
202
+ warnings.push(`agent ${driver.id} session id is invalid; shell was restored`);
203
+ continue;
204
+ }
205
+ try {
206
+ await access(transcriptPath);
207
+ }
208
+ catch {
209
+ warnings.push(`agent ${driver.id} context is unavailable; shell was restored`);
210
+ continue;
211
+ }
212
+ const [cmd, ...args] = driver.sessions.resumeArgs(sessionId);
213
+ if (!cmd) {
214
+ warnings.push(`agent ${driver.id} resume command is unavailable; shell was restored`);
215
+ continue;
216
+ }
217
+ const executable = agentReadiness.get(cmd);
218
+ if (executable) {
219
+ let readiness;
220
+ try {
221
+ readiness = await executable;
222
+ }
223
+ catch (error) {
224
+ readiness = { status: 'failed', error: message(error) };
225
+ }
226
+ if (readiness.status !== 'ready') {
227
+ warnings.push(`agent ${driver.id} executable is unavailable; shell was restored (${readiness.error})`);
228
+ continue;
229
+ }
230
+ }
231
+ try {
232
+ await tmux.startAgent(paneId, cmd, args);
233
+ }
234
+ catch (error) {
235
+ warnings.push(`agent ${driver.id} resume failed; shell was restored (${message(error)})`);
236
+ }
237
+ }
238
+ return {
239
+ logicalId: item.logicalId,
240
+ sourceName: item.sourceName,
241
+ targetName: item.targetName,
242
+ status: 'restored',
243
+ warnings,
244
+ mapping,
245
+ };
246
+ }
247
+ catch (error) {
248
+ let cleanupError;
249
+ if (temp && !topologyComplete) {
250
+ try {
251
+ await tmux.killCreatedSession(temp.sessionId);
252
+ }
253
+ catch (failure) {
254
+ cleanupError = failure;
255
+ }
256
+ }
257
+ const failure = cleanupError
258
+ ? new Error(`${message(error)}; cleanup failed: ${message(cleanupError)}`)
259
+ : error instanceof Error ? error : new Error(message(error));
260
+ failure.stage ||= errorStage(error) || 'topology';
261
+ throw failure;
262
+ }
263
+ }
264
+ function requiredAgentCommands(plan, checkpoint, agents) {
265
+ const sessions = sessionMap(checkpoint);
266
+ const windows = windowMap(checkpoint);
267
+ const dispositions = dispositionMap(plan);
268
+ const commands = new Set();
269
+ for (const item of plan.sessions) {
270
+ if (item.action === 'already-present' || item.action === 'unsupported')
271
+ continue;
272
+ if (typeof item.logicalId !== 'string')
273
+ continue;
274
+ const session = sessions.get(item.logicalId);
275
+ if (!session)
276
+ continue;
277
+ for (const link of session.windowLinks) {
278
+ if (dispositions.get(link.windowId)?.action === 'reuse')
279
+ continue;
280
+ for (const pane of windows.get(link.windowId)?.panes || []) {
281
+ const binding = pane.agent;
282
+ if (!binding)
283
+ continue;
284
+ const agentId = typeof binding.id === 'string' ? binding.id : '';
285
+ const sessionId = typeof binding.sessionId === 'string' ? binding.sessionId : '';
286
+ const driver = agents.find((candidate) => candidate.id === agentId);
287
+ if (!driver || !driver.sessions.isId(sessionId))
288
+ continue;
289
+ const [command] = driver.sessions.resumeArgs(sessionId);
290
+ if (command)
291
+ commands.add(command);
292
+ }
293
+ }
294
+ }
295
+ return [...commands];
296
+ }
297
+ function restoredTopologySummary(checkpoint, results) {
298
+ const restoredSessions = new Set(results
299
+ .filter((result) => result.status === 'restored')
300
+ .map((result) => result.logicalId));
301
+ const windowIds = new Set((checkpoint?.sessions || [])
302
+ .filter((session) => restoredSessions.has(session.id))
303
+ .flatMap((session) => (session.windowLinks || []).map((link) => link.windowId)));
304
+ const paneIds = new Set((checkpoint?.windows || [])
305
+ .filter((window) => windowIds.has(window.id))
306
+ .flatMap((window) => (window.panes || []).map((pane) => pane.id)));
307
+ return { sessions: restoredSessions.size, windows: windowIds.size, panes: paneIds.size };
308
+ }
309
+ export function summarizeRestore(results, checkpoint = null) {
310
+ const mapping = emptyMapping();
311
+ for (const result of results)
312
+ if (result.mapping)
313
+ mergeMapping(mapping, result.mapping);
314
+ const restored = results.filter((result) => result.status === 'restored').length;
315
+ const alreadyPresent = results.filter((result) => result.status === 'already-present').length;
316
+ const failed = results.filter((result) => result.status === 'failed').length;
317
+ const status = failed === 0 ? 'succeeded' : restored + alreadyPresent > 0 ? 'partial' : 'failed';
318
+ return { status, restored, alreadyPresent, failed, results, mapping, summary: restoredTopologySummary(checkpoint, results) };
319
+ }
320
+ export async function executeRestore({ plan, checkpoint, tmux, agents = AGENTS, onProgress, access = fsp.access, home = os.homedir(), }) {
321
+ const results = [];
322
+ const dispositions = dispositionMap(plan);
323
+ const restoredWindows = new Map();
324
+ try {
325
+ const commands = requiredAgentCommands(plan, checkpoint, agents);
326
+ const agentReadiness = tmux.waitForAgents?.(commands) ?? new Map();
327
+ for (const item of plan.sessions) {
328
+ let result;
329
+ if (item.action === 'already-present') {
330
+ result = { logicalId: item.logicalId, sourceName: item.sourceName, status: 'already-present' };
331
+ }
332
+ else if (item.action === 'unsupported') {
333
+ result = {
334
+ logicalId: item.logicalId, sourceName: item.sourceName, status: 'failed', stage: 'plan',
335
+ ...(item.reason ? { error: item.reason } : {}),
336
+ };
337
+ }
338
+ else {
339
+ try {
340
+ result = await restoreOneSession({
341
+ item: item,
342
+ checkpoint, tmux, agents, access, home, dispositions, restoredWindows, agentReadiness,
343
+ });
344
+ }
345
+ catch (error) {
346
+ result = {
347
+ logicalId: item.logicalId,
348
+ sourceName: item.sourceName,
349
+ status: 'failed',
350
+ stage: errorStage(error) || 'topology',
351
+ error: message(error),
352
+ };
353
+ }
354
+ }
355
+ results.push(result);
356
+ await notify(onProgress, results, result, plan.sessions.length);
357
+ }
358
+ return summarizeRestore(results, checkpoint);
359
+ }
360
+ finally {
361
+ tmux.revokeCreatedTargets?.();
362
+ }
363
+ }
@@ -0,0 +1,318 @@
1
+ import crypto from 'node:crypto';
2
+ import { buildRestorePlan } from './planner.js';
3
+ import { executeRestore } from './restore.js';
4
+ import { createOperationManager, normalizeRestoreRequest, } from './operations.js';
5
+ import { buildRecoveryMapping, } from './mapping.js';
6
+ const recordOf = (value) => (value && typeof value === 'object' && !Array.isArray(value)
7
+ ? value : null);
8
+ const errorMessage = (error) => error instanceof Error ? error.message : String(error);
9
+ function unwrapCheckpoint(result) {
10
+ const record = recordOf(result);
11
+ if (record?.status !== 'ok' || !record.value) {
12
+ const detail = typeof record?.error === 'string' ? record.error : `checkpoint is ${String(record?.status || 'unavailable')}`;
13
+ throw new Error(detail);
14
+ }
15
+ const warnings = [record.warning, ...(Array.isArray(record.warnings) ? record.warnings : [])]
16
+ .filter((warning) => typeof warning === 'string' && Boolean(warning));
17
+ return { checkpoint: record.value, warnings };
18
+ }
19
+ async function readCheckpoint(store, checkpointId) {
20
+ return unwrapCheckpoint(checkpointId === 'latest'
21
+ ? await store.readLatestCheckpoint()
22
+ : await store.readCheckpoint(checkpointId));
23
+ }
24
+ async function readRecovery(store, checkpointId, historical) {
25
+ const result = await store.readRecovery(checkpointId);
26
+ if (result.status === 'ok')
27
+ return result.value;
28
+ if (historical && result.status === 'missing')
29
+ return null;
30
+ throw new Error(result.status === 'corrupt' ? result.error : `recovery state is ${result.status}`);
31
+ }
32
+ async function captureLive(tmux) {
33
+ const live = await tmux.captureTopology({ readOnly: true });
34
+ if (live.status === 'ok')
35
+ return live;
36
+ if (live.status === 'empty')
37
+ return { ...live, sessions: [], windows: [] };
38
+ throw new Error(typeof live.error === 'string' ? live.error : 'current tmux topology is unavailable');
39
+ }
40
+ function blankMapping() {
41
+ return {
42
+ names: {},
43
+ runtime: { sessions: {}, windows: {}, panes: {} },
44
+ logical: { sessions: {}, windows: {}, panes: {} },
45
+ };
46
+ }
47
+ function mapRuntime(mapping, kind, source, logical, runtime) {
48
+ if (typeof source === 'string' && source)
49
+ mapping.runtime[kind][source] = runtime;
50
+ if (typeof logical === 'string' && logical)
51
+ mapping.logical[kind][logical] = runtime;
52
+ }
53
+ function mappingForAlreadyPresent(checkpoint, live, results) {
54
+ const mapping = blankMapping();
55
+ const resolved = new Set(results.filter((row) => row.status === 'already-present').map((row) => row.logicalId));
56
+ const liveSessions = new Map(live.sessions.map((session) => [session.id, session]));
57
+ const liveWindows = new Map(live.windows.map((window) => [window.id, window]));
58
+ const sourceWindows = new Map(checkpoint.windows.map((window) => [window.id, window]));
59
+ for (const source of checkpoint.sessions) {
60
+ if (!resolved.has(source.id))
61
+ continue;
62
+ const actual = liveSessions.get(source.id);
63
+ if (!actual)
64
+ continue;
65
+ mapping.names[source.name] = actual.name;
66
+ mapRuntime(mapping, 'sessions', source.runtimeId, source.id, actual.runtimeId);
67
+ for (const link of source.windowLinks) {
68
+ const sourceWindow = sourceWindows.get(link.windowId);
69
+ const actualWindow = liveWindows.get(link.windowId);
70
+ if (!sourceWindow || !actualWindow)
71
+ continue;
72
+ mapRuntime(mapping, 'windows', sourceWindow.runtimeId, sourceWindow.id, actualWindow.runtimeId);
73
+ const actualPanes = new Map(actualWindow.panes.map((pane) => [pane.id, pane]));
74
+ for (const sourcePane of sourceWindow.panes) {
75
+ const actualPane = actualPanes.get(sourcePane.id);
76
+ if (actualPane)
77
+ mapRuntime(mapping, 'panes', sourcePane.runtimeId, sourcePane.id, actualPane.runtimeId);
78
+ }
79
+ }
80
+ }
81
+ return mapping;
82
+ }
83
+ function sorted(value) {
84
+ if (Array.isArray(value))
85
+ return value.map(sorted);
86
+ if (!value || typeof value !== 'object')
87
+ return value;
88
+ const record = value;
89
+ return Object.fromEntries(Object.keys(record).sort().map((key) => [key, sorted(record[key])]));
90
+ }
91
+ function restoreGuard(live) {
92
+ const topology = {
93
+ status: live.status,
94
+ tmuxVersion: live.tmuxVersion || null,
95
+ active: live.active || null,
96
+ sessions: live.sessions || [],
97
+ windows: live.windows || [],
98
+ };
99
+ return {
100
+ topologyFingerprint: crypto.createHash('sha256').update(JSON.stringify(sorted(topology))).digest('hex'),
101
+ identities: {
102
+ sessions: (live.sessions || []).map(({ id, runtimeId, name }) => [id, runtimeId, name]).sort(),
103
+ windows: (live.windows || []).map(({ id, runtimeId, name }) => [id, runtimeId, name]).sort(),
104
+ panes: (live.windows || []).flatMap((window) => (window.panes || []).map(({ id, runtimeId }) => [id, runtimeId])).sort(),
105
+ },
106
+ };
107
+ }
108
+ function sameRestoreGuard(left, right) {
109
+ return JSON.stringify(left) === JSON.stringify(right);
110
+ }
111
+ export function createWorkspaceRuntime({ store, tmux, lock, checkpointer, now = Date.now, randomUUID = crypto.randomUUID, planner = buildRestorePlan, executor = executeRestore, agents, access, home, restoreGuardAttempts = 3, }) {
112
+ const operations = createOperationManager({
113
+ store,
114
+ now,
115
+ randomUUID,
116
+ tryAcquireOperationLock: (owner) => lock.tryAcquire(owner),
117
+ });
118
+ let startPromise = null;
119
+ let startupHealth = { status: 'starting', detail: 'workspace-starting' };
120
+ async function resolveOperationRequest(requestInput = {}) {
121
+ const request = normalizeRestoreRequest(requestInput);
122
+ if (request.checkpointId !== 'latest')
123
+ return request;
124
+ const { checkpoint } = unwrapCheckpoint(await store.readLatestCheckpoint());
125
+ return { ...request, checkpointId: checkpoint.id };
126
+ }
127
+ async function planRestore(requestInput = {}) {
128
+ const request = normalizeRestoreRequest(requestInput);
129
+ const { checkpoint, warnings } = await readCheckpoint(store, request.checkpointId);
130
+ const recovery = await readRecovery(store, checkpoint.id, request.historical);
131
+ const live = await captureLive(tmux);
132
+ const plan = planner(checkpoint, live, {
133
+ sessionNames: request.sessions,
134
+ recovery,
135
+ historical: request.historical,
136
+ warnings,
137
+ });
138
+ return { request, checkpoint, recovery, live, plan };
139
+ }
140
+ async function guardedPlanRestore(requestInput = {}) {
141
+ const request = normalizeRestoreRequest(requestInput);
142
+ const { checkpoint, warnings } = await readCheckpoint(store, request.checkpointId);
143
+ const recovery = await readRecovery(store, checkpoint.id, request.historical);
144
+ let live = await captureLive(tmux);
145
+ for (let attempt = 0; attempt < restoreGuardAttempts; attempt += 1) {
146
+ const plan = planner(checkpoint, live, {
147
+ sessionNames: request.sessions,
148
+ recovery,
149
+ historical: request.historical,
150
+ warnings,
151
+ });
152
+ const before = restoreGuard(live);
153
+ const verified = await captureLive(tmux);
154
+ if (sameRestoreGuard(before, restoreGuard(verified))) {
155
+ return { request, checkpoint, recovery, live: verified, plan };
156
+ }
157
+ live = verified;
158
+ }
159
+ throw new Error(`tmux topology changed during restore planning ${restoreGuardAttempts} times; retry restore`);
160
+ }
161
+ async function getRestorePlan(request = {}) {
162
+ await start();
163
+ const state = await planRestore(request);
164
+ const serverNow = new Date(now()).toISOString();
165
+ const promptEligible = Boolean(!state.request.historical
166
+ && state.recovery
167
+ && state.recovery.resolvedAt === null
168
+ && state.recovery.pendingSessionIds.length > 0
169
+ && Date.parse(state.recovery.expiresAt) > now());
170
+ return Object.freeze({ ...state.plan, mapping: state.recovery?.mapping || null, serverNow, promptEligible });
171
+ }
172
+ async function getProtectionStatus() {
173
+ let live;
174
+ try {
175
+ live = await store.readLive();
176
+ }
177
+ catch {
178
+ return { status: 'degraded', lastSuccessfulCaptureAt: null, errorCode: 'live-unavailable' };
179
+ }
180
+ if (live.status === 'ok') {
181
+ return { status: 'protected', lastSuccessfulCaptureAt: live.value.capturedAt || null, errorCode: null };
182
+ }
183
+ if (live.status === 'empty') {
184
+ return { status: 'unprotected', lastSuccessfulCaptureAt: null, errorCode: null };
185
+ }
186
+ return {
187
+ status: 'degraded',
188
+ lastSuccessfulCaptureAt: null,
189
+ errorCode: live.status === 'corrupt' ? 'live-corrupt' : 'live-unavailable',
190
+ };
191
+ }
192
+ async function performRestore(operationId, request, onProgress, onRunning, onTerminal) {
193
+ let result;
194
+ let restoreError;
195
+ try {
196
+ result = await lock.withLock({ operationId }, async () => {
197
+ try {
198
+ await onRunning();
199
+ // The preview may be stale by now. Capture and plan again while holding the writer lock so a name
200
+ // created after preview receives the next non-destructive `-restored` suffix.
201
+ const state = await guardedPlanRestore(request);
202
+ const restored = await executor({
203
+ plan: state.plan,
204
+ checkpoint: state.checkpoint,
205
+ tmux,
206
+ ...(agents ? { agents } : {}),
207
+ onProgress,
208
+ ...(access ? { access } : {}),
209
+ ...(home ? { home } : {}),
210
+ });
211
+ const resolvedIds = restored.results
212
+ .filter((row) => row.status === 'restored' || row.status === 'already-present')
213
+ .map((row) => row.logicalId);
214
+ const mapping = buildRecoveryMapping(state.checkpoint.id, state.recovery?.mapping, [
215
+ restored.mapping,
216
+ mappingForAlreadyPresent(state.checkpoint, state.live, restored.results),
217
+ ], now);
218
+ if (mapping && typeof store.mergeRecoveryMapping === 'function') {
219
+ await store.mergeRecoveryMapping(state.checkpoint.id, mapping);
220
+ }
221
+ // Persist migration data before resolving pending ids. If the second write fails, retry planning
222
+ // still sees the session and will recognize it as already-present instead of losing its mapping.
223
+ if (resolvedIds.length > 0)
224
+ await store.resolveSessions(state.checkpoint.id, resolvedIds);
225
+ const lockedResult = { ...restored, mapping };
226
+ await onTerminal(lockedResult);
227
+ return lockedResult;
228
+ }
229
+ catch (error) {
230
+ await onTerminal(error);
231
+ throw error;
232
+ }
233
+ });
234
+ }
235
+ catch (error) {
236
+ restoreError = error;
237
+ }
238
+ let reconcileWarning;
239
+ try {
240
+ const reconciled = await checkpointer.reconcile('restore-complete');
241
+ if (!['written', 'unchanged'].includes(reconciled?.status)) {
242
+ reconcileWarning = `live reconcile ${reconciled?.status || 'unknown'}; workspace protection may be degraded`;
243
+ }
244
+ }
245
+ catch (error) {
246
+ reconcileWarning = `live reconcile failed: ${errorMessage(error)}`;
247
+ }
248
+ if (restoreError)
249
+ throw restoreError;
250
+ const completed = result;
251
+ if (!completed)
252
+ throw new Error('restore completed without a result');
253
+ if (reconcileWarning) {
254
+ result = {
255
+ ...completed,
256
+ warnings: [...(completed.warnings || []), reconcileWarning],
257
+ };
258
+ }
259
+ return result ?? completed;
260
+ }
261
+ async function startOnce() {
262
+ let interrupted;
263
+ let sweepError;
264
+ try {
265
+ interrupted = await operations.interruptOrphans();
266
+ }
267
+ catch (error) {
268
+ sweepError = error;
269
+ }
270
+ let started;
271
+ let startError;
272
+ try {
273
+ started = await checkpointer.start?.();
274
+ }
275
+ catch (error) {
276
+ startError = error;
277
+ }
278
+ if (sweepError && startError) {
279
+ const sweepMessage = sweepError instanceof Error ? sweepError.message : String(sweepError);
280
+ const startMessage = startError instanceof Error ? startError.message : String(startError);
281
+ throw new AggregateError([sweepError, startError], `${sweepMessage}; workspace protection start failed: ${startMessage}`);
282
+ }
283
+ if (sweepError)
284
+ throw sweepError;
285
+ if (startError)
286
+ throw startError;
287
+ return [interrupted, started];
288
+ }
289
+ function start() {
290
+ if (!startPromise) {
291
+ startupHealth = { status: 'starting', detail: 'workspace-starting' };
292
+ startPromise = startOnce().then((result) => {
293
+ startupHealth = { status: 'ready', detail: null };
294
+ return result;
295
+ }).catch((error) => {
296
+ startupHealth = { status: 'degraded', detail: 'workspace-start-failed' };
297
+ startPromise = null;
298
+ throw error;
299
+ });
300
+ }
301
+ return startPromise;
302
+ }
303
+ return {
304
+ start,
305
+ health: () => (startupHealth.status === 'ready'
306
+ ? (checkpointer.health?.() || startupHealth)
307
+ : startupHealth),
308
+ stop: () => checkpointer.stop(),
309
+ requestReconcile: () => checkpointer.requestReconcile(),
310
+ confirmEmpty: () => checkpointer.confirmEmpty(),
311
+ listCheckpoints: () => store.listCheckpoints(),
312
+ getProtectionStatus,
313
+ getRestorePlan,
314
+ startRestore: async (request = {}) => operations.start(await resolveOperationRequest(request), ({ operationId, request: normalized, onProgress, onRunning, onTerminal }) => (performRestore(operationId, normalized, onProgress, onRunning, onTerminal)), { deferRunning: true }),
315
+ getOperation: (id) => operations.get(id),
316
+ restoreNow: async (request = {}) => operations.run(await resolveOperationRequest(request), ({ operationId, request: normalized, onProgress, onRunning, onTerminal }) => (performRestore(operationId, normalized, onProgress, onRunning, onTerminal)), { deferRunning: true }),
317
+ };
318
+ }