handmux 0.22.1 → 0.24.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 (247) hide show
  1. package/README.md +3 -3
  2. package/README.zh-CN.md +3 -3
  3. package/bin/handmux.js +3 -688
  4. package/dist/bin/handmux.js +850 -0
  5. package/{hooks → dist/hooks}/handmux-notify.sh +2 -4
  6. package/{hooks → dist/hooks}/handmux-statusline.cjs +28 -6
  7. package/{hooks → dist/hooks}/handmux-write.cjs +25 -26
  8. package/dist/package.json +126 -0
  9. package/dist/public/assets/index-CicdY9E9.css +32 -0
  10. package/dist/public/assets/index-D4VP5t8h.js +337 -0
  11. package/dist/public/assets/pcm-worklet-CxcDy7PB.js +1 -0
  12. package/{public → dist/public}/index.html +5 -4
  13. package/{src → dist/src}/agents/claude.js +106 -82
  14. package/dist/src/agents/codex.js +217 -0
  15. package/{src → dist/src}/agents/index.js +9 -10
  16. package/dist/src/agents/processIdentity.js +80 -0
  17. package/dist/src/agents/scanUtils.js +262 -0
  18. package/dist/src/apiErrors.js +77 -0
  19. package/{src → dist/src}/appName.js +12 -13
  20. package/dist/src/asr/iflyConfig.js +7 -0
  21. package/dist/src/asr/iflySign.js +14 -0
  22. package/dist/src/auth.js +27 -0
  23. package/dist/src/browser/bootstrap.js +72 -0
  24. package/dist/src/browser/cookieProfiles.js +413 -0
  25. package/dist/src/browser/coordinator.js +51 -0
  26. package/dist/src/browser/hammerheadCompat.js +104 -0
  27. package/dist/src/browser/hammerheadRedirectCompat.js +37 -0
  28. package/dist/src/browser/manager.js +560 -0
  29. package/dist/src/browser/originLabel.js +15 -0
  30. package/dist/src/browser/profilePersistence.js +235 -0
  31. package/dist/src/browser/publicProxy.js +176 -0
  32. package/dist/src/browser/routes.js +159 -0
  33. package/dist/src/browser/sessionStore.js +72 -0
  34. package/dist/src/browser/siteVersion.js +121 -0
  35. package/dist/src/browser/targetPolicy.js +163 -0
  36. package/{src → dist/src}/browser/worker.js +14 -18
  37. package/dist/src/browser/workerClient.js +286 -0
  38. package/dist/src/browser/workerServer.js +91 -0
  39. package/dist/src/captureBackground.js +95 -0
  40. package/dist/src/claudeEvents.js +499 -0
  41. package/{src → dist/src}/cli/claudeHooks.js +146 -108
  42. package/{src → dist/src}/cli/cloudflareUrl.js +2 -3
  43. package/dist/src/cli/cloudflared.js +106 -0
  44. package/dist/src/cli/codexManaged.js +77 -0
  45. package/{src → dist/src}/cli/cpolarUrl.js +8 -9
  46. package/dist/src/cli/drivers.js +98 -0
  47. package/dist/src/cli/hookScaffold.js +36 -0
  48. package/dist/src/cli/i18n/en.js +366 -0
  49. package/{src → dist/src}/cli/i18n/index.js +27 -25
  50. package/dist/src/cli/i18n/zh.js +365 -0
  51. package/dist/src/cli/legacyCodexHooks.js +52 -0
  52. package/{src → dist/src}/cli/natappUrl.js +2 -3
  53. package/dist/src/cli/options.js +245 -0
  54. package/dist/src/cli/probe.js +16 -0
  55. package/{src → dist/src}/cli/prompt.js +4 -6
  56. package/dist/src/cli/pushCmd.js +102 -0
  57. package/{src → dist/src}/cli/qr.js +19 -19
  58. package/dist/src/cli/service.js +144 -0
  59. package/dist/src/cli/setupModel.js +194 -0
  60. package/dist/src/cli/setupWizard.js +647 -0
  61. package/dist/src/cli/shortcutEditor.js +351 -0
  62. package/dist/src/cli/sshTunnel.js +18 -0
  63. package/dist/src/cli/state.js +114 -0
  64. package/{src → dist/src}/cli/statusLine.js +63 -46
  65. package/dist/src/cli/supervisor.js +278 -0
  66. package/dist/src/cli/supervisorHealth.js +21 -0
  67. package/dist/src/cli/supervisorLaunch.js +148 -0
  68. package/dist/src/cli/supervisorProcesses.js +62 -0
  69. package/dist/src/cli/supervisorState.js +62 -0
  70. package/dist/src/cli/tmuxVersion.js +53 -0
  71. package/{src → dist/src}/cli/tunlite.js +7 -10
  72. package/dist/src/cli/tunnelClients.js +72 -0
  73. package/{src → dist/src}/cli/updateCheck.js +98 -69
  74. package/{src → dist/src}/cli/urlHost.js +8 -4
  75. package/dist/src/cli/workspaceCmd.js +347 -0
  76. package/dist/src/codexAppServer.js +2299 -0
  77. package/dist/src/codexConversationProjection.js +199 -0
  78. package/dist/src/codexDiff.js +118 -0
  79. package/dist/src/codexMessageIdentity.js +18 -0
  80. package/dist/src/codexPlan.js +30 -0
  81. package/dist/src/codexQueueProtocol.js +96 -0
  82. package/dist/src/codexStreamProtocol.js +207 -0
  83. package/dist/src/codexToolProtocol.js +68 -0
  84. package/dist/src/codexTranscriptParse.js +724 -0
  85. package/dist/src/codexUsageSnapshot.js +205 -0
  86. package/dist/src/config.js +8 -0
  87. package/{src → dist/src}/docPath.js +22 -24
  88. package/dist/src/docs.js +337 -0
  89. package/dist/src/git.js +272 -0
  90. package/dist/src/healthProtocol.js +97 -0
  91. package/dist/src/httpApi.js +68 -0
  92. package/dist/src/jsonStore.js +15 -0
  93. package/{src → dist/src}/keyNames.js +3 -3
  94. package/dist/src/notifications.js +111 -0
  95. package/dist/src/orphans.js +157 -0
  96. package/dist/src/pendingPrompt.js +194 -0
  97. package/dist/src/previewServer.js +202 -0
  98. package/dist/src/previews.js +179 -0
  99. package/dist/src/privateStateStore.js +89 -0
  100. package/dist/src/push.js +224 -0
  101. package/dist/src/routes/codex.js +606 -0
  102. package/dist/src/routes/files.js +230 -0
  103. package/dist/src/routes/git.js +78 -0
  104. package/dist/src/routes/health.js +16 -0
  105. package/dist/src/routes/notifications.js +16 -0
  106. package/dist/src/routes/previews.js +43 -0
  107. package/dist/src/routes/push.js +145 -0
  108. package/dist/src/routes/sessions.js +232 -0
  109. package/dist/src/routes/system.js +156 -0
  110. package/dist/src/routes/terminal.js +238 -0
  111. package/dist/src/routes/transcript.js +197 -0
  112. package/dist/src/routes/workspace.js +267 -0
  113. package/{src → dist/src}/server.js +110 -76
  114. package/dist/src/shortcutConfig.js +83 -0
  115. package/{src → dist/src}/staticCache.js +5 -3
  116. package/{src → dist/src}/staticCompression.js +5 -6
  117. package/dist/src/terminalStream.js +441 -0
  118. package/{src → dist/src}/tmux/commands.js +160 -167
  119. package/dist/src/tmux/format.js +47 -0
  120. package/dist/src/transcriptParse.js +240 -0
  121. package/dist/src/transcriptReader.js +113 -0
  122. package/{src → dist/src}/trimCapture.js +11 -11
  123. package/dist/src/uploadTypes.js +29 -0
  124. package/dist/src/urlPolicy.js +18 -0
  125. package/dist/src/usage.js +260 -0
  126. package/dist/src/workspace/agentRunner.js +204 -0
  127. package/dist/src/workspace/atomicJson.js +31 -0
  128. package/dist/src/workspace/capture.js +139 -0
  129. package/dist/src/workspace/checkpointer.js +228 -0
  130. package/dist/src/workspace/environment.js +71 -0
  131. package/dist/src/workspace/lock.js +137 -0
  132. package/dist/src/workspace/mapping.js +128 -0
  133. package/dist/src/workspace/operations.js +271 -0
  134. package/dist/src/workspace/paths.js +15 -0
  135. package/dist/src/workspace/planner.js +292 -0
  136. package/dist/src/workspace/restore.js +314 -0
  137. package/dist/src/workspace/runtime.js +318 -0
  138. package/dist/src/workspace/schema.js +156 -0
  139. package/dist/src/workspace/store.js +464 -0
  140. package/dist/src/workspace/tmuxAdapter.js +694 -0
  141. package/dist/src/workspaceProtocol.js +158 -0
  142. package/package.json +28 -16
  143. package/hooks/handmux-codex-usage.cjs +0 -141
  144. package/public/assets/index-ClOByNCn.js +0 -335
  145. package/public/assets/index-rjrtUwoJ.css +0 -32
  146. package/src/.gitkeep +0 -0
  147. package/src/agents/codex.js +0 -121
  148. package/src/agents/processIdentity.js +0 -72
  149. package/src/agents/scanUtils.js +0 -225
  150. package/src/asr/iflyConfig.js +0 -10
  151. package/src/asr/iflySign.js +0 -16
  152. package/src/auth.js +0 -30
  153. package/src/browser/bootstrap.js +0 -68
  154. package/src/browser/cookieProfiles.js +0 -403
  155. package/src/browser/coordinator.js +0 -47
  156. package/src/browser/hammerheadCompat.js +0 -86
  157. package/src/browser/hammerheadRedirectCompat.js +0 -38
  158. package/src/browser/manager.js +0 -556
  159. package/src/browser/originLabel.js +0 -17
  160. package/src/browser/profilePersistence.js +0 -241
  161. package/src/browser/publicProxy.js +0 -175
  162. package/src/browser/routes.js +0 -154
  163. package/src/browser/sessionStore.js +0 -78
  164. package/src/browser/siteVersion.js +0 -124
  165. package/src/browser/targetPolicy.js +0 -137
  166. package/src/browser/workerClient.js +0 -253
  167. package/src/browser/workerServer.js +0 -98
  168. package/src/captureBackground.js +0 -93
  169. package/src/claudeEvents.js +0 -356
  170. package/src/cli/cloudflared.js +0 -99
  171. package/src/cli/codexHooks.js +0 -123
  172. package/src/cli/drivers.js +0 -97
  173. package/src/cli/hookScaffold.js +0 -35
  174. package/src/cli/i18n/en.js +0 -388
  175. package/src/cli/i18n/zh.js +0 -387
  176. package/src/cli/options.js +0 -211
  177. package/src/cli/probe.js +0 -16
  178. package/src/cli/pushCmd.js +0 -60
  179. package/src/cli/service.js +0 -126
  180. package/src/cli/setupModel.js +0 -154
  181. package/src/cli/setupWizard.js +0 -506
  182. package/src/cli/shortcutEditor.js +0 -288
  183. package/src/cli/sshTunnel.js +0 -12
  184. package/src/cli/state.js +0 -90
  185. package/src/cli/supervisor.js +0 -183
  186. package/src/cli/supervisorProcesses.js +0 -57
  187. package/src/cli/tmuxVersion.js +0 -49
  188. package/src/cli/tunnelClients.js +0 -62
  189. package/src/cli/workspaceCmd.js +0 -300
  190. package/src/config.js +0 -9
  191. package/src/docs.js +0 -228
  192. package/src/git.js +0 -208
  193. package/src/httpApi.js +0 -69
  194. package/src/jsonStore.js +0 -24
  195. package/src/notifications.js +0 -68
  196. package/src/orphans.js +0 -150
  197. package/src/pendingPrompt.js +0 -163
  198. package/src/previewServer.js +0 -168
  199. package/src/previews.js +0 -116
  200. package/src/push.js +0 -183
  201. package/src/routes/files.js +0 -172
  202. package/src/routes/git.js +0 -57
  203. package/src/routes/notifications.js +0 -19
  204. package/src/routes/previews.js +0 -35
  205. package/src/routes/push.js +0 -119
  206. package/src/routes/sessions.js +0 -163
  207. package/src/routes/system.js +0 -145
  208. package/src/routes/terminal.js +0 -210
  209. package/src/routes/transcript.js +0 -115
  210. package/src/routes/workspace.js +0 -267
  211. package/src/shortcutConfig.js +0 -75
  212. package/src/terminalStream.js +0 -391
  213. package/src/tmux/format.js +0 -45
  214. package/src/transcriptParse.js +0 -180
  215. package/src/transcriptReader.js +0 -101
  216. package/src/uploadTypes.js +0 -30
  217. package/src/urlPolicy.js +0 -14
  218. package/src/usage.js +0 -261
  219. package/src/workspace/agentRunner.js +0 -185
  220. package/src/workspace/atomicJson.js +0 -32
  221. package/src/workspace/capture.js +0 -87
  222. package/src/workspace/checkpointer.js +0 -183
  223. package/src/workspace/environment.js +0 -75
  224. package/src/workspace/lock.js +0 -128
  225. package/src/workspace/mapping.js +0 -124
  226. package/src/workspace/operations.js +0 -252
  227. package/src/workspace/paths.js +0 -16
  228. package/src/workspace/planner.js +0 -247
  229. package/src/workspace/restore.js +0 -279
  230. package/src/workspace/runtime.js +0 -314
  231. package/src/workspace/schema.js +0 -136
  232. package/src/workspace/store.js +0 -382
  233. package/src/workspace/tmuxAdapter.js +0 -554
  234. /package/{public → dist/public}/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
  235. /package/{public → dist/public}/fonts/TWUnifont.woff2 +0 -0
  236. /package/{public → dist/public}/icons/apple-touch-icon.png +0 -0
  237. /package/{public → dist/public}/icons/badge-96.png +0 -0
  238. /package/{public → dist/public}/icons/icon-192-maskable.png +0 -0
  239. /package/{public → dist/public}/icons/icon-192.png +0 -0
  240. /package/{public → dist/public}/icons/icon-512-maskable.png +0 -0
  241. /package/{public → dist/public}/icons/icon-512.png +0 -0
  242. /package/{public → dist/public}/icons/logo.svg +0 -0
  243. /package/{public → dist/public}/manifest.webmanifest +0 -0
  244. /package/{public → dist/public}/offline.html +0 -0
  245. /package/{public → dist/public}/sw.js +0 -0
  246. /package/{src → dist/src}/browser/protocol.js +0 -0
  247. /package/{src → dist/src}/cli/cfNamed.js +0 -0
