@adhdev/daemon-core 0.9.82-rc.65 → 0.9.82-rc.67

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/src/installer.ts CHANGED
@@ -122,20 +122,22 @@ export interface InstallResult {
122
122
  /**
123
123
  * Check if an extension is already installed
124
124
  */
125
- export function isExtensionInstalled(
125
+ import { promisify } from 'util';
126
+ const execAsync = promisify(exec);
127
+
128
+ export async function isExtensionInstalled(
126
129
  ide: IDEInfo,
127
130
  marketplaceId: string
128
- ): boolean {
131
+ ): Promise<boolean> {
129
132
  if (!ide.cliCommand) return false;
130
133
 
131
134
  try {
132
- const result = execSync(`"${ide.cliCommand}" --list-extensions`, {
135
+ const { stdout } = await execAsync(`"${ide.cliCommand}" --list-extensions`, {
133
136
  encoding: 'utf-8',
134
137
  timeout: 15000,
135
- stdio: ['pipe', 'pipe', 'pipe'],
136
138
  });
137
139
 
138
- const installed = result
140
+ const installed = stdout
139
141
  .trim()
140
142
  .split('\n')
141
143
  .map((e) => e.trim().toLowerCase());
@@ -163,7 +165,7 @@ export async function installExtension(
163
165
  }
164
166
 
165
167
  // Check if already installed
166
- const alreadyInstalled = isExtensionInstalled(ide, extension.marketplaceId);
168
+ const alreadyInstalled = await isExtensionInstalled(ide, extension.marketplaceId);
167
169
  if (alreadyInstalled) {
168
170
  return {
169
171
  extensionId: extension.id,
package/src/launch.d.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  /** Kill IDE process (graceful → force) */
19
19
  export declare function killIdeProcess(ideId: string): Promise<boolean>;
20
20
  /** Check if IDE process is running */
21
- export declare function isIdeRunning(ideId: string): boolean;
21
+ export declare function isIdeRunning(ideId: string): Promise<boolean>;
22
22
  export interface LaunchOptions {
23
23
  ideId?: string;
24
24
  workspace?: string;
package/src/launch.ts CHANGED
@@ -16,7 +16,16 @@
16
16
  * adhdev launch --workspace /path — Open specific workspace
17
17
  */
18
18
 
19
- import { execSync, spawn, spawnSync } from 'child_process';
19
+ import { exec, spawn, spawnSync } from 'child_process';
20
+
21
+ async function execQuiet(command: string, options: any = {}): Promise<string> {
22
+ return new Promise((resolve) => {
23
+ exec(command, options, (error, stdout) => {
24
+ if (error) return resolve('');
25
+ resolve(stdout.toString());
26
+ });
27
+ });
28
+ }
20
29
  import * as net from 'net';
21
30
  import * as os from 'os';
22
31
  import * as path from 'path';
@@ -76,11 +85,11 @@ function getIdePathCandidates(ideId: string): string[] {
76
85
  return getProviderLoader().getIdePathCandidates(ideId);
77
86
  }
78
87
 
79
- function getMacAppProcessPids(ideId: string): number[] {
88
+ async function getMacAppProcessPids(ideId: string): Promise<number[]> {
80
89
  const appPaths = getIdePathCandidates(ideId);
81
90
  if (appPaths.length === 0) return [];
82
91
  try {
83
- const output = execSync('ps axww -o pid=,args=', {
92
+ const output = await execQuiet('ps axww -o pid=,args=', {
84
93
  encoding: 'utf-8',
85
94
  timeout: 3000,
86
95
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -91,8 +100,8 @@ function getMacAppProcessPids(ideId: string): number[] {
91
100
  }
92
101
  }
93
102
 
94
- function killMacAppPathProcesses(ideId: string, signal: NodeJS.Signals): boolean {
95
- const pids = getMacAppProcessPids(ideId);
103
+ async function killMacAppPathProcesses(ideId: string, signal: NodeJS.Signals): Promise<boolean> {
104
+ const pids = (await getMacAppProcessPids(ideId));
96
105
  let signalled = false;
97
106
  for (const pid of pids) {
98
107
  try {
@@ -163,49 +172,49 @@ export async function killIdeProcess(ideId: string): Promise<boolean> {
163
172
  if (plat === 'darwin' && appName) {
164
173
  // macOS: graceful quit via osascript
165
174
  try {
166
- execSync(`osascript -e 'tell application "${escapeForAppleScript(appName)}" to quit' 2>/dev/null`, {
175
+ await execQuiet(`osascript -e 'tell application "${escapeForAppleScript(appName)}" to quit' 2>/dev/null`, {
167
176
  timeout: 5000,
168
177
  });
169
178
  } catch {
170
- try { execSync(`pkill -x "${appName}" 2>/dev/null`, { timeout: 5000 }); } catch { }
179
+ try { await execQuiet(`pkill -x "${appName}" 2>/dev/null`, { timeout: 5000 }); } catch { }
171
180
  }
172
- killMacAppPathProcesses(ideId, 'SIGTERM');
181
+ await killMacAppPathProcesses(ideId, 'SIGTERM');
173
182
  } else if (plat === 'win32' && winProcesses) {
174
183
  // Windows: taskkill for each process name
175
184
  for (const proc of winProcesses) {
176
185
  try {
177
- execSync(`taskkill /IM "${proc}" /F 2>nul`, { timeout: 5000 });
186
+ await execQuiet(`taskkill /IM "${proc}" /F 2>nul`, { timeout: 5000 });
178
187
  } catch { }
179
188
  }
180
189
  // Process name may differ, so also try via WMIC
181
190
  try {
182
191
  const exeName = winProcesses[0].replace('.exe', '');
183
- execSync(`powershell -Command "Get-Process -Name '${exeName}' -ErrorAction SilentlyContinue | Stop-Process -Force"`, {
192
+ await execQuiet(`powershell -Command "Get-Process -Name '${exeName}' -ErrorAction SilentlyContinue | Stop-Process -Force"`, {
184
193
  timeout: 10000,
185
194
  });
186
195
  } catch { }
187
196
  } else {
188
- try { execSync(`pkill -f "${ideId}" 2>/dev/null`); } catch { }
197
+ try { await execQuiet(`pkill -f "${ideId}" 2>/dev/null`); } catch { }
189
198
  }
190
199
 
191
200
  // Wait for process kill (max 15 seconds)
192
201
  for (let i = 0; i < 30; i++) {
193
202
  await new Promise(r => setTimeout(r, 500));
194
- if (!isIdeRunning(ideId)) return true;
203
+ if (!(await isIdeRunning(ideId))) return true;
195
204
  }
196
205
 
197
206
  // Force terminate retry
198
207
  if (plat === 'darwin' && appName) {
199
- try { execSync(`pkill -9 -x "${appName}" 2>/dev/null`, { timeout: 5000 }); } catch { }
200
- killMacAppPathProcesses(ideId, 'SIGKILL');
208
+ try { await execQuiet(`pkill -9 -x "${appName}" 2>/dev/null`, { timeout: 5000 }); } catch { }
209
+ await killMacAppPathProcesses(ideId, 'SIGKILL');
201
210
  } else if (plat === 'win32' && winProcesses) {
202
211
  for (const proc of winProcesses) {
203
- try { execSync(`taskkill /IM "${proc}" /F 2>nul`); } catch { }
212
+ try { await execQuiet(`taskkill /IM "${proc}" /F 2>nul`); } catch { }
204
213
  }
205
214
  }
206
215
 
207
216
  await new Promise(r => setTimeout(r, 2000));
208
- return !isIdeRunning(ideId);
217
+ return !(await isIdeRunning(ideId));
209
218
 
210
219
  } catch {
211
220
  return false;
@@ -213,15 +222,15 @@ export async function killIdeProcess(ideId: string): Promise<boolean> {
213
222
  }
214
223
 
215
224
  /** Check if IDE process is running */
216
- export function isIdeRunning(ideId: string): boolean {
225
+ export async function isIdeRunning(ideId: string): Promise<boolean> {
217
226
  const plat = os.platform();
218
227
 
219
228
  try {
220
229
  if (plat === 'darwin') {
221
230
  const appName = getMacAppIdentifiers()[ideId];
222
- if (!appName) return getMacAppProcessPids(ideId).length > 0;
231
+ if (!appName) return (await getMacAppProcessPids(ideId)).length > 0;
223
232
  try {
224
- const result = execSync(`pgrep -x "${appName}" 2>/dev/null`, {
233
+ const result = await execQuiet(`pgrep -x "${appName}" 2>/dev/null`, {
225
234
  encoding: 'utf-8',
226
235
  timeout: 3000,
227
236
  });
@@ -229,7 +238,7 @@ export function isIdeRunning(ideId: string): boolean {
229
238
  } catch { }
230
239
 
231
240
  try {
232
- const result = execSync(
241
+ const result = await execQuiet(
233
242
  `osascript -e 'tell application "System Events" to count (every process whose name is "${escapeForAppleScript(appName)}")'`,
234
243
  {
235
244
  encoding: 'utf-8',
@@ -240,21 +249,21 @@ export function isIdeRunning(ideId: string): boolean {
240
249
  if (Number.parseInt(result.trim() || '0', 10) > 0) return true;
241
250
  } catch { }
242
251
 
243
- return getMacAppProcessPids(ideId).length > 0;
252
+ return (await getMacAppProcessPids(ideId)).length > 0;
244
253
  } else if (plat === 'win32') {
245
254
  const winProcesses = getWinProcessNames()[ideId];
246
255
  if (!winProcesses) return false;
247
256
  // Check each process name
248
257
  for (const proc of winProcesses) {
249
258
  try {
250
- const result = execSync(`tasklist /FI "IMAGENAME eq ${proc}" /NH 2>nul`, { encoding: 'utf-8' });
259
+ const result = await execQuiet(`tasklist /FI "IMAGENAME eq ${proc}" /NH 2>nul`, { encoding: 'utf-8' });
251
260
  if (result.includes(proc)) return true;
252
261
  } catch { }
253
262
  }
254
263
  // Also check via PowerShell (when tasklist cannot find)
255
264
  try {
256
265
  const exeName = winProcesses[0].replace('.exe', '');
257
- const result = execSync(
266
+ const result = await execQuiet(
258
267
  `powershell -Command "(Get-Process -Name '${exeName}' -ErrorAction SilentlyContinue).Count"`,
259
268
  { encoding: 'utf-8', timeout: 5000 }
260
269
  );
@@ -262,7 +271,7 @@ export function isIdeRunning(ideId: string): boolean {
262
271
  } catch { }
263
272
  return false;
264
273
  } else {
265
- const result = execSync(`pgrep -f "${ideId}" 2>/dev/null`, { encoding: 'utf-8' });
274
+ const result = await execQuiet(`pgrep -f "${ideId}" 2>/dev/null`, { encoding: 'utf-8' });
266
275
  return result.trim().length > 0;
267
276
  }
268
277
  } catch {
@@ -271,14 +280,14 @@ export function isIdeRunning(ideId: string): boolean {
271
280
  }
272
281
 
273
282
  /** Detect currently open workspace path */
274
- function detectCurrentWorkspace(ideId: string): string | undefined {
283
+ async function detectCurrentWorkspace(ideId: string): Promise<string | undefined> {
275
284
  const plat = os.platform();
276
285
 
277
286
  if (plat === 'darwin') {
278
287
  try {
279
288
  const appName = getMacAppIdentifiers()[ideId];
280
289
  if (!appName) return undefined;
281
- const result = execSync(
290
+ const result = await execQuiet(
282
291
  `lsof -c "${appName}" 2>/dev/null | grep cwd | head -1 | awk '{print $NF}'`,
283
292
  { encoding: 'utf-8', timeout: 3000 }
284
293
  );
@@ -392,8 +401,8 @@ export async function launchWithCdp(options: LaunchOptions = {}): Promise<Launch
392
401
  }
393
402
 
394
403
  // 4. Check if IDE is currently running
395
- const alreadyRunning = isIdeRunning(targetIde.id);
396
- const workspace = options.workspace || (alreadyRunning ? detectCurrentWorkspace(targetIde.id) : undefined);
404
+ const alreadyRunning = await isIdeRunning(targetIde.id);
405
+ const workspace = options.workspace || (alreadyRunning ? await detectCurrentWorkspace(targetIde.id) : undefined);
397
406
 
398
407
  // 5. If IDE is running, terminate it
399
408
  if (alreadyRunning) {
@@ -0,0 +1,55 @@
1
+ import * as fs from 'fs';
2
+
3
+ export class AsyncBatchWriter {
4
+ // Maps filePath -> string buffer
5
+ private static buffers: Map<string, string[]> = new Map();
6
+ private static writePromises: Map<string, Promise<void>> = new Map();
7
+ private static flushTimer: NodeJS.Timeout | null = null;
8
+
9
+ /**
10
+ * Queues data to be written to a file asynchronously in a batch.
11
+ */
12
+ public static write(filePath: string, data: string) {
13
+ let buf = this.buffers.get(filePath);
14
+ if (!buf) {
15
+ buf = [];
16
+ this.buffers.set(filePath, buf);
17
+ }
18
+ buf.push(data);
19
+
20
+ if (!this.flushTimer) {
21
+ this.flushTimer = setTimeout(() => {
22
+ this.flushTimer = null;
23
+ this.flushAll();
24
+ }, 50);
25
+ }
26
+ }
27
+
28
+ private static async flushAll() {
29
+ const entries = Array.from(this.buffers.entries());
30
+ this.buffers.clear();
31
+
32
+ for (const [filePath, buffer] of entries) {
33
+ const dataToWrite = buffer.join('');
34
+
35
+ const doWrite = async () => {
36
+ try {
37
+ const prevPromise = this.writePromises.get(filePath);
38
+ if (prevPromise) await prevPromise;
39
+ await fs.promises.appendFile(filePath, dataToWrite, { encoding: 'utf-8', mode: 0o600 });
40
+ } catch {
41
+ // Logging must never create secondary failures or late console noise.
42
+ }
43
+ };
44
+
45
+ const writePromise = doWrite();
46
+ this.writePromises.set(filePath, writePromise);
47
+
48
+ writePromise.finally(() => {
49
+ if (this.writePromises.get(filePath) === writePromise) {
50
+ this.writePromises.delete(filePath);
51
+ }
52
+ });
53
+ }
54
+ }
55
+ }
@@ -20,6 +20,7 @@
20
20
  import * as fs from 'fs';
21
21
  import * as path from 'path';
22
22
  import * as os from 'os';
23
+ import { AsyncBatchWriter } from './async-batch-writer.js';
23
24
 
24
25
  // ─── Log Level ──────────────────────────────
25
26
  export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
@@ -123,7 +124,7 @@ function writeToFile(line: string): void {
123
124
  checkDateRotation();
124
125
  rotateSizeIfNeeded();
125
126
  }
126
- fs.appendFileSync(currentLogFile, line + '\n');
127
+ AsyncBatchWriter.write(currentLogFile, line + '\n');
127
128
  } catch { }
128
129
  }
129
130
 
@@ -0,0 +1,163 @@
1
+ import Database from 'better-sqlite3';
2
+ import { existsSync, mkdirSync, readFileSync } from 'fs';
3
+ import { dirname, join } from 'path';
4
+ import { getLedgerDir } from './mesh-ledger.js';
5
+ import type { MeshTaskStatus, MeshWorkQueueEntry } from './mesh-work-queue.js';
6
+
7
+ function safeMeshId(meshId: string): string {
8
+ return meshId.replace(/[^a-zA-Z0-9_-]/g, '_');
9
+ }
10
+
11
+ function legacyQueuePath(meshId: string): string {
12
+ return join(getLedgerDir(), `${safeMeshId(meshId)}.queue.json`);
13
+ }
14
+
15
+ export class BeadsDB {
16
+ private static instance: BeadsDB | undefined;
17
+ private readonly db: Database.Database;
18
+ private readonly migratedMeshIds = new Set<string>();
19
+
20
+ private constructor(dbPath: string) {
21
+ const dir = dirname(dbPath);
22
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
23
+
24
+ this.db = new Database(dbPath);
25
+ this.db.pragma('journal_mode = WAL');
26
+ this.db.pragma('synchronous = NORMAL');
27
+ this.db.pragma('foreign_keys = ON');
28
+ this.db.pragma('busy_timeout = 5000');
29
+ this.migrate();
30
+ }
31
+
32
+ static getInstance(): BeadsDB {
33
+ if (!this.instance) {
34
+ this.instance = new BeadsDB(join(getLedgerDir(), 'beads.db'));
35
+ }
36
+ return this.instance;
37
+ }
38
+
39
+ static resetForTests(): void {
40
+ this.instance?.close();
41
+ this.instance = undefined;
42
+ }
43
+
44
+ close(): void {
45
+ this.db.close();
46
+ }
47
+
48
+ transaction<T>(fn: () => T): T {
49
+ return this.db.transaction(fn).immediate();
50
+ }
51
+
52
+ private migrate(): void {
53
+ this.db.exec(`
54
+ CREATE TABLE IF NOT EXISTS mesh_queue (
55
+ id TEXT PRIMARY KEY,
56
+ mesh_id TEXT NOT NULL,
57
+ status TEXT NOT NULL,
58
+ target_node_id TEXT,
59
+ target_session_id TEXT,
60
+ assigned_node_id TEXT,
61
+ assigned_session_id TEXT,
62
+ created_at TEXT NOT NULL,
63
+ updated_at TEXT NOT NULL,
64
+ payload TEXT NOT NULL
65
+ );
66
+
67
+ CREATE INDEX IF NOT EXISTS idx_mesh_queue_mesh_status_created
68
+ ON mesh_queue(mesh_id, status, created_at);
69
+ CREATE INDEX IF NOT EXISTS idx_mesh_queue_assignment
70
+ ON mesh_queue(mesh_id, assigned_node_id, assigned_session_id, status);
71
+ `);
72
+ }
73
+
74
+ private ensureLegacyQueueMigrated(meshId: string): void {
75
+ if (this.migratedMeshIds.has(meshId)) return;
76
+ this.migratedMeshIds.add(meshId);
77
+
78
+ const count = this.db
79
+ .prepare('SELECT COUNT(*) AS count FROM mesh_queue WHERE mesh_id = ?')
80
+ .get(meshId) as { count: number };
81
+ if (count.count > 0) return;
82
+
83
+ const path = legacyQueuePath(meshId);
84
+ if (!existsSync(path)) return;
85
+
86
+ try {
87
+ const entries = JSON.parse(readFileSync(path, 'utf-8')) as MeshWorkQueueEntry[];
88
+ if (!Array.isArray(entries)) return;
89
+ const insert = this.db.prepare(`
90
+ INSERT OR REPLACE INTO mesh_queue (
91
+ id, mesh_id, status, target_node_id, target_session_id,
92
+ assigned_node_id, assigned_session_id, created_at, updated_at, payload
93
+ ) VALUES (
94
+ @id, @meshId, @status, @targetNodeId, @targetSessionId,
95
+ @assignedNodeId, @assignedSessionId, @createdAt, @updatedAt, @payload
96
+ )
97
+ `);
98
+ for (const entry of entries) {
99
+ insert.run(this.toRow(entry));
100
+ }
101
+ } catch {
102
+ return;
103
+ }
104
+ }
105
+
106
+ getQueueEntries(meshId: string, statuses?: MeshTaskStatus[]): MeshWorkQueueEntry[] {
107
+ this.ensureLegacyQueueMigrated(meshId);
108
+ if (statuses?.length) {
109
+ const placeholders = statuses.map(() => '?').join(', ');
110
+ const rows = this.db
111
+ .prepare(`SELECT payload FROM mesh_queue WHERE mesh_id = ? AND status IN (${placeholders}) ORDER BY created_at ASC`)
112
+ .all(meshId, ...statuses) as Array<{ payload: string }>;
113
+ return rows.map(row => JSON.parse(row.payload) as MeshWorkQueueEntry);
114
+ }
115
+ const rows = this.db
116
+ .prepare('SELECT payload FROM mesh_queue WHERE mesh_id = ? ORDER BY created_at ASC')
117
+ .all(meshId) as Array<{ payload: string }>;
118
+ return rows.map(row => JSON.parse(row.payload) as MeshWorkQueueEntry);
119
+ }
120
+
121
+ getQueueRevision(meshId: string): string {
122
+ this.ensureLegacyQueueMigrated(meshId);
123
+ const rows = this.db
124
+ .prepare('SELECT id, status, updated_at FROM mesh_queue WHERE mesh_id = ? ORDER BY id ASC')
125
+ .all(meshId) as Array<{ id: string; status: string; updated_at: string }>;
126
+ return rows.map(row => `${row.id}:${row.status}:${row.updated_at}`).join('|');
127
+ }
128
+
129
+ replaceQueue(meshId: string, queue: MeshWorkQueueEntry[]): void {
130
+ const deleteStmt = this.db.prepare('DELETE FROM mesh_queue WHERE mesh_id = ?');
131
+ const insert = this.db.prepare(`
132
+ INSERT INTO mesh_queue (
133
+ id, mesh_id, status, target_node_id, target_session_id,
134
+ assigned_node_id, assigned_session_id, created_at, updated_at, payload
135
+ ) VALUES (
136
+ @id, @meshId, @status, @targetNodeId, @targetSessionId,
137
+ @assignedNodeId, @assignedSessionId, @createdAt, @updatedAt, @payload
138
+ )
139
+ `);
140
+ deleteStmt.run(meshId);
141
+ for (const entry of queue) insert.run(this.toRow(entry));
142
+ }
143
+
144
+ deleteQueue(meshId: string): void {
145
+ this.db.prepare('DELETE FROM mesh_queue WHERE mesh_id = ?').run(meshId);
146
+ this.migratedMeshIds.delete(meshId);
147
+ }
148
+
149
+ private toRow(entry: MeshWorkQueueEntry): Record<string, unknown> {
150
+ return {
151
+ id: entry.id,
152
+ meshId: entry.meshId,
153
+ status: entry.status,
154
+ targetNodeId: entry.targetNodeId ?? null,
155
+ targetSessionId: entry.targetSessionId ?? null,
156
+ assignedNodeId: entry.assignedNodeId ?? null,
157
+ assignedSessionId: entry.assignedSessionId ?? null,
158
+ createdAt: entry.createdAt,
159
+ updatedAt: entry.updatedAt,
160
+ payload: JSON.stringify(entry),
161
+ };
162
+ }
163
+ }
@@ -13,7 +13,7 @@
13
13
  * Safety: mode 0o600, atomic append via appendFileSync
14
14
  */
15
15
 
16
- import { existsSync, mkdirSync, readFileSync, appendFileSync, statSync, renameSync } from 'fs';
16
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, renameSync } from 'fs';
17
17
  import { join } from 'path';
18
18
  import { randomUUID } from 'crypto';
19
19
  import { getConfigDir } from '../config/config.js';
@@ -1,9 +1,7 @@
1
- import { existsSync, writeFileSync, readFileSync, openSync, closeSync, unlinkSync } from 'fs';
2
- import { join } from 'path';
3
1
  import { randomUUID } from 'crypto';
4
- import { getLedgerDir } from './mesh-ledger.js';
5
2
  import { requireMeshHostQueueOwner } from './mesh-host-ownership.js';
6
3
  import type { RepoMeshDaemonRole } from '../repo-mesh-types.js';
4
+ import { BeadsDB } from './beads-db.js';
7
5
 
8
6
  export type MeshTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed' | 'cancelled';
9
7
  export type MeshActiveTaskStatus = Extract<MeshTaskStatus, 'pending' | 'assigned'>;
@@ -99,50 +97,16 @@ export interface MeshQueueMutationOptions {
99
97
  ownerRole?: RepoMeshDaemonRole;
100
98
  }
101
99
 
102
- function getQueuePath(meshId: string): string {
103
- const safe = meshId.replace(/[^a-zA-Z0-9_-]/g, '_');
104
- return join(getLedgerDir(), `${safe}.queue.json`);
105
- }
106
-
107
- function getLockPath(meshId: string): string {
108
- const safe = meshId.replace(/[^a-zA-Z0-9_-]/g, '_');
109
- return join(getLedgerDir(), `${safe}.queue.lock`);
110
- }
111
-
112
- /**
113
- * Simple advisory file lock using O_EXCL (atomic create) for queue mutations.
114
- * Retries up to 10 times at 30 ms intervals; proceeds without lock on timeout
115
- * to prevent deadlock (best-effort — far better than no locking at all).
116
- */
117
- function withQueueLock<T>(meshId: string, fn: () => T): T {
118
- const lockPath = getLockPath(meshId);
119
- let fd = -1;
120
- for (let i = 0; i < 10; i++) {
121
- try { fd = openSync(lockPath, 'wx'); break; } catch {
122
- const deadline = Date.now() + 30;
123
- while (Date.now() < deadline) { /* spin */ }
124
- }
125
- }
126
- try { return fn(); } finally {
127
- if (fd !== -1) try { closeSync(fd); } catch { /* noop */ }
128
- try { unlinkSync(lockPath); } catch { /* already removed */ }
129
- }
100
+ function withQueueLock<T>(_meshId: string, fn: () => T): T {
101
+ return BeadsDB.getInstance().transaction(fn);
130
102
  }
131
103
 
132
104
  function readQueue(meshId: string): MeshWorkQueueEntry[] {
133
- const path = getQueuePath(meshId);
134
- if (!existsSync(path)) return [];
135
- try {
136
- const content = readFileSync(path, 'utf-8');
137
- return JSON.parse(content) as MeshWorkQueueEntry[];
138
- } catch {
139
- return [];
140
- }
105
+ return BeadsDB.getInstance().getQueueEntries(meshId);
141
106
  }
142
107
 
143
108
  function writeQueue(meshId: string, queue: MeshWorkQueueEntry[]): void {
144
- const path = getQueuePath(meshId);
145
- writeFileSync(path, JSON.stringify(queue, null, 2), 'utf-8');
109
+ BeadsDB.getInstance().replaceQueue(meshId, queue);
146
110
  }
147
111
 
148
112
  /**
@@ -189,6 +153,10 @@ export function getQueue(meshId: string, opts?: { status?: MeshTaskStatus[] }):
189
153
  return queue;
190
154
  }
191
155
 
156
+ export function getMeshQueueRevision(meshId: string): string {
157
+ return BeadsDB.getInstance().getQueueRevision(meshId);
158
+ }
159
+
192
160
  /**
193
161
  * Find the next pending task that this node is allowed to claim, and mark it as assigned.
194
162
  */
@@ -408,3 +376,17 @@ export function getMeshQueueStats(meshId: string): MeshWorkQueueStats {
408
376
  })),
409
377
  };
410
378
  }
379
+
380
+ export function __replaceMeshQueueForTests(meshId: string, queue: MeshWorkQueueEntry[]): void {
381
+ BeadsDB.getInstance().transaction(() => {
382
+ BeadsDB.getInstance().replaceQueue(meshId, queue);
383
+ });
384
+ }
385
+
386
+ export function __clearMeshQueueForTests(meshId: string): void {
387
+ BeadsDB.getInstance().deleteQueue(meshId);
388
+ }
389
+
390
+ export function __resetBeadsDBForTests(): void {
391
+ BeadsDB.resetForTests();
392
+ }
@@ -43,6 +43,20 @@ type ReadChatPayload = {
43
43
  [key: string]: unknown;
44
44
  };
45
45
 
46
+ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
47
+ let timer: ReturnType<typeof setTimeout> | null = null;
48
+ try {
49
+ return await Promise.race([
50
+ promise,
51
+ new Promise<never>((_, reject) => {
52
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
53
+ }),
54
+ ]);
55
+ } finally {
56
+ if (timer) clearTimeout(timer);
57
+ }
58
+ }
59
+
46
60
  export class IdeProviderInstance implements ProviderInstance {
47
61
  readonly type: string;
48
62
  readonly category = 'ide' as const;
@@ -320,7 +334,7 @@ export class IdeProviderInstance implements ProviderInstance {
320
334
  if (webviewScript) {
321
335
  const matchText = this.provider.webviewMatchText;
322
336
  const matchFn = matchText ? (body: string) => body.includes(matchText) : undefined;
323
- const webviewRaw = await cdp.evaluateInWebviewFrame(webviewScript, matchFn);
337
+ const webviewRaw = await withTimeout(cdp.evaluateInWebviewFrame(webviewScript, matchFn), 30000, 'evaluateInWebviewFrame');
324
338
  if (webviewRaw) {
325
339
  raw = typeof webviewRaw === 'string' ? (() => { try { return JSON.parse(webviewRaw); } catch { return null; } })() : webviewRaw;
326
340
  }
@@ -331,7 +345,7 @@ export class IdeProviderInstance implements ProviderInstance {
331
345
  if (!raw) {
332
346
  const readChatScript = this.getReadChatScript();
333
347
  if (!readChatScript) return;
334
- raw = await cdp.evaluate(readChatScript, 30000);
348
+ raw = await withTimeout(cdp.evaluate(readChatScript, 30000), 30000, 'evaluate.readChatScript');
335
349
  if (typeof raw === 'string') {
336
350
  try { raw = JSON.parse(raw); } catch { return; }
337
351
  }
@@ -706,7 +720,7 @@ export class IdeProviderInstance implements ProviderInstance {
706
720
  );
707
721
 
708
722
  LOG.info('IdeInstance', `[IdeInstance:${this.type}] autoApprove: executing resolveAction for "${targetButton}"`);
709
- let rawResult = await cdp.evaluate(script, 10000);
723
+ let rawResult = await withTimeout(cdp.evaluate(script, 10000), 10000, 'evaluate.autoApprove');
710
724
  if (typeof rawResult === 'string') {
711
725
  try { rawResult = JSON.parse(rawResult); } catch { }
712
726
  }
@@ -1067,13 +1067,17 @@ export class ProviderLoader {
1067
1067
  awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
1068
1068
  });
1069
1069
 
1070
+ let reloadTimer: ReturnType<typeof setTimeout> | null = null;
1070
1071
  const handleChange = (filePath: string) => {
1071
1072
  if (/[\/\\]fixtures[\/\\]/.test(filePath)) {
1072
1073
  return;
1073
1074
  }
1074
1075
  if (filePath.endsWith('.js') || filePath.endsWith('.json')) {
1075
- this.log(`File changed: ${path.basename(filePath)}, reloading...`);
1076
- this.reload();
1076
+ if (reloadTimer) clearTimeout(reloadTimer);
1077
+ reloadTimer = setTimeout(() => {
1078
+ this.log(`File changed: ${path.basename(filePath)}, reloading...`);
1079
+ this.reload();
1080
+ }, 300);
1077
1081
  }
1078
1082
  };
1079
1083
 
@@ -1130,7 +1134,9 @@ export class ProviderLoader {
1130
1134
  return { updated: false };
1131
1135
  }
1132
1136
  const https = require('https') as typeof import('https');
1133
- const { execSync } = require('child_process') as typeof import('child_process');
1137
+ const { exec } = require('child_process') as typeof import('child_process');
1138
+ const { promisify } = require('util');
1139
+ const execAsync = promisify(exec);
1134
1140
 
1135
1141
  const metaPath = path.join(this.upstreamDir, ProviderLoader.META_FILE);
1136
1142
  let prevEtag = '';
@@ -1207,7 +1213,7 @@ export class ProviderLoader {
1207
1213
 
1208
1214
  // Extract
1209
1215
  fs.mkdirSync(tmpExtract, { recursive: true });
1210
- execSync(`tar -xzf "${tmpTar}" -C "${tmpExtract}"`, { timeout: 30000 });
1216
+ await execAsync(`tar -xzf "${tmpTar}" -C "${tmpExtract}"`, { timeout: 30000 });
1211
1217
 
1212
1218
  // Tarball internal structure: adhdev-providers-main/ide/... → strip 1 level
1213
1219
  const extracted = fs.readdirSync(tmpExtract);