handmux 0.23.0 → 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 (252) hide show
  1. package/README.md +3 -3
  2. package/README.zh-CN.md +3 -3
  3. package/bin/handmux.js +3 -689
  4. package/dist/bin/handmux.js +850 -0
  5. package/{hooks → dist/hooks}/handmux-statusline.cjs +28 -6
  6. package/{hooks → dist/hooks}/handmux-write.cjs +24 -3
  7. package/dist/package.json +126 -0
  8. package/dist/public/assets/index-CicdY9E9.css +32 -0
  9. package/dist/public/assets/index-D4VP5t8h.js +337 -0
  10. package/dist/public/assets/pcm-worklet-CxcDy7PB.js +1 -0
  11. package/{public → dist/public}/index.html +5 -4
  12. package/{src → dist/src}/agents/claude.js +105 -83
  13. package/dist/src/agents/codex.js +217 -0
  14. package/{src → dist/src}/agents/index.js +7 -7
  15. package/dist/src/agents/processIdentity.js +80 -0
  16. package/dist/src/agents/scanUtils.js +262 -0
  17. package/dist/src/apiErrors.js +77 -0
  18. package/{src → dist/src}/appName.js +12 -13
  19. package/dist/src/asr/iflyConfig.js +7 -0
  20. package/dist/src/asr/iflySign.js +14 -0
  21. package/dist/src/auth.js +27 -0
  22. package/dist/src/browser/bootstrap.js +72 -0
  23. package/dist/src/browser/cookieProfiles.js +413 -0
  24. package/dist/src/browser/coordinator.js +51 -0
  25. package/dist/src/browser/hammerheadCompat.js +104 -0
  26. package/dist/src/browser/hammerheadRedirectCompat.js +37 -0
  27. package/dist/src/browser/manager.js +560 -0
  28. package/dist/src/browser/originLabel.js +15 -0
  29. package/dist/src/browser/profilePersistence.js +235 -0
  30. package/dist/src/browser/publicProxy.js +176 -0
  31. package/dist/src/browser/routes.js +159 -0
  32. package/dist/src/browser/sessionStore.js +72 -0
  33. package/dist/src/browser/siteVersion.js +121 -0
  34. package/dist/src/browser/targetPolicy.js +163 -0
  35. package/{src → dist/src}/browser/worker.js +14 -18
  36. package/dist/src/browser/workerClient.js +286 -0
  37. package/dist/src/browser/workerServer.js +91 -0
  38. package/dist/src/captureBackground.js +95 -0
  39. package/dist/src/claudeEvents.js +499 -0
  40. package/{src → dist/src}/cli/claudeHooks.js +146 -108
  41. package/{src → dist/src}/cli/cloudflareUrl.js +2 -3
  42. package/dist/src/cli/cloudflared.js +106 -0
  43. package/dist/src/cli/codexManaged.js +77 -0
  44. package/{src → dist/src}/cli/cpolarUrl.js +8 -9
  45. package/dist/src/cli/drivers.js +98 -0
  46. package/dist/src/cli/hookScaffold.js +36 -0
  47. package/dist/src/cli/i18n/en.js +366 -0
  48. package/{src → dist/src}/cli/i18n/index.js +27 -25
  49. package/dist/src/cli/i18n/zh.js +365 -0
  50. package/dist/src/cli/legacyCodexHooks.js +52 -0
  51. package/{src → dist/src}/cli/natappUrl.js +2 -3
  52. package/dist/src/cli/options.js +245 -0
  53. package/dist/src/cli/probe.js +16 -0
  54. package/{src → dist/src}/cli/prompt.js +4 -6
  55. package/dist/src/cli/pushCmd.js +102 -0
  56. package/{src → dist/src}/cli/qr.js +19 -19
  57. package/dist/src/cli/service.js +144 -0
  58. package/dist/src/cli/setupModel.js +194 -0
  59. package/dist/src/cli/setupWizard.js +647 -0
  60. package/dist/src/cli/shortcutEditor.js +351 -0
  61. package/dist/src/cli/sshTunnel.js +18 -0
  62. package/dist/src/cli/state.js +114 -0
  63. package/{src → dist/src}/cli/statusLine.js +63 -46
  64. package/dist/src/cli/supervisor.js +278 -0
  65. package/dist/src/cli/supervisorHealth.js +21 -0
  66. package/dist/src/cli/supervisorLaunch.js +148 -0
  67. package/dist/src/cli/supervisorProcesses.js +62 -0
  68. package/dist/src/cli/supervisorState.js +62 -0
  69. package/dist/src/cli/tmuxVersion.js +53 -0
  70. package/{src → dist/src}/cli/tunlite.js +7 -10
  71. package/dist/src/cli/tunnelClients.js +72 -0
  72. package/{src → dist/src}/cli/updateCheck.js +98 -69
  73. package/{src → dist/src}/cli/urlHost.js +8 -4
  74. package/dist/src/cli/workspaceCmd.js +347 -0
  75. package/dist/src/codexAppServer.js +2299 -0
  76. package/dist/src/codexConversationProjection.js +199 -0
  77. package/dist/src/codexDiff.js +118 -0
  78. package/dist/src/codexMessageIdentity.js +18 -0
  79. package/dist/src/codexPlan.js +30 -0
  80. package/dist/src/codexQueueProtocol.js +96 -0
  81. package/dist/src/codexStreamProtocol.js +207 -0
  82. package/dist/src/codexToolProtocol.js +68 -0
  83. package/dist/src/codexTranscriptParse.js +724 -0
  84. package/dist/src/codexUsageSnapshot.js +205 -0
  85. package/dist/src/config.js +8 -0
  86. package/{src → dist/src}/docPath.js +22 -24
  87. package/dist/src/docs.js +337 -0
  88. package/dist/src/git.js +272 -0
  89. package/dist/src/healthProtocol.js +97 -0
  90. package/dist/src/httpApi.js +68 -0
  91. package/dist/src/jsonStore.js +15 -0
  92. package/{src → dist/src}/keyNames.js +3 -3
  93. package/dist/src/notifications.js +111 -0
  94. package/dist/src/orphans.js +157 -0
  95. package/dist/src/pendingPrompt.js +194 -0
  96. package/dist/src/previewServer.js +202 -0
  97. package/dist/src/previews.js +179 -0
  98. package/dist/src/privateStateStore.js +89 -0
  99. package/dist/src/push.js +224 -0
  100. package/dist/src/routes/codex.js +606 -0
  101. package/dist/src/routes/files.js +230 -0
  102. package/dist/src/routes/git.js +78 -0
  103. package/dist/src/routes/health.js +16 -0
  104. package/dist/src/routes/notifications.js +16 -0
  105. package/dist/src/routes/previews.js +43 -0
  106. package/dist/src/routes/push.js +145 -0
  107. package/dist/src/routes/sessions.js +232 -0
  108. package/dist/src/routes/system.js +156 -0
  109. package/dist/src/routes/terminal.js +238 -0
  110. package/dist/src/routes/transcript.js +197 -0
  111. package/dist/src/routes/workspace.js +267 -0
  112. package/{src → dist/src}/server.js +100 -74
  113. package/dist/src/shortcutConfig.js +83 -0
  114. package/{src → dist/src}/staticCache.js +5 -3
  115. package/{src → dist/src}/staticCompression.js +5 -6
  116. package/dist/src/terminalStream.js +441 -0
  117. package/{src → dist/src}/tmux/commands.js +156 -171
  118. package/dist/src/tmux/format.js +47 -0
  119. package/dist/src/transcriptParse.js +240 -0
  120. package/dist/src/transcriptReader.js +113 -0
  121. package/{src → dist/src}/trimCapture.js +11 -11
  122. package/dist/src/uploadTypes.js +29 -0
  123. package/dist/src/urlPolicy.js +18 -0
  124. package/dist/src/usage.js +260 -0
  125. package/dist/src/workspace/agentRunner.js +204 -0
  126. package/dist/src/workspace/atomicJson.js +31 -0
  127. package/dist/src/workspace/capture.js +139 -0
  128. package/dist/src/workspace/checkpointer.js +228 -0
  129. package/dist/src/workspace/environment.js +71 -0
  130. package/dist/src/workspace/lock.js +137 -0
  131. package/dist/src/workspace/mapping.js +128 -0
  132. package/dist/src/workspace/operations.js +271 -0
  133. package/dist/src/workspace/paths.js +15 -0
  134. package/dist/src/workspace/planner.js +292 -0
  135. package/dist/src/workspace/restore.js +314 -0
  136. package/dist/src/workspace/runtime.js +318 -0
  137. package/dist/src/workspace/schema.js +156 -0
  138. package/dist/src/workspace/store.js +464 -0
  139. package/dist/src/workspace/tmuxAdapter.js +694 -0
  140. package/dist/src/workspaceProtocol.js +158 -0
  141. package/package.json +27 -15
  142. package/public/assets/index-C3N7OkyP.css +0 -32
  143. package/public/assets/index-D1DWcC5u.js +0 -337
  144. package/src/.gitkeep +0 -0
  145. package/src/agents/codex.js +0 -168
  146. package/src/agents/processIdentity.js +0 -72
  147. package/src/agents/scanUtils.js +0 -225
  148. package/src/asr/iflyConfig.js +0 -10
  149. package/src/asr/iflySign.js +0 -16
  150. package/src/auth.js +0 -30
  151. package/src/browser/bootstrap.js +0 -68
  152. package/src/browser/cookieProfiles.js +0 -403
  153. package/src/browser/coordinator.js +0 -47
  154. package/src/browser/hammerheadCompat.js +0 -86
  155. package/src/browser/hammerheadRedirectCompat.js +0 -38
  156. package/src/browser/manager.js +0 -556
  157. package/src/browser/originLabel.js +0 -17
  158. package/src/browser/profilePersistence.js +0 -241
  159. package/src/browser/publicProxy.js +0 -175
  160. package/src/browser/routes.js +0 -154
  161. package/src/browser/sessionStore.js +0 -78
  162. package/src/browser/siteVersion.js +0 -124
  163. package/src/browser/targetPolicy.js +0 -137
  164. package/src/browser/workerClient.js +0 -253
  165. package/src/browser/workerServer.js +0 -98
  166. package/src/captureBackground.js +0 -93
  167. package/src/claudeEvents.js +0 -399
  168. package/src/cli/cloudflared.js +0 -99
  169. package/src/cli/codexManaged.js +0 -77
  170. package/src/cli/drivers.js +0 -97
  171. package/src/cli/hookScaffold.js +0 -33
  172. package/src/cli/i18n/en.js +0 -385
  173. package/src/cli/i18n/zh.js +0 -384
  174. package/src/cli/legacyCodexHooks.js +0 -49
  175. package/src/cli/options.js +0 -211
  176. package/src/cli/probe.js +0 -16
  177. package/src/cli/pushCmd.js +0 -60
  178. package/src/cli/service.js +0 -126
  179. package/src/cli/setupModel.js +0 -154
  180. package/src/cli/setupWizard.js +0 -506
  181. package/src/cli/shortcutEditor.js +0 -288
  182. package/src/cli/sshTunnel.js +0 -12
  183. package/src/cli/state.js +0 -90
  184. package/src/cli/supervisor.js +0 -183
  185. package/src/cli/supervisorProcesses.js +0 -57
  186. package/src/cli/tmuxVersion.js +0 -49
  187. package/src/cli/tunnelClients.js +0 -62
  188. package/src/cli/workspaceCmd.js +0 -300
  189. package/src/codexAppServer.js +0 -1620
  190. package/src/codexDiff.js +0 -108
  191. package/src/codexTranscriptParse.js +0 -413
  192. package/src/codexUsageSnapshot.cjs +0 -129
  193. package/src/config.js +0 -9
  194. package/src/docs.js +0 -228
  195. package/src/git.js +0 -208
  196. package/src/httpApi.js +0 -72
  197. package/src/jsonStore.js +0 -24
  198. package/src/notifications.js +0 -68
  199. package/src/orphans.js +0 -152
  200. package/src/pendingPrompt.js +0 -163
  201. package/src/previewServer.js +0 -168
  202. package/src/previews.js +0 -116
  203. package/src/push.js +0 -183
  204. package/src/routes/codex.js +0 -473
  205. package/src/routes/files.js +0 -172
  206. package/src/routes/git.js +0 -57
  207. package/src/routes/notifications.js +0 -19
  208. package/src/routes/previews.js +0 -35
  209. package/src/routes/push.js +0 -119
  210. package/src/routes/sessions.js +0 -168
  211. package/src/routes/system.js +0 -132
  212. package/src/routes/terminal.js +0 -210
  213. package/src/routes/transcript.js +0 -171
  214. package/src/routes/workspace.js +0 -267
  215. package/src/shortcutConfig.js +0 -75
  216. package/src/terminalStream.js +0 -391
  217. package/src/tmux/format.js +0 -45
  218. package/src/transcriptParse.js +0 -180
  219. package/src/transcriptReader.js +0 -101
  220. package/src/uploadTypes.js +0 -30
  221. package/src/urlPolicy.js +0 -14
  222. package/src/usage.js +0 -260
  223. package/src/workspace/agentRunner.js +0 -188
  224. package/src/workspace/atomicJson.js +0 -32
  225. package/src/workspace/capture.js +0 -139
  226. package/src/workspace/checkpointer.js +0 -187
  227. package/src/workspace/environment.js +0 -75
  228. package/src/workspace/lock.js +0 -128
  229. package/src/workspace/mapping.js +0 -124
  230. package/src/workspace/operations.js +0 -252
  231. package/src/workspace/paths.js +0 -16
  232. package/src/workspace/planner.js +0 -247
  233. package/src/workspace/restore.js +0 -279
  234. package/src/workspace/runtime.js +0 -314
  235. package/src/workspace/schema.js +0 -136
  236. package/src/workspace/store.js +0 -382
  237. package/src/workspace/tmuxAdapter.js +0 -554
  238. /package/{hooks → dist/hooks}/handmux-notify.sh +0 -0
  239. /package/{public → dist/public}/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
  240. /package/{public → dist/public}/fonts/TWUnifont.woff2 +0 -0
  241. /package/{public → dist/public}/icons/apple-touch-icon.png +0 -0
  242. /package/{public → dist/public}/icons/badge-96.png +0 -0
  243. /package/{public → dist/public}/icons/icon-192-maskable.png +0 -0
  244. /package/{public → dist/public}/icons/icon-192.png +0 -0
  245. /package/{public → dist/public}/icons/icon-512-maskable.png +0 -0
  246. /package/{public → dist/public}/icons/icon-512.png +0 -0
  247. /package/{public → dist/public}/icons/logo.svg +0 -0
  248. /package/{public → dist/public}/manifest.webmanifest +0 -0
  249. /package/{public → dist/public}/offline.html +0 -0
  250. /package/{public → dist/public}/sw.js +0 -0
  251. /package/{src → dist/src}/browser/protocol.js +0 -0
  252. /package/{src → dist/src}/cli/cfNamed.js +0 -0
