qz-browser-agent 0.1.3 → 0.1.6

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/daemon.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
- import { closeSync, openSync, readFileSync, readdirSync, realpathSync } from "node:fs";
4
- import { chmod, mkdir, readFile, readdir, rename, rm, rmdir, stat, writeFile } from "node:fs/promises";
3
+ import { closeSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync } from "node:fs";
4
+ import { chmod, lstat, mkdir, readFile, readdir, rename, rm, rmdir, stat, writeFile } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
6
  import { basename, dirname, join, resolve } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
@@ -23,8 +23,15 @@ export function buildIdentity(directory = packageDir) {
23
23
  export function daemonPaths(options = {}) {
24
24
  const identity = options.scopeId && options.buildId ? options : buildIdentity();
25
25
  const scopeId = options.scopeId || identity.scopeId;
26
- const directory = resolve(options.stateDir || process.env.QZ_STATE_DIR || join(homedir(), ".qz-browser-agent", "run"));
27
- return { scopeId, buildId: options.buildId || identity.buildId, directory, state: join(directory, `${scopeId}.json`), lock: join(directory, `${scopeId}.lock`), log: join(directory, `${scopeId}.log`) };
26
+ return { scopeId, buildId: options.buildId || identity.buildId, ...scopePaths(stateDirectory(options), scopeId) };
27
+ }
28
+
29
+ function stateDirectory(options) {
30
+ return resolve(options.stateDir || process.env.QZ_STATE_DIR || join(homedir(), ".qz-browser-agent", "run"));
31
+ }
32
+
33
+ function scopePaths(directory, scopeId) {
34
+ return { directory, state: join(directory, `${scopeId}.json`), lock: join(directory, `${scopeId}.lock`), log: join(directory, `${scopeId}.log`) };
28
35
  }
29
36
 
30
37
  async function readJson(path) {
@@ -55,29 +62,41 @@ function processExists(pid) {
55
62
  }
56
63
  }
57
64
 
65
+ function pidNamespace() {
66
+ if (process.platform !== "linux") return "";
67
+ try { return readlinkSync("/proc/self/ns/pid"); } catch { return ""; }
68
+ }
69
+
70
+ function processMayExist(owner) {
71
+ // A PID from another namespace cannot prove that its owner has exited.
72
+ return Boolean(owner.pidNamespace && owner.pidNamespace !== pidNamespace()) || processExists(owner.processId);
73
+ }
74
+
58
75
  export async function findDaemon(options = {}) {
59
76
  const paths = daemonPaths(options);
60
77
  const record = await readJson(paths.state);
61
- if (!record || record.scopeId !== paths.scopeId || !processExists(record.processId)) return null;
78
+ if (!record || record.scopeId !== paths.scopeId || !Number.isSafeInteger(record.processId) || record.processId <= 0) return null;
62
79
  for (let attempt = 0; attempt < 2; attempt += 1) {
63
80
  try {
64
- const health = await (options.readHealth || readHealth)(record.endpoint, { timeoutMs: 1000 });
81
+ const health = await (options.readHealth || readHealth)(record.endpoint, { timeoutMs: 1000, ...(options.allowIncompatible ? { allowIncompatible: true } : {}) });
65
82
  if (health.scopeId !== paths.scopeId || health.instanceId !== record.instanceId || health.processId !== record.processId) return null;
66
- return { ...record, health, compatible: true, updateAvailable: health.buildId !== paths.buildId };
83
+ return { ...record, health, compatible: health.protocolVersion === PROTOCOL_VERSION, updateAvailable: health.buildId !== paths.buildId };
67
84
  } catch (error) {
68
- if (!processExists(record.processId) || ["invalid_endpoint", "incompatible_service"].includes(error.code)) return null;
85
+ if (!processMayExist(record) || ["INVALID_ENDPOINT", "INCOMPATIBLE_SERVICE"].includes(error.code)) return null;
69
86
  if (attempt === 0) await delay(100);
70
87
  }
71
88
  }
72
- throw new QzError("connection_unavailable", "The qz local service is running but did not respond. Try qz status again shortly.", {
89
+ throw new QzError("CONNECTION_UNAVAILABLE", "The qz local service is running but did not respond. Try qz status again shortly.", {
73
90
  endpoint: record.endpoint, processId: record.processId, log: paths.log,
74
91
  });
75
92
  }
76
93
 
77
- async function withLock(paths, operation, { timeoutMs = 15000 } = {}) {
94
+ async function withLock(paths, operation, { timeoutMs = 15000, signal } = {}) {
95
+ signal?.throwIfAborted();
78
96
  await mkdir(paths.directory, { recursive: true, mode: 0o700 });
79
97
  await chmod(paths.directory, 0o700);
80
- const owner = { processId: process.pid, id: randomUUID() };
98
+ const namespace = pidNamespace();
99
+ const owner = { processId: process.pid, id: randomUUID(), ...(namespace ? { pidNamespace: namespace } : {}) };
81
100
  const ownerFile = `${owner.processId}-${owner.id}.json`;
82
101
  const candidate = `${paths.lock}.${owner.id}.pending`;
83
102
  await mkdir(candidate, { mode: 0o700 });
@@ -85,8 +104,10 @@ async function withLock(paths, operation, { timeoutMs = 15000 } = {}) {
85
104
  let acquired = false;
86
105
  let missingTargetPermissionError;
87
106
  try {
107
+ signal?.throwIfAborted();
88
108
  await writeFile(join(candidate, ownerFile), JSON.stringify(owner), { mode: 0o600 });
89
109
  while (true) {
110
+ signal?.throwIfAborted();
90
111
  try {
91
112
  // The lock becomes visible already containing its unique owner file.
92
113
  await rename(candidate, paths.lock);
@@ -104,11 +125,11 @@ async function withLock(paths, operation, { timeoutMs = 15000 } = {}) {
104
125
  // A persistent denial remains EPERM rather than becoming a busy lock.
105
126
  if (missingTargetPermissionError || Date.now() >= deadline) throw missingTargetPermissionError || error;
106
127
  missingTargetPermissionError = error;
107
- await delay(50);
128
+ await delay(50, undefined, { signal });
108
129
  continue;
109
130
  }
110
131
  if (!["EEXIST", "ENOTEMPTY", "ENOTDIR"].includes(error.code)) throw error;
111
- if (Date.now() >= deadline) throw new QzError("connection_busy", "Another qz command is starting the local service. Try qz status shortly.");
132
+ if (Date.now() >= deadline) throw new QzError("CONNECTION_BUSY", "Another qz command is starting the local service. Try qz status shortly.");
112
133
  continue;
113
134
  }
114
135
  missingTargetPermissionError = undefined;
@@ -117,21 +138,22 @@ async function withLock(paths, operation, { timeoutMs = 15000 } = {}) {
117
138
  if (!/^\d+-[\da-f-]+\.json$/.test(name)) continue;
118
139
  const oldPath = join(paths.lock, name);
119
140
  const oldOwner = await readJson(oldPath);
120
- if (oldOwner ? !processExists(oldOwner.processId) : Date.now() - existing.mtimeMs > 30000) await rm(oldPath, { force: true });
141
+ if (oldOwner ? !processMayExist(oldOwner) : Date.now() - existing.mtimeMs > 30000) await rm(oldPath, { force: true });
121
142
  }
122
143
  // A concurrent new owner keeps its directory non-empty and survives cleanup.
123
144
  await rmdir(paths.lock).catch(error => { if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error.code)) throw error; });
124
145
  } else {
125
146
  // Recover the file-lock format used by earlier local development builds.
126
147
  const oldOwner = await readJson(paths.lock);
127
- if (oldOwner ? !processExists(oldOwner.processId) : Date.now() - existing.mtimeMs > 30000) {
148
+ if (oldOwner ? !processMayExist(oldOwner) : Date.now() - existing.mtimeMs > 30000) {
128
149
  await rm(paths.lock, { force: true }).catch(error => { if (!["EISDIR", "ERR_FS_EISDIR"].includes(error.code)) throw error; });
129
150
  }
130
151
  }
131
- if (Date.now() >= deadline) throw new QzError("connection_busy", "Another qz command is starting the local service. Try qz status shortly.");
132
- await delay(50);
152
+ if (Date.now() >= deadline) throw new QzError("CONNECTION_BUSY", "Another qz command is starting the local service. Try qz status shortly.");
153
+ await delay(50, undefined, { signal });
133
154
  }
134
155
  }
156
+ signal?.throwIfAborted();
135
157
  return await operation();
136
158
  } finally {
137
159
  if (acquired) {
@@ -144,11 +166,21 @@ async function withLock(paths, operation, { timeoutMs = 15000 } = {}) {
144
166
 
145
167
  function recordFromHealth(endpoint, health) {
146
168
  return { service: SERVICE_NAME, protocolVersion: PROTOCOL_VERSION, endpoint, scopeId: health.scopeId,
147
- buildId: health.buildId, instanceId: health.instanceId, processId: health.processId };
169
+ buildId: health.buildId, instanceId: health.instanceId, processId: health.processId,
170
+ ...(health.pidNamespace ? { pidNamespace: health.pidNamespace } : {}) };
148
171
  }
149
172
 
150
173
  async function stopProcess(running) {
151
- if (running.processId === process.pid) throw new QzError("stop_failed", "The service is running inside this process.");
174
+ if (running.processId === process.pid) throw new QzError("STOP_FAILED", "The service is running inside this process.");
175
+ if (process.platform === "linux") {
176
+ let args = [];
177
+ try { args = readFileSync(`/proc/${running.processId}/cmdline`, "utf8").split("\0"); } catch {}
178
+ const index = args.indexOf("--run");
179
+ if (index < 1 || basename(args[index - 1]) !== "daemon.mjs"
180
+ || args[index + 2] !== running.scopeId || args[index + 5] !== running.instanceId) {
181
+ throw new QzError("STOP_FAILED", "The service process cannot be verified from this environment. Run qz cleanup from the terminal that started it.");
182
+ }
183
+ }
152
184
  try { process.kill(running.processId, "SIGTERM"); }
153
185
  catch (error) { if (error.code === "ESRCH") return { stopped: true, connected: false }; throw error; }
154
186
  const deadline = Date.now() + 5000;
@@ -156,7 +188,7 @@ async function stopProcess(running) {
156
188
  if (!processExists(running.processId)) return { stopped: true, connected: false };
157
189
  await delay(50);
158
190
  }
159
- throw new QzError("stop_failed", "The local service has not stopped yet. Check qz status.");
191
+ throw new QzError("STOP_FAILED", "The local service has not stopped yet. Check qz status.");
160
192
  }
161
193
 
162
194
  function childExited(child) {
@@ -180,24 +212,36 @@ async function discardStartup(child, paths, instanceId) {
180
212
  child.kill(signal);
181
213
  if (await waitForChildExit(child, 1000)) break;
182
214
  }
183
- if (!childExited(child)) throw new QzError("start_failed", "The failed qz startup process has not stopped.", { processId: child.pid, log: paths.log });
215
+ if (!childExited(child)) throw new QzError("START_FAILED", "The failed qz startup process has not stopped.", { processId: child.pid, log: paths.log });
184
216
  const record = await readJson(paths.state);
185
217
  if (record?.processId === child.pid && record.instanceId === instanceId) await rm(paths.state, { force: true });
186
218
  }
187
219
 
188
220
  export async function ensureDaemon(options = {}) {
221
+ options.signal?.throwIfAborted();
189
222
  const paths = daemonPaths(options);
223
+ // Reuse without writes, but honor an in-progress startup, cleanup or upgrade.
224
+ if (!await lstat(paths.lock).catch(missing)) {
225
+ const existing = await findDaemon({ ...options, stateDir: paths.directory, scopeId: paths.scopeId, buildId: paths.buildId });
226
+ options.signal?.throwIfAborted();
227
+ if (existing && !await lstat(paths.lock).catch(missing)) return existing;
228
+ }
229
+ return withLock(paths, () => ensureDaemonLocked(paths, options), options);
230
+ }
231
+
232
+ async function ensureDaemonLocked(paths, options) {
233
+ options.signal?.throwIfAborted();
190
234
  const lookup = { ...options, stateDir: paths.directory, scopeId: paths.scopeId, buildId: paths.buildId };
191
- return withLock(paths, async () => {
192
- // A running service keeps its loaded code until an explicit stop. Editing files or
193
- // invoking another npx cache copy must not interrupt browser tasks or MCP clients.
235
+ // Compatible services retain their loaded code across source edits or npx copies.
194
236
  const existing = await findDaemon(lookup);
237
+ options.signal?.throwIfAborted();
195
238
  if (existing) return existing;
196
239
  const port = options.preferredPort ?? 18765;
197
240
  if (port) {
198
241
  const endpoint = `http://127.0.0.1:${port}`;
199
242
  const health = await (options.readHealth || readHealth)(endpoint, { timeoutMs: 500 }).catch(() => null);
200
- if (health?.scopeId === paths.scopeId && processExists(health.processId)) {
243
+ options.signal?.throwIfAborted();
244
+ if (health?.scopeId === paths.scopeId && Number.isSafeInteger(health.processId) && health.processId > 0) {
201
245
  const record = recordFromHealth(endpoint, health);
202
246
  await atomicJson(paths.state, record);
203
247
  return { ...record, health, compatible: true, updateAvailable: health.buildId !== paths.buildId };
@@ -208,6 +252,7 @@ export async function ensureDaemon(options = {}) {
208
252
  try {
209
253
  const log = openSync(paths.log, "a", 0o600);
210
254
  try {
255
+ options.signal?.throwIfAborted();
211
256
  child = (options.spawnProcess || spawn)(process.execPath, [fileURLToPath(import.meta.url), "--run", paths.state, paths.scopeId, String(port), paths.buildId, instanceId], {
212
257
  detached: true, stdio: ["ignore", "ignore", log], windowsHide: true,
213
258
  });
@@ -215,27 +260,45 @@ export async function ensureDaemon(options = {}) {
215
260
  child.once("spawn", resolveSpawn);
216
261
  child.once("error", reject);
217
262
  });
263
+ options.signal?.throwIfAborted();
218
264
  child.unref();
219
265
  } finally { closeSync(log); }
220
266
  const deadline = Date.now() + (options.startTimeoutMs || 10000);
221
267
  while (Date.now() < deadline) {
268
+ options.signal?.throwIfAborted();
222
269
  const running = await findDaemon(lookup);
270
+ options.signal?.throwIfAborted();
223
271
  if (running) {
224
272
  if (running.instanceId !== instanceId || running.processId !== child.pid) {
225
- throw new QzError("start_failed", "Another qz service claimed the startup state. Check qz status.", { log: paths.log });
273
+ throw new QzError("START_FAILED", "Another qz service claimed the startup state. Check qz status.", { log: paths.log });
226
274
  }
227
275
  return running;
228
276
  }
229
277
  if (childExited(child) || !processExists(child.pid)) break;
230
- await delay(50);
278
+ await delay(50, undefined, { signal: options.signal });
231
279
  }
232
- throw new QzError("start_failed", "The qz local service did not start.", { log: paths.log });
280
+ throw new QzError("START_FAILED", "The qz local service did not start.", { log: paths.log });
233
281
  } catch (error) {
234
282
  // Hold the startup lock until this invocation's child is gone. Otherwise a
235
283
  // delayed child could overwrite the state published by a concurrent retry.
236
284
  await discardStartup(child, paths, instanceId);
237
285
  throw error;
238
286
  }
287
+ }
288
+
289
+ /** Replace only the exact service inspected by the caller, under the same startup lock. */
290
+ export async function restartDaemon({ expected, ...options } = {}) {
291
+ options.signal?.throwIfAborted();
292
+ const paths = daemonPaths(options);
293
+ return withLock(paths, async () => {
294
+ const running = await findDaemon({ ...options, stateDir: paths.directory, scopeId: paths.scopeId, buildId: paths.buildId, allowIncompatible: true });
295
+ options.signal?.throwIfAborted();
296
+ if (!running || !expected || ["scopeId", "instanceId", "processId"].some(key => running[key] !== expected[key])) {
297
+ throw new QzError("CONNECTION_CHANGED", "The local service changed before its upgrade. Retry the command.");
298
+ }
299
+ await stopProcess(running);
300
+ options.signal?.throwIfAborted();
301
+ return ensureDaemonLocked(paths, options);
239
302
  }, options);
240
303
  }
241
304
 
@@ -243,22 +306,108 @@ export async function stopDaemon(options = {}) {
243
306
  const paths = daemonPaths(options);
244
307
  return withLock(paths, async () => {
245
308
  const running = await findDaemon(options);
309
+ options.signal?.throwIfAborted();
246
310
  if (!running) return { stopped: false, connected: false };
247
311
  return stopProcess(running);
248
312
  }, options);
249
313
  }
250
314
 
315
+ // Only daemon-owned runtime names participate; pairing registries and unrelated files stay intact.
316
+ const runtimeName = /^([a-f\d]{24})(?:\.json(?:\.[a-f\d-]{36}\.tmp)?|\.log|\.lock(?:\.[a-f\d-]{36}\.pending)?)$/;
317
+ const sameInstance = (left, right) => ["service", "scopeId", "instanceId", "processId", "endpoint"].every(key => left?.[key] === right?.[key]);
318
+ const missing = error => { if (error.code !== "ENOENT") throw error; return null; };
319
+
320
+ /** Stop registered installations under their startup locks, without creating a service or browser. */
321
+ export async function cleanupDaemons(options = {}) {
322
+ const directory = stateDirectory(options);
323
+ options.signal?.throwIfAborted();
324
+ const names = await readdir(directory).catch(error => missing(error) || []);
325
+ const scopes = [...new Set(names.map(name => runtimeName.exec(name)?.[1]).filter(Boolean))].sort();
326
+ const result = { stopped: 0, removedFiles: 0, skipped: 0, services: [] };
327
+ for (const scopeId of scopes) {
328
+ options.signal?.throwIfAborted();
329
+ const paths = scopePaths(directory, scopeId);
330
+ const item = { scopeId, status: "cleaned" };
331
+ try {
332
+ const lock = await lstat(paths.lock).catch(missing);
333
+ if (lock?.isSymbolicLink()) throw new QzError("CLEANUP_UNVERIFIED", "The service lock is a symbolic link.");
334
+ await withLock(paths, async () => {
335
+ const state = await lstat(paths.state).catch(missing);
336
+ if (state && !state.isFile()) throw new QzError("CLEANUP_UNVERIFIED", "The service record is not a regular file.");
337
+ const record = state ? await readJson(paths.state) : null;
338
+ if (state && (!record || record.service !== SERVICE_NAME || record.scopeId !== scopeId
339
+ || !record.instanceId || !Number.isSafeInteger(record.processId) || record.processId <= 0)) {
340
+ throw new QzError("CLEANUP_UNVERIFIED", "The service record is invalid; its process cannot be identified.");
341
+ }
342
+ if (record) {
343
+ Object.assign(item, { processId: record.processId, endpoint: record.endpoint });
344
+ const running = await findDaemon({ ...options, stateDir: directory, scopeId, buildId: record.buildId || "cleanup", allowIncompatible: true });
345
+ options.signal?.throwIfAborted();
346
+ if (running ? !sameInstance(record, running) || !sameInstance(record, await readJson(paths.state)) : processMayExist(record)) {
347
+ throw new QzError("CLEANUP_UNVERIFIED", "The live process does not match its registered qz service.");
348
+ }
349
+ options.signal?.throwIfAborted();
350
+ if (running) {
351
+ await stopProcess(running);
352
+ result.stopped++;
353
+ item.status = "stopped";
354
+ }
355
+ const current = await readJson(paths.state);
356
+ if (current && !sameInstance(record, current)) throw new QzError("CONNECTION_CHANGED", "The service record changed during cleanup.");
357
+ }
358
+ options.signal?.throwIfAborted();
359
+ const remaining = await readdir(directory);
360
+ for (const name of remaining) {
361
+ if (runtimeName.exec(name)?.[1] !== scopeId || name === `${scopeId}.lock`) continue;
362
+ const path = join(directory, name);
363
+ const entry = await lstat(path).catch(missing);
364
+ if (!entry) continue;
365
+ if (entry.isFile()) {
366
+ await rm(path, { force: true });
367
+ result.removedFiles++;
368
+ } else if (entry.isDirectory() && name.endsWith(".pending")) {
369
+ // A pending starter may already be waiting on our lock. Only dead owners can be removed.
370
+ let removedOwner = false;
371
+ for (const ownerName of await readdir(path).catch(error => missing(error) || [])) {
372
+ if (!/^\d+-[a-f\d-]{36}\.json$/.test(ownerName)) continue;
373
+ const ownerPath = join(path, ownerName);
374
+ const owner = await readJson(ownerPath);
375
+ if (owner && Number.isSafeInteger(owner.processId) && owner.processId > 0 && !processMayExist(owner)) {
376
+ await rm(ownerPath, { force: true });
377
+ result.removedFiles++;
378
+ removedOwner = true;
379
+ }
380
+ }
381
+ // A starter publishes its owner just after mkdir; leave that fresh empty directory alone.
382
+ if (removedOwner || Date.now() - entry.mtimeMs > 30000) {
383
+ await rmdir(path).catch(error => { if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error.code)) throw error; });
384
+ }
385
+ }
386
+ }
387
+ }, options);
388
+ } catch (error) {
389
+ options.signal?.throwIfAborted();
390
+ result.skipped++;
391
+ Object.assign(item, { status: "skipped", code: error.code || "CLEANUP_FAILED", message: error.message });
392
+ }
393
+ result.services.push(item);
394
+ }
395
+ return result;
396
+ }
397
+
251
398
  export async function startManagedBridge({ stateFile, scopeId, buildId = "", instanceId = randomUUID(), port = 18765, startBridge } = {}) {
252
399
  const start = startBridge || (await import("./server.mjs")).startBridge;
253
400
  const registryPath = stateFile.replace(/\.json$/, "") + ".browsers.json";
401
+ const namespace = pidNamespace();
402
+ const identity = { scopeId, instanceId, buildId, ...(namespace ? { pidNamespace: namespace } : {}) };
254
403
  let running;
255
- try { running = await start({ port, scopeId, instanceId, buildId, registryPath }); }
404
+ try { running = await start({ port, ...identity, registryPath }); }
256
405
  catch (error) {
257
406
  if (error.code !== "EADDRINUSE" || !port) throw error;
258
- running = await start({ port: 0, scopeId, instanceId, buildId, registryPath });
407
+ running = await start({ port: 0, ...identity, registryPath });
259
408
  }
260
409
  const record = { service: SERVICE_NAME, protocolVersion: PROTOCOL_VERSION, endpoint: running.endpoint,
261
- processId: process.pid, scopeId, instanceId, buildId };
410
+ processId: process.pid, ...identity };
262
411
  try { await atomicJson(stateFile, record); }
263
412
  catch (error) { await running.close(); throw error; }
264
413
  let closing;
@@ -0,0 +1,38 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ const MARKER_START = "<!-- qz-drive-guide:en -->";
4
+ const MARKER_END = "<!-- qz-drive-guide:end -->";
5
+ const candidates = [
6
+ new URL("./qz-drive-SKILL.md", import.meta.url),
7
+ new URL("../../../skills/qz-browser-agent/SKILL.md", import.meta.url),
8
+ ];
9
+
10
+ let cached = "";
11
+
12
+ function loadGuideSource() {
13
+ if (cached) return cached;
14
+ const errors = [];
15
+ for (const url of candidates) {
16
+ try {
17
+ cached = readFileSync(url, "utf8");
18
+ return cached;
19
+ } catch (error) {
20
+ if (error?.code !== "ENOENT") throw error;
21
+ errors.push(url.pathname);
22
+ }
23
+ }
24
+ throw new Error(`direct-drive guide is missing. Looked in ${errors.join(", ")}.`);
25
+ }
26
+
27
+ /** Read the direct-drive usage guide. */
28
+ export function driveGuide() {
29
+ const text = loadGuideSource();
30
+ const start = text.indexOf(MARKER_START);
31
+ const end = text.indexOf(MARKER_END, start + MARKER_START.length);
32
+ if (start < 0 || end < 0) throw new Error("direct-drive guide content is missing.");
33
+ return `${text.slice(start + MARKER_START.length, end).trim()}\n`;
34
+ }
35
+
36
+ export function resetDriveGuideCache() {
37
+ cached = "";
38
+ }
@@ -0,0 +1,21 @@
1
+ /** Shared CLI/bridge wait budget. Never rewrites script or batch execution deadlines. */
2
+ export function driveExtensionTimeoutSeconds(params, fallback = 180) {
3
+ const client = Number(params?.timeoutSeconds);
4
+ const base = Number.isFinite(client) && client > 0 ? client : Number(fallback) || 180;
5
+ const scripts = [];
6
+ if (params?.name === "run_script") scripts.push(params.args);
7
+ if (Array.isArray(params?.calls)) {
8
+ for (const call of params.calls) if (call?.name === "run_script") scripts.push(call.args);
9
+ }
10
+ let scriptMax = 0;
11
+ let unlimited = false;
12
+ for (const raw of scripts) {
13
+ const timeout = Number(raw && typeof raw === "object" ? raw.timeout : undefined);
14
+ if (!Number.isFinite(timeout)) continue;
15
+ if (timeout <= 0) unlimited = true;
16
+ else scriptMax = Math.max(scriptMax, timeout);
17
+ }
18
+ if (unlimited) return 86400;
19
+ if (scriptMax > 0) return Math.max(base, scriptMax + 60);
20
+ return base;
21
+ }
@@ -1,20 +1,49 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { QzError } from "./client.mjs";
3
3
 
4
- export async function openBrowser(url, { platform = process.platform, spawnProcess = spawn } = {}) {
5
- const target = new URL(url);
6
- if (!["http:", "https:"].includes(target.protocol)) throw new QzError("browser_open_failed", "Only web URLs can be opened.", { url });
4
+ const connectionHint = "Open the connection URL in the browser profile where qz is installed, then retry.";
5
+
6
+ /** Open the connection page once with the system URL opener; the page guides browsers without qz. */
7
+ export async function openConnectionBrowser(url, { platform = process.platform, spawnProcess = spawn, signal, timeoutMs = 10000 } = {}) {
8
+ try {
9
+ return { ...await openBrowser(url, { platform, spawnProcess, signal, waitForExit: false, timeoutMs }), browser: "System default browser" };
10
+ } catch (error) {
11
+ signal?.throwIfAborted();
12
+ throw new QzError("BROWSER_OPEN_FAILED", error.message, { url, hint: connectionHint });
13
+ }
14
+ }
15
+
16
+ export async function openBrowser(url, { platform = process.platform, spawnProcess = spawn, signal, timeoutMs = 10000, waitForExit = true } = {}) {
17
+ webUrl(url);
18
+ signal?.throwIfAborted();
7
19
  const [command, args] = platform === "darwin" ? ["open", [url]]
8
20
  : platform === "win32" ? ["rundll32.exe", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]];
21
+ const failure = () => new QzError("BROWSER_OPEN_FAILED", "Could not open the system default browser. Open the URL manually.", { url });
9
22
  await new Promise((resolve, reject) => {
10
- const child = spawnProcess(command, args, { stdio: "ignore", windowsHide: true });
11
- const timer = setTimeout(() => {
12
- child.kill();
13
- reject(new QzError("browser_open_failed", "The browser did not open. Open the URL in Chrome with qz installed.", { url }));
14
- }, 10000);
15
- const finish = error => { clearTimeout(timer); error ? reject(error) : resolve(); };
16
- child.once("error", () => finish(new QzError("browser_open_failed", "Could not open a browser. Open the URL in Chrome with qz installed.", { url })));
17
- child.once("exit", code => finish(code === 0 ? null : new QzError("browser_open_failed", "Could not open a browser. Open the URL in Chrome with qz installed.", { url })));
23
+ const child = spawnProcess(command, args, { detached: true, stdio: "ignore", windowsHide: true });
24
+ let settled = false;
25
+ const finish = error => {
26
+ if (settled) return;
27
+ settled = true;
28
+ clearTimeout(timer);
29
+ signal?.removeEventListener("abort", onAbort);
30
+ // The system opener may itself become the browser process. Never kill it on timeout or cancellation.
31
+ child.unref?.();
32
+ error ? reject(error) : resolve();
33
+ };
34
+ const onAbort = () => finish(signal.reason);
35
+ const timer = setTimeout(() => finish(failure()), Math.max(1, timeoutMs));
36
+ if (!waitForExit) child.once("spawn", () => finish());
37
+ child.once("error", () => finish(failure()));
38
+ child.once("exit", code => finish(code === 0 ? null : failure()));
39
+ signal?.addEventListener("abort", onAbort, { once: true });
40
+ if (signal?.aborted) onAbort();
18
41
  });
19
42
  return { url, opened: true };
20
43
  }
44
+
45
+ function webUrl(url) {
46
+ const target = new URL(url);
47
+ if (!["http:", "https:"].includes(target.protocol)) throw new QzError("BROWSER_OPEN_FAILED", "Only web URLs can be opened.", { url });
48
+ return target;
49
+ }