sparda-mcp 0.5.2 → 0.5.4

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.
@@ -11,10 +11,10 @@
11
11
  * ESM, Node ≥ 18. Zero new dependencies.
12
12
  */
13
13
 
14
- import { fork, spawn, spawnSync } from 'node:child_process';
15
- import { fileURLToPath } from 'node:url';
14
+ import { fork, spawn } from 'node:child_process';
15
+ import { fileURLToPath } from 'node:url';
16
16
  import { dirname, resolve, extname } from 'node:path';
17
- import { platform } from 'node:process';
17
+ import { platform } from 'node:process';
18
18
 
19
19
  const __dirname = dirname(fileURLToPath(import.meta.url));
20
20
 
@@ -26,13 +26,18 @@ const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
26
26
 
27
27
  // ── Public API ─────────────────────────────────────────────────────────────────
28
28
 
29
- export async function probeRoutes({ framework, entryFile, projectRoot, timeoutMs = 8000 }) {
29
+ export async function probeRoutes({
30
+ framework,
31
+ entryFile,
32
+ projectRoot,
33
+ timeoutMs = 8000,
34
+ }) {
30
35
  if (framework === 'express') return probeExpress({ entryFile, projectRoot, timeoutMs });
31
36
  if (framework === 'fastapi') return probeFastAPI({ entryFile, projectRoot, timeoutMs });
32
- throw Object.assign(
33
- new Error(`Unknown framework: ${framework}`),
34
- { code: 'USER', hint: 'framework must be "express" or "fastapi"' }
35
- );
37
+ throw Object.assign(new Error(`Unknown framework: ${framework}`), {
38
+ code: 'USER',
39
+ hint: 'framework must be "express" or "fastapi"',
40
+ });
36
41
  }
37
42
 
38
43
  // ── Express probe ─────────────────────────────────────────────────────────────
@@ -42,12 +47,12 @@ async function probeExpress({ entryFile, projectRoot, timeoutMs }) {
42
47
 
43
48
  if (ext === '.ts' || ext === '.mts' || ext === '.cts') {
44
49
  process.stderr.write(
45
- '[sparda] --probe skipped: .ts entry needs a runtime loader; static discovery still applied.\n'
50
+ '[sparda] --probe skipped: .ts entry needs a runtime loader; static discovery still applied.\n',
46
51
  );
47
52
  return [];
48
53
  }
49
54
 
50
- const isEsm = ext === '.mjs';
55
+ const isEsm = ext === '.mjs';
51
56
  const shimFlag = isEsm ? ['--import', SHIM_ESM] : ['--require', SHIM_CJS];
52
57
 
53
58
  const routes = [];
@@ -61,24 +66,30 @@ async function probeExpress({ entryFile, projectRoot, timeoutMs }) {
61
66
  if (settled) return;
62
67
  settled = true;
63
68
  clearTimeout(killTimer);
64
- try { child && child.kill('SIGKILL'); } catch {}
69
+ try {
70
+ child && child.kill('SIGKILL');
71
+ } catch {}
65
72
  resolve_(result);
66
73
  }
67
74
 
68
75
  killTimer = setTimeout(() => {
69
- process.stderr.write('[sparda] --probe: timeout waiting for Express routes; using static floor.\n');
76
+ process.stderr.write(
77
+ '[sparda] --probe: timeout waiting for Express routes; using static floor.\n',
78
+ );
70
79
  settle(routes);
71
80
  }, timeoutMs);
72
81
 
73
82
  try {
74
83
  child = fork(entryFile, [], {
75
- cwd: projectRoot,
84
+ cwd: projectRoot,
76
85
  execArgv: shimFlag,
77
- silent: true,
78
- env: { ...process.env, SPARDA_PROBE: '1' },
86
+ silent: true,
87
+ env: { ...process.env, SPARDA_PROBE: '1' },
79
88
  });
80
89
  } catch (spawnErr) {
81
- process.stderr.write(`[sparda] --probe: failed to spawn child: ${spawnErr.message}\n`);
90
+ process.stderr.write(
91
+ `[sparda] --probe: failed to spawn child: ${spawnErr.message}\n`,
92
+ );
82
93
  clearTimeout(killTimer);
83
94
  resolve_([]);
84
95
  return;
@@ -88,15 +99,18 @@ async function probeExpress({ entryFile, projectRoot, timeoutMs }) {
88
99
 
89
100
  child.on('message', (msg) => {
90
101
  if (!msg || typeof msg !== 'object') return;
91
- if (msg.type === '__done__') { settle(routes); return; }
102
+ if (msg.type === '__done__') {
103
+ settle(routes);
104
+ return;
105
+ }
92
106
  if (msg.type === 'route') {
93
107
  const method = normalizeMethod(msg.method);
94
- const path = normalizePath(msg.path);
108
+ const path = normalizePath(msg.path);
95
109
  routes.push({
96
110
  method,
97
111
  path,
98
112
  pathParams: extractPathParams(path),
99
- source: 'dynamic',
113
+ source: 'dynamic',
100
114
  writeClass: WRITE_METHODS.has(method) ? 'write' : 'read',
101
115
  });
102
116
  }
@@ -116,7 +130,9 @@ async function probeExpress({ entryFile, projectRoot, timeoutMs }) {
116
130
  async function probeFastAPI({ entryFile, projectRoot, timeoutMs }) {
117
131
  const python = await resolvePython();
118
132
  if (!python) {
119
- process.stderr.write('[sparda] --probe: python3/python not found; static discovery still applied.\n');
133
+ process.stderr.write(
134
+ '[sparda] --probe: python3/python not found; static discovery still applied.\n',
135
+ );
120
136
  return [];
121
137
  }
122
138
 
@@ -129,21 +145,25 @@ async function probeFastAPI({ entryFile, projectRoot, timeoutMs }) {
129
145
  if (settled) return;
130
146
  settled = true;
131
147
  clearTimeout(killTimer);
132
- try { child && child.kill(); } catch {}
148
+ try {
149
+ child && child.kill();
150
+ } catch {}
133
151
  resolve_(result);
134
152
  }
135
153
 
136
154
  killTimer = setTimeout(() => {
137
- process.stderr.write('[sparda] --probe: FastAPI probe timeout; using static floor.\n');
155
+ process.stderr.write(
156
+ '[sparda] --probe: FastAPI probe timeout; using static floor.\n',
157
+ );
138
158
  settle(parsePythonOutput(Buffer.concat(stdoutChunks).toString('utf8')));
139
159
  }, timeoutMs);
140
160
 
141
161
  let child;
142
162
  try {
143
163
  child = spawn(python, [PY_PROBE, entryFile], {
144
- cwd: projectRoot,
164
+ cwd: projectRoot,
145
165
  stdio: ['ignore', 'pipe', 'pipe'],
146
- env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
166
+ env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
147
167
  });
148
168
  } catch (err) {
149
169
  process.stderr.write(`[sparda] --probe: failed to spawn python: ${err.message}\n`);
@@ -169,10 +189,10 @@ function parsePythonOutput(raw) {
169
189
  const arr = JSON.parse(raw.trim());
170
190
  if (!Array.isArray(arr)) return [];
171
191
  return arr.map((r) => ({
172
- method: normalizeMethod(r.method),
173
- path: normalizeFastAPIParams(r.path ?? '/'),
192
+ method: normalizeMethod(r.method),
193
+ path: normalizeFastAPIParams(r.path ?? '/'),
174
194
  pathParams: extractPathParams(r.path ?? '/'),
175
- source: 'dynamic',
195
+ source: 'dynamic',
176
196
  writeClass: WRITE_METHODS.has(normalizeMethod(r.method)) ? 'write' : 'read',
177
197
  }));
178
198
  } catch {
@@ -187,9 +207,7 @@ function parsePythonOutput(raw) {
187
207
  // §A.6: 1500 ms timeout kills MS Store stub that never returns.
188
208
 
189
209
  async function resolvePython() {
190
- const candidates = platform === 'win32'
191
- ? ['python', 'python3']
192
- : ['python3', 'python'];
210
+ const candidates = platform === 'win32' ? ['python', 'python3'] : ['python3', 'python'];
193
211
  for (const cmd of candidates) {
194
212
  if (await checkCommand(cmd)) return cmd;
195
213
  }
@@ -201,7 +219,9 @@ function checkCommand(cmd) {
201
219
  let child;
202
220
  // §A.6: 1500 ms timeout — kills Microsoft Store python stub that hangs
203
221
  const timer = setTimeout(() => {
204
- try { child && child.kill(); } catch {}
222
+ try {
223
+ child && child.kill();
224
+ } catch {}
205
225
  res(false);
206
226
  }, 1500);
207
227
 
@@ -213,8 +233,14 @@ function checkCommand(cmd) {
213
233
  return;
214
234
  }
215
235
 
216
- child.on('error', () => { clearTimeout(timer); res(false); });
217
- child.on('exit', (code) => { clearTimeout(timer); res(code === 0); });
236
+ child.on('error', () => {
237
+ clearTimeout(timer);
238
+ res(false);
239
+ });
240
+ child.on('exit', (code) => {
241
+ clearTimeout(timer);
242
+ res(code === 0);
243
+ });
218
244
  });
219
245
  }
220
246
 
@@ -37,9 +37,9 @@ export function reconcile(staticRoutes, probedRoutes) {
37
37
  if (!probedRoutes || probedRoutes.length === 0) {
38
38
  const sorted = sortRoutes([...staticRoutes]);
39
39
  return {
40
- routes: sorted,
41
- gaps: [],
42
- staticCount: staticRoutes.length,
40
+ routes: sorted,
41
+ gaps: [],
42
+ staticCount: staticRoutes.length,
43
43
  dynamicCount: 0,
44
44
  };
45
45
  }
@@ -66,11 +66,11 @@ export function reconcile(staticRoutes, probedRoutes) {
66
66
  } else {
67
67
  // Gap: probe found something the AST missed
68
68
  const gap = {
69
- method: (dyn.method ?? 'GET').toUpperCase(),
70
- path: normalizeFastAPIParams(dyn.path ?? '/'),
69
+ method: (dyn.method ?? 'GET').toUpperCase(),
70
+ path: normalizeFastAPIParams(dyn.path ?? '/'),
71
71
  pathParams: extractPathParams(dyn.path ?? '/'),
72
- source: 'dynamic',
73
- confirmed: false,
72
+ source: 'dynamic',
73
+ confirmed: false,
74
74
  writeClass: dyn.writeClass ?? inferWriteClass(dyn.method),
75
75
  };
76
76
  gaps.push(gap);
@@ -82,9 +82,9 @@ export function reconcile(staticRoutes, probedRoutes) {
82
82
  const sorted = sortRoutes(union);
83
83
 
84
84
  return {
85
- routes: sorted,
85
+ routes: sorted,
86
86
  gaps,
87
- staticCount: staticRoutes.length,
87
+ staticCount: staticRoutes.length,
88
88
  dynamicCount: gaps.length,
89
89
  };
90
90
  }
@@ -97,7 +97,7 @@ export function reconcile(staticRoutes, probedRoutes) {
97
97
  */
98
98
  function routeKey(route) {
99
99
  const method = (route.method ?? 'GET').toUpperCase();
100
- const path = normalizePath(route.path ?? '/');
100
+ const path = normalizePath(route.path ?? '/');
101
101
  return `${method} ${path}`;
102
102
  }
103
103
 
@@ -106,14 +106,15 @@ function routeKey(route) {
106
106
  * Used only for comparison (key generation).
107
107
  */
108
108
  function normalizePath(path) {
109
- return (path ?? '/')
110
- .replace(/\{[^}]+\}/g, ':param') // FastAPI {id}
111
- .replace(/:[a-zA-Z_]\w*/g, ':param') // Express :id
112
- .replace(/\*\*[^/]*/g, ':wildcard') // FastAPI **rest
113
- .replace(/\/+/g, '/')
114
- .replace(/\/$/, '') // strip trailing slash (except root)
115
- .toLowerCase()
116
- || '/';
109
+ return (
110
+ (path ?? '/')
111
+ .replace(/\{[^}]+\}/g, ':param') // FastAPI {id}
112
+ .replace(/:[a-zA-Z_]\w*/g, ':param') // Express :id
113
+ .replace(/\*\*[^/]*/g, ':wildcard') // FastAPI **rest
114
+ .replace(/\/+/g, '/')
115
+ .replace(/\/$/, '') // strip trailing slash (except root)
116
+ .toLowerCase() || '/'
117
+ );
117
118
  }
118
119
 
119
120
  /**
@@ -121,10 +122,11 @@ function normalizePath(path) {
121
122
  * Express :param style is already canonical.
122
123
  */
123
124
  function normalizeFastAPIParams(path) {
124
- return (path ?? '/')
125
- .replace(/\{([^}]+)\}/g, ':$1') // {id} → :id
126
- .replace(/\/+/g, '/')
127
- || '/';
125
+ return (
126
+ (path ?? '/')
127
+ .replace(/\{([^}]+)\}/g, ':$1') // {id} → :id
128
+ .replace(/\/+/g, '/') || '/'
129
+ );
128
130
  }
129
131
 
130
132
  /** Extract ordered param names from a path (Express or FastAPI style). */
@@ -8,7 +8,11 @@ const DANGEROUS = [
8
8
  ];
9
9
 
10
10
  export function sanitizeDescription(raw, fallback) {
11
- let text = String(raw ?? '').replace(/[\r\n]+/g, ' ').replace(/\s{2,}/g, ' ').trim().slice(0, 300);
11
+ let text = String(raw ?? '')
12
+ .replace(/[\r\n]+/g, ' ')
13
+ .replace(/\s{2,}/g, ' ')
14
+ .trim()
15
+ .slice(0, 300);
12
16
  const flagged = DANGEROUS.some((rx) => rx.test(text));
13
17
  if (flagged) text = '';
14
18
  text = text.replace(/[<>{}]/g, '');
@@ -6,11 +6,11 @@
6
6
  import fs from 'node:fs';
7
7
  import { writeManifestSync } from './persistence.js';
8
8
 
9
- const WINDOW = 20; // calls remembered per session (ring)
10
- const MAX_VALUES = 50; // scalars kept per payload
11
- const MAX_NODES = 200; // payload walk budget — nothing heavy, ever
9
+ const WINDOW = 20; // calls remembered per session (ring)
10
+ const MAX_VALUES = 50; // scalars kept per payload
11
+ const MAX_NODES = 200; // payload walk budget — nothing heavy, ever
12
12
  const MAX_DEPTH = 4;
13
- const MAX_CHAIN = 5; // a circuit longer than this is noise, not a workflow
13
+ const MAX_CHAIN = 5; // a circuit longer than this is noise, not a workflow
14
14
  const MAX_LINKS = 10;
15
15
  const MAX_CIRCUITS = 30; // bounded memory, same philosophy as antibodies (ADR-010)
16
16
  // an emergent capability is a suggestion only after N observations (survival rule)
@@ -35,8 +35,7 @@ export function createSequenceRecorder({ manifest, manifestPath, harvester, onCi
35
35
  const argEntries = extractArgEntries(args);
36
36
  let link = null;
37
37
  // most recent producer wins: the freshest output is the likeliest source
38
- outer:
39
- for (let i = ring.length - 1; i >= 0; i--) {
38
+ outer: for (let i = ring.length - 1; i >= 0; i--) {
40
39
  for (const [argName, value] of argEntries) {
41
40
  if (ring[i].outValues.has(value)) {
42
41
  // fromKey = where the value lived in the producer's output — the structure
@@ -54,12 +53,29 @@ export function createSequenceRecorder({ manifest, manifestPath, harvester, onCi
54
53
  const sig = chain.join('>');
55
54
  const now = new Date().toISOString();
56
55
  const circuits = manifest.labs.circuits;
57
- const c = circuits[sig] ?? (circuits[sig] = { steps: chain, links: [], seen: 0, firstSeen: now, lastSeen: now });
56
+ const c =
57
+ circuits[sig] ??
58
+ (circuits[sig] = {
59
+ steps: chain,
60
+ links: [],
61
+ seen: 0,
62
+ firstSeen: now,
63
+ lastSeen: now,
64
+ });
58
65
  c.seen += 1;
59
66
  c.lastSeen = now;
60
- if (c.links.length < MAX_LINKS &&
61
- !c.links.some((l) => l.from === link.from.tool && l.to === tool && l.arg === link.arg)) {
62
- c.links.push({ from: link.from.tool, to: tool, arg: link.arg, fromKey: link.fromKey });
67
+ if (
68
+ c.links.length < MAX_LINKS &&
69
+ !c.links.some(
70
+ (l) => l.from === link.from.tool && l.to === tool && l.arg === link.arg,
71
+ )
72
+ ) {
73
+ c.links.push({
74
+ from: link.from.tool,
75
+ to: tool,
76
+ arg: link.arg,
77
+ fromKey: link.fromKey,
78
+ });
63
79
  }
64
80
  evict(circuits);
65
81
  persist();
@@ -71,7 +87,11 @@ export function createSequenceRecorder({ manifest, manifestPath, harvester, onCi
71
87
  const keys = Object.keys(circuits);
72
88
  if (keys.length <= MAX_CIRCUITS) return;
73
89
  keys
74
- .sort((a, b) => (circuits[a].seen - circuits[b].seen) || circuits[a].lastSeen.localeCompare(circuits[b].lastSeen))
90
+ .sort(
91
+ (a, b) =>
92
+ circuits[a].seen - circuits[b].seen ||
93
+ circuits[a].lastSeen.localeCompare(circuits[b].lastSeen),
94
+ )
75
95
  .slice(0, keys.length - MAX_CIRCUITS)
76
96
  .forEach((k) => delete circuits[k]);
77
97
  }
@@ -82,7 +102,9 @@ export function createSequenceRecorder({ manifest, manifestPath, harvester, onCi
82
102
  const onDisk = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
83
103
  onDisk.labs = { ...onDisk.labs, circuits: manifest.labs.circuits };
84
104
  writeManifestSync(manifestPath, onDisk);
85
- } catch { /* disk briefly unavailable — circuits stay in memory */ }
105
+ } catch {
106
+ /* disk briefly unavailable — circuits stay in memory */
107
+ }
86
108
  }
87
109
 
88
110
  return { record, circuits: () => manifest.labs.circuits };
@@ -49,9 +49,15 @@ export function initiateWrite({ token, label } = {}) {
49
49
  sweep();
50
50
  if (pendingWrites.has(token)) return; // dialog already armed for this token
51
51
  if (pendingWrites.size >= MAX_PENDING) evictOldest();
52
- pendingWrites.set(token, { state: S1, expiresAt: Date.now() + TIMEOUT_MS, label: String(label ?? token.slice(0, 12)) });
52
+ pendingWrites.set(token, {
53
+ state: S1,
54
+ expiresAt: Date.now() + TIMEOUT_MS,
55
+ label: String(label ?? token.slice(0, 12)),
56
+ });
53
57
  // Signal 2 runs in parallel — it NEVER blocks the MCP response.
54
- setImmediate(() => { requestSignal2(token).catch(() => markDenied(token)); });
58
+ setImmediate(() => {
59
+ requestSignal2(token).catch(() => markDenied(token));
60
+ });
55
61
  }
56
62
 
57
63
  // ── Pre-approval: the human already said yes out-of-band via native elicitation (client UI). ──
@@ -60,7 +66,11 @@ export function preapproveWrite(token) {
60
66
  if (typeof token !== 'string' || !token) return;
61
67
  sweep();
62
68
  if (!pendingWrites.has(token) && pendingWrites.size >= MAX_PENDING) evictOldest();
63
- pendingWrites.set(token, { state: S2, expiresAt: Date.now() + TIMEOUT_MS, label: 'elicitation' });
69
+ pendingWrites.set(token, {
70
+ state: S2,
71
+ expiresAt: Date.now() + TIMEOUT_MS,
72
+ label: 'elicitation',
73
+ });
64
74
  }
65
75
 
66
76
  async function requestSignal2(token) {
@@ -71,7 +81,10 @@ async function requestSignal2(token) {
71
81
  : await openOSDialog(entry.label, token);
72
82
  const e = pendingWrites.get(token);
73
83
  if (!e || e.state !== S1) return; // consumed, expired, or pre-approved meanwhile
74
- if (Date.now() > e.expiresAt) { pendingWrites.delete(token); return; }
84
+ if (Date.now() > e.expiresAt) {
85
+ pendingWrites.delete(token);
86
+ return;
87
+ }
75
88
  e.state = approved ? S2 : DENIED;
76
89
  }
77
90
 
@@ -88,12 +101,22 @@ export function confirmWrite(token) {
88
101
  sweep();
89
102
  const entry = pendingWrites.get(token);
90
103
  if (!entry) return { ok: false, reason: 'unknown_token' };
91
- if (Date.now() > entry.expiresAt) { pendingWrites.delete(token); return { ok: false, reason: 'expired' }; }
104
+ if (Date.now() > entry.expiresAt) {
105
+ pendingWrites.delete(token);
106
+ return { ok: false, reason: 'expired' };
107
+ }
92
108
  switch (entry.state) {
93
- case S1: return { ok: false, reason: 'awaiting_human' }; // keep — the AI must retry after the click
94
- case DENIED: pendingWrites.delete(token); return { ok: false, reason: 'human_denied' };
95
- case S2: pendingWrites.delete(token); return { ok: true }; // burn — single use
96
- default: pendingWrites.delete(token); return { ok: false, reason: 'invalid_state' };
109
+ case S1:
110
+ return { ok: false, reason: 'awaiting_human' }; // keep — the AI must retry after the click
111
+ case DENIED:
112
+ pendingWrites.delete(token);
113
+ return { ok: false, reason: 'human_denied' };
114
+ case S2:
115
+ pendingWrites.delete(token);
116
+ return { ok: true }; // burn — single use
117
+ default:
118
+ pendingWrites.delete(token);
119
+ return { ok: false, reason: 'invalid_state' };
97
120
  }
98
121
  }
99
122
 
@@ -114,17 +137,31 @@ export function buildDialogSpawn(platform, env, msg, title) {
114
137
  '[System.Windows.Forms.MessageBoxDefaultButton]::Button2);',
115
138
  'if ($r -eq [System.Windows.Forms.DialogResult]::Yes) { exit 0 } else { exit 1 }',
116
139
  ].join(' ');
117
- return { cmd: 'powershell', args: ['-NoProfile', '-NonInteractive', '-STA', '-Command', ps] };
140
+ return {
141
+ cmd: 'powershell',
142
+ args: ['-NoProfile', '-NonInteractive', '-STA', '-Command', ps],
143
+ };
118
144
  }
119
145
  if (platform === 'darwin') {
120
146
  // osascript reads the message from the inherited env (system attribute) — no interpolation.
121
- const osa = 'display dialog (system attribute "SPARDA_DLG_MSG") with title (system attribute "SPARDA_DLG_TITLE") '
122
- + 'buttons {"Deny", "Allow"} default button "Deny" cancel button "Deny" with icon caution';
147
+ const osa =
148
+ 'display dialog (system attribute "SPARDA_DLG_MSG") with title (system attribute "SPARDA_DLG_TITLE") ' +
149
+ 'buttons {"Deny", "Allow"} default button "Deny" cancel button "Deny" with icon caution';
123
150
  return { cmd: 'osascript', args: ['-e', osa] };
124
151
  }
125
152
  // Linux/BSD: zenity needs an X or Wayland display. Without one, fail closed.
126
153
  if (env.DISPLAY || env.WAYLAND_DISPLAY) {
127
- return { cmd: 'zenity', args: ['--question', `--title=${title}`, `--text=${msg}`, '--ok-label=Allow', '--cancel-label=Deny', '--width=420'] };
154
+ return {
155
+ cmd: 'zenity',
156
+ args: [
157
+ '--question',
158
+ `--title=${title}`,
159
+ `--text=${msg}`,
160
+ '--ok-label=Allow',
161
+ '--cancel-label=Deny',
162
+ '--width=420',
163
+ ],
164
+ };
128
165
  }
129
166
  return null; // headless → caller denies
130
167
  }
@@ -136,7 +173,9 @@ function openOSDialog(label, token) {
136
173
  const plan = buildDialogSpawn(process.platform, process.env, msg, title);
137
174
  if (!plan) {
138
175
  // R2: human log to stderr. Fail closed — no approval channel means no write.
139
- process.stderr.write(`[sparda] no desktop display — write auto-denied (Signal 2 unreachable): ${label}\n`);
176
+ process.stderr.write(
177
+ `[sparda] no desktop display — write auto-denied (Signal 2 unreachable): ${label}\n`,
178
+ );
140
179
  return resolve(false);
141
180
  }
142
181
  let proc;
@@ -148,16 +187,33 @@ function openOSDialog(label, token) {
148
187
  } catch {
149
188
  return resolve(false); // spawner missing → deny
150
189
  }
151
- const timer = setTimeout(() => { try { proc.kill(); } catch { /* already gone */ } resolve(false); }, TIMEOUT_MS);
152
- proc.on('close', (code) => { clearTimeout(timer); resolve(code === 0); });
153
- proc.on('error', () => { clearTimeout(timer); resolve(false); }); // binary absent (e.g. no zenity) → deny
190
+ const timer = setTimeout(() => {
191
+ try {
192
+ proc.kill();
193
+ } catch {
194
+ /* already gone */
195
+ }
196
+ resolve(false);
197
+ }, TIMEOUT_MS);
198
+ proc.on('close', (code) => {
199
+ clearTimeout(timer);
200
+ resolve(code === 0);
201
+ });
202
+ proc.on('error', () => {
203
+ clearTimeout(timer);
204
+ resolve(false);
205
+ }); // binary absent (e.g. no zenity) → deny
154
206
  });
155
207
  }
156
208
 
157
209
  // test hooks: inject a fake dialog, inspect/clear the pending map. Never used in production.
158
210
  export const _confirmTestHooks = {
159
- setDialogProvider: (fn) => { dialogProvider = fn; },
160
- clearDialogProvider: () => { dialogProvider = null; },
211
+ setDialogProvider: (fn) => {
212
+ dialogProvider = fn;
213
+ },
214
+ clearDialogProvider: () => {
215
+ dialogProvider = null;
216
+ },
161
217
  getPendingCount: () => pendingWrites.size,
162
218
  clearAll: () => pendingWrites.clear(),
163
219
  TIMEOUT_MS,