@dassi_ai/cli 0.5.0 → 0.7.1
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/.claude-plugin/plugin.json +8 -3
- package/README.md +116 -108
- package/daemon-client.mjs +41 -85
- package/dassi-daemon.mjs +109 -156
- package/dassi-shared.mjs +40 -172
- package/dassi.mjs +100 -62
- package/format-response.mjs +26 -11
- package/group-expansion.mjs +12 -3
- package/help-text.mjs +59 -48
- package/launch.mjs +1 -1
- package/package.json +5 -2
- package/setup.mjs +200 -0
- package/skills/dassi/SKILL.md +69 -0
- package/skills/dassi/scripts/dassi.mjs +3 -0
- package/tool-commands.mjs +48 -89
- package/skills/operate/SKILL.md +0 -124
- package/skills/operate/command-reference.md +0 -65
- package/skills/pick-tabs/SKILL.md +0 -93
package/dassi-daemon.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { CLI_PROTOCOL_VERSION } from './dassi-shared.mjs';
|
|
2
4
|
/**
|
|
3
5
|
* Dassi CLI Daemon
|
|
4
6
|
*
|
|
@@ -13,6 +15,7 @@
|
|
|
13
15
|
*/
|
|
14
16
|
|
|
15
17
|
import * as net from 'net';
|
|
18
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
16
19
|
import * as fs from 'fs';
|
|
17
20
|
import { pathToFileURL } from 'url';
|
|
18
21
|
import {
|
|
@@ -22,10 +25,9 @@ import {
|
|
|
22
25
|
getPidFile,
|
|
23
26
|
validateSession,
|
|
24
27
|
cleanupDaemonFiles,
|
|
25
|
-
buildReadyPayload,
|
|
26
|
-
createCommandQueue,
|
|
27
28
|
createConnectionRegistry,
|
|
28
29
|
getDaemonBridgePort,
|
|
30
|
+
unknownToolOutcome,
|
|
29
31
|
} from './dassi-shared.mjs';
|
|
30
32
|
|
|
31
33
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
@@ -33,8 +35,6 @@ import {
|
|
|
33
35
|
const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
34
36
|
const IDLE_CHECK_INTERVAL_MS = 5 * 60 * 1000;
|
|
35
37
|
|
|
36
|
-
// Reason: a registry of connected extensions keyed by profile label replaces
|
|
37
|
-
// the old single-socket model so multiple Chrome profiles can connect at once.
|
|
38
38
|
const registry = createConnectionRegistry();
|
|
39
39
|
|
|
40
40
|
// ─── WebSocket loader helper ──────────────────────────────────────────────────
|
|
@@ -72,7 +72,7 @@ function setupResponseRouter(entry) {
|
|
|
72
72
|
if (!pending) return;
|
|
73
73
|
entry.pending.delete(String(resp.id));
|
|
74
74
|
if (resp.error) {
|
|
75
|
-
pending.reject(new Error(resp.error.message ?? String(resp.error)));
|
|
75
|
+
pending.reject(Object.assign(new Error(resp.error.message ?? String(resp.error)), { taskId: resp.error.taskId, existingTaskId: resp.error.existingTaskId }));
|
|
76
76
|
} else {
|
|
77
77
|
pending.resolve(resp.result);
|
|
78
78
|
}
|
|
@@ -85,7 +85,7 @@ function setupResponseRouter(entry) {
|
|
|
85
85
|
*/
|
|
86
86
|
function drainPendingResponses(entry) {
|
|
87
87
|
for (const [id, pending] of entry.pending) {
|
|
88
|
-
pending.reject(new Error('Extension disconnected'));
|
|
88
|
+
pending.reject(Object.assign(new Error('Extension disconnected'), { outcome: 'unknown' }));
|
|
89
89
|
entry.pending.delete(id);
|
|
90
90
|
}
|
|
91
91
|
}
|
|
@@ -113,45 +113,12 @@ function acceptExtensionSocket(ws, identity, port) {
|
|
|
113
113
|
return entry;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
|
|
117
|
-
* Handles a registration message from a newly connected extension WebSocket.
|
|
118
|
-
* On first registration, resolves the startup promise with auth status.
|
|
119
|
-
* On subsequent registrations (reconnects), logs and continues silently.
|
|
120
|
-
* @param {import('ws').WebSocket} ws - The connected WebSocket.
|
|
121
|
-
* @param {Buffer} data - Raw message data.
|
|
122
|
-
* @param {{ resolved: boolean; timeout: ReturnType<typeof setTimeout> }} ctx - Shared state.
|
|
123
|
-
* @param {(value: unknown) => void} resolve - Promise resolve callback.
|
|
124
|
-
* @param {(reason: Error) => void} reject - Promise reject callback.
|
|
125
|
-
* @param {number} port - The port the WS server is bound to.
|
|
126
|
-
*/
|
|
127
|
-
async function handleRegistration(ws, data, ctx, resolve, reject, port) {
|
|
116
|
+
function handleRegistration(ws, data, port) {
|
|
128
117
|
let msg;
|
|
129
|
-
try { msg = JSON.parse(data.toString()); } catch { return; }
|
|
130
|
-
if (msg.type !== 'register' || msg.client !== 'dassi-extension') return;
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
ws,
|
|
134
|
-
{ label: typeof msg.label === 'string' ? msg.label : null, installId: String(msg.installId ?? '') },
|
|
135
|
-
port,
|
|
136
|
-
);
|
|
137
|
-
|
|
138
|
-
if (!ctx.resolved) {
|
|
139
|
-
clearTimeout(ctx.timeout);
|
|
140
|
-
try {
|
|
141
|
-
// Reason: route the startup status check to the connection that just registered.
|
|
142
|
-
const status = await dispatchToExtension({ id: 'init', action: 'status', target: entry.label });
|
|
143
|
-
ctx.resolved = true;
|
|
144
|
-
resolve(status);
|
|
145
|
-
} catch (err) {
|
|
146
|
-
// Reason: leave ctx.resolved false so a reconnecting extension can retry
|
|
147
|
-
// the startup status check rather than failing the daemon permanently.
|
|
148
|
-
console.error('[dassi-daemon] Initial status check failed:', err.message);
|
|
149
|
-
reject(err);
|
|
150
|
-
}
|
|
151
|
-
} else {
|
|
152
|
-
// Reason: daemon already started — this is a reconnect or an additional profile.
|
|
153
|
-
console.log('[dassi-daemon] Extension registered (daemon already started)');
|
|
154
|
-
}
|
|
118
|
+
try { msg = JSON.parse(data.toString()); } catch { ws.close(); return; }
|
|
119
|
+
if (msg.type !== 'register' || msg.client !== 'dassi-extension') { ws.close(); return; }
|
|
120
|
+
acceptExtensionSocket(ws, { label: typeof msg.label === 'string' ? msg.label : null,
|
|
121
|
+
installId: String(msg.installId ?? '') }, port);
|
|
155
122
|
}
|
|
156
123
|
|
|
157
124
|
// Reason: Port is configurable via DASSI_BRIDGE_PORT (single source of truth in
|
|
@@ -161,35 +128,15 @@ async function handleRegistration(ws, data, ctx, resolve, reject, port) {
|
|
|
161
128
|
// silently accept and bind to a random ephemeral port.
|
|
162
129
|
const BRIDGE_PORT = getDaemonBridgePort();
|
|
163
130
|
|
|
164
|
-
/**
|
|
165
|
-
* Starts a WebSocket server on the configured bridge port (default 18790).
|
|
166
|
-
* Stays alive for the daemon's lifetime. Waits for the first extension
|
|
167
|
-
* connection, then resolves with the auth status. Subsequent reconnections
|
|
168
|
-
* are handled transparently.
|
|
169
|
-
* @returns {Promise<{ authenticated: boolean; email: string | null; optionsUrl: string }>}
|
|
170
|
-
*/
|
|
131
|
+
/** Listen independently of whether Chrome is connected or signed in. */
|
|
171
132
|
async function startExtensionServer() {
|
|
172
133
|
const { WSServer } = await loadWebSocket();
|
|
173
|
-
|
|
174
134
|
return new Promise((resolve, reject) => {
|
|
175
135
|
const wss = new WSServer({ host: '127.0.0.1', port: BRIDGE_PORT });
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
ctx.timeout = setTimeout(() => {
|
|
179
|
-
wss.close();
|
|
180
|
-
reject(new Error('extension_not_installed'));
|
|
181
|
-
}, 30_000);
|
|
182
|
-
|
|
183
|
-
wss.on('error', (err) => {
|
|
184
|
-
if (!ctx.resolved) {
|
|
185
|
-
clearTimeout(ctx.timeout);
|
|
186
|
-
// Reason: EADDRINUSE means another daemon instance already owns the port
|
|
187
|
-
reject(err.code === 'EADDRINUSE' ? new Error('port_in_use') : new Error('extension_not_installed'));
|
|
188
|
-
}
|
|
189
|
-
});
|
|
190
|
-
|
|
136
|
+
wss.once('listening', () => resolve(wss));
|
|
137
|
+
wss.on('error', reject);
|
|
191
138
|
wss.on('connection', (ws) => {
|
|
192
|
-
ws.once('message', (data) => handleRegistration(ws, data,
|
|
139
|
+
ws.once('message', (data) => handleRegistration(ws, data, BRIDGE_PORT));
|
|
193
140
|
});
|
|
194
141
|
});
|
|
195
142
|
}
|
|
@@ -203,76 +150,98 @@ async function startExtensionServer() {
|
|
|
203
150
|
* @param {Record<string, unknown>} cmd - Command object with {id, action, target?, ...params}
|
|
204
151
|
* @returns {Promise<unknown>} The extension's result value (not wrapped in success/data).
|
|
205
152
|
*/
|
|
206
|
-
async function dispatchToExtension(cmd) {
|
|
153
|
+
export async function dispatchToExtension(cmd, reg = registry) {
|
|
207
154
|
const { id, action, target = null, ...params } = cmd;
|
|
208
|
-
const entry =
|
|
155
|
+
const entry = reg.resolve(target); // throws with a helpful message if ambiguous/unknown/none
|
|
209
156
|
if (!entry.ws || entry.ws.readyState !== 1 /* OPEN */) {
|
|
210
157
|
throw new Error('Extension not connected');
|
|
211
158
|
}
|
|
212
159
|
|
|
213
160
|
// Reason: the extension expects JSON-RPC {id, method, params}; `target` is a
|
|
214
161
|
// daemon-only routing field and must not leak into the extension params.
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const timeoutMs =
|
|
162
|
+
const correlationId = randomUUID();
|
|
163
|
+
const message = { id: correlationId, method: action ?? String(id), params };
|
|
164
|
+
// The canonical tool controls execution deadlines, including for dynamic plugins.
|
|
165
|
+
const timeoutMs = action === 'tool_exec' ? null : action === 'status' ? 10_000 : action === 'run' ? 65_000
|
|
166
|
+
: Number.isFinite(params.timeoutMs) && params.timeoutMs > 0 ? Math.min(params.timeoutMs + 10_000, 2147483647) : 65_000;
|
|
219
167
|
|
|
220
168
|
return new Promise((resolve, reject) => {
|
|
221
|
-
const timer = setTimeout(() => {
|
|
222
|
-
entry.pending.delete(
|
|
169
|
+
const timer = timeoutMs == null ? null : setTimeout(() => {
|
|
170
|
+
entry.pending.delete(correlationId);
|
|
223
171
|
reject(new Error(`Extension timeout after ${timeoutMs}ms`));
|
|
224
172
|
}, timeoutMs);
|
|
225
173
|
|
|
226
|
-
entry.pending.set(
|
|
174
|
+
entry.pending.set(correlationId, {
|
|
227
175
|
resolve: (result) => { clearTimeout(timer); resolve(result); },
|
|
228
176
|
reject: (err) => { clearTimeout(timer); reject(err); },
|
|
229
177
|
});
|
|
178
|
+
try { entry.ws.send(JSON.stringify(message)); } catch (error) {
|
|
179
|
+
entry.pending.delete(correlationId); clearTimeout(timer); reject(error);
|
|
180
|
+
}
|
|
230
181
|
});
|
|
231
182
|
}
|
|
232
183
|
|
|
233
|
-
/**
|
|
234
|
-
|
|
235
|
-
* Returns a response object, or null if the command should be dispatched to
|
|
236
|
-
* an extension instead.
|
|
237
|
-
* @param {Record<string, unknown>} cmd
|
|
238
|
-
* @param {ReturnType<typeof createConnectionRegistry>} reg
|
|
239
|
-
* @returns {object | null}
|
|
240
|
-
*/
|
|
241
|
-
export function handleDaemonLocalCommand(cmd, reg) {
|
|
242
|
-
if (cmd?.action === 'list_profiles') {
|
|
243
|
-
return { id: String(cmd.id ?? 'unknown'), success: true, data: reg.list() };
|
|
244
|
-
}
|
|
245
|
-
return null;
|
|
246
|
-
}
|
|
184
|
+
/** Discovery is independent of authentication and in-flight agent work. */
|
|
185
|
+
const session = validateSession(process.env.DASSI_SESSION ?? 'default');
|
|
247
186
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
* Reason: The daemon previously wrote the ready file exactly once on startup.
|
|
252
|
-
* When the user wasn't signed in at that moment, the file stayed `needs_login`
|
|
253
|
-
* even after waitForLogin's polling confirmed authentication via socket — so
|
|
254
|
-
* every subsequent CLI invocation re-read the stale file and re-triggered the
|
|
255
|
-
* login flow. Refreshing on every `status` response — which waitForLogin's
|
|
256
|
-
* poll loop drives naturally — converges the file to the truth without needing
|
|
257
|
-
* a separate signal channel.
|
|
258
|
-
*
|
|
259
|
-
* @param {string} readyFile - Absolute path to the daemon's ready file.
|
|
260
|
-
* @param {{ action?: string }} cmd - The command that was dispatched.
|
|
261
|
-
* @param {unknown} result - The dispatch result (extension's status payload).
|
|
262
|
-
* @returns {void}
|
|
263
|
-
*/
|
|
264
|
-
export function refreshReadyFileFromStatusResult(readyFile, cmd, result) {
|
|
265
|
-
if (cmd?.action !== 'status') return;
|
|
266
|
-
if (!result || typeof result !== 'object') return;
|
|
187
|
+
export async function routeCommand(cmd, reg = registry, dispatch = dispatchToExtension) {
|
|
188
|
+
let resolvedProfile;
|
|
189
|
+
const call = (command) => dispatch(command, reg);
|
|
267
190
|
try {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
191
|
+
if (['run', 'task_status', 'task_stop'].includes(cmd.action) && cmd.protocolVersion !== CLI_PROTOCOL_VERSION) throw new Error(cmd.protocolVersion > CLI_PROTOCOL_VERSION
|
|
192
|
+
? `This Dassi daemon is older than the CLI (daemon protocol ${CLI_PROTOCOL_VERSION}, CLI ${cmd.protocolVersion}). Stop it (kill the PID in ${getPidFile(session)}) and retry.`
|
|
193
|
+
: `Incompatible CLI (protocol ${cmd.protocolVersion ?? 'none'}, this daemon speaks ${CLI_PROTOCOL_VERSION}). Run \`npm install -g @dassi_ai/cli@latest\` and retry.`);
|
|
194
|
+
let target = cmd.target ?? null;
|
|
195
|
+
let localTaskId = cmd.taskId;
|
|
196
|
+
if (cmd.action === 'task_status' || cmd.action === 'task_stop' || (cmd.action === 'run' && cmd.taskId)) {
|
|
197
|
+
const split = typeof cmd.taskId === 'string' ? cmd.taskId.indexOf('~') : -1;
|
|
198
|
+
if (split < 1) throw new Error('Invalid task ID. Copy the ID returned by dassi run.');
|
|
199
|
+
const taskProfile = cmd.taskId.slice(0, split);
|
|
200
|
+
if (target && reg.resolve(target).id !== taskProfile) throw new Error('Task belongs to another profile.');
|
|
201
|
+
target = taskProfile;
|
|
202
|
+
localTaskId = cmd.taskId.slice(split + 1);
|
|
203
|
+
}
|
|
204
|
+
const discovery = ['list_profiles', 'list_tabs', 'list_groups', 'status'].includes(cmd.action);
|
|
205
|
+
if (discovery) {
|
|
206
|
+
const profiles = target ? [reg.resolve(target)] : reg.list();
|
|
207
|
+
const profileOnly = cmd.action === 'list_profiles' || cmd.action === 'status';
|
|
208
|
+
const snapshots = await Promise.all(profiles.map(async (profile) => {
|
|
209
|
+
const [status, listing] = await Promise.allSettled([
|
|
210
|
+
call({ id: `${cmd.id}-${profile.id}-status`, action: 'status', target: profile.id }),
|
|
211
|
+
profileOnly ? Promise.resolve([]) : call({ ...cmd, id: `${cmd.id}-${profile.id}`, target: profile.id }),
|
|
212
|
+
]);
|
|
213
|
+
const info = { id: profile.id, label: profile.label,
|
|
214
|
+
...(status.status === 'fulfilled' ? status.value
|
|
215
|
+
: { [profileOnly ? 'error' : 'authError']: status.reason.message }),
|
|
216
|
+
...(listing.status === 'rejected' ? { error: listing.reason.message } : {}),
|
|
217
|
+
};
|
|
218
|
+
const items = listing.status === 'fulfilled' ? listing.value : [];
|
|
219
|
+
return { profile: info, items: items.map(item => ({ ...item, profileId: profile.id, profile: profile.label,
|
|
220
|
+
target: `${profile.id}:${cmd.action === 'list_tabs' ? item.tabId : item.id}` })) };
|
|
221
|
+
}));
|
|
222
|
+
return { id: cmd.id, success: true,
|
|
223
|
+
data: cmd.action === 'list_profiles' || cmd.action === 'status' ? snapshots.map(s => s.profile) : snapshots.flatMap(s => s.items),
|
|
224
|
+
profiles: snapshots.map(s => s.profile) };
|
|
225
|
+
}
|
|
226
|
+
const profile = reg.resolve(target);
|
|
227
|
+
resolvedProfile = profile;
|
|
228
|
+
if (cmd.action === 'run') {
|
|
229
|
+
const status = await call({ id: `${cmd.id}-auth`, action: 'status', target: profile.id });
|
|
230
|
+
if (status.cliProtocolVersion !== CLI_PROTOCOL_VERSION) throw new Error(`Update Dassi in profile "${profile.label}" to use task commands.`);
|
|
231
|
+
if (!status.authenticated) throw new Error(`Sign in to Dassi in profile "${profile.label}" (${profile.id}), then retry.`);
|
|
232
|
+
}
|
|
233
|
+
const data = await call({ ...cmd, target: profile.id, ...(localTaskId ? { taskId: localTaskId } : {}) });
|
|
234
|
+
return { id: cmd.id, success: true, data: data?.taskId
|
|
235
|
+
? { ...data, taskId: `${profile.id}~${data.taskId}`, profileId: profile.id, profile: profile.label } : data };
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (cmd.action === 'tool_exec' && error.outcome === 'unknown') {
|
|
238
|
+
const uncertain = unknownToolOutcome(error);
|
|
239
|
+
return { id: cmd.id, success: false, error: uncertain.message, outcome: uncertain.outcome };
|
|
240
|
+
}
|
|
241
|
+
const taskId = error.taskId && resolvedProfile ? `${resolvedProfile.id}~${error.taskId}` : undefined;
|
|
242
|
+
const existingTaskId = error.existingTaskId && resolvedProfile ? `${resolvedProfile.id}~${error.existingTaskId}` : undefined;
|
|
243
|
+
const checkId = taskId ?? existingTaskId;
|
|
244
|
+
return { id: cmd.id, success: false, ...(taskId ? { taskId } : {}), ...(existingTaskId ? { existingTaskId } : {}), error: `${error.message}${checkId ? ` Check: dassi status '${checkId}'` : ''}` };
|
|
276
245
|
}
|
|
277
246
|
}
|
|
278
247
|
|
|
@@ -298,7 +267,6 @@ function initDaemonProcess(session) {
|
|
|
298
267
|
cleanupDaemonFiles(session);
|
|
299
268
|
const pidFile = getPidFile(session);
|
|
300
269
|
fs.writeFileSync(pidFile, String(process.pid), { mode: 0o600 });
|
|
301
|
-
// Reason: see refreshReadyFileFromStatusResult — chmod normalizes pre-existing files.
|
|
302
270
|
try { fs.chmodSync(pidFile, 0o600); } catch { /* best-effort */ }
|
|
303
271
|
|
|
304
272
|
const shutdown = () => {
|
|
@@ -312,12 +280,12 @@ function initDaemonProcess(session) {
|
|
|
312
280
|
}
|
|
313
281
|
|
|
314
282
|
/**
|
|
315
|
-
* Creates
|
|
316
|
-
* @param {
|
|
283
|
+
* Creates a Unix socket server that dispatches independent NDJSON commands.
|
|
284
|
+
* @param {(cmd: Record<string, unknown>) => Promise<unknown>} dispatch
|
|
317
285
|
* @param {string} socketPath
|
|
318
286
|
* @returns {net.Server}
|
|
319
287
|
*/
|
|
320
|
-
function createSocketServer(
|
|
288
|
+
function createSocketServer(dispatch, socketPath) {
|
|
321
289
|
const server = net.createServer((socket) => {
|
|
322
290
|
let buffer = '';
|
|
323
291
|
|
|
@@ -337,11 +305,11 @@ function createSocketServer(queue, socketPath) {
|
|
|
337
305
|
continue;
|
|
338
306
|
}
|
|
339
307
|
|
|
340
|
-
|
|
308
|
+
dispatch(cmd)
|
|
341
309
|
.then((response) => {
|
|
342
310
|
if (!socket.destroyed) socket.write(JSON.stringify(response) + '\n');
|
|
343
311
|
})
|
|
344
|
-
.catch((err) => console.error('[dassi-daemon]
|
|
312
|
+
.catch((err) => console.error('[dassi-daemon] dispatch error:', err));
|
|
345
313
|
}
|
|
346
314
|
});
|
|
347
315
|
|
|
@@ -379,49 +347,34 @@ function startIdleShutdown(server, session, getLastCommandAt) {
|
|
|
379
347
|
// ─── Daemon entry point ───────────────────────────────────────────────────────
|
|
380
348
|
|
|
381
349
|
/**
|
|
382
|
-
*
|
|
383
|
-
* the extension to register, runs onboarding checks, starts the Unix socket
|
|
384
|
-
* server for CLI commands, and sets up idle shutdown.
|
|
350
|
+
* Starts the two local transports and idle shutdown. Browser tasks outlive this process.
|
|
385
351
|
* @returns {Promise<void>}
|
|
386
352
|
*/
|
|
387
353
|
export async function startDaemon() {
|
|
388
|
-
const session = validateSession(process.env.DASSI_SESSION ?? 'default');
|
|
389
354
|
const { socketPath, readyFile } = initDaemonProcess(session);
|
|
390
355
|
|
|
391
|
-
let statusData;
|
|
392
356
|
try {
|
|
393
|
-
|
|
394
|
-
} catch {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
fs.writeFileSync(readyFile, JSON.stringify({ status: 'extension_not_installed' }), { mode: 0o600 });
|
|
398
|
-
try { fs.chmodSync(readyFile, 0o600); } catch { /* best-effort */ }
|
|
357
|
+
await startExtensionServer();
|
|
358
|
+
} catch (error) {
|
|
359
|
+
fs.writeFileSync(readyFile, JSON.stringify({ status: 'error', error: error.code === 'EADDRINUSE'
|
|
360
|
+
? `Bridge port ${BRIDGE_PORT} is already in use. Use the existing daemon; --session does not select a browser.` : error.message }), { mode: 0o600 });
|
|
399
361
|
process.exit(1);
|
|
400
362
|
}
|
|
401
|
-
|
|
402
363
|
let lastCommandAt = Date.now();
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
// registry and intentionally skip extension dispatch + ready-file refresh.
|
|
407
|
-
const local = handleDaemonLocalCommand(cmd, registry);
|
|
408
|
-
if (local) return local;
|
|
364
|
+
let inFlight = 0;
|
|
365
|
+
const server = createSocketServer(async (cmd) => {
|
|
366
|
+
inFlight++;
|
|
409
367
|
try {
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
return { id: String(cmd.id ?? 'unknown'), success: false, error: err.message };
|
|
368
|
+
// Chrome reconnects every five seconds after an idle daemon exits.
|
|
369
|
+
const connectDeadline = Date.now() + 6000;
|
|
370
|
+
while (!registry.size() && Date.now() < connectDeadline) await sleep(100);
|
|
371
|
+
return await routeCommand(cmd);
|
|
415
372
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
fs.writeFileSync(readyFile, JSON.stringify(buildReadyPayload(statusData)), { mode: 0o600 });
|
|
422
|
-
try { fs.chmodSync(readyFile, 0o600); } catch { /* best-effort */ }
|
|
423
|
-
|
|
424
|
-
startIdleShutdown(server, session, () => lastCommandAt);
|
|
373
|
+
finally { inFlight--; lastCommandAt = Date.now(); }
|
|
374
|
+
}, socketPath);
|
|
375
|
+
await new Promise(resolve => server.listening ? resolve() : server.once('listening', resolve));
|
|
376
|
+
fs.writeFileSync(readyFile, JSON.stringify({ status: 'ok', protocolVersion: CLI_PROTOCOL_VERSION }), { mode: 0o600 });
|
|
377
|
+
startIdleShutdown(server, session, () => inFlight ? Date.now() : lastCommandAt);
|
|
425
378
|
}
|
|
426
379
|
|
|
427
380
|
// ── Entry point ───────────────────────────────────────────────────────────────
|