qz-browser-agent 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -25
- package/package.json +5 -2
- package/src/args.mjs +57 -16
- package/src/bin.mjs +2 -2
- package/src/catalog.json +73 -10
- package/src/cli-resources.mjs +2 -2
- package/src/cli.mjs +135 -120
- package/src/client.mjs +61 -19
- package/src/commands.mjs +45 -10
- package/src/connect-page.mjs +89 -0
- package/src/connection.mjs +145 -0
- package/src/daemon.mjs +180 -31
- package/src/drive-guide.mjs +38 -0
- package/src/drive-timeout.mjs +21 -0
- package/src/open-browser.mjs +40 -11
- package/src/qz-drive-SKILL.md +165 -0
- package/src/qz-logo-day.png +0 -0
- package/src/qz-logo.png +0 -0
- package/src/script-call.mjs +40 -0
- package/src/server.mjs +25 -8
- package/src/state.mjs +81 -29
- package/src/task-stream.mjs +4 -4
- package/src/terminal.mjs +68 -18
- package/src/tools.mjs +148 -30
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
|
-
|
|
27
|
-
|
|
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 || !
|
|
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:
|
|
83
|
+
return { ...record, health, compatible: health.protocolVersion === PROTOCOL_VERSION, updateAvailable: health.buildId !== paths.buildId };
|
|
67
84
|
} catch (error) {
|
|
68
|
-
if (!
|
|
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("
|
|
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
|
|
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("
|
|
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 ? !
|
|
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 ? !
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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("
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
+
}
|
package/src/open-browser.mjs
CHANGED
|
@@ -1,20 +1,49 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { QzError } from "./client.mjs";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
+
}
|