@bbigbang/runtime-acp 0.1.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.
@@ -0,0 +1,372 @@
1
+ import path from 'node:path';
2
+ import { resolveWorkspacePath } from '../tools/workspace.js';
3
+ export const TOOL_KINDS = [
4
+ 'read',
5
+ 'edit',
6
+ 'delete',
7
+ 'move',
8
+ 'search',
9
+ 'execute',
10
+ 'think',
11
+ 'fetch',
12
+ 'switch_mode',
13
+ 'other',
14
+ ];
15
+ export function parseToolKind(value) {
16
+ if (typeof value !== 'string')
17
+ return null;
18
+ const normalized = value.trim().toLowerCase();
19
+ return TOOL_KINDS.includes(normalized)
20
+ ? normalized
21
+ : null;
22
+ }
23
+ const PATH_PREFIX_TOOL_KINDS = new Set([
24
+ 'read',
25
+ 'edit',
26
+ 'delete',
27
+ 'move',
28
+ ]);
29
+ export class ToolAuth {
30
+ db;
31
+ onceGrants = new Map();
32
+ constructor(db) {
33
+ this.db = db;
34
+ }
35
+ grantOnce(sessionKey, toolKind, count = 1) {
36
+ const perSession = this.onceGrants.get(sessionKey) ?? new Map();
37
+ perSession.set(toolKind, (perSession.get(toolKind) ?? 0) + count);
38
+ this.onceGrants.set(sessionKey, perSession);
39
+ }
40
+ setPersistentPolicy(bindingKey, toolKind, policy) {
41
+ const now = Date.now();
42
+ this.db
43
+ .prepare(`
44
+ INSERT INTO tool_policies(binding_key, tool_kind, policy, created_at, updated_at)
45
+ VALUES(?, ?, ?, ?, ?)
46
+ ON CONFLICT(binding_key, tool_kind) DO UPDATE SET
47
+ policy = excluded.policy,
48
+ updated_at = excluded.updated_at
49
+ `)
50
+ .run(bindingKey, toolKind, policy, now, now);
51
+ }
52
+ getPersistentPolicy(bindingKey, toolKind) {
53
+ const row = this.db
54
+ .prepare('SELECT policy FROM tool_policies WHERE binding_key = ? AND tool_kind = ? LIMIT 1')
55
+ .get(bindingKey, toolKind);
56
+ return row?.policy ?? null;
57
+ }
58
+ listPersistentPolicies(bindingKey, policy) {
59
+ const rows = policy
60
+ ? this.db
61
+ .prepare(`
62
+ SELECT tool_kind as toolKind, policy
63
+ FROM tool_policies
64
+ WHERE binding_key = ? AND policy = ?
65
+ ORDER BY tool_kind ASC
66
+ `)
67
+ .all(bindingKey, policy)
68
+ : this.db
69
+ .prepare(`
70
+ SELECT tool_kind as toolKind, policy
71
+ FROM tool_policies
72
+ WHERE binding_key = ?
73
+ ORDER BY tool_kind ASC
74
+ `)
75
+ .all(bindingKey);
76
+ return rows
77
+ .map((row) => {
78
+ const toolKind = parseToolKind(row.toolKind);
79
+ if (!toolKind)
80
+ return null;
81
+ return { toolKind, policy: row.policy };
82
+ })
83
+ .filter(Boolean);
84
+ }
85
+ clearPersistentPolicy(bindingKey, toolKind, policy) {
86
+ const result = policy
87
+ ? this.db
88
+ .prepare(`
89
+ DELETE FROM tool_policies
90
+ WHERE binding_key = ? AND tool_kind = ? AND policy = ?
91
+ `)
92
+ .run(bindingKey, toolKind, policy)
93
+ : this.db
94
+ .prepare(`
95
+ DELETE FROM tool_policies
96
+ WHERE binding_key = ? AND tool_kind = ?
97
+ `)
98
+ .run(bindingKey, toolKind);
99
+ return result.changes > 0;
100
+ }
101
+ clearPersistentPolicies(bindingKey, policy) {
102
+ const result = policy
103
+ ? this.db
104
+ .prepare(`
105
+ DELETE FROM tool_policies
106
+ WHERE binding_key = ? AND policy = ?
107
+ `)
108
+ .run(bindingKey, policy)
109
+ : this.db
110
+ .prepare(`
111
+ DELETE FROM tool_policies
112
+ WHERE binding_key = ?
113
+ `)
114
+ .run(bindingKey);
115
+ return result.changes;
116
+ }
117
+ setAllowPrefixRule(bindingKey, toolKind, argPrefix) {
118
+ const normalizedPrefix = normalizeStoredPrefix(toolKind, argPrefix);
119
+ if (!normalizedPrefix) {
120
+ throw new Error('Invalid allow prefix.');
121
+ }
122
+ const now = Date.now();
123
+ this.db
124
+ .prepare(`
125
+ INSERT INTO tool_allow_prefixes(binding_key, tool_kind, arg_prefix, created_at, updated_at)
126
+ VALUES(?, ?, ?, ?, ?)
127
+ ON CONFLICT(binding_key, tool_kind, arg_prefix) DO UPDATE SET
128
+ updated_at = excluded.updated_at
129
+ `)
130
+ .run(bindingKey, toolKind, normalizedPrefix, now, now);
131
+ }
132
+ listAllowPrefixRules(bindingKey, toolKind) {
133
+ const rows = toolKind
134
+ ? this.db
135
+ .prepare(`
136
+ SELECT tool_kind as toolKind, arg_prefix as argPrefix
137
+ FROM tool_allow_prefixes
138
+ WHERE binding_key = ? AND tool_kind = ?
139
+ ORDER BY tool_kind ASC, arg_prefix ASC
140
+ `)
141
+ .all(bindingKey, toolKind)
142
+ : this.db
143
+ .prepare(`
144
+ SELECT tool_kind as toolKind, arg_prefix as argPrefix
145
+ FROM tool_allow_prefixes
146
+ WHERE binding_key = ?
147
+ ORDER BY tool_kind ASC, arg_prefix ASC
148
+ `)
149
+ .all(bindingKey);
150
+ return rows
151
+ .map((row) => {
152
+ const parsedKind = parseToolKind(row.toolKind);
153
+ if (!parsedKind)
154
+ return null;
155
+ const normalizedPrefix = normalizeStoredPrefix(parsedKind, row.argPrefix);
156
+ if (!normalizedPrefix)
157
+ return null;
158
+ return {
159
+ toolKind: parsedKind,
160
+ argPrefix: normalizedPrefix,
161
+ };
162
+ })
163
+ .filter(Boolean);
164
+ }
165
+ clearAllowPrefixRule(bindingKey, toolKind, argPrefix) {
166
+ const normalizedPrefix = normalizeStoredPrefix(toolKind, argPrefix);
167
+ if (!normalizedPrefix)
168
+ return false;
169
+ const result = this.db
170
+ .prepare(`
171
+ DELETE FROM tool_allow_prefixes
172
+ WHERE binding_key = ? AND tool_kind = ? AND arg_prefix = ?
173
+ `)
174
+ .run(bindingKey, toolKind, normalizedPrefix);
175
+ return result.changes > 0;
176
+ }
177
+ clearAllowPrefixRules(bindingKey, toolKind) {
178
+ const result = toolKind
179
+ ? this.db
180
+ .prepare(`
181
+ DELETE FROM tool_allow_prefixes
182
+ WHERE binding_key = ? AND tool_kind = ?
183
+ `)
184
+ .run(bindingKey, toolKind)
185
+ : this.db
186
+ .prepare(`
187
+ DELETE FROM tool_allow_prefixes
188
+ WHERE binding_key = ?
189
+ `)
190
+ .run(bindingKey);
191
+ return result.changes;
192
+ }
193
+ evaluatePersistentPolicy(bindingKey, toolKind, context) {
194
+ const policy = this.getPersistentPolicy(bindingKey, toolKind);
195
+ if (policy === 'reject')
196
+ return 'reject';
197
+ if (policy === 'allow')
198
+ return 'allow';
199
+ return this.matchesAllowPrefixRule(bindingKey, toolKind, context)
200
+ ? 'allow'
201
+ : null;
202
+ }
203
+ consume(sessionKey, toolKind, context) {
204
+ const bindingRow = this.db
205
+ .prepare('SELECT binding_key as bindingKey FROM bindings WHERE session_key = ? LIMIT 1')
206
+ .get(sessionKey);
207
+ if (!bindingRow)
208
+ return false;
209
+ const persistent = this.evaluatePersistentPolicy(bindingRow.bindingKey, toolKind, context);
210
+ if (persistent === 'reject')
211
+ return false;
212
+ if (persistent === 'allow')
213
+ return true;
214
+ const perSession = this.onceGrants.get(sessionKey);
215
+ const remaining = perSession?.get(toolKind) ?? 0;
216
+ if (remaining <= 0)
217
+ return false;
218
+ perSession.set(toolKind, remaining - 1);
219
+ return true;
220
+ }
221
+ matchesAllowPrefixRule(bindingKey, toolKind, context) {
222
+ if (!context)
223
+ return false;
224
+ const rules = this.listAllowPrefixRules(bindingKey, toolKind);
225
+ if (rules.length === 0)
226
+ return false;
227
+ const candidates = extractMatchCandidates(toolKind, context);
228
+ if (candidates.length === 0)
229
+ return false;
230
+ for (const rule of rules) {
231
+ if (candidates.some((candidate) => prefixMatches(toolKind, candidate, rule.argPrefix))) {
232
+ return true;
233
+ }
234
+ }
235
+ return false;
236
+ }
237
+ }
238
+ function extractMatchCandidates(toolKind, context) {
239
+ const out = [];
240
+ const seen = new Set();
241
+ const push = (raw) => {
242
+ if (typeof raw !== 'string')
243
+ return;
244
+ const normalized = normalizeCandidate(toolKind, raw, context.workspaceRoot);
245
+ if (!normalized || seen.has(normalized))
246
+ return;
247
+ seen.add(normalized);
248
+ out.push(normalized);
249
+ };
250
+ const params = asRecord(context.params);
251
+ const method = String(context.method ?? '').trim();
252
+ if (method === 'fs/read_text_file' || method === 'fs/write_text_file') {
253
+ push(params?.path);
254
+ }
255
+ if (method === 'terminal/create') {
256
+ push(formatCommandLine(params?.command, params?.args));
257
+ }
258
+ const toolCall = asRecord(context.toolCall);
259
+ if (toolCall) {
260
+ push(toolCall.path);
261
+ push(getPathValue(toolCall, 'arguments.path'));
262
+ push(getPathValue(toolCall, 'input.path'));
263
+ push(formatCommandLine(toolCall.command, toolCall.args));
264
+ push(formatCommandLine(getPathValue(toolCall, 'arguments.command'), getPathValue(toolCall, 'arguments.args')));
265
+ push(extractTargetFromToolTitle(toolKind, toolCall.title));
266
+ }
267
+ if (PATH_PREFIX_TOOL_KINDS.has(toolKind)) {
268
+ push(params?.file);
269
+ push(params?.target);
270
+ push(params?.uri);
271
+ }
272
+ else if (toolKind === 'execute') {
273
+ push(params?.command);
274
+ }
275
+ else {
276
+ push(params?.path);
277
+ push(params?.query);
278
+ push(params?.pattern);
279
+ push(params?.text);
280
+ }
281
+ return out;
282
+ }
283
+ function normalizeCandidate(toolKind, raw, workspaceRoot) {
284
+ if (PATH_PREFIX_TOOL_KINDS.has(toolKind)) {
285
+ return normalizePathPrefix(raw, workspaceRoot);
286
+ }
287
+ return normalizeTextPrefix(raw);
288
+ }
289
+ function normalizeStoredPrefix(toolKind, raw) {
290
+ if (PATH_PREFIX_TOOL_KINDS.has(toolKind)) {
291
+ return normalizePathPrefix(raw);
292
+ }
293
+ return normalizeTextPrefix(raw);
294
+ }
295
+ function normalizePathPrefix(raw, workspaceRoot) {
296
+ const trimmed = raw.trim();
297
+ if (!trimmed || !path.isAbsolute(trimmed))
298
+ return null;
299
+ if (workspaceRoot) {
300
+ try {
301
+ return resolveWorkspacePath(workspaceRoot, trimmed);
302
+ }
303
+ catch {
304
+ return null;
305
+ }
306
+ }
307
+ return path.resolve(trimmed);
308
+ }
309
+ function normalizeTextPrefix(raw) {
310
+ const normalized = raw.replace(/\s+/g, ' ').trim();
311
+ return normalized || null;
312
+ }
313
+ function prefixMatches(toolKind, candidate, prefix) {
314
+ if (PATH_PREFIX_TOOL_KINDS.has(toolKind)) {
315
+ return pathPrefixMatches(candidate, prefix);
316
+ }
317
+ return candidate.startsWith(prefix);
318
+ }
319
+ function pathPrefixMatches(candidate, prefix) {
320
+ const normalizedCandidate = path.resolve(candidate);
321
+ const normalizedPrefix = path.resolve(prefix);
322
+ if (normalizedCandidate === normalizedPrefix)
323
+ return true;
324
+ return normalizedCandidate.startsWith(normalizedPrefix + path.sep);
325
+ }
326
+ function asRecord(value) {
327
+ if (!value || typeof value !== 'object' || Array.isArray(value))
328
+ return null;
329
+ return value;
330
+ }
331
+ function getPathValue(source, pathExpr) {
332
+ const parts = pathExpr.split('.');
333
+ let current = source;
334
+ for (const part of parts) {
335
+ const obj = asRecord(current);
336
+ if (!obj)
337
+ return undefined;
338
+ current = obj[part];
339
+ }
340
+ return current;
341
+ }
342
+ function formatCommandLine(commandRaw, argsRaw) {
343
+ if (typeof commandRaw !== 'string' || !commandRaw.trim())
344
+ return null;
345
+ const command = commandRaw.trim();
346
+ const args = Array.isArray(argsRaw)
347
+ ? argsRaw.filter((item) => typeof item === 'string')
348
+ : [];
349
+ const full = args.length > 0 ? `${command} ${args.join(' ')}` : command;
350
+ return normalizeTextPrefix(full);
351
+ }
352
+ function extractTargetFromToolTitle(toolKind, titleRaw) {
353
+ if (typeof titleRaw !== 'string')
354
+ return null;
355
+ const title = titleRaw.trim();
356
+ if (!title)
357
+ return null;
358
+ if (PATH_PREFIX_TOOL_KINDS.has(toolKind)) {
359
+ const match = title.match(/^(?:read|edit|delete|move)\s*:\s*(.+)$/i);
360
+ if (match) {
361
+ return match[1]?.trim() ?? null;
362
+ }
363
+ return null;
364
+ }
365
+ if (toolKind === 'execute') {
366
+ const match = title.match(/^run\s*:\s*(.+)$/i);
367
+ if (match) {
368
+ return match[1]?.trim() ?? null;
369
+ }
370
+ }
371
+ return null;
372
+ }
@@ -0,0 +1,79 @@
1
+ export type DeliveryState = {
2
+ text: string;
3
+ messageId: string | null;
4
+ };
5
+ export type UiMode = 'verbose' | 'summary';
6
+ export type ToolUiStage = 'start' | 'update' | 'complete';
7
+ export type PermissionUiRequest = {
8
+ uiMode: UiMode;
9
+ sessionKey: string;
10
+ requestId: string;
11
+ toolTitle: string;
12
+ toolKind: string | null;
13
+ toolName?: string;
14
+ toolArgs?: unknown;
15
+ approvalKind?: 'tool' | 'plan' | 'question';
16
+ title?: string;
17
+ description?: string;
18
+ input?: unknown;
19
+ actions?: Array<{
20
+ id: string;
21
+ label: string;
22
+ variant?: 'primary' | 'secondary' | 'danger';
23
+ requiresInput?: boolean;
24
+ inputPlaceholder?: string;
25
+ }>;
26
+ };
27
+ export type UiEvent = {
28
+ kind: 'plan' | 'task';
29
+ mode: UiMode;
30
+ title: string;
31
+ detail?: string;
32
+ silent?: boolean;
33
+ } | {
34
+ kind: 'plan_phase';
35
+ mode: UiMode;
36
+ phase: 'planning' | 'implementation';
37
+ } | {
38
+ kind: 'usage';
39
+ mode: UiMode;
40
+ inputTokens?: number;
41
+ cachedInputTokens?: number;
42
+ outputTokens?: number;
43
+ reasoningOutputTokens?: number;
44
+ totalTokens?: number;
45
+ currentInputTokens?: number;
46
+ currentCachedInputTokens?: number;
47
+ modelContextWindow?: number;
48
+ metadata?: Record<string, unknown>;
49
+ } | {
50
+ kind: 'compact';
51
+ mode: UiMode;
52
+ threadId: string;
53
+ turnId: string;
54
+ itemId?: string;
55
+ source: 'thread_compacted' | 'raw_response_item' | 'thread_item';
56
+ eventKey: string;
57
+ } | {
58
+ kind: 'tool';
59
+ mode: UiMode;
60
+ title: string;
61
+ detail?: string;
62
+ input?: unknown;
63
+ output?: string;
64
+ toolCallId?: string;
65
+ stage?: ToolUiStage;
66
+ status?: string;
67
+ metadata?: Record<string, unknown>;
68
+ };
69
+ export type OutboundSink = {
70
+ sendAgentText?: (text: string) => Promise<void>;
71
+ sendActivityText?: (text: string) => Promise<void>;
72
+ sendThinkingText?: (text: string) => Promise<void>;
73
+ sendText: (text: string) => Promise<void>;
74
+ breakTextStream?: () => Promise<void>;
75
+ flush?: () => Promise<void>;
76
+ getDeliveryState?: () => DeliveryState;
77
+ requestPermission?: (req: PermissionUiRequest) => Promise<void>;
78
+ sendUi?: (event: UiEvent) => Promise<void>;
79
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,26 @@
1
+ export { AcpClient } from './acp/client.js';
2
+ export type { PermissionRequest, PermissionDecision, AcpClientEvents, ClientToolEvent } from './acp/client.js';
3
+ export { spawnAcpAgent } from './acp/stdio.js';
4
+ export type { StdioProcess } from './acp/stdio.js';
5
+ export { isRequest, isResponse, isNotification, } from './acp/jsonrpc.js';
6
+ export type { JsonRpcRequest, JsonRpcResponse, JsonRpcNotification, JsonRpcMessage, } from './acp/jsonrpc.js';
7
+ export type { InitializeParams, InitializeResult, NewSessionParams, NewSessionResult, PromptParams, PromptResult, ContentBlock, RequestPermissionParams, McpServerEntry, } from './acp/types.js';
8
+ export { BindingRuntime } from './gateway/bindingRuntime.js';
9
+ export type { RuntimeConfig } from './gateway/bindingRuntime.js';
10
+ export { createSession, createRun, finishRun, getSession, getBinding, upsertBinding, updateAcpSessionId, updateSessionRuntimeState, clearAcpSessionId, updateLoadSupported, updateClaudeSessionControls, insertClaudeSessionUserMessage, getLatestClaudeSessionUserMessage, bindingKeyFromConversationKey, SHARED_CHAT_SCOPE_USER_ID, } from './gateway/sessionStore.js';
11
+ export type { Platform, ConversationKey, SessionBinding, StoredClaudeSessionModeId } from './gateway/sessionStore.js';
12
+ export { ToolAuth, parseToolKind, TOOL_KINDS } from './gateway/toolAuth.js';
13
+ export type { ToolKind } from './gateway/toolAuth.js';
14
+ export { buildReplayContextFromRecentRuns } from './gateway/history.js';
15
+ export type { OutboundSink, PermissionUiRequest, UiEvent, UiMode, ToolUiStage, DeliveryState, } from './gateway/types.js';
16
+ export { openDb } from './db/db.js';
17
+ export type { Db } from './db/db.js';
18
+ export { migrate } from './db/migrations.js';
19
+ export { getUiMode, setUiMode } from './db/uiPrefStore.js';
20
+ export { upsertDeliveryCheckpoint, getDeliveryCheckpoint, } from './db/deliveryCheckpointStore.js';
21
+ export { acquireProcessLock } from './runtime/lock.js';
22
+ export type { ProcessLock } from './runtime/lock.js';
23
+ export { WorkspaceLockManager } from './runtime/workspaceLockManager.js';
24
+ export type { WorkspaceLockLease } from './runtime/workspaceLockManager.js';
25
+ export { resolveWorkspacePath } from './tools/workspace.js';
26
+ export { log } from './logging.js';
package/dist/index.js ADDED
@@ -0,0 +1,19 @@
1
+ // ACP protocol layer
2
+ export { AcpClient } from './acp/client.js';
3
+ export { spawnAcpAgent } from './acp/stdio.js';
4
+ export { isRequest, isResponse, isNotification, } from './acp/jsonrpc.js';
5
+ // Gateway / runtime layer
6
+ export { BindingRuntime } from './gateway/bindingRuntime.js';
7
+ export { createSession, createRun, finishRun, getSession, getBinding, upsertBinding, updateAcpSessionId, updateSessionRuntimeState, clearAcpSessionId, updateLoadSupported, updateClaudeSessionControls, insertClaudeSessionUserMessage, getLatestClaudeSessionUserMessage, bindingKeyFromConversationKey, SHARED_CHAT_SCOPE_USER_ID, } from './gateway/sessionStore.js';
8
+ export { ToolAuth, parseToolKind, TOOL_KINDS } from './gateway/toolAuth.js';
9
+ export { buildReplayContextFromRecentRuns } from './gateway/history.js';
10
+ // Database layer
11
+ export { openDb } from './db/db.js';
12
+ export { migrate } from './db/migrations.js';
13
+ export { getUiMode, setUiMode } from './db/uiPrefStore.js';
14
+ export { upsertDeliveryCheckpoint, getDeliveryCheckpoint, } from './db/deliveryCheckpointStore.js';
15
+ // Utilities
16
+ export { acquireProcessLock } from './runtime/lock.js';
17
+ export { WorkspaceLockManager } from './runtime/workspaceLockManager.js';
18
+ export { resolveWorkspacePath } from './tools/workspace.js';
19
+ export { log } from './logging.js';
@@ -0,0 +1,7 @@
1
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
2
+ export declare const log: {
3
+ debug: (...args: unknown[]) => void;
4
+ info: (...args: unknown[]) => void;
5
+ warn: (...args: unknown[]) => void;
6
+ error: (...args: unknown[]) => void;
7
+ };
@@ -0,0 +1,28 @@
1
+ const levelOrder = {
2
+ debug: 10,
3
+ info: 20,
4
+ warn: 30,
5
+ error: 40,
6
+ };
7
+ const currentLevel = process.env.LOG_LEVEL ?? 'info';
8
+ function shouldLog(level) {
9
+ return levelOrder[level] >= levelOrder[currentLevel];
10
+ }
11
+ export const log = {
12
+ debug: (...args) => {
13
+ if (shouldLog('debug'))
14
+ console.log('[debug]', ...args);
15
+ },
16
+ info: (...args) => {
17
+ if (shouldLog('info'))
18
+ console.log('[info]', ...args);
19
+ },
20
+ warn: (...args) => {
21
+ if (shouldLog('warn'))
22
+ console.warn('[warn]', ...args);
23
+ },
24
+ error: (...args) => {
25
+ if (shouldLog('error'))
26
+ console.error('[error]', ...args);
27
+ },
28
+ };
@@ -0,0 +1,5 @@
1
+ export type ProcessLock = {
2
+ path: string;
3
+ release: () => void;
4
+ };
5
+ export declare function acquireProcessLock(lockPath: string): ProcessLock;
@@ -0,0 +1,87 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ export function acquireProcessLock(lockPath) {
4
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
5
+ try {
6
+ const fd = fs.openSync(lockPath, 'wx');
7
+ try {
8
+ fs.writeFileSync(fd, JSON.stringify({
9
+ pid: process.pid,
10
+ startedAt: Date.now(),
11
+ }, null, 2) + '\n', 'utf8');
12
+ }
13
+ finally {
14
+ fs.closeSync(fd);
15
+ }
16
+ return {
17
+ path: lockPath,
18
+ release: () => {
19
+ try {
20
+ fs.unlinkSync(lockPath);
21
+ }
22
+ catch {
23
+ // ignore
24
+ }
25
+ },
26
+ };
27
+ }
28
+ catch (err) {
29
+ if (err?.code !== 'EEXIST')
30
+ throw err;
31
+ // If lock exists, verify the process is alive.
32
+ const existing = readLockFile(lockPath);
33
+ if (existing?.pid && isPidAlive(existing.pid)) {
34
+ throw new Error(`Another instance is running (pid=${existing.pid})`, {
35
+ cause: err,
36
+ });
37
+ }
38
+ // Stale lock.
39
+ try {
40
+ fs.unlinkSync(lockPath);
41
+ }
42
+ catch {
43
+ // ignore
44
+ }
45
+ // Retry once.
46
+ const fd = fs.openSync(lockPath, 'wx');
47
+ try {
48
+ fs.writeFileSync(fd, JSON.stringify({
49
+ pid: process.pid,
50
+ startedAt: Date.now(),
51
+ }, null, 2) + '\n', 'utf8');
52
+ }
53
+ finally {
54
+ fs.closeSync(fd);
55
+ }
56
+ return {
57
+ path: lockPath,
58
+ release: () => {
59
+ try {
60
+ fs.unlinkSync(lockPath);
61
+ }
62
+ catch {
63
+ // ignore
64
+ }
65
+ },
66
+ };
67
+ }
68
+ }
69
+ function isPidAlive(pid) {
70
+ try {
71
+ process.kill(pid, 0);
72
+ return true;
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ }
78
+ function readLockFile(lockPath) {
79
+ try {
80
+ const raw = fs.readFileSync(lockPath, 'utf8');
81
+ const parsed = JSON.parse(raw);
82
+ return parsed && typeof parsed === 'object' ? parsed : null;
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
@@ -0,0 +1,23 @@
1
+ export type WorkspaceLockLease = {
2
+ key: string;
3
+ waited: boolean;
4
+ release: () => void;
5
+ };
6
+ type WorkspaceLockHooks = {
7
+ onWaitStart?: () => void;
8
+ onAcquired?: (params: {
9
+ waited: boolean;
10
+ }) => void;
11
+ signal?: AbortSignal;
12
+ };
13
+ export declare class WorkspaceLockManager {
14
+ private readonly locks;
15
+ normalizeKey(workspaceRoot: string): string;
16
+ acquire(workspaceRoot: string, hooks?: WorkspaceLockHooks): Promise<WorkspaceLockLease>;
17
+ runExclusive<T>(workspaceRoot: string, action: () => Promise<T> | T, hooks?: WorkspaceLockHooks): Promise<T>;
18
+ runAfterPendingWrites<T>(workspaceRoot: string, action: () => Promise<T> | T, hooks?: WorkspaceLockHooks): Promise<T>;
19
+ waitForPendingWrites(workspaceRoot: string, hooks?: WorkspaceLockHooks): Promise<void>;
20
+ private getState;
21
+ private release;
22
+ }
23
+ export {};