@@ -1,136 +0,0 @@
1
- import crypto from 'node:crypto';
2
-
3
- export const WORKSPACE_SCHEMA_VERSION = 1;
4
- const byId = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
5
- const byLink = (a, b) => a.index - b.index || (a.windowId < b.windowId ? -1 : a.windowId > b.windowId ? 1 : 0);
6
- const hash = (value) => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex');
7
-
8
- function requireObject(value, label) {
9
- if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object`);
10
- }
11
-
12
- function requireArray(value, label) {
13
- if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
14
- }
15
-
16
- function requireString(value, label) {
17
- if (typeof value !== 'string' || !value) throw new Error(`${label} must be a non-empty string`);
18
- }
19
-
20
- function requireIndex(value, label) {
21
- if (!Number.isInteger(value) || value < 0) throw new Error(`${label} must be a non-negative integer`);
22
- }
23
-
24
- function validateShape(input) {
25
- requireString(input.capturedAt, 'capturedAt');
26
- requireObject(input.environment, 'environment');
27
- for (const field of ['id', 'bootIdentity']) requireString(input.environment[field], `environment.${field}`);
28
- if (input.environment.tmuxServerId !== null) requireString(input.environment.tmuxServerId, 'environment.tmuxServerId');
29
- requireString(input.tmuxVersion, 'tmuxVersion');
30
- requireArray(input.sessions, 'sessions');
31
- requireArray(input.windows, 'windows');
32
- const empty = input.sessions.length === 0 && input.windows.length === 0;
33
- if ((input.sessions.length === 0) !== (input.windows.length === 0)) throw new Error('sessions and windows must both be empty or non-empty');
34
- if (empty) {
35
- if (input.active !== null) throw new Error('active must be null for an empty workspace');
36
- } else {
37
- requireString(input.environment.tmuxServerId, 'environment.tmuxServerId');
38
- requireObject(input.active, 'active');
39
- for (const field of ['sessionId', 'windowId', 'paneId']) requireString(input.active[field], `active.${field}`);
40
- }
41
-
42
- for (const [index, session] of input.sessions.entries()) {
43
- const label = `sessions[${index}]`;
44
- requireObject(session, label);
45
- for (const field of ['id', 'runtimeId', 'name', 'activeWindowId']) requireString(session[field], `${label}.${field}`);
46
- if (session.windowIds !== undefined) throw new Error(`${label}.windowIds is unsupported; use windowLinks`);
47
- requireArray(session.windowLinks, `${label}.windowLinks`);
48
- for (const [linkIndex, link] of session.windowLinks.entries()) {
49
- const linkLabel = `${label}.windowLinks[${linkIndex}]`;
50
- requireObject(link, linkLabel);
51
- requireString(link.windowId, `${linkLabel}.windowId`);
52
- requireIndex(link.index, `${linkLabel}.index`);
53
- }
54
- }
55
-
56
- for (const [windowIndex, window] of input.windows.entries()) {
57
- const label = `windows[${windowIndex}]`;
58
- requireObject(window, label);
59
- for (const field of ['id', 'runtimeId', 'name', 'layout', 'activePaneId']) requireString(window[field], `${label}.${field}`);
60
- requireIndex(window.index, `${label}.index`);
61
- requireArray(window.panes, `${label}.panes`);
62
- for (const [paneIndex, pane] of window.panes.entries()) {
63
- const paneLabel = `${label}.panes[${paneIndex}]`;
64
- requireObject(pane, paneLabel);
65
- for (const field of ['id', 'runtimeId', 'cwd']) requireString(pane[field], `${paneLabel}.${field}`);
66
- requireIndex(pane.index, `${paneLabel}.index`);
67
- if (pane.agent !== undefined && pane.agent !== null) requireObject(pane.agent, `${paneLabel}.agent`);
68
- }
69
- }
70
- }
71
-
72
- function validateReferences(input, sessions, windows) {
73
- if (sessions.length === 0) return;
74
- const windowsById = new Map(windows.map((window) => [window.id, window]));
75
- const sessionsById = new Map(sessions.map((session) => [session.id, session]));
76
-
77
- for (const session of sessions) {
78
- const windowIds = session.windowLinks.map((link) => link.windowId);
79
- const windowIndexes = session.windowLinks.map((link) => link.index);
80
- if (new Set(windowIds).size !== windowIds.length) throw new Error(`duplicate window link in session ${session.id}`);
81
- if (new Set(windowIndexes).size !== windowIndexes.length) throw new Error(`duplicate window link index in session ${session.id}`);
82
- for (const { windowId } of session.windowLinks) {
83
- if (!windowsById.has(windowId)) throw new Error(`dangling windowLinks reference ${windowId}`);
84
- }
85
- if (!windowIds.includes(session.activeWindowId)) throw new Error(`dangling activeWindowId reference ${session.activeWindowId}`);
86
- }
87
-
88
- for (const window of windows) {
89
- if (!window.panes.some((pane) => pane.id === window.activePaneId)) throw new Error(`dangling activePaneId reference ${window.activePaneId}`);
90
- }
91
-
92
- const activeSession = sessionsById.get(input.active.sessionId);
93
- if (!activeSession) throw new Error(`dangling active.sessionId reference ${input.active.sessionId}`);
94
- if (!activeSession.windowLinks.some((link) => link.windowId === input.active.windowId)) throw new Error(`dangling active.windowId reference ${input.active.windowId}`);
95
- const activeWindow = windowsById.get(input.active.windowId);
96
- if (!activeWindow?.panes.some((pane) => pane.id === input.active.paneId)) throw new Error(`dangling active.paneId reference ${input.active.paneId}`);
97
- }
98
-
99
- export function canonicalizeSnapshot(input) {
100
- if (input?.schemaVersion !== WORKSPACE_SCHEMA_VERSION) throw new Error('unsupported workspace schema');
101
- validateShape(input);
102
- const sessions = input.sessions.map((s) => ({ ...s, windowLinks: s.windowLinks.map((link) => ({ ...link })).sort(byLink) })).sort(byId);
103
- const windows = input.windows.map((w) => ({ ...w, panes: [...w.panes].sort(byId) })).sort(byId);
104
- for (const [label, ids] of [
105
- ['session', sessions.map((x) => x.id)],
106
- ['window', windows.map((x) => x.id)],
107
- ['pane', windows.flatMap((w) => w.panes.map((p) => p.id))],
108
- ]) {
109
- if (new Set(ids).size !== ids.length) throw new Error(`duplicate ${label} id`);
110
- }
111
- validateReferences(input, sessions, windows);
112
- return { ...input, sessions, windows };
113
- }
114
-
115
- export function fingerprintSnapshot(input) {
116
- const { capturedAt, revision, payloadHash, ...payload } = canonicalizeSnapshot(input);
117
- return hash(payload);
118
- }
119
-
120
- export function sealPayload(input) {
121
- const payload = canonicalizeSnapshot(input);
122
- return { ...payload, payloadHash: fingerprintSnapshot(payload) };
123
- }
124
-
125
- export function validateCheckpoint(input) {
126
- try {
127
- requireString(input?.id, 'checkpoint id');
128
- requireString(input?.archivedAt, 'checkpoint archivedAt');
129
- if (typeof input?.payloadHash !== 'string' || !/^[0-9a-f]{64}$/.test(input.payloadHash)) throw new Error('invalid checkpoint payloadHash');
130
- const expected = fingerprintSnapshot(input);
131
- if (input.payloadHash !== expected) throw new Error('checkpoint hash mismatch');
132
- return { ok: true, value: canonicalizeSnapshot(input) };
133
- } catch (error) {
134
- return { ok: false, error: error.message };
135
- }
136
- }
@@ -1,382 +0,0 @@
1
- import fsp from 'node:fs/promises';
2
- import path from 'node:path';
3
- import { ensurePrivateDir, writeJsonAtomic } from './atomicJson.js';
4
- import { workspacePaths } from './paths.js';
5
- import { canonicalizeSnapshot, fingerprintSnapshot, sealPayload, validateCheckpoint } from './schema.js';
6
- import { validateRecoveryMapping } from './mapping.js';
7
-
8
- const DAY_MS = 24 * 60 * 60 * 1000;
9
- const RECOVERY_WINDOW_MS = 60 * 60 * 1000;
10
- const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
11
-
12
- function requireSafeId(id) {
13
- if (typeof id !== 'string' || !SAFE_ID.test(id) || id === '.' || id === '..') throw new Error('workspace id must be a safe filename');
14
- return id;
15
- }
16
-
17
- function errorMessage(error) {
18
- return error instanceof Error ? error.message : String(error);
19
- }
20
-
21
- function projectObject(value, fields) {
22
- if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
23
- return Object.fromEntries(fields.map((field) => [field, value[field]]));
24
- }
25
-
26
- function projectSnapshot(input) {
27
- if (!input || typeof input !== 'object' || Array.isArray(input)) return input;
28
- const environment = projectObject(input.environment, ['id', 'bootIdentity', 'tmuxServerId']);
29
- const active = input.active === null ? null : projectObject(input.active, ['sessionId', 'windowId', 'paneId']);
30
- const sessions = Array.isArray(input.sessions) ? input.sessions.map((session) => {
31
- const projected = projectObject(session, ['id', 'runtimeId', 'name', 'windowLinks', 'activeWindowId']);
32
- if (!projected || typeof projected !== 'object' || Array.isArray(projected) || !Array.isArray(projected.windowLinks)) return projected;
33
- projected.windowLinks = projected.windowLinks.map((link) => projectObject(link, ['windowId', 'index']));
34
- return projected;
35
- }) : input.sessions;
36
- const windows = Array.isArray(input.windows) ? input.windows.map((window) => {
37
- const projected = projectObject(window, ['id', 'runtimeId', 'name', 'index', 'layout', 'activePaneId', 'panes']);
38
- if (!projected || typeof projected !== 'object' || Array.isArray(projected) || !Array.isArray(projected.panes)) return projected;
39
- projected.panes = projected.panes.map((pane) => {
40
- const projectedPane = projectObject(pane, ['id', 'runtimeId', 'index', 'cwd', 'agent']);
41
- if (!projectedPane || typeof projectedPane !== 'object' || Array.isArray(projectedPane)) return projectedPane;
42
- if (projectedPane.agent !== null && projectedPane.agent !== undefined) {
43
- projectedPane.agent = projectObject(projectedPane.agent, ['id', 'sessionId', 'transcriptPath']);
44
- }
45
- return projectedPane;
46
- });
47
- return projected;
48
- }) : input.windows;
49
- return {
50
- schemaVersion: input.schemaVersion,
51
- capturedAt: input.capturedAt,
52
- environment,
53
- tmuxVersion: input.tmuxVersion,
54
- active,
55
- sessions,
56
- windows,
57
- };
58
- }
59
-
60
- function validateLive(value) {
61
- try {
62
- if (!Number.isInteger(value?.revision) || value.revision < 1) throw new Error('invalid live revision');
63
- if (typeof value.payloadHash !== 'string' || !/^[0-9a-f]{64}$/.test(value.payloadHash)) throw new Error('invalid live payloadHash');
64
- if (fingerprintSnapshot(value) !== value.payloadHash) throw new Error('live hash mismatch');
65
- return { ok: true, value: canonicalizeSnapshot(value) };
66
- } catch (error) {
67
- return { ok: false, error: errorMessage(error) };
68
- }
69
- }
70
-
71
- async function readJson(file, fs) {
72
- try {
73
- return { status: 'ok', value: JSON.parse(await fs.readFile(file, 'utf8')) };
74
- } catch (error) {
75
- if (error?.code === 'ENOENT') return { status: 'missing' };
76
- return { status: 'corrupt', error: errorMessage(error) };
77
- }
78
- }
79
-
80
- async function readValidated(file, fs, validate) {
81
- const result = await readJson(file, fs);
82
- if (result.status !== 'ok') return result;
83
- try {
84
- const value = validate(result.value);
85
- return { status: 'ok', value };
86
- } catch (error) {
87
- return { status: 'corrupt', error: errorMessage(error) };
88
- }
89
- }
90
-
91
- function validateRecovery(value, checkpointId) {
92
- if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid recovery state');
93
- if (value.checkpointId !== checkpointId) throw new Error('recovery checkpoint id mismatch');
94
- for (const field of ['detectedAt', 'expiresAt']) {
95
- if (typeof value[field] !== 'string' || Number.isNaN(Date.parse(value[field]))) throw new Error(`invalid recovery ${field}`);
96
- }
97
- for (const field of ['initialSessionIds', 'pendingSessionIds']) {
98
- if (!Array.isArray(value[field]) || value[field].some((id) => typeof id !== 'string' || !id)) throw new Error(`invalid recovery ${field}`);
99
- if (new Set(value[field]).size !== value[field].length) throw new Error(`duplicate recovery ${field}`);
100
- }
101
- const initial = new Set(value.initialSessionIds);
102
- if (value.pendingSessionIds.some((id) => !initial.has(id))) throw new Error('recovery pending ids must be initial ids');
103
- if (value.resolvedAt !== null && (typeof value.resolvedAt !== 'string' || Number.isNaN(Date.parse(value.resolvedAt)))) throw new Error('invalid recovery resolvedAt');
104
- if (value.resolvedAt !== null && value.pendingSessionIds.length > 0) throw new Error('resolved recovery cannot contain pending session ids');
105
- if (value.mapping !== null) {
106
- validateRecoveryMapping(value.mapping, checkpointId);
107
- }
108
- return value;
109
- }
110
-
111
- export function selectRetainedCheckpoints(rows, now, latestId) {
112
- const valid = rows
113
- .filter((row) => row.status === 'ok' && Number.isFinite(Date.parse(row.value.archivedAt)))
114
- .sort((a, b) => Date.parse(b.value.archivedAt) - Date.parse(a.value.archivedAt));
115
- const retained = [];
116
- const retainedIds = new Set();
117
- const keep = (row) => {
118
- if (!row || retainedIds.has(row.id)) return;
119
- retained.push(row);
120
- retainedIds.add(row.id);
121
- };
122
- for (const row of valid) {
123
- if (Date.parse(row.value.archivedAt) >= now - DAY_MS) keep(row);
124
- }
125
- for (const row of valid) {
126
- if (retained.length >= 10) break;
127
- keep(row);
128
- }
129
- keep(rows.find((row) => row.id === latestId));
130
- return retained;
131
- }
132
-
133
- export function createWorkspaceStore({ home, now = Date.now, fs = fsp }) {
134
- const paths = workspacePaths(home);
135
-
136
- async function ensureDirectories() {
137
- for (const dir of [path.dirname(paths.root), paths.root, paths.liveDir, paths.checkpointsDir, paths.recoveryDir, paths.operationsDir]) {
138
- await ensurePrivateDir(dir, { fs });
139
- }
140
- }
141
-
142
- async function readLiveCopy(file) {
143
- const result = await readJson(file, fs);
144
- if (result.status !== 'ok') return result;
145
- const validated = validateLive(result.value);
146
- return validated.ok ? { status: 'ok', value: validated.value } : { status: 'corrupt', error: validated.error };
147
- }
148
-
149
- async function readLive() {
150
- const copies = await Promise.all([paths.liveCurrent, paths.liveMirror].map(readLiveCopy));
151
- const valid = copies
152
- .map((result, index) => ({ ...result, index }))
153
- .filter((result) => result.status === 'ok')
154
- .sort((a, b) => b.value.revision - a.value.revision || a.index - b.index);
155
- if (valid.length === 0) {
156
- if (copies.every((copy) => copy.status === 'missing')) return { status: 'empty' };
157
- return { status: 'corrupt', errors: copies.map((copy) => copy.error ?? copy.status) };
158
- }
159
-
160
- const chosen = valid[0].value;
161
- let repaired = false;
162
- await ensureDirectories();
163
- for (let index = 0; index < copies.length; index += 1) {
164
- const copy = copies[index];
165
- if (copy.status === 'ok' && copy.value.revision === chosen.revision && copy.value.payloadHash === chosen.payloadHash) continue;
166
- await writeJsonAtomic(index === 0 ? paths.liveCurrent : paths.liveMirror, chosen, { fs });
167
- repaired = true;
168
- }
169
- return { status: 'ok', value: chosen, repaired };
170
- }
171
-
172
- async function writeLive(snapshot) {
173
- const payload = projectSnapshot(snapshot);
174
- canonicalizeSnapshot(payload);
175
- await ensureDirectories();
176
- const copies = await Promise.all([paths.liveCurrent, paths.liveMirror].map(readLiveCopy));
177
- const revision = Math.max(0, ...copies.filter((copy) => copy.status === 'ok').map((copy) => copy.value.revision)) + 1;
178
- const value = sealPayload({ ...payload, revision });
179
- await writeJsonAtomic(paths.liveCurrent, value, { fs });
180
- await writeJsonAtomic(paths.liveMirror, value, { fs });
181
- return value;
182
- }
183
-
184
- async function readCheckpoint(id) {
185
- requireSafeId(id);
186
- const result = await readJson(path.join(paths.checkpointsDir, `${id}.json`), fs);
187
- if (result.status !== 'ok') return result;
188
- const validation = validateCheckpoint(result.value);
189
- if (!validation.ok) return { status: 'corrupt', error: validation.error };
190
- if (validation.value.id !== id) return { status: 'corrupt', error: 'checkpoint id does not match filename' };
191
- if (validation.value.environment.id !== id) return { status: 'corrupt', error: 'checkpoint environment id does not match filename' };
192
- return { status: 'ok', value: validation.value };
193
- }
194
-
195
- async function listCheckpoints() {
196
- let names;
197
- try {
198
- names = await fs.readdir(paths.checkpointsDir);
199
- } catch (error) {
200
- if (error?.code === 'ENOENT') return [];
201
- throw error;
202
- }
203
- const rows = await Promise.all(names.filter((name) => name.endsWith('.json')).map(async (name) => {
204
- const id = name.slice(0, -'.json'.length);
205
- if (!SAFE_ID.test(id) || id === '.' || id === '..') return { status: 'corrupt', id, error: 'checkpoint filename is not a safe id' };
206
- const result = await readCheckpoint(id);
207
- return result.status === 'ok' ? { status: 'ok', id, value: result.value } : { status: 'corrupt', id, error: result.error };
208
- }));
209
- return rows.sort((a, b) => {
210
- if (a.status === 'ok' && b.status === 'ok') return Date.parse(b.value.archivedAt) - Date.parse(a.value.archivedAt);
211
- if (a.status === 'ok') return -1;
212
- if (b.status === 'ok') return 1;
213
- return a.id.localeCompare(b.id);
214
- });
215
- }
216
-
217
- async function readLatestCheckpoint() {
218
- const pointer = await readJson(paths.latest, fs);
219
- let warning;
220
- if (pointer.status === 'ok') {
221
- try {
222
- requireSafeId(pointer.value?.checkpointId);
223
- if (typeof pointer.value.payloadHash !== 'string') throw new Error('invalid latest payloadHash');
224
- const pointed = await readCheckpoint(pointer.value.checkpointId);
225
- if (pointed.status === 'ok' && pointed.value.payloadHash === pointer.value.payloadHash) return pointed;
226
- warning = `latest checkpoint is ${pointed.status === 'ok' ? 'hash-mismatched' : pointed.status}`;
227
- } catch (error) {
228
- warning = `latest pointer is corrupt: ${errorMessage(error)}`;
229
- }
230
- } else if (pointer.status === 'corrupt') {
231
- warning = `latest pointer is corrupt: ${pointer.error}`;
232
- }
233
-
234
- const rows = await listCheckpoints();
235
- const fallback = rows.find((row) => row.status === 'ok');
236
- if (fallback) return { status: 'ok', value: fallback.value, ...(warning ? { warning } : {}) };
237
- if (pointer.status === 'missing' && rows.length === 0) return { status: 'missing' };
238
- return { status: 'corrupt', error: warning ?? 'no valid checkpoint' };
239
- }
240
-
241
- async function readRecovery(checkpointId) {
242
- requireSafeId(checkpointId);
243
- return readValidated(path.join(paths.recoveryDir, `${checkpointId}.json`), fs, (value) => validateRecovery(value, checkpointId));
244
- }
245
-
246
- async function createRecovery(checkpointId, detectedAt) {
247
- requireSafeId(checkpointId);
248
- const existing = await readRecovery(checkpointId);
249
- if (existing.status === 'ok') return existing;
250
- if (existing.status === 'corrupt') throw new Error(existing.error);
251
- const checkpoint = await readCheckpoint(checkpointId);
252
- if (checkpoint.status !== 'ok') return checkpoint;
253
- if (typeof detectedAt !== 'string' || Number.isNaN(Date.parse(detectedAt))) throw new Error('detectedAt must be an ISO timestamp');
254
- const initialSessionIds = checkpoint.value.sessions.map((session) => session.id);
255
- const value = {
256
- checkpointId,
257
- detectedAt,
258
- expiresAt: new Date(Date.parse(detectedAt) + RECOVERY_WINDOW_MS).toISOString(),
259
- initialSessionIds,
260
- pendingSessionIds: [...initialSessionIds],
261
- resolvedAt: null,
262
- mapping: null,
263
- };
264
- await ensureDirectories();
265
- await writeJsonAtomic(path.join(paths.recoveryDir, `${checkpointId}.json`), value, { fs });
266
- return { status: 'ok', value };
267
- }
268
-
269
- async function resolveSessions(checkpointId, ids) {
270
- if (!Array.isArray(ids)) throw new Error('resolved session ids must be an array');
271
- const recovery = await readRecovery(checkpointId);
272
- if (recovery.status !== 'ok') return recovery;
273
- if (recovery.value.resolvedAt !== null) return recovery;
274
- const resolved = new Set(ids);
275
- const pendingSessionIds = recovery.value.pendingSessionIds.filter((id) => !resolved.has(id));
276
- if (pendingSessionIds.length === recovery.value.pendingSessionIds.length && pendingSessionIds.length > 0) return recovery;
277
- const value = {
278
- ...recovery.value,
279
- pendingSessionIds,
280
- resolvedAt: pendingSessionIds.length === 0 ? new Date(now()).toISOString() : null,
281
- };
282
- await writeJsonAtomic(path.join(paths.recoveryDir, `${checkpointId}.json`), value, { fs });
283
- return { status: 'ok', value };
284
- }
285
-
286
- async function mergeRecoveryMapping(checkpointId, mapping) {
287
- const recovery = await readRecovery(checkpointId);
288
- if (recovery.status !== 'ok') return recovery;
289
- validateRecoveryMapping(mapping, checkpointId);
290
- const value = { ...recovery.value, mapping };
291
- validateRecovery(value, checkpointId);
292
- await writeJsonAtomic(path.join(paths.recoveryDir, `${checkpointId}.json`), value, { fs });
293
- return { status: 'ok', value };
294
- }
295
-
296
- async function writeOperation(operation) {
297
- requireSafeId(operation?.id);
298
- await ensureDirectories();
299
- await writeJsonAtomic(path.join(paths.operationsDir, `${operation.id}.json`), operation, { fs });
300
- return operation;
301
- }
302
-
303
- async function readOperation(id) {
304
- requireSafeId(id);
305
- return readJson(path.join(paths.operationsDir, `${id}.json`), fs);
306
- }
307
-
308
- async function listOperations() {
309
- let names;
310
- try {
311
- names = await fs.readdir(paths.operationsDir);
312
- } catch (error) {
313
- if (error?.code === 'ENOENT') return [];
314
- throw error;
315
- }
316
- return Promise.all(names.filter((name) => name.endsWith('.json')).sort().map(async (name) => {
317
- const id = name.slice(0, -'.json'.length);
318
- if (!SAFE_ID.test(id) || id === '.' || id === '..') return { status: 'corrupt', id, error: 'operation filename is not a safe id' };
319
- const result = await readOperation(id);
320
- if (result.status !== 'ok') return { ...result, id };
321
- if (result.value?.id !== id) return { status: 'corrupt', id, error: 'operation id does not match filename' };
322
- return { status: 'ok', id, value: result.value };
323
- }));
324
- }
325
-
326
- async function prune() {
327
- const pointer = await readJson(paths.latest, fs);
328
- const latestId = pointer.status === 'ok' && typeof pointer.value?.checkpointId === 'string' ? pointer.value.checkpointId : undefined;
329
- const rows = await listCheckpoints();
330
- const retained = new Set(selectRetainedCheckpoints(rows, now(), latestId).map((row) => row.id));
331
- const canDeleteCorrupt = pointer.status === 'ok' && SAFE_ID.test(latestId ?? '');
332
- await Promise.all(rows.map(async (row) => {
333
- if (retained.has(row.id) || (row.status === 'corrupt' && !canDeleteCorrupt)) return;
334
- await fs.unlink(path.join(paths.checkpointsDir, `${row.id}.json`));
335
- }));
336
- return rows.filter((row) => retained.has(row.id));
337
- }
338
-
339
- async function archiveEnvironment({ endedReason, detectedAt }) {
340
- if (typeof endedReason !== 'string' || !endedReason) throw new Error('endedReason must be a non-empty string');
341
- if (typeof detectedAt !== 'string' || Number.isNaN(Date.parse(detectedAt))) throw new Error('detectedAt must be an ISO timestamp');
342
- const live = await readLive();
343
- if (live.status !== 'ok') return live;
344
- if (live.value.sessions.length === 0) return { status: 'empty' };
345
- const id = requireSafeId(live.value.environment.id);
346
- await ensureDirectories();
347
- let checkpoint = await readCheckpoint(id);
348
- if (checkpoint.status !== 'ok') {
349
- const { payloadHash: ignoredHash, revision: ignoredRevision, ...unsealed } = live.value;
350
- const value = sealPayload({
351
- ...unsealed,
352
- id,
353
- archivedAt: new Date(now()).toISOString(),
354
- environment: { ...unsealed.environment, endedReason },
355
- });
356
- await writeJsonAtomic(path.join(paths.checkpointsDir, `${id}.json`), value, { fs });
357
- checkpoint = { status: 'ok', value };
358
- }
359
- await writeJsonAtomic(paths.latest, { checkpointId: id, payloadHash: checkpoint.value.payloadHash }, { fs });
360
- await createRecovery(id, detectedAt);
361
- await prune().catch(() => {});
362
- return checkpoint;
363
- }
364
-
365
- return {
366
- paths,
367
- readLive,
368
- writeLive,
369
- archiveEnvironment,
370
- listCheckpoints,
371
- readCheckpoint,
372
- readLatestCheckpoint,
373
- readRecovery,
374
- createRecovery,
375
- resolveSessions,
376
- mergeRecoveryMapping,
377
- writeOperation,
378
- readOperation,
379
- listOperations,
380
- prune,
381
- };
382
- }