@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/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,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 = 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
- }
184
+ /** Discovery is independent of authentication and in-flight agent work. */
185
+ const session = validateSession(process.env.DASSI_SESSION ?? 'default');
247
186
 
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;
187
+ export async function routeCommand(cmd, reg = registry, dispatch = dispatchToExtension) {
188
+ let resolvedProfile;
189
+ const call = (command) => dispatch(command, reg);
267
190
  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.
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 and starts a Unix socket server that dispatches NDJSON commands to the queue.
316
- * @param {{ enqueue: (cmd: Record<string, unknown>) => Promise<unknown> }} queue
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(queue, socketPath) {
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
- queue.enqueue(cmd)
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] enqueue error:', err));
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
- * 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.
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
- 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 */ }
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
- 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;
364
+ let inFlight = 0;
365
+ const server = createSocketServer(async (cmd) => {
366
+ inFlight++;
409
367
  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 };
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
- // 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);
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 ───────────────────────────────────────────────────────────────