@dassi_ai/cli 0.5.0 → 0.7.0

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/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
- const entry = acceptExtensionSocket(
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
- const ctx = { resolved: false, timeout: null };
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, ctx, resolve, reject, BRIDGE_PORT));
139
+ ws.once('message', (data) => handleRegistration(ws, data, BRIDGE_PORT));
193
140
  });
194
141
  });
195
142
  }
@@ -203,76 +150,94 @@ 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 = registry.resolve(target); // throws with a helpful message if ambiguous/unknown/none
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 message = { id, method: action ?? String(id), params };
216
- entry.ws.send(JSON.stringify(message));
217
-
218
- const timeoutMs = typeof params.timeoutMs === 'number' ? params.timeoutMs + 10_000 : 65_000;
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(String(id));
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(String(id), {
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
- * Handle commands the daemon answers itself (no extension round-trip).
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
- }
247
-
248
- /**
249
- * Refresh the ready file when the latest dispatched command was a `status` query.
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;
184
+ /** Discovery is independent of authentication and in-flight agent work. */
185
+ export async function routeCommand(cmd, reg = registry, dispatch = dispatchToExtension) {
186
+ let resolvedProfile;
187
+ const call = (command) => dispatch(command, reg);
267
188
  try {
268
- fs.writeFileSync(readyFile, JSON.stringify(buildReadyPayload(result)), { mode: 0o600 });
269
- // Reason: writeFileSync's `mode` is ignored when the file already exists.
270
- // chmod explicitly so installs with a pre-hardening 0o644 ready file get
271
- // tightened. The file contains the signed-in email owner-only.
272
- try { fs.chmodSync(readyFile, 0o600); } catch { /* best-effort */ }
273
- } catch {
274
- // Reason: best-effort sync; don't fail the command if the FS write itself
275
- // races (e.g. file permission flake). The next status query will retry.
189
+ if (['run', 'task_status', 'task_stop'].includes(cmd.action) && cmd.protocolVersion !== CLI_PROTOCOL_VERSION) throw new Error('Incompatible CLI. Update Dassi CLI and restart its daemon.');
190
+ let target = cmd.target ?? null;
191
+ let localTaskId = cmd.taskId;
192
+ if (cmd.action === 'task_status' || cmd.action === 'task_stop' || (cmd.action === 'run' && cmd.taskId)) {
193
+ const split = typeof cmd.taskId === 'string' ? cmd.taskId.indexOf('~') : -1;
194
+ if (split < 1) throw new Error('Invalid task ID. Copy the ID returned by dassi run.');
195
+ const taskProfile = cmd.taskId.slice(0, split);
196
+ if (target && reg.resolve(target).id !== taskProfile) throw new Error('Task belongs to another profile.');
197
+ target = taskProfile;
198
+ localTaskId = cmd.taskId.slice(split + 1);
199
+ }
200
+ const discovery = ['list_profiles', 'list_tabs', 'list_groups', 'status'].includes(cmd.action);
201
+ if (discovery) {
202
+ const profiles = target ? [reg.resolve(target)] : reg.list();
203
+ const profileOnly = cmd.action === 'list_profiles' || cmd.action === 'status';
204
+ const snapshots = await Promise.all(profiles.map(async (profile) => {
205
+ const [status, listing] = await Promise.allSettled([
206
+ call({ id: `${cmd.id}-${profile.id}-status`, action: 'status', target: profile.id }),
207
+ profileOnly ? Promise.resolve([]) : call({ ...cmd, id: `${cmd.id}-${profile.id}`, target: profile.id }),
208
+ ]);
209
+ const info = { id: profile.id, label: profile.label,
210
+ ...(status.status === 'fulfilled' ? status.value
211
+ : { [profileOnly ? 'error' : 'authError']: status.reason.message }),
212
+ ...(listing.status === 'rejected' ? { error: listing.reason.message } : {}),
213
+ };
214
+ const items = listing.status === 'fulfilled' ? listing.value : [];
215
+ return { profile: info, items: items.map(item => ({ ...item, profileId: profile.id, profile: profile.label,
216
+ target: `${profile.id}:${cmd.action === 'list_tabs' ? item.tabId : item.id}` })) };
217
+ }));
218
+ return { id: cmd.id, success: true,
219
+ data: cmd.action === 'list_profiles' || cmd.action === 'status' ? snapshots.map(s => s.profile) : snapshots.flatMap(s => s.items),
220
+ profiles: snapshots.map(s => s.profile) };
221
+ }
222
+ const profile = reg.resolve(target);
223
+ resolvedProfile = profile;
224
+ if (cmd.action === 'run') {
225
+ const status = await call({ id: `${cmd.id}-auth`, action: 'status', target: profile.id });
226
+ if (status.cliProtocolVersion !== CLI_PROTOCOL_VERSION) throw new Error(`Update Dassi in profile "${profile.label}" to use task commands.`);
227
+ if (!status.authenticated) throw new Error(`Sign in to Dassi in profile "${profile.label}" (${profile.id}), then retry.`);
228
+ }
229
+ const data = await call({ ...cmd, target: profile.id, ...(localTaskId ? { taskId: localTaskId } : {}) });
230
+ return { id: cmd.id, success: true, data: data?.taskId
231
+ ? { ...data, taskId: `${profile.id}~${data.taskId}`, profileId: profile.id, profile: profile.label } : data };
232
+ } catch (error) {
233
+ if (cmd.action === 'tool_exec' && error.outcome === 'unknown') {
234
+ const uncertain = unknownToolOutcome(error);
235
+ return { id: cmd.id, success: false, error: uncertain.message, outcome: uncertain.outcome };
236
+ }
237
+ const taskId = error.taskId && resolvedProfile ? `${resolvedProfile.id}~${error.taskId}` : undefined;
238
+ const existingTaskId = error.existingTaskId && resolvedProfile ? `${resolvedProfile.id}~${error.existingTaskId}` : undefined;
239
+ const checkId = taskId ?? existingTaskId;
240
+ return { id: cmd.id, success: false, ...(taskId ? { taskId } : {}), ...(existingTaskId ? { existingTaskId } : {}), error: `${error.message}${checkId ? ` Check: dassi status '${checkId}'` : ''}` };
276
241
  }
277
242
  }
278
243
 
@@ -298,7 +263,6 @@ function initDaemonProcess(session) {
298
263
  cleanupDaemonFiles(session);
299
264
  const pidFile = getPidFile(session);
300
265
  fs.writeFileSync(pidFile, String(process.pid), { mode: 0o600 });
301
- // Reason: see refreshReadyFileFromStatusResult — chmod normalizes pre-existing files.
302
266
  try { fs.chmodSync(pidFile, 0o600); } catch { /* best-effort */ }
303
267
 
304
268
  const shutdown = () => {
@@ -312,12 +276,12 @@ function initDaemonProcess(session) {
312
276
  }
313
277
 
314
278
  /**
315
- * Creates and starts a Unix socket server that dispatches NDJSON commands to the queue.
316
- * @param {{ enqueue: (cmd: Record<string, unknown>) => Promise<unknown> }} queue
279
+ * Creates a Unix socket server that dispatches independent NDJSON commands.
280
+ * @param {(cmd: Record<string, unknown>) => Promise<unknown>} dispatch
317
281
  * @param {string} socketPath
318
282
  * @returns {net.Server}
319
283
  */
320
- function createSocketServer(queue, socketPath) {
284
+ function createSocketServer(dispatch, socketPath) {
321
285
  const server = net.createServer((socket) => {
322
286
  let buffer = '';
323
287
 
@@ -337,11 +301,11 @@ function createSocketServer(queue, socketPath) {
337
301
  continue;
338
302
  }
339
303
 
340
- queue.enqueue(cmd)
304
+ dispatch(cmd)
341
305
  .then((response) => {
342
306
  if (!socket.destroyed) socket.write(JSON.stringify(response) + '\n');
343
307
  })
344
- .catch((err) => console.error('[dassi-daemon] enqueue error:', err));
308
+ .catch((err) => console.error('[dassi-daemon] dispatch error:', err));
345
309
  }
346
310
  });
347
311
 
@@ -379,49 +343,35 @@ function startIdleShutdown(server, session, getLastCommandAt) {
379
343
  // ─── Daemon entry point ───────────────────────────────────────────────────────
380
344
 
381
345
  /**
382
- * Main daemon entry point. Starts the extension WebSocket server, waits for
383
- * the extension to register, runs onboarding checks, starts the Unix socket
384
- * server for CLI commands, and sets up idle shutdown.
346
+ * Starts the two local transports and idle shutdown. Browser tasks outlive this process.
385
347
  * @returns {Promise<void>}
386
348
  */
387
349
  export async function startDaemon() {
388
350
  const session = validateSession(process.env.DASSI_SESSION ?? 'default');
389
351
  const { socketPath, readyFile } = initDaemonProcess(session);
390
352
 
391
- let statusData;
392
353
  try {
393
- statusData = await startExtensionServer();
394
- } catch {
395
- // Reason: write the ready file even on failure so the CLI can read the error state,
396
- // then exit so the WSS doesn't keep the process alive as a zombie.
397
- fs.writeFileSync(readyFile, JSON.stringify({ status: 'extension_not_installed' }), { mode: 0o600 });
398
- try { fs.chmodSync(readyFile, 0o600); } catch { /* best-effort */ }
354
+ await startExtensionServer();
355
+ } catch (error) {
356
+ fs.writeFileSync(readyFile, JSON.stringify({ status: 'error', error: error.code === 'EADDRINUSE'
357
+ ? `Bridge port ${BRIDGE_PORT} is already in use. Use the existing daemon; --session does not select a browser.` : error.message }), { mode: 0o600 });
399
358
  process.exit(1);
400
359
  }
401
-
402
360
  let lastCommandAt = Date.now();
403
- const queue = createCommandQueue(async (cmd) => {
404
- lastCommandAt = Date.now();
405
- // Reason: daemon-local commands (e.g. list_profiles) are answered from the
406
- // registry and intentionally skip extension dispatch + ready-file refresh.
407
- const local = handleDaemonLocalCommand(cmd, registry);
408
- if (local) return local;
361
+ let inFlight = 0;
362
+ const server = createSocketServer(async (cmd) => {
363
+ inFlight++;
409
364
  try {
410
- const result = await dispatchToExtension(cmd);
411
- refreshReadyFileFromStatusResult(readyFile, cmd, result);
412
- return { id: String(cmd.id ?? 'unknown'), success: true, data: result };
413
- } catch (err) {
414
- return { id: String(cmd.id ?? 'unknown'), success: false, error: err.message };
365
+ // Chrome reconnects every five seconds after an idle daemon exits.
366
+ const connectDeadline = Date.now() + 6000;
367
+ while (!registry.size() && Date.now() < connectDeadline) await sleep(100);
368
+ return await routeCommand(cmd);
415
369
  }
416
- });
417
-
418
- // Reason: start the socket server before writing the ready file so the CLI
419
- // can connect immediately (e.g. for needs_login polling).
420
- const server = createSocketServer(queue, socketPath);
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);
370
+ finally { inFlight--; lastCommandAt = Date.now(); }
371
+ }, socketPath);
372
+ await new Promise(resolve => server.listening ? resolve() : server.once('listening', resolve));
373
+ fs.writeFileSync(readyFile, JSON.stringify({ status: 'ok', protocolVersion: CLI_PROTOCOL_VERSION }), { mode: 0o600 });
374
+ startIdleShutdown(server, session, () => inFlight ? Date.now() : lastCommandAt);
425
375
  }
426
376
 
427
377
  // ── Entry point ───────────────────────────────────────────────────────────────
package/dassi-shared.mjs CHANGED
@@ -1,10 +1,10 @@
1
+ export const CLI_PROTOCOL_VERSION = 3;
1
2
  /**
2
3
  * Dassi CLI Shared Utilities
3
4
  *
4
5
  * Pure helper functions shared between the CLI client (dassi.mjs) and the
5
6
  * daemon process (dassi-daemon.mjs). Includes path resolution, session
6
- * validation, daemon lifecycle helpers, ready-file payload handling, and
7
- * the serialized command queue.
7
+ * validation, daemon lifecycle helpers, readiness, and browser identity.
8
8
  */
9
9
 
10
10
  import * as fs from 'fs';
@@ -19,6 +19,7 @@ import * as os from 'os';
19
19
  * path — letting `dassi launch` open the extension's options page directly.
20
20
  */
21
21
  export const DASSI_EXTENSION_ID = 'bjcngahpcjeililljmfegmlanlpgibdi';
22
+ export const CHROME_WEB_STORE_URL = `https://chromewebstore.google.com/detail/dassi-ai-browser-agent-fo/${DASSI_EXTENSION_ID}`;
22
23
 
23
24
  // ─── Path helpers ─────────────────────────────────────────────────────────────
24
25
 
@@ -54,7 +55,7 @@ export function getPidFile(session) {
54
55
 
55
56
  /**
56
57
  * Returns the ready file path for the given session.
57
- * Written by the daemon once the extension WS is connected and auth is verified.
58
+ * Written when the local command transport is listening.
58
59
  * @param {string} session
59
60
  * @returns {string}
60
61
  */
@@ -132,205 +133,67 @@ export function isDaemonRunning(session) {
132
133
  // Reason: signal 0 checks process existence without delivering a real signal
133
134
  process.kill(pid, 0);
134
135
  return true;
135
- } catch {
136
+ } catch (error) {
137
+ if (error.code === 'EPERM') return true;
136
138
  // Process does not exist — clean up stale files
137
139
  cleanupDaemonFiles(session);
138
140
  return false;
139
141
  }
140
142
  }
141
143
 
142
- // ─── Onboarding payload helpers ───────────────────────────────────────────────
143
-
144
- /**
145
- * Builds the ready file payload from an extension status response.
146
- * @param {{ authenticated: boolean; email: string | null; optionsUrl: string }} status
147
- * @returns {{ status: string; email?: string | null; optionsUrl?: string }}
148
- */
149
- export function buildReadyPayload(status) {
150
- if (status.authenticated) {
151
- return { status: 'ok', email: status.email };
152
- }
153
- return { status: 'needs_login', optionsUrl: status.optionsUrl };
154
- }
144
+ // ─── Readiness payload helpers ───────────────────────────────────────────────
155
145
 
156
146
  /**
157
147
  * Parses the ready file content into a status object.
158
- * Returns extension_not_installed on parse failure as a safe fallback.
148
+ * Throws for incomplete writes so startup can keep polling.
159
149
  * @param {string} raw
160
150
  * @returns {{ status: string; [key: string]: unknown }}
161
151
  */
162
152
  export function parseReadyPayload(raw) {
163
- try {
164
- return JSON.parse(raw);
165
- } catch {
166
- return { status: 'extension_not_installed' };
167
- }
153
+ const payload = JSON.parse(raw);
154
+ if (!payload || typeof payload.status !== 'string') throw new Error('Invalid daemon readiness file');
155
+ return payload;
168
156
  }
169
157
 
170
- // ─── Serialized command queue ─────────────────────────────────────────────────
158
+ // ─── Connected browser installations ──────────────────────────────────────────
171
159
 
172
- /**
173
- * Creates a serialized command queue.
174
- * Commands are executed one at a time in FIFO order regardless of how many
175
- * socket clients are connected simultaneously. This prevents concurrent `run`
176
- * calls from racing inside the extension.
177
- *
178
- * @param {(cmd: Record<string, unknown>) => Promise<{id: string; success: boolean; data?: unknown; error?: string}>} handler
179
- * @returns {{ enqueue: (cmd: Record<string, unknown>) => Promise<{id: string; success: boolean; data?: unknown; error?: string}> }}
180
- */
181
- export function createCommandQueue(handler) {
182
- const queue = [];
183
- let processing = false;
184
-
185
- async function processNext() {
186
- if (processing || queue.length === 0) return;
187
- processing = true;
188
- const { cmd, resolve } = queue.shift();
189
- try {
190
- const result = await handler(cmd);
191
- resolve(result);
192
- } catch (err) {
193
- resolve({ id: String(cmd.id ?? 'unknown'), success: false, error: err.message ?? String(err) });
194
- } finally {
195
- processing = false;
196
- // Reason: .catch() prevents unhandled rejection from crashing the daemon
197
- // if the recursive call fails before reaching its own try/catch
198
- processNext().catch(() => {});
199
- }
200
- }
201
-
202
- return {
203
- /**
204
- * Enqueue a command and return a promise that resolves with the response.
205
- * The returned Promise always resolves; errors are encoded as { success: false, error } values.
206
- * @param {Record<string, unknown>} cmd
207
- * @returns {Promise<{id: string; success: boolean; data?: unknown; error?: string}>}
208
- */
209
- enqueue(cmd) {
210
- return new Promise((resolve) => {
211
- queue.push({ cmd, resolve });
212
- processNext().catch(() => {});
213
- });
214
- },
215
- };
160
+ /** A lost transport is not evidence that a browser action failed or stopped. */
161
+ export function unknownToolOutcome(error) {
162
+ return Object.assign(new Error(`${error.message} Tool outcome unknown; it may still be running. Inspect the browser before retrying.`), { outcome: 'unknown' });
216
163
  }
217
164
 
218
- // ─── Connection registry ──────────────────────────────────────────────────────
219
-
220
- /**
221
- * Creates a registry of connected extension sockets, keyed by a unique label.
222
- * Replaces the daemon's old single-socket model so multiple Chrome profiles can
223
- * connect at once and commands can be routed to a chosen one by label.
224
- *
225
- * @returns {{
226
- * add: (conn: { ws: any; label: string | null; installId: string; port: number }) => string;
227
- * remove: (ws: any) => ({ label: string; installId: string } | null);
228
- * getByWs: (ws: any) => (object | null);
229
- * resolve: (target: string | null) => object;
230
- * list: () => Array<{ label: string; port: number }>;
231
- * size: () => number;
232
- * }}
233
- */
234
165
  export function createConnectionRegistry() {
235
- /** @type {Map<string, { ws: any; label: string; installId: string; port: number; pending: Map<string, any> }>} */
236
166
  const conns = new Map();
237
-
238
- // Reason: label is the routing key; if two profiles share a label, suffix the
239
- // installId so a connection never silently fails to register (suffix > reject).
240
- function uniqueKey(label) {
241
- if (!conns.has(label)) return label;
242
- let i = 2;
243
- while (conns.has(`${label}#${i}`)) i++;
244
- return `${label}#${i}`;
245
- }
246
-
247
- function labels() {
248
- return [...conns.values()].map((c) => c.label);
249
- }
250
-
251
167
  return {
252
- /**
253
- * Register a new extension connection. Returns the unique key (label) assigned.
254
- * Duplicate labels are disambiguated by appending a numeric suffix.
255
- * @param {{ ws: any; label: string | null; installId: string; port: number }} conn
256
- * @returns {string}
257
- */
258
168
  add({ ws, label, installId, port }) {
259
- // Reason: on reconnect (port/label change) the new TCP connection can register
260
- // before the daemon observes the old socket's close. Evict any stale entry for
261
- // the same install first, so we don't end up with phantom duplicates (dev +
262
- // dev#2) that make resolve(null) wrongly report "multiple profiles".
263
- if (installId) {
264
- for (const [k, c] of conns) {
265
- if (c.installId === installId) { conns.delete(k); break; }
266
- }
169
+ const id = installId || `legacy-${port}`;
170
+ const old = conns.get(id);
171
+ if (old) {
172
+ for (const pending of old.pending.values()) pending.reject(Object.assign(new Error('Extension reconnected; check the existing task before retrying.'), { outcome: 'unknown' }));
173
+ old.ws.close();
267
174
  }
268
- // Reason: identity fallback chain label, then installId, then port so a
269
- // connection is always addressable even if a legacy build reports neither.
270
- const base =
271
- (label && String(label).trim()) || (installId && String(installId).trim()) || String(port);
272
- const key = uniqueKey(base);
273
- conns.set(key, { ws, label: key, installId, port, pending: new Map() });
274
- return key;
175
+ conns.set(id, { id, installId, label: label?.trim() || id, port, ws, pending: new Map() });
176
+ return id;
275
177
  },
276
-
277
- /**
278
- * Remove the connection associated with the given WebSocket.
279
- * @param {any} ws
280
- * @returns {{ label: string; installId: string } | null}
281
- */
282
178
  remove(ws) {
283
- for (const [key, c] of conns) {
284
- if (c.ws === ws) {
285
- conns.delete(key);
286
- return { label: c.label, installId: c.installId };
287
- }
288
- }
289
- return null;
290
- },
291
-
292
- /**
293
- * Look up the registry entry for a given WebSocket (includes its pending map).
294
- * @param {any} ws
295
- * @returns {object | null}
296
- */
297
- getByWs(ws) {
298
- for (const c of conns.values()) if (c.ws === ws) return c;
299
- return null;
179
+ const entry = this.getByWs(ws);
180
+ if (entry) conns.delete(entry.id);
181
+ return entry ?? null;
300
182
  },
301
-
302
- /**
303
- * Resolve a target label to its registry entry.
304
- * Passing null auto-resolves when exactly one connection is registered.
305
- * Throws descriptive errors for no connections, ambiguity, or unknown label.
306
- * @param {string | null} target
307
- * @returns {object}
308
- */
183
+ getByWs(ws) { return [...conns.values()].find((entry) => entry.ws === ws) ?? null; },
309
184
  resolve(target) {
310
- if (conns.size === 0) throw new Error('No extension connected');
185
+ if (!conns.size) throw new Error('No extension connected. Open Dassi in Chrome, then retry.');
311
186
  if (target == null) {
312
187
  if (conns.size === 1) return [...conns.values()][0];
313
- throw new Error(`Multiple profiles connected (${labels().join(', ')}). Specify --profile <label>.`);
188
+ throw new Error(`Multiple profiles connected (${[...conns.values()].map(e => e.label).join(', ')}). Copy a target from dassi list-tabs or use --profile <id>.`);
314
189
  }
315
- const entry = conns.get(String(target));
316
- if (!entry) throw new Error(`Unknown profile "${target}". Connected: ${labels().join(', ')}`);
317
- return entry;
318
- },
319
-
320
- /**
321
- * Returns a shallow list of all connected profiles (label + port only).
322
- * @returns {Array<{ label: string; port: number }>}
323
- */
324
- list() {
325
- return [...conns.values()].map((c) => ({ label: c.label, port: c.port }));
326
- },
327
-
328
- /**
329
- * Returns the number of currently connected profiles.
330
- * @returns {number}
331
- */
332
- size() {
333
- return conns.size;
190
+ if (conns.has(String(target))) return conns.get(String(target));
191
+ const matches = [...conns.values()].filter(entry => entry.label === target);
192
+ if (matches.length === 1) return matches[0];
193
+ if (matches.length > 1) throw new Error(`Profile name "${target}" is ambiguous. Use a profile ID from dassi list-profiles.`);
194
+ throw new Error(`Unknown profile "${target}". Connected: ${[...conns.values()].map(e => `${e.label} (${e.id})`).join(', ')}`);
334
195
  },
196
+ list() { return [...conns.values()].map(({ id, label, port }) => ({ id, label, port })); },
197
+ size() { return conns.size; },
335
198
  };
336
199
  }