@dassi_ai/cli 0.1.2 → 0.2.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/README.md CHANGED
@@ -47,6 +47,13 @@ dassi list-tabs --json
47
47
  # Use a named session
48
48
  dassi list-tabs --session work
49
49
 
50
+ # List connected profiles (multiple Chrome profiles can connect at once)
51
+ dassi list-profiles
52
+
53
+ # Target a specific profile by label when more than one is connected
54
+ dassi list-tabs --profile dev
55
+ dassi run "summarize" --tab 456 --profile dev # alias: --label
56
+
50
57
  # Show version / help
51
58
  dassi --version
52
59
  dassi --help
package/dassi-daemon.mjs CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  cleanupDaemonFiles,
25
25
  buildReadyPayload,
26
26
  createCommandQueue,
27
+ createConnectionRegistry,
27
28
  } from './dassi-shared.mjs';
28
29
 
29
30
  // ─── Constants ────────────────────────────────────────────────────────────────
@@ -31,12 +32,9 @@ import {
31
32
  const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
32
33
  const IDLE_CHECK_INTERVAL_MS = 5 * 60 * 1000;
33
34
 
34
- // ─── Module-level extension connection state ──────────────────────────────────
35
-
36
- /** Active WebSocket connection to the registered Dassi extension. */
37
- let extensionSocket = null;
38
- /** Pending response callbacks keyed by request ID. */
39
- const pendingExtResponses = new Map();
35
+ // Reason: a registry of connected extensions keyed by profile label replaces
36
+ // the old single-socket model so multiple Chrome profiles can connect at once.
37
+ const registry = createConnectionRegistry();
40
38
 
41
39
  // ─── WebSocket loader helper ──────────────────────────────────────────────────
42
40
 
@@ -62,17 +60,16 @@ async function loadWebSocket() {
62
60
  // ─── Extension response routing ──────────────────────────────────────────────
63
61
 
64
62
  /**
65
- * Sets up the persistent message handler on the extension WebSocket that routes
66
- * responses back to callers waiting in pendingExtResponses.
67
- * @param {import('ws').WebSocket} ws
63
+ * Route responses from one extension connection back to its pending callers.
64
+ * @param {{ ws: any; pending: Map<string, any> }} entry
68
65
  */
69
- function setupResponseRouter(ws) {
70
- ws.on('message', (responseData) => {
66
+ function setupResponseRouter(entry) {
67
+ entry.ws.on('message', (responseData) => {
71
68
  let resp;
72
69
  try { resp = JSON.parse(responseData.toString()); } catch { return; }
73
- const pending = pendingExtResponses.get(String(resp.id));
70
+ const pending = entry.pending.get(String(resp.id));
74
71
  if (!pending) return;
75
- pendingExtResponses.delete(String(resp.id));
72
+ entry.pending.delete(String(resp.id));
76
73
  if (resp.error) {
77
74
  pending.reject(new Error(resp.error.message ?? String(resp.error)));
78
75
  } else {
@@ -82,38 +79,37 @@ function setupResponseRouter(ws) {
82
79
  }
83
80
 
84
81
  /**
85
- * Reject all pending extension responses with a disconnection error.
86
- * Called when the extension WebSocket closes so callers don't hang forever.
82
+ * Reject all pending responses for one connection (called when it closes).
83
+ * @param {{ pending: Map<string, any> }} entry
87
84
  */
88
- function drainPendingResponses() {
89
- for (const [id, pending] of pendingExtResponses) {
85
+ function drainPendingResponses(entry) {
86
+ for (const [id, pending] of entry.pending) {
90
87
  pending.reject(new Error('Extension disconnected'));
91
- pendingExtResponses.delete(id);
88
+ entry.pending.delete(id);
92
89
  }
93
90
  }
94
91
 
95
92
  // ─── Extension server ─────────────────────────────────────────────────────────
96
93
 
97
94
  /**
98
- * Accepts a newly registered extension WebSocket: replaces any existing socket,
99
- * drains pending responses from the old connection, and wires up the new one.
100
- * @param {import('ws').WebSocket} ws - The new WebSocket connection.
95
+ * Register a newly connected extension socket into the registry and wire up
96
+ * its response router + close handler.
97
+ * @param {any} ws
98
+ * @param {{ label: string | null; installId: string }} identity
99
+ * @param {number} port - The port this daemon session listens on.
100
+ * @returns {object} The registry entry.
101
101
  */
102
- function acceptExtensionSocket(ws) {
103
- // Reason: if an old socket exists, close it cleanly before replacing
104
- if (extensionSocket && extensionSocket !== ws) {
105
- try { extensionSocket.close(); } catch { /* already closed */ }
106
- }
107
- drainPendingResponses();
108
- extensionSocket = ws;
102
+ function acceptExtensionSocket(ws, identity, port) {
103
+ const key = registry.add({ ws, label: identity.label, installId: identity.installId, port });
104
+ const entry = registry.getByWs(ws);
109
105
  ws.on('close', () => {
110
- if (extensionSocket === ws) {
111
- extensionSocket = null;
112
- drainPendingResponses();
113
- console.log('[dassi-daemon] Extension disconnected, waiting for reconnect…');
114
- }
106
+ const removed = registry.remove(ws);
107
+ drainPendingResponses(entry);
108
+ if (removed) console.log(`[dassi-daemon] Extension "${removed.label}" disconnected`);
115
109
  });
116
- setupResponseRouter(ws);
110
+ setupResponseRouter(entry);
111
+ console.log(`[dassi-daemon] Extension registered as "${key}"`);
112
+ return entry;
117
113
  }
118
114
 
119
115
  /**
@@ -123,39 +119,37 @@ function acceptExtensionSocket(ws) {
123
119
  * @param {import('ws').WebSocket} ws - The connected WebSocket.
124
120
  * @param {Buffer} data - Raw message data.
125
121
  * @param {{ resolved: boolean; timeout: ReturnType<typeof setTimeout> }} ctx - Shared state.
126
- * @param {import('ws').WebSocketServer} wss - The server (closed on fatal error).
127
122
  * @param {(value: unknown) => void} resolve - Promise resolve callback.
128
123
  * @param {(reason: Error) => void} reject - Promise reject callback.
124
+ * @param {number} port - The port the WS server is bound to.
129
125
  */
130
- async function handleRegistration(ws, data, ctx, wss, resolve, reject) {
126
+ async function handleRegistration(ws, data, ctx, resolve, reject, port) {
131
127
  let msg;
132
128
  try { msg = JSON.parse(data.toString()); } catch { return; }
133
129
  if (msg.type !== 'register' || msg.client !== 'dassi-extension') return;
134
130
 
135
- // Reason: only accept a new registration when the current socket is dead.
136
- // This prevents a rogue local process from hijacking the extension socket
137
- // while a healthy connection exists.
138
- if (extensionSocket && extensionSocket.readyState === 1 /* OPEN */) {
139
- console.log('[dassi-daemon] Rejected registration — existing socket still healthy');
140
- ws.close();
141
- return;
142
- }
143
-
144
- acceptExtensionSocket(ws);
131
+ const entry = acceptExtensionSocket(
132
+ ws,
133
+ { label: typeof msg.label === 'string' ? msg.label : null, installId: String(msg.installId ?? '') },
134
+ port,
135
+ );
145
136
 
146
137
  if (!ctx.resolved) {
147
138
  clearTimeout(ctx.timeout);
148
139
  try {
149
- const status = await dispatchToExtension({ id: 'init', action: 'status' });
140
+ // Reason: route the startup status check to the connection that just registered.
141
+ const status = await dispatchToExtension({ id: 'init', action: 'status', target: entry.label });
150
142
  ctx.resolved = true;
151
143
  resolve(status);
152
144
  } catch (err) {
153
- // Reason: don't close WSS extension may reconnect and retry
145
+ // Reason: leave ctx.resolved false so a reconnecting extension can retry
146
+ // the startup status check rather than failing the daemon permanently.
154
147
  console.error('[dassi-daemon] Initial status check failed:', err.message);
155
148
  reject(err);
156
149
  }
157
150
  } else {
158
- console.log('[dassi-daemon] Extension reconnected');
151
+ // Reason: daemon already started — this is a reconnect or an additional profile.
152
+ console.log('[dassi-daemon] Extension registered (daemon already started)');
159
153
  }
160
154
  }
161
155
 
@@ -199,7 +193,7 @@ async function startExtensionServer() {
199
193
  });
200
194
 
201
195
  wss.on('connection', (ws) => {
202
- ws.once('message', (data) => handleRegistration(ws, data, ctx, wss, resolve, reject));
196
+ ws.once('message', (data) => handleRegistration(ws, data, ctx, resolve, reject, BRIDGE_PORT));
203
197
  });
204
198
  });
205
199
  }
@@ -210,34 +204,51 @@ async function startExtensionServer() {
210
204
  * Dispatches a single command to the Dassi extension via the persistent WS
211
205
  * connection, translating from the daemon's internal format to the extension's
212
206
  * JSON-RPC-style protocol.
213
- * @param {Record<string, unknown>} cmd - Command object with {id, action, ...params}
207
+ * @param {Record<string, unknown>} cmd - Command object with {id, action, target?, ...params}
214
208
  * @returns {Promise<unknown>} The extension's result value (not wrapped in success/data).
215
209
  */
216
210
  async function dispatchToExtension(cmd) {
217
- if (!extensionSocket || extensionSocket.readyState !== 1 /* OPEN */) {
211
+ const { id, action, target = null, ...params } = cmd;
212
+ const entry = registry.resolve(target); // throws with a helpful message if ambiguous/unknown/none
213
+ if (!entry.ws || entry.ws.readyState !== 1 /* OPEN */) {
218
214
  throw new Error('Extension not connected');
219
215
  }
220
216
 
221
- const { id, action, ...params } = cmd;
222
- // Reason: the extension expects JSON-RPC style {id, method, params}, not {id, action, ...}
217
+ // Reason: the extension expects JSON-RPC {id, method, params}; `target` is a
218
+ // daemon-only routing field and must not leak into the extension params.
223
219
  const message = { id, method: action ?? String(id), params };
224
- extensionSocket.send(JSON.stringify(message));
220
+ entry.ws.send(JSON.stringify(message));
225
221
 
226
222
  const timeoutMs = typeof params.timeoutMs === 'number' ? params.timeoutMs + 10_000 : 65_000;
227
223
 
228
224
  return new Promise((resolve, reject) => {
229
225
  const timer = setTimeout(() => {
230
- pendingExtResponses.delete(String(id));
226
+ entry.pending.delete(String(id));
231
227
  reject(new Error(`Extension timeout after ${timeoutMs}ms`));
232
228
  }, timeoutMs);
233
229
 
234
- pendingExtResponses.set(String(id), {
230
+ entry.pending.set(String(id), {
235
231
  resolve: (result) => { clearTimeout(timer); resolve(result); },
236
232
  reject: (err) => { clearTimeout(timer); reject(err); },
237
233
  });
238
234
  });
239
235
  }
240
236
 
237
+ /**
238
+ * Handle commands the daemon answers itself (no extension round-trip).
239
+ * Returns a response object, or null if the command should be dispatched to
240
+ * an extension instead.
241
+ * @param {Record<string, unknown>} cmd
242
+ * @param {ReturnType<typeof createConnectionRegistry>} reg
243
+ * @returns {object | null}
244
+ */
245
+ export function handleDaemonLocalCommand(cmd, reg) {
246
+ if (cmd?.action === 'list_profiles') {
247
+ return { id: String(cmd.id ?? 'unknown'), success: true, data: reg.list() };
248
+ }
249
+ return null;
250
+ }
251
+
241
252
  /**
242
253
  * Refresh the ready file when the latest dispatched command was a `status` query.
243
254
  *
@@ -395,6 +406,10 @@ export async function startDaemon() {
395
406
  let lastCommandAt = Date.now();
396
407
  const queue = createCommandQueue(async (cmd) => {
397
408
  lastCommandAt = Date.now();
409
+ // Reason: daemon-local commands (e.g. list_profiles) are answered from the
410
+ // registry and intentionally skip extension dispatch + ready-file refresh.
411
+ const local = handleDaemonLocalCommand(cmd, registry);
412
+ if (local) return local;
398
413
  try {
399
414
  const result = await dispatchToExtension(cmd);
400
415
  refreshReadyFileFromStatusResult(readyFile, cmd, result);
package/dassi-shared.mjs CHANGED
@@ -180,3 +180,123 @@ export function createCommandQueue(handler) {
180
180
  },
181
181
  };
182
182
  }
183
+
184
+ // ─── Connection registry ──────────────────────────────────────────────────────
185
+
186
+ /**
187
+ * Creates a registry of connected extension sockets, keyed by a unique label.
188
+ * Replaces the daemon's old single-socket model so multiple Chrome profiles can
189
+ * connect at once and commands can be routed to a chosen one by label.
190
+ *
191
+ * @returns {{
192
+ * add: (conn: { ws: any; label: string | null; installId: string; port: number }) => string;
193
+ * remove: (ws: any) => ({ label: string; installId: string } | null);
194
+ * getByWs: (ws: any) => (object | null);
195
+ * resolve: (target: string | null) => object;
196
+ * list: () => Array<{ label: string; port: number }>;
197
+ * size: () => number;
198
+ * }}
199
+ */
200
+ export function createConnectionRegistry() {
201
+ /** @type {Map<string, { ws: any; label: string; installId: string; port: number; pending: Map<string, any> }>} */
202
+ const conns = new Map();
203
+
204
+ // Reason: label is the routing key; if two profiles share a label, suffix the
205
+ // installId so a connection never silently fails to register (suffix > reject).
206
+ function uniqueKey(label) {
207
+ if (!conns.has(label)) return label;
208
+ let i = 2;
209
+ while (conns.has(`${label}#${i}`)) i++;
210
+ return `${label}#${i}`;
211
+ }
212
+
213
+ function labels() {
214
+ return [...conns.values()].map((c) => c.label);
215
+ }
216
+
217
+ return {
218
+ /**
219
+ * Register a new extension connection. Returns the unique key (label) assigned.
220
+ * Duplicate labels are disambiguated by appending a numeric suffix.
221
+ * @param {{ ws: any; label: string | null; installId: string; port: number }} conn
222
+ * @returns {string}
223
+ */
224
+ add({ ws, label, installId, port }) {
225
+ // Reason: on reconnect (port/label change) the new TCP connection can register
226
+ // before the daemon observes the old socket's close. Evict any stale entry for
227
+ // the same install first, so we don't end up with phantom duplicates (dev +
228
+ // dev#2) that make resolve(null) wrongly report "multiple profiles".
229
+ if (installId) {
230
+ for (const [k, c] of conns) {
231
+ if (c.installId === installId) { conns.delete(k); break; }
232
+ }
233
+ }
234
+ // Reason: identity fallback chain — label, then installId, then port — so a
235
+ // connection is always addressable even if a legacy build reports neither.
236
+ const base =
237
+ (label && String(label).trim()) || (installId && String(installId).trim()) || String(port);
238
+ const key = uniqueKey(base);
239
+ conns.set(key, { ws, label: key, installId, port, pending: new Map() });
240
+ return key;
241
+ },
242
+
243
+ /**
244
+ * Remove the connection associated with the given WebSocket.
245
+ * @param {any} ws
246
+ * @returns {{ label: string; installId: string } | null}
247
+ */
248
+ remove(ws) {
249
+ for (const [key, c] of conns) {
250
+ if (c.ws === ws) {
251
+ conns.delete(key);
252
+ return { label: c.label, installId: c.installId };
253
+ }
254
+ }
255
+ return null;
256
+ },
257
+
258
+ /**
259
+ * Look up the registry entry for a given WebSocket (includes its pending map).
260
+ * @param {any} ws
261
+ * @returns {object | null}
262
+ */
263
+ getByWs(ws) {
264
+ for (const c of conns.values()) if (c.ws === ws) return c;
265
+ return null;
266
+ },
267
+
268
+ /**
269
+ * Resolve a target label to its registry entry.
270
+ * Passing null auto-resolves when exactly one connection is registered.
271
+ * Throws descriptive errors for no connections, ambiguity, or unknown label.
272
+ * @param {string | null} target
273
+ * @returns {object}
274
+ */
275
+ resolve(target) {
276
+ if (conns.size === 0) throw new Error('No extension connected');
277
+ if (target == null) {
278
+ if (conns.size === 1) return [...conns.values()][0];
279
+ throw new Error(`Multiple profiles connected (${labels().join(', ')}). Specify --profile <label>.`);
280
+ }
281
+ const entry = conns.get(String(target));
282
+ if (!entry) throw new Error(`Unknown profile "${target}". Connected: ${labels().join(', ')}`);
283
+ return entry;
284
+ },
285
+
286
+ /**
287
+ * Returns a shallow list of all connected profiles (label + port only).
288
+ * @returns {Array<{ label: string; port: number }>}
289
+ */
290
+ list() {
291
+ return [...conns.values()].map((c) => ({ label: c.label, port: c.port }));
292
+ },
293
+
294
+ /**
295
+ * Returns the number of currently connected profiles.
296
+ * @returns {number}
297
+ */
298
+ size() {
299
+ return conns.size;
300
+ },
301
+ };
302
+ }
package/dassi.mjs CHANGED
@@ -37,7 +37,7 @@ const CHROME_WEB_STORE_URL = 'https://chromewebstore.google.com/detail/dassi-ai-
37
37
  * agent commands (run, list-tabs, status, raw), and top-level flags (--version, --help).
38
38
  * Run `dassi --help` for the full command reference.
39
39
  * @param {string[]} argv process.argv.slice(2)
40
- * @returns {{ action: string; params: Record<string, unknown>; session: string; json: boolean }}
40
+ * @returns {{ action: string; params: Record<string, unknown>; session: string; json: boolean; profile: string | null }}
41
41
  */
42
42
  export function parseCliArgs(argv) {
43
43
  const args = [...argv];
@@ -45,30 +45,39 @@ export function parseCliArgs(argv) {
45
45
  // Reason: handle --version and --help before any flag parsing so they work
46
46
  // even when other flags like --session are incomplete (e.g. `dassi --version --session`)
47
47
  if (consumeFlag(args, '--version')) {
48
- return { action: 'version', params: {}, session: 'default', json: false };
48
+ return { action: 'version', params: {}, session: 'default', json: false, profile: null };
49
49
  }
50
50
  if (consumeFlag(args, '--help')) {
51
- return { action: 'help', params: {}, session: 'default', json: false };
51
+ return { action: 'help', params: {}, session: 'default', json: false, profile: null };
52
52
  }
53
53
 
54
54
  const session = validateSession(getFlag(args, '--session') ?? process.env.DASSI_SESSION ?? 'default');
55
55
  const json = consumeFlag(args, '--json');
56
+ const profile = getFlag(args, '--profile') ?? getFlag(args, '--label') ?? null;
57
+
58
+ // Reason: centralise the return shape so every command branch includes profile
59
+ // without editing each return individually
60
+ const finish = (action, params) => ({ action, params, session, json, profile });
56
61
 
57
62
  const command = args.shift();
58
63
  if (!command) throw new Error('No command specified. Run: dassi --help');
59
64
 
60
65
  if (command === 'list-tabs') {
61
66
  const all = consumeFlag(args, '--all');
62
- return { action: 'list_tabs', params: all ? { all: true } : {}, session, json };
67
+ return finish('list_tabs', all ? { all: true } : {});
63
68
  }
64
69
 
65
70
  if (command === 'list-groups') {
66
71
  const all = consumeFlag(args, '--all');
67
- return { action: 'list_groups', params: all ? { all: true } : {}, session, json };
72
+ return finish('list_groups', all ? { all: true } : {});
68
73
  }
69
74
 
70
75
  if (command === 'status') {
71
- return { action: 'status', params: {}, session, json };
76
+ return finish('status', {});
77
+ }
78
+
79
+ if (command === 'list-profiles') {
80
+ return finish('list_profiles', {});
72
81
  }
73
82
 
74
83
  if (command === 'run') {
@@ -78,12 +87,12 @@ export function parseCliArgs(argv) {
78
87
  const timeoutRaw = getFlag(args, '--timeout');
79
88
  const timeoutMs = timeoutRaw ? parseStrictInt(timeoutRaw, '--timeout') : undefined;
80
89
  const base = { prompt, ...(timeoutMs !== undefined ? { timeoutMs } : {}) };
81
- return { action: 'run', params: { ...base, ...target }, session, json };
90
+ return finish('run', { ...base, ...target });
82
91
  }
83
92
 
84
93
  if (command === 'bug-report') {
85
94
  const output = getFlag(args, '-o') ?? getFlag(args, '--output');
86
- return { action: 'export_logs', params: { output }, session, json };
95
+ return finish('export_logs', { output });
87
96
  }
88
97
 
89
98
  if (command === 'panel-screenshot') {
@@ -95,7 +104,7 @@ export function parseCliArgs(argv) {
95
104
  const heightRaw = getFlag(args, '--height');
96
105
  const width = widthRaw ? parseStrictInt(widthRaw, '--width') : undefined;
97
106
  const height = heightRaw ? parseStrictInt(heightRaw, '--height') : undefined;
98
- return { action: 'panel_screenshot', params: { tabId, ...(output ? { _output: output } : {}), ...(width ? { width } : {}), ...(height ? { height } : {}) }, session, json };
107
+ return finish('panel_screenshot', { tabId, ...(output ? { _output: output } : {}), ...(width ? { width } : {}), ...(height ? { height } : {}) });
99
108
  }
100
109
 
101
110
  if (command === 'raw') {
@@ -104,14 +113,14 @@ export function parseCliArgs(argv) {
104
113
  const cmd = JSON.parse(rawArg);
105
114
  // Reason: store action='raw' as a sentinel so run() can send the full cmd envelope
106
115
  // verbatim, letting the user control every field (including action) without reconstruction
107
- return { action: 'raw', params: cmd, session, json };
116
+ return finish('raw', cmd);
108
117
  }
109
118
 
110
119
  // Reason: Tool commands are extracted to tool-commands.mjs to keep parseCliArgs
111
120
  // and dassi.mjs within CLAUDE.md size limits (40 lines / 500 lines).
112
121
  const toolParams = parseToolCommand(command, args, getFlag, consumeFlag);
113
122
  if (toolParams) {
114
- return { action: 'tool_exec', params: toolParams, session, json };
123
+ return finish('tool_exec', toolParams);
115
124
  }
116
125
 
117
126
  throw new Error(`Unknown command: "${command}". Run: dassi --help`);
@@ -331,6 +340,7 @@ const HELP_TEXT =
331
340
  ' Run AI agent on a tab or group (group = sequential)\n' +
332
341
  ' list-tabs [--all] List tabs in groups dassi has open (--all = every Chrome tab)\n' +
333
342
  ' list-groups [--all] List dassi-open tab groups (--all = every Chrome group)\n' +
343
+ ' list-profiles List connected profiles (Chrome instances)\n' +
334
344
  ' status Check extension status\n' +
335
345
  ' bug-report [-o file] Export debug logs from all contexts\n' +
336
346
  ' raw <json> Send raw JSON command\n\n' +
@@ -339,6 +349,7 @@ const HELP_TEXT =
339
349
  ' --all list-tabs/list-groups: include every Chrome tab/group\n' +
340
350
  ' --timeout <ms> Timeout for run command (default: 300000)\n' +
341
351
  ' --session <name> Daemon session name (default: "default")\n' +
352
+ ' --profile <label> Target a specific connected profile (alias: --label)\n' +
342
353
  ' --json Output raw JSON\n' +
343
354
  ' --filter <type> Filter for read-page (interactive|all)\n' +
344
355
  ' --depth <n> Depth for read-page tree\n' +
@@ -417,7 +428,7 @@ export async function run() {
417
428
  process.exit(1);
418
429
  }
419
430
 
420
- const { action, params, session, json } = parsed;
431
+ const { action, params, session, json, profile } = parsed;
421
432
  handleImmediateAction(action);
422
433
 
423
434
  const socketPath = await ensureDaemonReady(session);
@@ -429,13 +440,16 @@ export async function run() {
429
440
  (action === 'run' || action === 'tool_exec') &&
430
441
  (params.groupId !== undefined || params.groupTitle !== undefined)
431
442
  ) {
432
- const allOk = await runWithGroupExpansion(socketPath, action, params, json, sendCommand, formatResponse);
443
+ const allOk = await runWithGroupExpansion(socketPath, action, params, json, sendCommand, formatResponse, profile);
433
444
  if (!allOk) process.exit(1);
434
445
  return;
435
446
  }
436
447
 
437
- // Reason: for 'raw' the user controls the full envelope — send params verbatim
438
- const command = action === 'raw' ? { id, ...params } : { id, action, ...params };
448
+ // Reason: for 'raw' the user controls the full envelope — send params verbatim (no target injection).
449
+ // For all other commands, include target only when --profile/--label was given so back-compat is exact.
450
+ const command = action === 'raw'
451
+ ? { id, ...params }
452
+ : { id, action, ...params, ...(profile ? { target: profile } : {}) };
439
453
  const response = await sendCommand(socketPath, command);
440
454
 
441
455
  console.log(formatResponse(action, response, json, params));
@@ -95,6 +95,15 @@ function formatExportLogs(response, params) {
95
95
  }
96
96
  }
97
97
 
98
+ /** @param {{ success: boolean; data?: unknown }} response */
99
+ function formatListProfiles(response) {
100
+ const rows = /** @type {Array<{label:string;port:number}>} */ (response.data ?? []);
101
+ if (rows.length === 0) return '(no profiles connected)';
102
+ const header = 'PROFILE PORT';
103
+ const body = rows.map((r) => `${String(r.label).padEnd(32)} ${r.port}`);
104
+ return [header, ...body].join('\n');
105
+ }
106
+
98
107
  /** @param {{ success: boolean; data?: unknown }} response */
99
108
  function formatStatus(response) {
100
109
  const d = /** @type {{ authenticated?: boolean; email?: string | null }} */ (response.data ?? {});
@@ -114,6 +123,7 @@ const FORMATTERS = {
114
123
  panel_screenshot: (r, p, a) => formatToolExec(r, p, a),
115
124
  export_logs: (r, p, _a) => formatExportLogs(r, p),
116
125
  status: (r, _p, _a) => formatStatus(r),
126
+ list_profiles: (r, _p, _a) => formatListProfiles(r),
117
127
  };
118
128
 
119
129
  /**
@@ -39,16 +39,20 @@ export function uniquifyOutputForTab(output, tabId) {
39
39
  * @param {string} socketPath Daemon Unix socket
40
40
  * @param {{ groupId?: number; groupTitle?: string }} ref
41
41
  * @param {(socketPath: string, command: object) => Promise<{success:boolean,data?:any,error?:string}>} sendFn - send function (required, injectable for tests)
42
+ * @param {string | null} [target] - Profile label to route the resolving queries to (when --profile is set).
42
43
  * @returns {Promise<number[]>} Member tab IDs (order: ascending)
43
44
  */
44
- export async function expandGroupToTabIds(socketPath, ref, sendFn) {
45
+ export async function expandGroupToTabIds(socketPath, ref, sendFn, target = null) {
46
+ // Reason: route the group-resolving queries to the same profile the command
47
+ // targets, so resolution isn't ambiguous when multiple profiles are connected.
48
+ const targetField = target ? { target } : {};
45
49
  // Reason: pass all:true (flat, NOT nested under `params`) so --group /
46
50
  // --group-title can resolve against any Chrome tab group, not just groups
47
51
  // dassi currently has open. The daemon's dispatchToExtension does
48
52
  // `{ id, action, ...rest } = cmd` and forwards `rest` as JSON-RPC params,
49
53
  // so nesting under `params` would arrive at the extension as
50
54
  // `params.params.all` — and the filter would NOT bypass.
51
- const groupsResp = await sendFn(socketPath, { id: `cli_lg_${Date.now()}`, action: 'list_groups', all: true });
55
+ const groupsResp = await sendFn(socketPath, { id: `cli_lg_${Date.now()}`, action: 'list_groups', all: true, ...targetField });
52
56
  if (!groupsResp.success) throw new Error(`Failed to list groups: ${groupsResp.error ?? 'unknown'}`);
53
57
  const groups = /** @type {Array<{id:number;title:string;windowId:number}>} */ (groupsResp.data ?? []);
54
58
 
@@ -70,7 +74,7 @@ export async function expandGroupToTabIds(socketPath, ref, sendFn) {
70
74
  throw new Error('expandGroupToTabIds: pass groupId or groupTitle');
71
75
  }
72
76
 
73
- const tabsResp = await sendFn(socketPath, { id: `cli_lt_${Date.now()}`, action: 'list_tabs', all: true });
77
+ const tabsResp = await sendFn(socketPath, { id: `cli_lt_${Date.now()}`, action: 'list_tabs', all: true, ...targetField });
74
78
  if (!tabsResp.success) throw new Error(`Failed to list tabs: ${tabsResp.error ?? 'unknown'}`);
75
79
  const tabs = /** @type {Array<{tabId:number;groupId:number}>} */ (tabsResp.data ?? []);
76
80
  const memberIds = tabs.filter((t) => t.groupId === groupId).map((t) => t.tabId);
@@ -100,14 +104,16 @@ export function formatGroupJsonResults(results) {
100
104
  * @param {number} tabId
101
105
  * @param {boolean} multiTab True when >1 tab in the expansion (triggers output uniquification)
102
106
  * @param {(socketPath: string, command: object) => Promise<{success:boolean,data?:any,error?:string}>} sendFn
107
+ * @param {string | null} [target] - Profile label to route the child command to (when --profile is set).
103
108
  * @returns {Promise<{tabId: number, response: {success:boolean,data?:any,error?:string}, childParams: object}>}
104
109
  */
105
- async function dispatchChildForTab(socketPath, action, childBase, tabId, multiTab, sendFn) {
110
+ async function dispatchChildForTab(socketPath, action, childBase, tabId, multiTab, sendFn, target = null) {
106
111
  // Reason: uniquify _output per tab when fanning out so each file write targets a distinct path
107
112
  const perTabOutput =
108
113
  multiTab && childBase._output ? uniquifyOutputForTab(childBase._output, tabId) : childBase._output;
109
114
  const childParams = { ...childBase, tabId, ...(perTabOutput !== childBase._output ? { _output: perTabOutput } : {}) };
110
- const response = await sendFn(socketPath, { id: `cli_${Date.now()}_${tabId}`, action, ...childParams });
115
+ // Reason: include target only when set so the fan-out routes to the chosen profile.
116
+ const response = await sendFn(socketPath, { id: `cli_${Date.now()}_${tabId}`, action, ...childParams, ...(target ? { target } : {}) });
111
117
  return { tabId, response, childParams };
112
118
  }
113
119
 
@@ -128,13 +134,15 @@ async function dispatchChildForTab(socketPath, action, childBase, tabId, multiTa
128
134
  * @param {boolean} json
129
135
  * @param {(socketPath: string, command: object) => Promise<{success:boolean,data?:any,error?:string}>} sendFn
130
136
  * @param {(action: string, response: object, json: boolean, params: object) => string} formatFn
137
+ * @param {string | null} [target] - Profile label to route every query/child command to (when --profile is set).
131
138
  * @returns {Promise<boolean>} true if all child calls succeeded, false if any failed
132
139
  */
133
- export async function runWithGroupExpansion(socketPath, action, params, json, sendFn, formatFn) {
140
+ export async function runWithGroupExpansion(socketPath, action, params, json, sendFn, formatFn, target = null) {
134
141
  const tabIds = await expandGroupToTabIds(
135
142
  socketPath,
136
143
  { groupId: params.groupId, groupTitle: params.groupTitle },
137
144
  sendFn,
145
+ target,
138
146
  );
139
147
  // Reason: always on stderr so it doesn't pollute JSON output on stdout
140
148
  console.error(`Running on ${tabIds.length} tab${tabIds.length === 1 ? '' : 's'}: ${tabIds.join(', ')}`);
@@ -146,7 +154,7 @@ export async function runWithGroupExpansion(socketPath, action, params, json, se
146
154
  let allOk = true;
147
155
 
148
156
  for (const tabId of tabIds) {
149
- const { response, childParams } = await dispatchChildForTab(socketPath, action, childBase, tabId, multiTab, sendFn);
157
+ const { response, childParams } = await dispatchChildForTab(socketPath, action, childBase, tabId, multiTab, sendFn, target);
150
158
  if (!response.success) allOk = false;
151
159
  if (json) {
152
160
  // Reason: collect for a single JSON array emission after the loop
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dassi_ai/cli",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "CLI for the Dassi Chrome extension — run browser automation from the terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,6 +37,11 @@
37
37
  "scripts": {
38
38
  "test": "vitest run"
39
39
  },
40
- "keywords": ["dassi", "cli", "browser-automation", "chrome-extension"],
40
+ "keywords": [
41
+ "dassi",
42
+ "cli",
43
+ "browser-automation",
44
+ "chrome-extension"
45
+ ],
41
46
  "license": "MIT"
42
47
  }