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.
- package/README.md +3 -3
- package/README.zh-CN.md +3 -3
- package/bin/handmux.js +3 -689
- package/dist/bin/handmux.js +850 -0
- package/{hooks → dist/hooks}/handmux-statusline.cjs +28 -6
- package/{hooks → dist/hooks}/handmux-write.cjs +24 -3
- package/dist/package.json +126 -0
- package/dist/public/assets/index-CicdY9E9.css +32 -0
- package/dist/public/assets/index-D4VP5t8h.js +337 -0
- package/dist/public/assets/pcm-worklet-CxcDy7PB.js +1 -0
- package/{public → dist/public}/index.html +5 -4
- package/{src → dist/src}/agents/claude.js +105 -83
- package/dist/src/agents/codex.js +217 -0
- package/{src → dist/src}/agents/index.js +7 -7
- package/dist/src/agents/processIdentity.js +80 -0
- package/dist/src/agents/scanUtils.js +262 -0
- package/dist/src/apiErrors.js +77 -0
- package/{src → dist/src}/appName.js +12 -13
- package/dist/src/asr/iflyConfig.js +7 -0
- package/dist/src/asr/iflySign.js +14 -0
- package/dist/src/auth.js +27 -0
- package/dist/src/browser/bootstrap.js +72 -0
- package/dist/src/browser/cookieProfiles.js +413 -0
- package/dist/src/browser/coordinator.js +51 -0
- package/dist/src/browser/hammerheadCompat.js +104 -0
- package/dist/src/browser/hammerheadRedirectCompat.js +37 -0
- package/dist/src/browser/manager.js +560 -0
- package/dist/src/browser/originLabel.js +15 -0
- package/dist/src/browser/profilePersistence.js +235 -0
- package/dist/src/browser/publicProxy.js +176 -0
- package/dist/src/browser/routes.js +159 -0
- package/dist/src/browser/sessionStore.js +72 -0
- package/dist/src/browser/siteVersion.js +121 -0
- package/dist/src/browser/targetPolicy.js +163 -0
- package/{src → dist/src}/browser/worker.js +14 -18
- package/dist/src/browser/workerClient.js +286 -0
- package/dist/src/browser/workerServer.js +91 -0
- package/dist/src/captureBackground.js +95 -0
- package/dist/src/claudeEvents.js +499 -0
- package/{src → dist/src}/cli/claudeHooks.js +146 -108
- package/{src → dist/src}/cli/cloudflareUrl.js +2 -3
- package/dist/src/cli/cloudflared.js +106 -0
- package/dist/src/cli/codexManaged.js +77 -0
- package/{src → dist/src}/cli/cpolarUrl.js +8 -9
- package/dist/src/cli/drivers.js +98 -0
- package/dist/src/cli/hookScaffold.js +36 -0
- package/dist/src/cli/i18n/en.js +366 -0
- package/{src → dist/src}/cli/i18n/index.js +27 -25
- package/dist/src/cli/i18n/zh.js +365 -0
- package/dist/src/cli/legacyCodexHooks.js +52 -0
- package/{src → dist/src}/cli/natappUrl.js +2 -3
- package/dist/src/cli/options.js +245 -0
- package/dist/src/cli/probe.js +16 -0
- package/{src → dist/src}/cli/prompt.js +4 -6
- package/dist/src/cli/pushCmd.js +102 -0
- package/{src → dist/src}/cli/qr.js +19 -19
- package/dist/src/cli/service.js +144 -0
- package/dist/src/cli/setupModel.js +194 -0
- package/dist/src/cli/setupWizard.js +647 -0
- package/dist/src/cli/shortcutEditor.js +351 -0
- package/dist/src/cli/sshTunnel.js +18 -0
- package/dist/src/cli/state.js +114 -0
- package/{src → dist/src}/cli/statusLine.js +63 -46
- package/dist/src/cli/supervisor.js +278 -0
- package/dist/src/cli/supervisorHealth.js +21 -0
- package/dist/src/cli/supervisorLaunch.js +148 -0
- package/dist/src/cli/supervisorProcesses.js +62 -0
- package/dist/src/cli/supervisorState.js +62 -0
- package/dist/src/cli/tmuxVersion.js +53 -0
- package/{src → dist/src}/cli/tunlite.js +7 -10
- package/dist/src/cli/tunnelClients.js +72 -0
- package/{src → dist/src}/cli/updateCheck.js +98 -69
- package/{src → dist/src}/cli/urlHost.js +8 -4
- package/dist/src/cli/workspaceCmd.js +347 -0
- package/dist/src/codexAppServer.js +2299 -0
- package/dist/src/codexConversationProjection.js +199 -0
- package/dist/src/codexDiff.js +118 -0
- package/dist/src/codexMessageIdentity.js +18 -0
- package/dist/src/codexPlan.js +30 -0
- package/dist/src/codexQueueProtocol.js +96 -0
- package/dist/src/codexStreamProtocol.js +207 -0
- package/dist/src/codexToolProtocol.js +68 -0
- package/dist/src/codexTranscriptParse.js +724 -0
- package/dist/src/codexUsageSnapshot.js +205 -0
- package/dist/src/config.js +8 -0
- package/{src → dist/src}/docPath.js +22 -24
- package/dist/src/docs.js +337 -0
- package/dist/src/git.js +272 -0
- package/dist/src/healthProtocol.js +97 -0
- package/dist/src/httpApi.js +68 -0
- package/dist/src/jsonStore.js +15 -0
- package/{src → dist/src}/keyNames.js +3 -3
- package/dist/src/notifications.js +111 -0
- package/dist/src/orphans.js +157 -0
- package/dist/src/pendingPrompt.js +194 -0
- package/dist/src/previewServer.js +202 -0
- package/dist/src/previews.js +179 -0
- package/dist/src/privateStateStore.js +89 -0
- package/dist/src/push.js +224 -0
- package/dist/src/routes/codex.js +606 -0
- package/dist/src/routes/files.js +230 -0
- package/dist/src/routes/git.js +78 -0
- package/dist/src/routes/health.js +16 -0
- package/dist/src/routes/notifications.js +16 -0
- package/dist/src/routes/previews.js +43 -0
- package/dist/src/routes/push.js +145 -0
- package/dist/src/routes/sessions.js +232 -0
- package/dist/src/routes/system.js +156 -0
- package/dist/src/routes/terminal.js +238 -0
- package/dist/src/routes/transcript.js +197 -0
- package/dist/src/routes/workspace.js +267 -0
- package/{src → dist/src}/server.js +100 -74
- package/dist/src/shortcutConfig.js +83 -0
- package/{src → dist/src}/staticCache.js +5 -3
- package/{src → dist/src}/staticCompression.js +5 -6
- package/dist/src/terminalStream.js +441 -0
- package/{src → dist/src}/tmux/commands.js +156 -171
- package/dist/src/tmux/format.js +47 -0
- package/dist/src/transcriptParse.js +240 -0
- package/dist/src/transcriptReader.js +113 -0
- package/{src → dist/src}/trimCapture.js +11 -11
- package/dist/src/uploadTypes.js +29 -0
- package/dist/src/urlPolicy.js +18 -0
- package/dist/src/usage.js +260 -0
- package/dist/src/workspace/agentRunner.js +204 -0
- package/dist/src/workspace/atomicJson.js +31 -0
- package/dist/src/workspace/capture.js +139 -0
- package/dist/src/workspace/checkpointer.js +228 -0
- package/dist/src/workspace/environment.js +71 -0
- package/dist/src/workspace/lock.js +137 -0
- package/dist/src/workspace/mapping.js +128 -0
- package/dist/src/workspace/operations.js +271 -0
- package/dist/src/workspace/paths.js +15 -0
- package/dist/src/workspace/planner.js +292 -0
- package/dist/src/workspace/restore.js +314 -0
- package/dist/src/workspace/runtime.js +318 -0
- package/dist/src/workspace/schema.js +156 -0
- package/dist/src/workspace/store.js +464 -0
- package/dist/src/workspace/tmuxAdapter.js +694 -0
- package/dist/src/workspaceProtocol.js +158 -0
- package/package.json +27 -15
- package/public/assets/index-C3N7OkyP.css +0 -32
- package/public/assets/index-D1DWcC5u.js +0 -337
- package/src/.gitkeep +0 -0
- package/src/agents/codex.js +0 -168
- package/src/agents/processIdentity.js +0 -72
- package/src/agents/scanUtils.js +0 -225
- package/src/asr/iflyConfig.js +0 -10
- package/src/asr/iflySign.js +0 -16
- package/src/auth.js +0 -30
- package/src/browser/bootstrap.js +0 -68
- package/src/browser/cookieProfiles.js +0 -403
- package/src/browser/coordinator.js +0 -47
- package/src/browser/hammerheadCompat.js +0 -86
- package/src/browser/hammerheadRedirectCompat.js +0 -38
- package/src/browser/manager.js +0 -556
- package/src/browser/originLabel.js +0 -17
- package/src/browser/profilePersistence.js +0 -241
- package/src/browser/publicProxy.js +0 -175
- package/src/browser/routes.js +0 -154
- package/src/browser/sessionStore.js +0 -78
- package/src/browser/siteVersion.js +0 -124
- package/src/browser/targetPolicy.js +0 -137
- package/src/browser/workerClient.js +0 -253
- package/src/browser/workerServer.js +0 -98
- package/src/captureBackground.js +0 -93
- package/src/claudeEvents.js +0 -399
- package/src/cli/cloudflared.js +0 -99
- package/src/cli/codexManaged.js +0 -77
- package/src/cli/drivers.js +0 -97
- package/src/cli/hookScaffold.js +0 -33
- package/src/cli/i18n/en.js +0 -385
- package/src/cli/i18n/zh.js +0 -384
- package/src/cli/legacyCodexHooks.js +0 -49
- package/src/cli/options.js +0 -211
- package/src/cli/probe.js +0 -16
- package/src/cli/pushCmd.js +0 -60
- package/src/cli/service.js +0 -126
- package/src/cli/setupModel.js +0 -154
- package/src/cli/setupWizard.js +0 -506
- package/src/cli/shortcutEditor.js +0 -288
- package/src/cli/sshTunnel.js +0 -12
- package/src/cli/state.js +0 -90
- package/src/cli/supervisor.js +0 -183
- package/src/cli/supervisorProcesses.js +0 -57
- package/src/cli/tmuxVersion.js +0 -49
- package/src/cli/tunnelClients.js +0 -62
- package/src/cli/workspaceCmd.js +0 -300
- package/src/codexAppServer.js +0 -1620
- package/src/codexDiff.js +0 -108
- package/src/codexTranscriptParse.js +0 -413
- package/src/codexUsageSnapshot.cjs +0 -129
- package/src/config.js +0 -9
- package/src/docs.js +0 -228
- package/src/git.js +0 -208
- package/src/httpApi.js +0 -72
- package/src/jsonStore.js +0 -24
- package/src/notifications.js +0 -68
- package/src/orphans.js +0 -152
- package/src/pendingPrompt.js +0 -163
- package/src/previewServer.js +0 -168
- package/src/previews.js +0 -116
- package/src/push.js +0 -183
- package/src/routes/codex.js +0 -473
- package/src/routes/files.js +0 -172
- package/src/routes/git.js +0 -57
- package/src/routes/notifications.js +0 -19
- package/src/routes/previews.js +0 -35
- package/src/routes/push.js +0 -119
- package/src/routes/sessions.js +0 -168
- package/src/routes/system.js +0 -132
- package/src/routes/terminal.js +0 -210
- package/src/routes/transcript.js +0 -171
- package/src/routes/workspace.js +0 -267
- package/src/shortcutConfig.js +0 -75
- package/src/terminalStream.js +0 -391
- package/src/tmux/format.js +0 -45
- package/src/transcriptParse.js +0 -180
- package/src/transcriptReader.js +0 -101
- package/src/uploadTypes.js +0 -30
- package/src/urlPolicy.js +0 -14
- package/src/usage.js +0 -260
- package/src/workspace/agentRunner.js +0 -188
- package/src/workspace/atomicJson.js +0 -32
- package/src/workspace/capture.js +0 -139
- package/src/workspace/checkpointer.js +0 -187
- package/src/workspace/environment.js +0 -75
- package/src/workspace/lock.js +0 -128
- package/src/workspace/mapping.js +0 -124
- package/src/workspace/operations.js +0 -252
- package/src/workspace/paths.js +0 -16
- package/src/workspace/planner.js +0 -247
- package/src/workspace/restore.js +0 -279
- package/src/workspace/runtime.js +0 -314
- package/src/workspace/schema.js +0 -136
- package/src/workspace/store.js +0 -382
- package/src/workspace/tmuxAdapter.js +0 -554
- /package/{hooks → dist/hooks}/handmux-notify.sh +0 -0
- /package/{public → dist/public}/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
- /package/{public → dist/public}/fonts/TWUnifont.woff2 +0 -0
- /package/{public → dist/public}/icons/apple-touch-icon.png +0 -0
- /package/{public → dist/public}/icons/badge-96.png +0 -0
- /package/{public → dist/public}/icons/icon-192-maskable.png +0 -0
- /package/{public → dist/public}/icons/icon-192.png +0 -0
- /package/{public → dist/public}/icons/icon-512-maskable.png +0 -0
- /package/{public → dist/public}/icons/icon-512.png +0 -0
- /package/{public → dist/public}/icons/logo.svg +0 -0
- /package/{public → dist/public}/manifest.webmanifest +0 -0
- /package/{public → dist/public}/offline.html +0 -0
- /package/{public → dist/public}/sw.js +0 -0
- /package/{src → dist/src}/browser/protocol.js +0 -0
- /package/{src → dist/src}/cli/cfNamed.js +0 -0
package/src/workspace/schema.js
DELETED
|
@@ -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
|
-
}
|
package/src/workspace/store.js
DELETED
|
@@ -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
|
-
}
|