handmux 0.17.7 → 0.18.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 +18 -2
- package/README.zh-CN.md +18 -2
- package/bin/handmux.js +25 -1
- package/package.json +6 -5
- package/public/assets/index-CvNuQ-bC.js +330 -0
- package/public/assets/index-DjJf1FX9.css +32 -0
- package/public/index.html +2 -2
- package/src/agents/claude.js +1 -0
- package/src/agents/codex.js +1 -0
- package/src/agents/scanUtils.js +3 -4
- package/src/claudeEvents.js +23 -6
- package/src/cli/i18n/en.js +87 -2
- package/src/cli/i18n/zh.js +87 -2
- package/src/cli/options.js +23 -5
- package/src/cli/prompt.js +1 -1
- package/src/cli/setupModel.js +11 -2
- package/src/cli/setupWizard.js +19 -18
- package/src/cli/shortcutEditor.js +288 -0
- package/src/cli/supervisor.js +1 -0
- package/src/cli/workspaceCmd.js +300 -0
- package/src/config.js +3 -0
- package/src/httpApi.js +6 -1
- package/src/internalPreviewGateway.js +487 -0
- package/src/keyNames.js +10 -0
- package/src/orphans.js +3 -2
- package/src/routes/sessions.js +13 -1
- package/src/routes/system.js +13 -2
- package/src/routes/terminal.js +3 -13
- package/src/routes/workspace.js +267 -0
- package/src/server.js +46 -5
- package/src/shortcutConfig.js +75 -0
- package/src/tmux/commands.js +17 -17
- package/src/tmux/format.js +45 -0
- package/src/workspace/agentRunner.js +185 -0
- package/src/workspace/atomicJson.js +32 -0
- package/src/workspace/capture.js +87 -0
- package/src/workspace/checkpointer.js +179 -0
- package/src/workspace/environment.js +75 -0
- package/src/workspace/lock.js +128 -0
- package/src/workspace/mapping.js +124 -0
- package/src/workspace/operations.js +252 -0
- package/src/workspace/paths.js +16 -0
- package/src/workspace/planner.js +247 -0
- package/src/workspace/restore.js +279 -0
- package/src/workspace/runtime.js +314 -0
- package/src/workspace/schema.js +136 -0
- package/src/workspace/store.js +382 -0
- package/src/workspace/tmuxAdapter.js +554 -0
- package/public/assets/index-CiaHwIWj.css +0 -32
- package/public/assets/index-cjqG9l7v.js +0 -330
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { spawn as spawnChild } from 'node:child_process';
|
|
2
|
+
import fsp from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { ensurePrivateDir, writeJsonAtomic } from './atomicJson.js';
|
|
7
|
+
|
|
8
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
9
|
+
const PANE_RE = /^%\d+$/;
|
|
10
|
+
const RUNNER_FILE = fileURLToPath(import.meta.url);
|
|
11
|
+
|
|
12
|
+
function errorText(error) {
|
|
13
|
+
return error instanceof Error ? error.message : String(error);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function validateAgentRequest(request) {
|
|
17
|
+
const valid = request?.cmd === 'claude'
|
|
18
|
+
? Array.isArray(request.args) && request.args.length === 2 && request.args[0] === '--resume' && UUID_RE.test(request.args[1])
|
|
19
|
+
: request?.cmd === 'codex'
|
|
20
|
+
&& Array.isArray(request.args) && request.args.length === 2 && request.args[0] === 'resume' && UUID_RE.test(request.args[1]);
|
|
21
|
+
if (!valid) throw new Error('unsafe agent request');
|
|
22
|
+
return { cmd: request.cmd, args: [...request.args] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function earlyFailure(request, error, code) {
|
|
26
|
+
if (error?.code === 'ENOENT') return `${request.cmd} binary not found`;
|
|
27
|
+
if (error) return `${request.cmd} failed to start: ${errorText(error)}`;
|
|
28
|
+
return `${request.cmd} exited ${code ?? 'unknown'} before ready`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function launchAgentRequest(requestInput, {
|
|
32
|
+
spawn = spawnChild,
|
|
33
|
+
readinessMs = 500,
|
|
34
|
+
setTimeout = globalThis.setTimeout,
|
|
35
|
+
clearTimeout = globalThis.clearTimeout,
|
|
36
|
+
} = {}) {
|
|
37
|
+
const request = validateAgentRequest(requestInput);
|
|
38
|
+
let child;
|
|
39
|
+
let readyDone = false;
|
|
40
|
+
let timer = null;
|
|
41
|
+
let completionDone = false;
|
|
42
|
+
let settleReady;
|
|
43
|
+
let settleCompletion;
|
|
44
|
+
const ready = new Promise((resolve) => { settleReady = resolve; });
|
|
45
|
+
const completion = new Promise((resolve) => { settleCompletion = resolve; });
|
|
46
|
+
const finishCompletion = (value) => {
|
|
47
|
+
if (completionDone) return;
|
|
48
|
+
completionDone = true;
|
|
49
|
+
settleCompletion(value);
|
|
50
|
+
};
|
|
51
|
+
const failReady = (error, code) => {
|
|
52
|
+
if (readyDone) return;
|
|
53
|
+
readyDone = true;
|
|
54
|
+
if (timer) clearTimeout(timer);
|
|
55
|
+
settleReady({ status: 'failed', error: earlyFailure(request, error, code) });
|
|
56
|
+
};
|
|
57
|
+
try {
|
|
58
|
+
child = spawn(request.cmd, request.args, { stdio: 'inherit', shell: false });
|
|
59
|
+
} catch (error) {
|
|
60
|
+
failReady(error);
|
|
61
|
+
finishCompletion({ code: null, signal: null, error: errorText(error) });
|
|
62
|
+
return { child: null, ready, completion, terminate: async () => completion };
|
|
63
|
+
}
|
|
64
|
+
child.once('spawn', () => {
|
|
65
|
+
timer = setTimeout(() => {
|
|
66
|
+
if (readyDone) return;
|
|
67
|
+
readyDone = true;
|
|
68
|
+
settleReady({ status: 'ready' });
|
|
69
|
+
}, readinessMs);
|
|
70
|
+
});
|
|
71
|
+
child.once('error', (error) => {
|
|
72
|
+
failReady(error);
|
|
73
|
+
finishCompletion({ code: null, signal: null, error: errorText(error) });
|
|
74
|
+
});
|
|
75
|
+
child.once('exit', (code, signal) => {
|
|
76
|
+
failReady(null, code);
|
|
77
|
+
finishCompletion({ code, signal });
|
|
78
|
+
});
|
|
79
|
+
return {
|
|
80
|
+
child,
|
|
81
|
+
ready,
|
|
82
|
+
completion,
|
|
83
|
+
async terminate() {
|
|
84
|
+
if (!completionDone) child.kill('SIGTERM');
|
|
85
|
+
return completion;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function publishAgentReadiness(launched, writeStatus) {
|
|
91
|
+
const ready = await launched.ready;
|
|
92
|
+
try {
|
|
93
|
+
await writeStatus(ready);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
let cleanupError;
|
|
96
|
+
try { await launched.terminate(); } catch (failure) { cleanupError = failure; }
|
|
97
|
+
if (cleanupError) throw new Error(`${errorText(error)}; cleanup failed: ${errorText(cleanupError)}`);
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
return ready;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function shellQuote(value) {
|
|
104
|
+
return `'${String(value).replaceAll("'", `'"'"'`)}'`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
108
|
+
|
|
109
|
+
async function unlinkIfPresent(fs, file) {
|
|
110
|
+
try { await fs.unlink(file); } catch (error) {
|
|
111
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function createAgentRunner({
|
|
116
|
+
home = os.homedir(),
|
|
117
|
+
fs = fsp,
|
|
118
|
+
now = Date.now,
|
|
119
|
+
wait = delay,
|
|
120
|
+
timeoutMs = 3_000,
|
|
121
|
+
pollMs = 25,
|
|
122
|
+
} = {}) {
|
|
123
|
+
const dir = path.join(home, '.handmux', 'workspaces', 'agent-runs');
|
|
124
|
+
const command = `${shellQuote(process.execPath)} ${shellQuote(RUNNER_FILE)}`;
|
|
125
|
+
const files = (paneId) => {
|
|
126
|
+
if (!PANE_RE.test(paneId)) throw new Error('invalid agent runner pane id');
|
|
127
|
+
const base = paneId.slice(1);
|
|
128
|
+
return { request: path.join(dir, `${base}.request.json`), status: path.join(dir, `${base}.status.json`) };
|
|
129
|
+
};
|
|
130
|
+
return {
|
|
131
|
+
command,
|
|
132
|
+
async prepare({ paneId, cmd, args }) {
|
|
133
|
+
const request = validateAgentRequest({ cmd, args });
|
|
134
|
+
const target = files(paneId);
|
|
135
|
+
await ensurePrivateDir(dir, { fs });
|
|
136
|
+
await unlinkIfPresent(fs, target.status);
|
|
137
|
+
await writeJsonAtomic(target.request, { paneId, ...request }, { fs });
|
|
138
|
+
},
|
|
139
|
+
async waitReady(paneId) {
|
|
140
|
+
const target = files(paneId);
|
|
141
|
+
const started = now();
|
|
142
|
+
while (now() - started <= timeoutMs) {
|
|
143
|
+
try {
|
|
144
|
+
const status = JSON.parse(await fs.readFile(target.status, 'utf8'));
|
|
145
|
+
if (status?.status === 'ready' || status?.status === 'failed') return status;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (error?.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error;
|
|
148
|
+
}
|
|
149
|
+
await wait(pollMs);
|
|
150
|
+
}
|
|
151
|
+
return { status: 'failed', error: 'agent readiness timed out' };
|
|
152
|
+
},
|
|
153
|
+
async cancel(paneId) {
|
|
154
|
+
const target = files(paneId);
|
|
155
|
+
await Promise.all([target.request, target.status].map((file) => unlinkIfPresent(fs, file)));
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function runPreparedAgent() {
|
|
161
|
+
const paneId = process.env.TMUX_PANE;
|
|
162
|
+
const target = (() => {
|
|
163
|
+
if (!PANE_RE.test(paneId || '')) throw new Error('agent runner requires TMUX_PANE');
|
|
164
|
+
const dir = path.join(os.homedir(), '.handmux', 'workspaces', 'agent-runs');
|
|
165
|
+
const base = paneId.slice(1);
|
|
166
|
+
return { request: path.join(dir, `${base}.request.json`), status: path.join(dir, `${base}.status.json`) };
|
|
167
|
+
})();
|
|
168
|
+
const input = JSON.parse(await fsp.readFile(target.request, 'utf8'));
|
|
169
|
+
if (input.paneId !== paneId) throw new Error('agent runner pane mismatch');
|
|
170
|
+
const launched = launchAgentRequest(input);
|
|
171
|
+
try {
|
|
172
|
+
const ready = await publishAgentReadiness(launched, (status) => writeJsonAtomic(target.status, status));
|
|
173
|
+
if (ready.status === 'ready') await launched.completion;
|
|
174
|
+
if (ready.status !== 'ready') throw new Error(ready.error);
|
|
175
|
+
} finally {
|
|
176
|
+
await unlinkIfPresent(fsp, target.request);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === RUNNER_FILE) {
|
|
181
|
+
runPreparedAgent().catch((error) => {
|
|
182
|
+
process.stderr.write(`handmux agent resume: ${errorText(error)}\n`);
|
|
183
|
+
process.exitCode = 1;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fsp from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export async function ensurePrivateDir(dir, { fs = fsp } = {}) {
|
|
6
|
+
const resolved = path.resolve(dir);
|
|
7
|
+
const { root } = path.parse(resolved);
|
|
8
|
+
const parts = resolved.slice(root.length).split(path.sep).filter(Boolean);
|
|
9
|
+
const privateStart = parts.indexOf('.handmux');
|
|
10
|
+
const first = privateStart === -1 ? parts.length - 1 : privateStart;
|
|
11
|
+
for (let index = first; index < parts.length; index += 1) {
|
|
12
|
+
const current = path.join(root, ...parts.slice(0, index + 1));
|
|
13
|
+
await fs.mkdir(current, { recursive: true, mode: 0o700 });
|
|
14
|
+
await fs.chmod(current, 0o700);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function writeJsonAtomic(file, value, { fs = fsp } = {}) {
|
|
19
|
+
const temp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
20
|
+
const handle = await fs.open(temp, 'wx', 0o600);
|
|
21
|
+
try {
|
|
22
|
+
await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
23
|
+
await handle.sync();
|
|
24
|
+
await handle.close();
|
|
25
|
+
await fs.rename(temp, file);
|
|
26
|
+
await fs.chmod(file, 0o600);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
await handle.close().catch(() => {});
|
|
29
|
+
await fs.unlink(temp).catch(() => {});
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import fsp from 'node:fs/promises';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { AGENTS } from '../agents/index.js';
|
|
4
|
+
import { canonicalizeSnapshot, WORKSPACE_SCHEMA_VERSION } from './schema.js';
|
|
5
|
+
|
|
6
|
+
function agentMap(agents) {
|
|
7
|
+
return new Map(agents.map((agent) => [agent.id, agent]));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function readAgentBindings(stateFile, agents = AGENTS, readFile = fsp.readFile, access = fsp.access, stat = fsp.stat) {
|
|
11
|
+
let state;
|
|
12
|
+
try {
|
|
13
|
+
state = JSON.parse(await readFile(stateFile, 'utf8'));
|
|
14
|
+
if (!state || typeof state !== 'object' || Array.isArray(state)) return new Map();
|
|
15
|
+
} catch {
|
|
16
|
+
return new Map();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const known = agentMap(agents);
|
|
20
|
+
const bindings = new Map();
|
|
21
|
+
for (const [paneRuntimeId, record] of Object.entries(state)) {
|
|
22
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) continue;
|
|
23
|
+
const id = record.agent === undefined ? 'claude' : record.agent;
|
|
24
|
+
if (typeof id !== 'string' || !known.has(id)) continue;
|
|
25
|
+
const payload = record.payload;
|
|
26
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) continue;
|
|
27
|
+
const sessionId = payload.session_id;
|
|
28
|
+
const transcriptPath = payload.transcript_path;
|
|
29
|
+
const driver = known.get(id);
|
|
30
|
+
if (!driver.sessions?.isId(sessionId) || typeof transcriptPath !== 'string' || !transcriptPath) continue;
|
|
31
|
+
try {
|
|
32
|
+
await access(transcriptPath, constants.R_OK);
|
|
33
|
+
if (!(await stat(transcriptPath)).isFile()) continue;
|
|
34
|
+
} catch { continue; }
|
|
35
|
+
bindings.set(paneRuntimeId, { id, sessionId, transcriptPath });
|
|
36
|
+
}
|
|
37
|
+
return bindings;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function attachBindings(topology, bindings) {
|
|
41
|
+
return {
|
|
42
|
+
...topology,
|
|
43
|
+
windows: topology.windows.map((window) => ({
|
|
44
|
+
...window,
|
|
45
|
+
panes: window.panes.map((pane) => ({ ...pane, agent: bindings.get(pane.runtimeId) || null })),
|
|
46
|
+
})),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isUnknownFingerprint(value) {
|
|
51
|
+
return value === null || value === undefined || value?.status === 'unknown';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function captureWorkspace({
|
|
55
|
+
tmux,
|
|
56
|
+
stateFile,
|
|
57
|
+
environment,
|
|
58
|
+
agents = AGENTS,
|
|
59
|
+
readFile = fsp.readFile,
|
|
60
|
+
access = fsp.access,
|
|
61
|
+
stat = fsp.stat,
|
|
62
|
+
now = Date.now,
|
|
63
|
+
}) {
|
|
64
|
+
try {
|
|
65
|
+
const before = await tmux.topologyFingerprint();
|
|
66
|
+
if (isUnknownFingerprint(before)) return { status: 'unknown' };
|
|
67
|
+
const topology = await tmux.captureTopology();
|
|
68
|
+
if (!topology || topology.status === 'unknown') return { status: 'unknown' };
|
|
69
|
+
const bindings = await readAgentBindings(stateFile, agents, readFile, access, stat);
|
|
70
|
+
const after = await tmux.topologyFingerprint();
|
|
71
|
+
if (isUnknownFingerprint(after)) return { status: 'unknown' };
|
|
72
|
+
if (before !== after) return { status: 'changed-during-capture' };
|
|
73
|
+
|
|
74
|
+
const snapshot = canonicalizeSnapshot(attachBindings({
|
|
75
|
+
schemaVersion: WORKSPACE_SCHEMA_VERSION,
|
|
76
|
+
capturedAt: new Date(now()).toISOString(),
|
|
77
|
+
environment: { ...environment },
|
|
78
|
+
tmuxVersion: topology.tmuxVersion,
|
|
79
|
+
active: topology.active,
|
|
80
|
+
sessions: topology.sessions,
|
|
81
|
+
windows: topology.windows,
|
|
82
|
+
}, bindings));
|
|
83
|
+
return { status: topology.status === 'empty' ? 'empty' : 'ok', snapshot };
|
|
84
|
+
} catch {
|
|
85
|
+
return { status: 'unknown' };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { captureWorkspace } from './capture.js';
|
|
2
|
+
import { detectEnvironmentChange } from './environment.js';
|
|
3
|
+
import { fingerprintSnapshot } from './schema.js';
|
|
4
|
+
|
|
5
|
+
function sameSnapshot(left, right) {
|
|
6
|
+
return fingerprintSnapshot(left) === fingerprintSnapshot(right);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function snapshotEnvironment(observed) {
|
|
10
|
+
return {
|
|
11
|
+
id: observed.id,
|
|
12
|
+
bootIdentity: observed.bootIdentity,
|
|
13
|
+
tmuxServerId: observed.tmuxServerId,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function reconcileOnce(deps, cause) {
|
|
18
|
+
const handle = await deps.lock.tryAcquire({ operationId: `checkpointer:${cause}` });
|
|
19
|
+
if (!handle) return { status: 'locked' };
|
|
20
|
+
try {
|
|
21
|
+
const observed = await deps.observeEnvironment();
|
|
22
|
+
if (!observed || observed.status === 'unknown') return { status: 'unknown' };
|
|
23
|
+
|
|
24
|
+
const live = await deps.store.readLive();
|
|
25
|
+
if (live.status === 'corrupt') return live;
|
|
26
|
+
const previous = live.status === 'ok' ? live.value.environment : null;
|
|
27
|
+
let change = detectEnvironmentChange(previous, observed);
|
|
28
|
+
if (cause === 'confirmed-empty' && observed.status === 'absent') {
|
|
29
|
+
change = { status: 'same', reason: 'same', current: observed };
|
|
30
|
+
}
|
|
31
|
+
if (change.status === 'unknown') return change;
|
|
32
|
+
|
|
33
|
+
// An absent tmux server only ends a known live generation. An already-empty generation remains
|
|
34
|
+
// unknown unless a Handmux deletion explicitly confirms it.
|
|
35
|
+
if (observed.status === 'absent' && cause !== 'confirmed-empty' && change.status !== 'changed') {
|
|
36
|
+
return { status: 'unknown' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const captured = await deps.capture(snapshotEnvironment(change.current ?? observed));
|
|
40
|
+
if (captured.status === 'changed-during-capture') {
|
|
41
|
+
deps.scheduleRetry?.();
|
|
42
|
+
return captured;
|
|
43
|
+
}
|
|
44
|
+
if (captured.status !== 'ok' && captured.status !== 'empty') return captured;
|
|
45
|
+
if (captured.status === 'empty' && cause !== 'confirmed-empty' && observed.status !== 'present' && change.status !== 'changed') {
|
|
46
|
+
return { status: 'unknown' };
|
|
47
|
+
}
|
|
48
|
+
if (change.status === 'changed') {
|
|
49
|
+
const archived = await deps.store.archiveEnvironment({
|
|
50
|
+
endedReason: change.reason,
|
|
51
|
+
detectedAt: new Date(deps.now()).toISOString(),
|
|
52
|
+
});
|
|
53
|
+
if (archived.status !== 'ok' && archived.status !== 'empty') return archived;
|
|
54
|
+
}
|
|
55
|
+
if (live.status === 'ok' && sameSnapshot(live.value, captured.snapshot)) return { status: 'unchanged' };
|
|
56
|
+
await deps.store.writeLive(captured.snapshot);
|
|
57
|
+
return { status: 'written', snapshot: captured.snapshot };
|
|
58
|
+
} finally {
|
|
59
|
+
await handle.release();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function createCheckpointer({
|
|
64
|
+
setInterval = globalThis.setInterval,
|
|
65
|
+
clearInterval = globalThis.clearInterval,
|
|
66
|
+
setTimeout = globalThis.setTimeout,
|
|
67
|
+
clearTimeout = globalThis.clearTimeout,
|
|
68
|
+
now = Date.now,
|
|
69
|
+
...rest
|
|
70
|
+
} = {}) {
|
|
71
|
+
const deps = { ...rest, now, setInterval, clearInterval, setTimeout, clearTimeout };
|
|
72
|
+
let interval = null;
|
|
73
|
+
let debounce = null;
|
|
74
|
+
let running = null;
|
|
75
|
+
let pendingConfirmation = null;
|
|
76
|
+
let stopping = false;
|
|
77
|
+
let stopped = false;
|
|
78
|
+
let stopPromise = null;
|
|
79
|
+
|
|
80
|
+
function launch(cause) {
|
|
81
|
+
const current = reconcileOnce(deps, cause);
|
|
82
|
+
running = current;
|
|
83
|
+
const settled = () => {
|
|
84
|
+
if (running !== current) return;
|
|
85
|
+
running = null;
|
|
86
|
+
if (pendingConfirmation) {
|
|
87
|
+
const pending = pendingConfirmation;
|
|
88
|
+
pendingConfirmation = null;
|
|
89
|
+
launch('confirmed-empty').then(pending.resolve, pending.reject);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
current.then(settled, settled);
|
|
93
|
+
return current;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function confirmEmpty() {
|
|
97
|
+
if (stopping || stopped) return Promise.resolve({ status: 'stopped' });
|
|
98
|
+
if (!running) return launch('confirmed-empty');
|
|
99
|
+
if (!pendingConfirmation) {
|
|
100
|
+
let resolve;
|
|
101
|
+
let reject;
|
|
102
|
+
const promise = new Promise((onResolve, onReject) => { resolve = onResolve; reject = onReject; });
|
|
103
|
+
pendingConfirmation = { promise, resolve, reject };
|
|
104
|
+
}
|
|
105
|
+
return pendingConfirmation.promise;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function reconcile(cause = 'timer') {
|
|
109
|
+
if (cause === 'confirmed-empty') return confirmEmpty();
|
|
110
|
+
if (stopping || stopped) return Promise.resolve({ status: 'stopped' });
|
|
111
|
+
return running || launch(cause);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function requestReconcile() {
|
|
115
|
+
if (stopping || stopped) return;
|
|
116
|
+
clearTimeout(debounce);
|
|
117
|
+
debounce = setTimeout(() => {
|
|
118
|
+
debounce = null;
|
|
119
|
+
reconcile('event').catch(() => {});
|
|
120
|
+
}, 2_000);
|
|
121
|
+
}
|
|
122
|
+
deps.scheduleRetry = requestReconcile;
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
reconcile,
|
|
126
|
+
start() {
|
|
127
|
+
if (stopping || stopped) return Promise.resolve({ status: 'stopped' });
|
|
128
|
+
if (!interval) interval = setInterval(() => { reconcile('timer').catch(() => {}); }, 60_000);
|
|
129
|
+
return reconcile('start');
|
|
130
|
+
},
|
|
131
|
+
requestReconcile,
|
|
132
|
+
confirmEmpty,
|
|
133
|
+
stop() {
|
|
134
|
+
if (stopPromise) return stopPromise;
|
|
135
|
+
stopping = true;
|
|
136
|
+
stopPromise = (async () => {
|
|
137
|
+
if (interval) clearInterval(interval);
|
|
138
|
+
if (debounce) clearTimeout(debounce);
|
|
139
|
+
interval = null;
|
|
140
|
+
debounce = null;
|
|
141
|
+
while (running || pendingConfirmation) {
|
|
142
|
+
await (running || pendingConfirmation.promise).catch(() => {});
|
|
143
|
+
}
|
|
144
|
+
await launch('shutdown').catch(() => {});
|
|
145
|
+
while (running || pendingConfirmation) {
|
|
146
|
+
await (running || pendingConfirmation.promise).catch(() => {});
|
|
147
|
+
}
|
|
148
|
+
stopped = true;
|
|
149
|
+
})();
|
|
150
|
+
return stopPromise;
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function createWorkspaceBackground({ store, tmux, observeEnvironment, lock, stateFile, now = Date.now } = {}) {
|
|
156
|
+
return createCheckpointer({
|
|
157
|
+
store,
|
|
158
|
+
observeEnvironment,
|
|
159
|
+
lock,
|
|
160
|
+
now,
|
|
161
|
+
capture: (environment) => captureWorkspace({ tmux, stateFile, environment, now }),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function createGracefulShutdown({ events, workspace, server }) {
|
|
166
|
+
let closing = null;
|
|
167
|
+
return function shutdown() {
|
|
168
|
+
if (!closing) {
|
|
169
|
+
closing = (async () => {
|
|
170
|
+
try {
|
|
171
|
+
await events.stop();
|
|
172
|
+
} finally {
|
|
173
|
+
try { await workspace.stop(); } finally { server.close(); }
|
|
174
|
+
}
|
|
175
|
+
})();
|
|
176
|
+
}
|
|
177
|
+
return closing;
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import fsp from 'node:fs/promises';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
|
|
8
|
+
function normalizeMacBootTime(value) {
|
|
9
|
+
const seconds = String(value).match(/\bsec\s*=\s*(\d+)\b/)?.[1];
|
|
10
|
+
return seconds && seconds !== '0' ? seconds : null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function environmentId(bootIdentity, tmuxServerId) {
|
|
14
|
+
return crypto
|
|
15
|
+
.createHash('sha256')
|
|
16
|
+
.update(`${bootIdentity}\0${tmuxServerId || 'no-tmux'}`)
|
|
17
|
+
.digest('hex');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function detectEnvironmentChange(previous, observed) {
|
|
21
|
+
if (!observed || observed.status === 'unknown') return { status: 'unknown' };
|
|
22
|
+
if (!previous) return { status: 'initial', current: observed };
|
|
23
|
+
if (previous.bootIdentity !== observed.bootIdentity) return { status: 'changed', reason: 'boot-changed', current: observed };
|
|
24
|
+
if (observed.status === 'absent') {
|
|
25
|
+
return previous.tmuxServerId
|
|
26
|
+
? { status: 'changed', reason: 'tmux-changed', current: observed }
|
|
27
|
+
: { status: 'unknown' };
|
|
28
|
+
}
|
|
29
|
+
if (!previous.tmuxServerId) return { status: 'attached', reason: 'same', current: observed };
|
|
30
|
+
if (previous.tmuxServerId !== observed.tmuxServerId) return { status: 'changed', reason: 'tmux-changed', current: observed };
|
|
31
|
+
return { status: 'same', reason: 'same', current: observed };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createBootIdentityProvider({
|
|
35
|
+
platform = process.platform,
|
|
36
|
+
readFile = fsp.readFile,
|
|
37
|
+
exec = execFileAsync,
|
|
38
|
+
} = {}) {
|
|
39
|
+
return async () => {
|
|
40
|
+
try {
|
|
41
|
+
if (platform === 'linux') {
|
|
42
|
+
const identity = String(await readFile('/proc/sys/kernel/random/boot_id', 'utf8')).trim();
|
|
43
|
+
return identity || null;
|
|
44
|
+
}
|
|
45
|
+
if (platform === 'darwin') {
|
|
46
|
+
return normalizeMacBootTime((await exec('sysctl', ['-n', 'kern.boottime'])).stdout);
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createEnvironmentProvider({
|
|
56
|
+
bootIdentityProvider = createBootIdentityProvider(),
|
|
57
|
+
tmuxServerIdProvider,
|
|
58
|
+
} = {}) {
|
|
59
|
+
return async () => {
|
|
60
|
+
try {
|
|
61
|
+
const bootIdentity = await bootIdentityProvider();
|
|
62
|
+
if (typeof bootIdentity !== 'string' || !bootIdentity) return { status: 'unknown' };
|
|
63
|
+
const tmuxServerId = await tmuxServerIdProvider();
|
|
64
|
+
if (tmuxServerId !== null && (typeof tmuxServerId !== 'string' || !tmuxServerId)) return { status: 'unknown' };
|
|
65
|
+
return {
|
|
66
|
+
status: tmuxServerId === null ? 'absent' : 'present',
|
|
67
|
+
id: environmentId(bootIdentity, tmuxServerId),
|
|
68
|
+
bootIdentity,
|
|
69
|
+
tmuxServerId,
|
|
70
|
+
};
|
|
71
|
+
} catch {
|
|
72
|
+
return { status: 'unknown' };
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fsp from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const OWNER_FILE = 'owner.json';
|
|
6
|
+
const SAFE_TOKEN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
7
|
+
|
|
8
|
+
function defaultProcessAlive(pid) {
|
|
9
|
+
try {
|
|
10
|
+
process.kill(pid, 0);
|
|
11
|
+
return true;
|
|
12
|
+
} catch (error) {
|
|
13
|
+
return error?.code === 'EPERM';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
18
|
+
|
|
19
|
+
function ownerLabel(owner) {
|
|
20
|
+
if (!owner) return 'unknown owner';
|
|
21
|
+
const operation = typeof owner.operationId === 'string' && owner.operationId ? owner.operationId : 'unknown operation';
|
|
22
|
+
const pid = Number.isInteger(owner.pid) ? owner.pid : 'unknown';
|
|
23
|
+
return `${operation} (pid ${pid})`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createWorkspaceLock({
|
|
27
|
+
dir,
|
|
28
|
+
fs = fsp,
|
|
29
|
+
pid = process.pid,
|
|
30
|
+
now = Date.now,
|
|
31
|
+
isProcessAlive = defaultProcessAlive,
|
|
32
|
+
wait = delay,
|
|
33
|
+
timeoutMs = 5_000,
|
|
34
|
+
retryMs = 50,
|
|
35
|
+
staleGraceMs = 5_000,
|
|
36
|
+
randomUUID = crypto.randomUUID,
|
|
37
|
+
} = {}) {
|
|
38
|
+
if (typeof dir !== 'string' || !dir) throw new Error('workspace lock directory is required');
|
|
39
|
+
|
|
40
|
+
async function readOwner() {
|
|
41
|
+
let stat = null;
|
|
42
|
+
try { stat = await fs.stat(dir); } catch (error) {
|
|
43
|
+
if (error?.code === 'ENOENT') return null;
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const value = JSON.parse(await fs.readFile(path.join(dir, OWNER_FILE), 'utf8'));
|
|
48
|
+
return { value, mtimeMs: stat.mtimeMs };
|
|
49
|
+
} catch {
|
|
50
|
+
return { value: null, mtimeMs: stat.mtimeMs };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function reclaimIfStale(record) {
|
|
55
|
+
if (!record) return true;
|
|
56
|
+
const owner = record.value;
|
|
57
|
+
if (!SAFE_TOKEN.test(owner?.token)) return false;
|
|
58
|
+
const startedAt = Date.parse(owner?.startedAt);
|
|
59
|
+
const age = now() - (Number.isFinite(startedAt) ? startedAt : record.mtimeMs);
|
|
60
|
+
if (age < staleGraceMs) return false;
|
|
61
|
+
if (Number.isInteger(owner?.pid) && await isProcessAlive(owner.pid)) return false;
|
|
62
|
+
|
|
63
|
+
// Every contender that observed this owner uses the SAME destination. The winner leaves the renamed
|
|
64
|
+
// directory as a tombstone, so a loser cannot later rename a newly-created lock out of the way.
|
|
65
|
+
const staleDir = `${dir}.stale.${owner.token}`;
|
|
66
|
+
try {
|
|
67
|
+
await fs.rename(dir, staleDir);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error?.code === 'ENOENT') return true;
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function tryAcquire({ operationId = 'workspace-writer' } = {}) {
|
|
76
|
+
await fs.mkdir(path.dirname(dir), { recursive: true, mode: 0o700 });
|
|
77
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
78
|
+
const token = randomUUID();
|
|
79
|
+
if (!SAFE_TOKEN.test(token)) throw new Error('workspace lock token must be a UUID');
|
|
80
|
+
const owner = { pid, startedAt: new Date(now()).toISOString(), operationId, token };
|
|
81
|
+
try {
|
|
82
|
+
await fs.mkdir(dir, { mode: 0o700 });
|
|
83
|
+
try {
|
|
84
|
+
await fs.writeFile(path.join(dir, OWNER_FILE), `${JSON.stringify(owner)}\n`, { mode: 0o600, flag: 'wx' });
|
|
85
|
+
} catch (error) {
|
|
86
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
let released = false;
|
|
90
|
+
return {
|
|
91
|
+
owner,
|
|
92
|
+
async release() {
|
|
93
|
+
if (released) return;
|
|
94
|
+
released = true;
|
|
95
|
+
const current = await readOwner();
|
|
96
|
+
if (current?.value?.token === token) await fs.rm(dir, { recursive: true, force: true });
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
101
|
+
const record = await readOwner();
|
|
102
|
+
if (!await reclaimIfStale(record)) return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function acquire(owner = {}, options = {}) {
|
|
109
|
+
const limit = options.timeoutMs ?? timeoutMs;
|
|
110
|
+
const started = now();
|
|
111
|
+
while (true) {
|
|
112
|
+
const handle = await tryAcquire(owner);
|
|
113
|
+
if (handle) return handle;
|
|
114
|
+
const current = await readOwner();
|
|
115
|
+
if (now() - started >= limit) {
|
|
116
|
+
throw new Error(`workspace writer lock timed out; held by ${ownerLabel(current?.value)}`);
|
|
117
|
+
}
|
|
118
|
+
await wait(options.retryMs ?? retryMs);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function withLock(owner, fn, options) {
|
|
123
|
+
const handle = await acquire(owner, options);
|
|
124
|
+
try { return await fn(handle.owner); } finally { await handle.release(); }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { tryAcquire, acquire, withLock, readOwner: async () => (await readOwner())?.value ?? null };
|
|
128
|
+
}
|