@@ -1,279 +0,0 @@
1
- import fsp from 'node:fs/promises';
2
- import os from 'node:os';
3
- import { AGENTS } from '../agents/index.js';
4
-
5
- function message(error) {
6
- return error instanceof Error ? error.message : String(error);
7
- }
8
-
9
- function windowMap(checkpoint) {
10
- return new Map(checkpoint.windows.map((window) => [window.id, window]));
11
- }
12
-
13
- function sessionMap(checkpoint) {
14
- return new Map(checkpoint.sessions.map((session) => [session.id, session]));
15
- }
16
-
17
- function emptyMapping() {
18
- return {
19
- names: {},
20
- runtime: { sessions: {}, windows: {}, panes: {} },
21
- logical: { sessions: {}, windows: {}, panes: {} },
22
- };
23
- }
24
-
25
- function mergeMapping(target, source) {
26
- Object.assign(target.names, source.names);
27
- for (const kind of ['sessions', 'windows', 'panes']) {
28
- Object.assign(target.runtime[kind], source.runtime[kind]);
29
- Object.assign(target.logical[kind], source.logical[kind]);
30
- }
31
- return target;
32
- }
33
-
34
- function mapRuntime(mapping, kind, source, logical, actual) {
35
- if (typeof source === 'string' && source) mapping.runtime[kind][source] = actual;
36
- if (typeof logical === 'string' && logical) mapping.logical[kind][logical] = actual;
37
- }
38
-
39
- async function usableCwd(cwd, { access, home, warnings }) {
40
- if (typeof cwd === 'string' && cwd) {
41
- try {
42
- await access(cwd);
43
- return cwd;
44
- } catch { /* fall through */ }
45
- }
46
- warnings.push(`cwd ${typeof cwd === 'string' && cwd ? cwd : '(missing)'} is unavailable; restored in ${home}`);
47
- return home;
48
- }
49
-
50
- function sortedPanes(window) {
51
- return [...window.panes].sort((a, b) => a.index - b.index);
52
- }
53
-
54
- function dispositionMap(plan) {
55
- return new Map((plan.windows || []).map((window) => [window.logicalId, window]));
56
- }
57
-
58
- async function notify(onProgress, results, result, total) {
59
- if (typeof onProgress !== 'function') return;
60
- await onProgress({ completed: results.length, total, result });
61
- }
62
-
63
- async function restoreOneSession({
64
- item, checkpoint, tmux, agents, access, home, dispositions, restoredWindows,
65
- }) {
66
- const sessions = sessionMap(checkpoint);
67
- const windows = windowMap(checkpoint);
68
- const source = sessions.get(item.logicalId);
69
- if (!source) throw Object.assign(new Error('checkpoint session is missing'), { stage: 'plan' });
70
-
71
- const warnings = [];
72
- const mapping = emptyMapping();
73
- const localWindows = new Map();
74
- const localPanes = new Map();
75
- const links = [...item.windowLinks].sort((a, b) => a.index - b.index);
76
- const seedLink = links.find((link) => {
77
- if (restoredWindows.has(link.windowId)) return false;
78
- return dispositions.get(link.windowId)?.action !== 'reuse';
79
- });
80
- let temp = null;
81
- let topologyComplete = false;
82
-
83
- try {
84
- if (seedLink) {
85
- const seedWindow = windows.get(seedLink.windowId);
86
- const seedPane = sortedPanes(seedWindow)[0];
87
- const cwd = await usableCwd(seedPane.cwd, { access, home, warnings });
88
- temp = await tmux.createTemporarySession({
89
- cwd,
90
- sessionLogicalId: item.logicalId,
91
- windowLogicalId: seedWindow.id,
92
- paneLogicalId: seedPane.id,
93
- windowName: seedWindow.name,
94
- windowIndex: seedLink.index,
95
- });
96
- localWindows.set(seedWindow.id, temp.windowId);
97
- localPanes.set(seedPane.id, temp.paneId);
98
- mapRuntime(mapping, 'windows', seedWindow.runtimeId, seedWindow.id, temp.windowId);
99
- mapRuntime(mapping, 'panes', seedPane.runtimeId, seedPane.id, temp.paneId);
100
- } else {
101
- const fallbackWindow = windows.get(links[0]?.windowId);
102
- const cwd = await usableCwd(sortedPanes(fallbackWindow)[0].cwd, { access, home, warnings });
103
- temp = await tmux.createTemporarySession({ cwd, sessionLogicalId: item.logicalId });
104
- }
105
- mapRuntime(mapping, 'sessions', source.runtimeId, source.id, temp.sessionId);
106
-
107
- for (const link of links) {
108
- const window = windows.get(link.windowId);
109
- if (!window) throw new Error(`checkpoint window ${link.windowId} is missing`);
110
- if (localWindows.has(window.id)) continue;
111
-
112
- const sharedRuntime = restoredWindows.get(window.id);
113
- const disposition = dispositions.get(window.id);
114
- if (sharedRuntime || disposition?.action === 'reuse') {
115
- const windowId = sharedRuntime || disposition.runtimeId;
116
- await tmux.linkWindow(windowId, temp.sessionId, link.index, { existing: !sharedRuntime });
117
- if (!sharedRuntime) mapRuntime(mapping, 'windows', window.runtimeId, window.id, windowId);
118
- continue;
119
- }
120
-
121
- const firstPane = sortedPanes(window)[0];
122
- const cwd = await usableCwd(firstPane.cwd, { access, home, warnings });
123
- const created = await tmux.createWindow(temp.sessionId, {
124
- name: window.name,
125
- index: link.index,
126
- cwd,
127
- windowLogicalId: window.id,
128
- paneLogicalId: firstPane.id,
129
- });
130
- localWindows.set(window.id, created.windowId);
131
- localPanes.set(firstPane.id, created.paneId);
132
- mapRuntime(mapping, 'windows', window.runtimeId, window.id, created.windowId);
133
- mapRuntime(mapping, 'panes', firstPane.runtimeId, firstPane.id, created.paneId);
134
- }
135
-
136
- for (const [logicalId, runtimeId] of localWindows) {
137
- const window = windows.get(logicalId);
138
- const panes = sortedPanes(window);
139
- const seedPaneId = localPanes.get(panes[0].id);
140
- for (const pane of panes.slice(1)) {
141
- const cwd = await usableCwd(pane.cwd, { access, home, warnings });
142
- const paneId = await tmux.splitPane(seedPaneId, { cwd, paneLogicalId: pane.id });
143
- localPanes.set(pane.id, paneId);
144
- mapRuntime(mapping, 'panes', pane.runtimeId, pane.id, paneId);
145
- }
146
- try {
147
- await tmux.applyLayout(runtimeId, window.layout);
148
- } catch (error) {
149
- warnings.push(`layout for ${window.name} was unavailable; kept the default layout (${message(error)})`);
150
- }
151
- const activePane = localPanes.get(window.activePaneId);
152
- if (activePane) await tmux.selectPane(activePane);
153
- }
154
-
155
- const activeLink = links.find((link) => link.windowId === item.activeWindowId);
156
- if (activeLink) {
157
- if (typeof tmux.selectWindowInSession === 'function') await tmux.selectWindowInSession(temp.sessionId, activeLink.index);
158
- else {
159
- const activeWindow = localWindows.get(activeLink.windowId) || restoredWindows.get(activeLink.windowId);
160
- if (activeWindow) await tmux.selectWindow(activeWindow);
161
- }
162
- }
163
-
164
- if (!seedLink && temp.windowId) await tmux.killCreatedWindow(temp.windowId);
165
- await tmux.renameCreatedSession(temp.sessionId, item.targetName);
166
- topologyComplete = true;
167
- mapping.names[item.sourceName] = item.targetName;
168
- for (const [logicalId, runtimeId] of localWindows) restoredWindows.set(logicalId, runtimeId);
169
-
170
- for (const [logicalId, paneId] of localPanes) {
171
- const pane = [...windows.values()].flatMap((window) => window.panes).find((candidate) => candidate.id === logicalId);
172
- if (!pane?.agent) continue;
173
- const driver = agents.find((candidate) => candidate.id === pane.agent.id);
174
- if (!driver) {
175
- warnings.push(`agent ${pane.agent.id || '(missing)'} is unsupported; shell was restored`);
176
- continue;
177
- }
178
- if (!driver.sessions.isId(pane.agent.sessionId)) {
179
- warnings.push(`agent ${driver.id} session id is invalid; shell was restored`);
180
- continue;
181
- }
182
- try {
183
- await access(pane.agent.transcriptPath);
184
- } catch {
185
- warnings.push(`agent ${driver.id} context is unavailable; shell was restored`);
186
- continue;
187
- }
188
- const [cmd, ...args] = driver.sessions.resumeArgs(pane.agent.sessionId);
189
- try {
190
- await tmux.startAgent(paneId, cmd, args);
191
- } catch (error) {
192
- warnings.push(`agent ${driver.id} resume failed; shell was restored (${message(error)})`);
193
- }
194
- }
195
-
196
- return {
197
- logicalId: item.logicalId,
198
- sourceName: item.sourceName,
199
- targetName: item.targetName,
200
- status: 'restored',
201
- warnings,
202
- mapping,
203
- };
204
- } catch (error) {
205
- let cleanupError;
206
- if (temp && !topologyComplete) {
207
- try { await tmux.killCreatedSession(temp.sessionId); } catch (failure) { cleanupError = failure; }
208
- }
209
- const failure = cleanupError
210
- ? new Error(`${message(error)}; cleanup failed: ${message(cleanupError)}`)
211
- : error;
212
- failure.stage ||= error?.stage || 'topology';
213
- throw failure;
214
- }
215
- }
216
-
217
- function restoredTopologySummary(checkpoint, results) {
218
- const restoredSessions = new Set(results
219
- .filter((result) => result.status === 'restored')
220
- .map((result) => result.logicalId));
221
- const windowIds = new Set((checkpoint?.sessions || [])
222
- .filter((session) => restoredSessions.has(session.id))
223
- .flatMap((session) => (session.windowLinks || []).map((link) => link.windowId)));
224
- const paneIds = new Set((checkpoint?.windows || [])
225
- .filter((window) => windowIds.has(window.id))
226
- .flatMap((window) => (window.panes || []).map((pane) => pane.id)));
227
- return { sessions: restoredSessions.size, windows: windowIds.size, panes: paneIds.size };
228
- }
229
-
230
- export function summarizeRestore(results, checkpoint = null) {
231
- const mapping = emptyMapping();
232
- for (const result of results) if (result.mapping) mergeMapping(mapping, result.mapping);
233
- const restored = results.filter((result) => result.status === 'restored').length;
234
- const alreadyPresent = results.filter((result) => result.status === 'already-present').length;
235
- const failed = results.filter((result) => result.status === 'failed').length;
236
- const status = failed === 0 ? 'succeeded' : restored + alreadyPresent > 0 ? 'partial' : 'failed';
237
- return { status, restored, alreadyPresent, failed, results, mapping, summary: restoredTopologySummary(checkpoint, results) };
238
- }
239
-
240
- export async function executeRestore({
241
- plan,
242
- checkpoint,
243
- tmux,
244
- agents = AGENTS,
245
- onProgress,
246
- access = fsp.access,
247
- home = os.homedir(),
248
- } = {}) {
249
- const results = [];
250
- const dispositions = dispositionMap(plan);
251
- const restoredWindows = new Map();
252
- try {
253
- for (const item of plan.sessions) {
254
- let result;
255
- if (item.action === 'already-present') {
256
- result = { logicalId: item.logicalId, sourceName: item.sourceName, status: 'already-present' };
257
- } else if (item.action === 'unsupported') {
258
- result = { logicalId: item.logicalId, sourceName: item.sourceName, status: 'failed', stage: 'plan', error: item.reason };
259
- } else {
260
- try {
261
- result = await restoreOneSession({ item, checkpoint, tmux, agents, access, home, dispositions, restoredWindows });
262
- } catch (error) {
263
- result = {
264
- logicalId: item.logicalId,
265
- sourceName: item.sourceName,
266
- status: 'failed',
267
- stage: error.stage || 'topology',
268
- error: message(error),
269
- };
270
- }
271
- }
272
- results.push(result);
273
- await notify(onProgress, results, result, plan.sessions.length);
274
- }
275
- return summarizeRestore(results, checkpoint);
276
- } finally {
277
- tmux.revokeCreatedTargets?.();
278
- }
279
- }
@@ -1,314 +0,0 @@
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
-
7
- function unwrapCheckpoint(result) {
8
- if (result?.status !== 'ok' || !result.value) {
9
- throw new Error(result?.error || `checkpoint is ${result?.status || 'unavailable'}`);
10
- }
11
- return { checkpoint: result.value, warnings: [result.warning, ...(result.warnings || [])].filter(Boolean) };
12
- }
13
-
14
- async function readCheckpoint(store, checkpointId) {
15
- return unwrapCheckpoint(checkpointId === 'latest'
16
- ? await store.readLatestCheckpoint()
17
- : await store.readCheckpoint(checkpointId));
18
- }
19
-
20
- async function readRecovery(store, checkpointId, historical) {
21
- const result = await store.readRecovery(checkpointId);
22
- if (result.status === 'ok') return result.value;
23
- if (historical && result.status === 'missing') return null;
24
- throw new Error(result.error || `recovery state is ${result.status}`);
25
- }
26
-
27
- async function captureLive(tmux) {
28
- const live = await tmux.captureTopology({ readOnly: true });
29
- if (live?.status === 'ok') return live;
30
- if (live?.status === 'empty') return { ...live, sessions: [], windows: [] };
31
- throw new Error(live?.error || 'current tmux topology is unavailable');
32
- }
33
-
34
- function blankMapping() {
35
- return {
36
- names: {},
37
- runtime: { sessions: {}, windows: {}, panes: {} },
38
- logical: { sessions: {}, windows: {}, panes: {} },
39
- };
40
- }
41
-
42
- function mapRuntime(mapping, kind, source, logical, runtime) {
43
- if (typeof source === 'string' && source) mapping.runtime[kind][source] = runtime;
44
- if (typeof logical === 'string' && logical) mapping.logical[kind][logical] = runtime;
45
- }
46
-
47
- function mappingForAlreadyPresent(checkpoint, live, results) {
48
- const mapping = blankMapping();
49
- const resolved = new Set(results.filter((row) => row.status === 'already-present').map((row) => row.logicalId));
50
- const liveSessions = new Map(live.sessions.map((session) => [session.id, session]));
51
- const liveWindows = new Map(live.windows.map((window) => [window.id, window]));
52
- const sourceWindows = new Map(checkpoint.windows.map((window) => [window.id, window]));
53
- for (const source of checkpoint.sessions) {
54
- if (!resolved.has(source.id)) continue;
55
- const actual = liveSessions.get(source.id);
56
- if (!actual) continue;
57
- mapping.names[source.name] = actual.name;
58
- mapRuntime(mapping, 'sessions', source.runtimeId, source.id, actual.runtimeId);
59
- for (const link of source.windowLinks) {
60
- const sourceWindow = sourceWindows.get(link.windowId);
61
- const actualWindow = liveWindows.get(link.windowId);
62
- if (!sourceWindow || !actualWindow) continue;
63
- mapRuntime(mapping, 'windows', sourceWindow.runtimeId, sourceWindow.id, actualWindow.runtimeId);
64
- const actualPanes = new Map(actualWindow.panes.map((pane) => [pane.id, pane]));
65
- for (const sourcePane of sourceWindow.panes) {
66
- const actualPane = actualPanes.get(sourcePane.id);
67
- if (actualPane) mapRuntime(mapping, 'panes', sourcePane.runtimeId, sourcePane.id, actualPane.runtimeId);
68
- }
69
- }
70
- }
71
- return mapping;
72
- }
73
-
74
- function sorted(value) {
75
- if (Array.isArray(value)) return value.map(sorted);
76
- if (!value || typeof value !== 'object') return value;
77
- return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sorted(value[key])]));
78
- }
79
-
80
- function restoreGuard(live) {
81
- const topology = {
82
- status: live.status,
83
- tmuxVersion: live.tmuxVersion || null,
84
- active: live.active || null,
85
- sessions: live.sessions || [],
86
- windows: live.windows || [],
87
- };
88
- return {
89
- topologyFingerprint: crypto.createHash('sha256').update(JSON.stringify(sorted(topology))).digest('hex'),
90
- identities: {
91
- sessions: (live.sessions || []).map(({ id, runtimeId, name }) => [id, runtimeId, name]).sort(),
92
- windows: (live.windows || []).map(({ id, runtimeId, name }) => [id, runtimeId, name]).sort(),
93
- panes: (live.windows || []).flatMap((window) => (window.panes || []).map(({ id, runtimeId }) => [id, runtimeId])).sort(),
94
- },
95
- };
96
- }
97
-
98
- function sameRestoreGuard(left, right) {
99
- return JSON.stringify(left) === JSON.stringify(right);
100
- }
101
-
102
- export function createWorkspaceRuntime({
103
- store,
104
- tmux,
105
- lock,
106
- checkpointer,
107
- now = Date.now,
108
- randomUUID = crypto.randomUUID,
109
- planner = buildRestorePlan,
110
- executor = executeRestore,
111
- agents,
112
- access,
113
- home,
114
- restoreGuardAttempts = 3,
115
- } = {}) {
116
- const operations = createOperationManager({
117
- store,
118
- now,
119
- randomUUID,
120
- tryAcquireOperationLock: (owner) => lock.tryAcquire(owner),
121
- });
122
- let startPromise = null;
123
-
124
- async function resolveOperationRequest(requestInput = {}) {
125
- const request = normalizeRestoreRequest(requestInput);
126
- if (request.checkpointId !== 'latest') return request;
127
- const { checkpoint } = unwrapCheckpoint(await store.readLatestCheckpoint());
128
- return { ...request, checkpointId: checkpoint.id };
129
- }
130
-
131
- async function planRestore(requestInput = {}) {
132
- const request = normalizeRestoreRequest(requestInput);
133
- const { checkpoint, warnings } = await readCheckpoint(store, request.checkpointId);
134
- const recovery = await readRecovery(store, checkpoint.id, request.historical);
135
- const live = await captureLive(tmux);
136
- const plan = planner(checkpoint, live, {
137
- sessionNames: request.sessions,
138
- recovery,
139
- historical: request.historical,
140
- warnings,
141
- });
142
- return { request, checkpoint, recovery, live, plan };
143
- }
144
-
145
- async function guardedPlanRestore(requestInput = {}) {
146
- const request = normalizeRestoreRequest(requestInput);
147
- const { checkpoint, warnings } = await readCheckpoint(store, request.checkpointId);
148
- const recovery = await readRecovery(store, checkpoint.id, request.historical);
149
- let live = await captureLive(tmux);
150
- for (let attempt = 0; attempt < restoreGuardAttempts; attempt += 1) {
151
- const plan = planner(checkpoint, live, {
152
- sessionNames: request.sessions,
153
- recovery,
154
- historical: request.historical,
155
- warnings,
156
- });
157
- const before = restoreGuard(live);
158
- const verified = await captureLive(tmux);
159
- if (sameRestoreGuard(before, restoreGuard(verified))) {
160
- return { request, checkpoint, recovery, live: verified, plan };
161
- }
162
- live = verified;
163
- }
164
- throw new Error(`tmux topology changed during restore planning ${restoreGuardAttempts} times; retry restore`);
165
- }
166
-
167
- async function getRestorePlan(request = {}) {
168
- await start();
169
- const state = await planRestore(request);
170
- const serverNow = new Date(now()).toISOString();
171
- const promptEligible = Boolean(
172
- !state.request.historical
173
- && state.recovery
174
- && state.recovery.resolvedAt === null
175
- && state.recovery.pendingSessionIds.length > 0
176
- && Date.parse(state.recovery.expiresAt) > now(),
177
- );
178
- return Object.freeze({ ...state.plan, mapping: state.recovery?.mapping || null, serverNow, promptEligible });
179
- }
180
-
181
- async function getProtectionStatus() {
182
- let live;
183
- try {
184
- live = await store.readLive();
185
- } catch {
186
- return { status: 'degraded', lastSuccessfulCaptureAt: null, errorCode: 'live-unavailable' };
187
- }
188
- if (live?.status === 'ok') {
189
- return { status: 'protected', lastSuccessfulCaptureAt: live.value?.capturedAt || null, errorCode: null };
190
- }
191
- if (live?.status === 'empty') {
192
- return { status: 'unprotected', lastSuccessfulCaptureAt: null, errorCode: null };
193
- }
194
- return {
195
- status: 'degraded',
196
- lastSuccessfulCaptureAt: null,
197
- errorCode: live?.status === 'corrupt' ? 'live-corrupt' : 'live-unavailable',
198
- };
199
- }
200
-
201
- async function performRestore(operationId, request, onProgress, onRunning, onTerminal) {
202
- let result;
203
- let restoreError;
204
- try {
205
- result = await lock.withLock({ operationId }, async () => {
206
- try {
207
- await onRunning();
208
- // The preview may be stale by now. Capture and plan again while holding the writer lock so a name
209
- // created after preview receives the next non-destructive `-restored` suffix.
210
- const state = await guardedPlanRestore(request);
211
- const restored = await executor({
212
- plan: state.plan,
213
- checkpoint: state.checkpoint,
214
- tmux,
215
- agents,
216
- onProgress,
217
- access,
218
- home,
219
- });
220
- const resolvedIds = restored.results
221
- .filter((row) => row.status === 'restored' || row.status === 'already-present')
222
- .map((row) => row.logicalId);
223
- const mapping = buildRecoveryMapping(state.checkpoint.id, state.recovery?.mapping, [
224
- restored.mapping,
225
- mappingForAlreadyPresent(state.checkpoint, state.live, restored.results),
226
- ], now);
227
- if (mapping && typeof store.mergeRecoveryMapping === 'function') {
228
- await store.mergeRecoveryMapping(state.checkpoint.id, mapping);
229
- }
230
- // Persist migration data before resolving pending ids. If the second write fails, retry planning
231
- // still sees the session and will recognize it as already-present instead of losing its mapping.
232
- if (resolvedIds.length > 0) await store.resolveSessions(state.checkpoint.id, resolvedIds);
233
- const lockedResult = { ...restored, mapping };
234
- await onTerminal(lockedResult);
235
- return lockedResult;
236
- } catch (error) {
237
- await onTerminal(error);
238
- throw error;
239
- }
240
- });
241
- } catch (error) {
242
- restoreError = error;
243
- }
244
- let reconcileWarning;
245
- try {
246
- const reconciled = await checkpointer.reconcile('restore-complete');
247
- if (!['written', 'unchanged'].includes(reconciled?.status)) {
248
- reconcileWarning = `live reconcile ${reconciled?.status || 'unknown'}; workspace protection may be degraded`;
249
- }
250
- } catch (error) {
251
- reconcileWarning = `live reconcile failed: ${error?.message || String(error)}`;
252
- }
253
- if (restoreError) throw restoreError;
254
- if (reconcileWarning) {
255
- result = {
256
- ...result,
257
- warnings: [...(result.warnings || []), reconcileWarning],
258
- };
259
- }
260
- return result;
261
- }
262
-
263
- async function startOnce() {
264
- let interrupted;
265
- let sweepError;
266
- try {
267
- interrupted = await operations.interruptOrphans();
268
- } catch (error) {
269
- sweepError = error;
270
- }
271
- let started;
272
- let startError;
273
- try {
274
- started = await checkpointer.start?.();
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) throw sweepError;
284
- if (startError) throw startError;
285
- return [interrupted, started];
286
- }
287
-
288
- function start() {
289
- if (!startPromise) {
290
- startPromise = startOnce().catch((error) => {
291
- startPromise = null;
292
- throw error;
293
- });
294
- }
295
- return startPromise;
296
- }
297
-
298
- return {
299
- start,
300
- stop: () => checkpointer.stop(),
301
- requestReconcile: () => checkpointer.requestReconcile(),
302
- confirmEmpty: () => checkpointer.confirmEmpty(),
303
- listCheckpoints: () => store.listCheckpoints(),
304
- getProtectionStatus,
305
- getRestorePlan,
306
- startRestore: async (request = {}) => operations.start(await resolveOperationRequest(request), ({ operationId, request: normalized, onProgress, onRunning, onTerminal }) => (
307
- performRestore(operationId, normalized, onProgress, onRunning, onTerminal)
308
- ), { deferRunning: true }),
309
- getOperation: (id) => operations.get(id),
310
- restoreNow: async (request = {}) => operations.run(await resolveOperationRequest(request), ({ operationId, request: normalized, onProgress, onRunning, onTerminal }) => (
311
- performRestore(operationId, normalized, onProgress, onRunning, onTerminal)
312
- ), { deferRunning: true }),
313
- };
314
- }