@dassi_ai/cli 0.1.2 → 0.3.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,25 @@ 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
+
57
+ # Launch a fresh Chrome with a locally-built dev dist (for testing extension changes)
58
+ pnpm build # build extension/dist first
59
+ dassi launch # loads extension/dist as profile "dev"
60
+ dassi launch --label qa --dist some/dist # custom label + dist
61
+ # Drive the launched Chrome — run still needs a tab/group target; --profile selects which Chrome:
62
+ dassi list-tabs --profile dev # find a tab id in the launched profile
63
+ dassi run "summarize this page" --tab <id> --profile dev
64
+ dassi launch --stop # close the "dev" Chrome (or --stop-all)
65
+ # Note: launch reuses the default daemon (port 18790). To launch on an isolated
66
+ # port (DASSI_BRIDGE_PORT=18791 dassi launch), no default daemon may be running —
67
+ # it errors clearly otherwise, since it can't confirm the running daemon's port.
68
+
50
69
  # Show version / help
51
70
  dassi --version
52
71
  dassi --help
package/dassi-daemon.mjs CHANGED
@@ -24,6 +24,8 @@ import {
24
24
  cleanupDaemonFiles,
25
25
  buildReadyPayload,
26
26
  createCommandQueue,
27
+ createConnectionRegistry,
28
+ getDaemonBridgePort,
27
29
  } from './dassi-shared.mjs';
28
30
 
29
31
  // ─── Constants ────────────────────────────────────────────────────────────────
@@ -31,12 +33,9 @@ import {
31
33
  const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
32
34
  const IDLE_CHECK_INTERVAL_MS = 5 * 60 * 1000;
33
35
 
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();
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
+ const registry = createConnectionRegistry();
40
39
 
41
40
  // ─── WebSocket loader helper ──────────────────────────────────────────────────
42
41
 
@@ -62,17 +61,16 @@ async function loadWebSocket() {
62
61
  // ─── Extension response routing ──────────────────────────────────────────────
63
62
 
64
63
  /**
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
64
+ * Route responses from one extension connection back to its pending callers.
65
+ * @param {{ ws: any; pending: Map<string, any> }} entry
68
66
  */
69
- function setupResponseRouter(ws) {
70
- ws.on('message', (responseData) => {
67
+ function setupResponseRouter(entry) {
68
+ entry.ws.on('message', (responseData) => {
71
69
  let resp;
72
70
  try { resp = JSON.parse(responseData.toString()); } catch { return; }
73
- const pending = pendingExtResponses.get(String(resp.id));
71
+ const pending = entry.pending.get(String(resp.id));
74
72
  if (!pending) return;
75
- pendingExtResponses.delete(String(resp.id));
73
+ entry.pending.delete(String(resp.id));
76
74
  if (resp.error) {
77
75
  pending.reject(new Error(resp.error.message ?? String(resp.error)));
78
76
  } else {
@@ -82,38 +80,37 @@ function setupResponseRouter(ws) {
82
80
  }
83
81
 
84
82
  /**
85
- * Reject all pending extension responses with a disconnection error.
86
- * Called when the extension WebSocket closes so callers don't hang forever.
83
+ * Reject all pending responses for one connection (called when it closes).
84
+ * @param {{ pending: Map<string, any> }} entry
87
85
  */
88
- function drainPendingResponses() {
89
- for (const [id, pending] of pendingExtResponses) {
86
+ function drainPendingResponses(entry) {
87
+ for (const [id, pending] of entry.pending) {
90
88
  pending.reject(new Error('Extension disconnected'));
91
- pendingExtResponses.delete(id);
89
+ entry.pending.delete(id);
92
90
  }
93
91
  }
94
92
 
95
93
  // ─── Extension server ─────────────────────────────────────────────────────────
96
94
 
97
95
  /**
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.
96
+ * Register a newly connected extension socket into the registry and wire up
97
+ * its response router + close handler.
98
+ * @param {any} ws
99
+ * @param {{ label: string | null; installId: string }} identity
100
+ * @param {number} port - The port this daemon session listens on.
101
+ * @returns {object} The registry entry.
101
102
  */
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;
103
+ function acceptExtensionSocket(ws, identity, port) {
104
+ const key = registry.add({ ws, label: identity.label, installId: identity.installId, port });
105
+ const entry = registry.getByWs(ws);
109
106
  ws.on('close', () => {
110
- if (extensionSocket === ws) {
111
- extensionSocket = null;
112
- drainPendingResponses();
113
- console.log('[dassi-daemon] Extension disconnected, waiting for reconnect…');
114
- }
107
+ const removed = registry.remove(ws);
108
+ drainPendingResponses(entry);
109
+ if (removed) console.log(`[dassi-daemon] Extension "${removed.label}" disconnected`);
115
110
  });
116
- setupResponseRouter(ws);
111
+ setupResponseRouter(entry);
112
+ console.log(`[dassi-daemon] Extension registered as "${key}"`);
113
+ return entry;
117
114
  }
118
115
 
119
116
  /**
@@ -123,53 +120,46 @@ function acceptExtensionSocket(ws) {
123
120
  * @param {import('ws').WebSocket} ws - The connected WebSocket.
124
121
  * @param {Buffer} data - Raw message data.
125
122
  * @param {{ resolved: boolean; timeout: ReturnType<typeof setTimeout> }} ctx - Shared state.
126
- * @param {import('ws').WebSocketServer} wss - The server (closed on fatal error).
127
123
  * @param {(value: unknown) => void} resolve - Promise resolve callback.
128
124
  * @param {(reason: Error) => void} reject - Promise reject callback.
125
+ * @param {number} port - The port the WS server is bound to.
129
126
  */
130
- async function handleRegistration(ws, data, ctx, wss, resolve, reject) {
127
+ async function handleRegistration(ws, data, ctx, resolve, reject, port) {
131
128
  let msg;
132
129
  try { msg = JSON.parse(data.toString()); } catch { return; }
133
130
  if (msg.type !== 'register' || msg.client !== 'dassi-extension') return;
134
131
 
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);
132
+ const entry = acceptExtensionSocket(
133
+ ws,
134
+ { label: typeof msg.label === 'string' ? msg.label : null, installId: String(msg.installId ?? '') },
135
+ port,
136
+ );
145
137
 
146
138
  if (!ctx.resolved) {
147
139
  clearTimeout(ctx.timeout);
148
140
  try {
149
- const status = await dispatchToExtension({ id: 'init', action: 'status' });
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 });
150
143
  ctx.resolved = true;
151
144
  resolve(status);
152
145
  } catch (err) {
153
- // Reason: don't close WSS extension may reconnect and retry
146
+ // Reason: leave ctx.resolved false so a reconnecting extension can retry
147
+ // the startup status check rather than failing the daemon permanently.
154
148
  console.error('[dassi-daemon] Initial status check failed:', err.message);
155
149
  reject(err);
156
150
  }
157
151
  } else {
158
- console.log('[dassi-daemon] Extension reconnected');
152
+ // Reason: daemon already started — this is a reconnect or an additional profile.
153
+ console.log('[dassi-daemon] Extension registered (daemon already started)');
159
154
  }
160
155
  }
161
156
 
162
- // Reason: Port is configurable via DASSI_BRIDGE_PORT so the benchmark harness
163
- // can run a daemon that doesn't conflict with the developer's real Chrome
164
- // daemon. Defaults to 18790 the production port that the prod extension
165
- // SW connects to.
166
- // Validate so an empty/garbage env var (DASSI_BRIDGE_PORT="") doesn't yield NaN,
167
- // which `new WSServer({ port: NaN })` would silently accept and bind to a random
168
- // ephemeral port — leaving the extension's known-port connect attempt hanging.
169
- const _rawPort = parseInt(process.env.DASSI_BRIDGE_PORT ?? '', 10);
170
- const BRIDGE_PORT = Number.isInteger(_rawPort) && _rawPort > 0 && _rawPort <= 65535
171
- ? _rawPort
172
- : 18790;
157
+ // Reason: Port is configurable via DASSI_BRIDGE_PORT (single source of truth in
158
+ // getDaemonBridgePort also seeded into launched extensions by `dassi launch`).
159
+ // Defaults to 18790, the production port. The validation guards against an
160
+ // empty/garbage env var yielding NaN, which `new WSServer({ port: NaN })` would
161
+ // silently accept and bind to a random ephemeral port.
162
+ const BRIDGE_PORT = getDaemonBridgePort();
173
163
 
174
164
  /**
175
165
  * Starts a WebSocket server on the configured bridge port (default 18790).
@@ -199,7 +189,7 @@ async function startExtensionServer() {
199
189
  });
200
190
 
201
191
  wss.on('connection', (ws) => {
202
- ws.once('message', (data) => handleRegistration(ws, data, ctx, wss, resolve, reject));
192
+ ws.once('message', (data) => handleRegistration(ws, data, ctx, resolve, reject, BRIDGE_PORT));
203
193
  });
204
194
  });
205
195
  }
@@ -210,34 +200,51 @@ async function startExtensionServer() {
210
200
  * Dispatches a single command to the Dassi extension via the persistent WS
211
201
  * connection, translating from the daemon's internal format to the extension's
212
202
  * JSON-RPC-style protocol.
213
- * @param {Record<string, unknown>} cmd - Command object with {id, action, ...params}
203
+ * @param {Record<string, unknown>} cmd - Command object with {id, action, target?, ...params}
214
204
  * @returns {Promise<unknown>} The extension's result value (not wrapped in success/data).
215
205
  */
216
206
  async function dispatchToExtension(cmd) {
217
- if (!extensionSocket || extensionSocket.readyState !== 1 /* OPEN */) {
207
+ const { id, action, target = null, ...params } = cmd;
208
+ const entry = registry.resolve(target); // throws with a helpful message if ambiguous/unknown/none
209
+ if (!entry.ws || entry.ws.readyState !== 1 /* OPEN */) {
218
210
  throw new Error('Extension not connected');
219
211
  }
220
212
 
221
- const { id, action, ...params } = cmd;
222
- // Reason: the extension expects JSON-RPC style {id, method, params}, not {id, action, ...}
213
+ // Reason: the extension expects JSON-RPC {id, method, params}; `target` is a
214
+ // daemon-only routing field and must not leak into the extension params.
223
215
  const message = { id, method: action ?? String(id), params };
224
- extensionSocket.send(JSON.stringify(message));
216
+ entry.ws.send(JSON.stringify(message));
225
217
 
226
218
  const timeoutMs = typeof params.timeoutMs === 'number' ? params.timeoutMs + 10_000 : 65_000;
227
219
 
228
220
  return new Promise((resolve, reject) => {
229
221
  const timer = setTimeout(() => {
230
- pendingExtResponses.delete(String(id));
222
+ entry.pending.delete(String(id));
231
223
  reject(new Error(`Extension timeout after ${timeoutMs}ms`));
232
224
  }, timeoutMs);
233
225
 
234
- pendingExtResponses.set(String(id), {
226
+ entry.pending.set(String(id), {
235
227
  resolve: (result) => { clearTimeout(timer); resolve(result); },
236
228
  reject: (err) => { clearTimeout(timer); reject(err); },
237
229
  });
238
230
  });
239
231
  }
240
232
 
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
+
241
248
  /**
242
249
  * Refresh the ready file when the latest dispatched command was a `status` query.
243
250
  *
@@ -395,6 +402,10 @@ export async function startDaemon() {
395
402
  let lastCommandAt = Date.now();
396
403
  const queue = createCommandQueue(async (cmd) => {
397
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;
398
409
  try {
399
410
  const result = await dispatchToExtension(cmd);
400
411
  refreshReadyFileFromStatusResult(readyFile, cmd, result);
package/dassi-shared.mjs CHANGED
@@ -11,6 +11,15 @@ import * as fs from 'fs';
11
11
  import * as path from 'path';
12
12
  import * as os from 'os';
13
13
 
14
+ // ─── Extension identity ───────────────────────────────────────────────────────
15
+
16
+ /**
17
+ * Stable Dassi extension ID. The extension manifest ships a fixed `key`, so the
18
+ * unpacked dev build gets the SAME id as the Web Store build regardless of load
19
+ * path — letting `dassi launch` open the extension's options page directly.
20
+ */
21
+ export const DASSI_EXTENSION_ID = 'bjcngahpcjeililljmfegmlanlpgibdi';
22
+
14
23
  // ─── Path helpers ─────────────────────────────────────────────────────────────
15
24
 
16
25
  /**
@@ -53,6 +62,31 @@ export function getReadyFile(session) {
53
62
  return path.join(getAppDir(), `${session}.ready`);
54
63
  }
55
64
 
65
+ /**
66
+ * Resolve the bridge (WebSocket) port the daemon listens on. Configurable via
67
+ * DASSI_BRIDGE_PORT (e.g. the benchmark harness uses 18791 to isolate from a
68
+ * developer's real Chrome on 18790); falls back to 18790 on empty/invalid input.
69
+ * Single source of truth shared by the daemon (which binds it) and `dassi launch`
70
+ * (which seeds it into the launched extension so it connects to the same port).
71
+ * @param {Record<string, string|undefined>} [env]
72
+ * @returns {number} A valid TCP port (1–65535).
73
+ */
74
+ export const DEFAULT_BRIDGE_PORT = 18790;
75
+
76
+ export function getDaemonBridgePort(env = process.env) {
77
+ const p = parseInt(env.DASSI_BRIDGE_PORT ?? '', 10);
78
+ return Number.isInteger(p) && p > 0 && p <= 65535 ? p : DEFAULT_BRIDGE_PORT;
79
+ }
80
+
81
+ /**
82
+ * Path to the JSON file tracking Chrome instances started by `dassi launch`,
83
+ * so `dassi launch --stop` can find and kill them.
84
+ * @returns {string} Absolute path to launches.json under the app dir.
85
+ */
86
+ export function getLaunchesFile() {
87
+ return path.join(getAppDir(), 'launches.json');
88
+ }
89
+
56
90
  // ─── Session validation ───────────────────────────────────────────────────────
57
91
 
58
92
  /**
@@ -180,3 +214,123 @@ export function createCommandQueue(handler) {
180
214
  },
181
215
  };
182
216
  }
217
+
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
+ export function createConnectionRegistry() {
235
+ /** @type {Map<string, { ws: any; label: string; installId: string; port: number; pending: Map<string, any> }>} */
236
+ 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
+ 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
+ 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
+ }
267
+ }
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;
275
+ },
276
+
277
+ /**
278
+ * Remove the connection associated with the given WebSocket.
279
+ * @param {any} ws
280
+ * @returns {{ label: string; installId: string } | null}
281
+ */
282
+ 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;
300
+ },
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
+ */
309
+ resolve(target) {
310
+ if (conns.size === 0) throw new Error('No extension connected');
311
+ if (target == null) {
312
+ if (conns.size === 1) return [...conns.values()][0];
313
+ throw new Error(`Multiple profiles connected (${labels().join(', ')}). Specify --profile <label>.`);
314
+ }
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;
334
+ },
335
+ };
336
+ }
package/dassi.mjs CHANGED
@@ -15,10 +15,15 @@ import {
15
15
  isDaemonRunning,
16
16
  parseReadyPayload,
17
17
  validateSession,
18
+ DASSI_EXTENSION_ID,
19
+ getAppDir,
20
+ getLaunchesFile,
18
21
  } from './dassi-shared.mjs';
22
+ import { handleLaunch, handleStop } from './launch.mjs';
19
23
  import { parseToolCommand, requireTarget, parseStrictInt } from './tool-commands.mjs';
20
24
  import { runWithGroupExpansion } from './group-expansion.mjs';
21
25
  import { formatResponse } from './format-response.mjs';
26
+ import { HELP_TEXT } from './help-text.mjs';
22
27
 
23
28
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
29
  const DAEMON_SCRIPT = path.join(__dirname, 'dassi-daemon.mjs');
@@ -27,7 +32,7 @@ const READY_POLL_MS = 100;
27
32
  const READY_TIMEOUT_MS = 30_000;
28
33
  const LOGIN_POLL_MS = 2_000;
29
34
  const LOGIN_TIMEOUT_MS = 5 * 60_000;
30
- const CHROME_WEB_STORE_URL = 'https://chromewebstore.google.com/detail/dassi-ai-browser-agent-fo/bjcngahpcjeililljmfegmlanlpgibdi';
35
+ const CHROME_WEB_STORE_URL = `https://chromewebstore.google.com/detail/dassi-ai-browser-agent-fo/${DASSI_EXTENSION_ID}`;
31
36
 
32
37
  // ─── Arg parsing ──────────────────────────────────────────────────────────────
33
38
 
@@ -37,7 +42,7 @@ const CHROME_WEB_STORE_URL = 'https://chromewebstore.google.com/detail/dassi-ai-
37
42
  * agent commands (run, list-tabs, status, raw), and top-level flags (--version, --help).
38
43
  * Run `dassi --help` for the full command reference.
39
44
  * @param {string[]} argv process.argv.slice(2)
40
- * @returns {{ action: string; params: Record<string, unknown>; session: string; json: boolean }}
45
+ * @returns {{ action: string; params: Record<string, unknown>; session: string; json: boolean; profile: string | null }}
41
46
  */
42
47
  export function parseCliArgs(argv) {
43
48
  const args = [...argv];
@@ -45,30 +50,71 @@ export function parseCliArgs(argv) {
45
50
  // Reason: handle --version and --help before any flag parsing so they work
46
51
  // even when other flags like --session are incomplete (e.g. `dassi --version --session`)
47
52
  if (consumeFlag(args, '--version')) {
48
- return { action: 'version', params: {}, session: 'default', json: false };
53
+ return { action: 'version', params: {}, session: 'default', json: false, profile: null };
49
54
  }
50
55
  if (consumeFlag(args, '--help')) {
51
- return { action: 'help', params: {}, session: 'default', json: false };
56
+ return { action: 'help', params: {}, session: 'default', json: false, profile: null };
52
57
  }
53
58
 
54
59
  const session = validateSession(getFlag(args, '--session') ?? process.env.DASSI_SESSION ?? 'default');
55
60
  const json = consumeFlag(args, '--json');
61
+ const profile = getFlag(args, '--profile') ?? getFlag(args, '--label') ?? null;
62
+
63
+ // Reason: centralise the return shape so every command branch includes profile
64
+ // without editing each return individually
65
+ const finish = (action, params) => ({ action, params, session, json, profile });
56
66
 
57
67
  const command = args.shift();
58
68
  if (!command) throw new Error('No command specified. Run: dassi --help');
59
69
 
60
70
  if (command === 'list-tabs') {
61
71
  const all = consumeFlag(args, '--all');
62
- return { action: 'list_tabs', params: all ? { all: true } : {}, session, json };
72
+ return finish('list_tabs', all ? { all: true } : {});
63
73
  }
64
74
 
65
75
  if (command === 'list-groups') {
66
76
  const all = consumeFlag(args, '--all');
67
- return { action: 'list_groups', params: all ? { all: true } : {}, session, json };
77
+ return finish('list_groups', all ? { all: true } : {});
68
78
  }
69
79
 
70
80
  if (command === 'status') {
71
- return { action: 'status', params: {}, session, json };
81
+ return finish('status', {});
82
+ }
83
+
84
+ if (command === 'list-profiles') {
85
+ return finish('list_profiles', {});
86
+ }
87
+
88
+ if (command === 'launch') {
89
+ if (consumeFlag(args, '--stop-all')) return finish('launch_stop', { all: true });
90
+ const stopIdx = args.indexOf('--stop');
91
+ if (stopIdx !== -1) {
92
+ args.splice(stopIdx, 1);
93
+ // Reason: resolve the stop label like the start path does — a positional
94
+ // token after --stop wins, else the global --label/--profile (parsed into
95
+ // `profile` before this branch), else the default. Keeps `--stop --label qa`
96
+ // symmetric with `launch --label qa` instead of silently targeting "dev".
97
+ const positional = args[0] && !args[0].startsWith('--') ? args.shift() : null;
98
+ return finish('launch_stop', { label: positional ?? profile ?? 'dev', all: false });
99
+ }
100
+ const timeoutRaw = getFlag(args, '--timeout');
101
+ // Reason: --label is consumed early as a global flag (aliased to --profile).
102
+ // The launch branch reads it from `profile` as the canonical source; any
103
+ // remaining --label token in args is a second occurrence and takes precedence.
104
+ const launchLabel = getFlag(args, '--label') ?? profile ?? 'dev';
105
+ // Reason: the label becomes a path segment (per-label profile dir) + the seed URL,
106
+ // so restrict to a path-safe token (blocks "x/../../.ssh" traversal) within the
107
+ // extension's 64-char cap — same character set as validateSession.
108
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(launchLabel)) {
109
+ throw new Error('--label must be 1–64 characters of letters, digits, hyphen, or underscore.');
110
+ }
111
+ return finish('launch', {
112
+ label: launchLabel,
113
+ dist: getFlag(args, '--dist') ?? null,
114
+ chrome: getFlag(args, '--chrome') ?? null,
115
+ profileDir: getFlag(args, '--profile-dir') ?? null,
116
+ timeoutMs: timeoutRaw ? parseStrictInt(timeoutRaw, '--timeout') : 30000,
117
+ });
72
118
  }
73
119
 
74
120
  if (command === 'run') {
@@ -78,12 +124,12 @@ export function parseCliArgs(argv) {
78
124
  const timeoutRaw = getFlag(args, '--timeout');
79
125
  const timeoutMs = timeoutRaw ? parseStrictInt(timeoutRaw, '--timeout') : undefined;
80
126
  const base = { prompt, ...(timeoutMs !== undefined ? { timeoutMs } : {}) };
81
- return { action: 'run', params: { ...base, ...target }, session, json };
127
+ return finish('run', { ...base, ...target });
82
128
  }
83
129
 
84
130
  if (command === 'bug-report') {
85
131
  const output = getFlag(args, '-o') ?? getFlag(args, '--output');
86
- return { action: 'export_logs', params: { output }, session, json };
132
+ return finish('export_logs', { output });
87
133
  }
88
134
 
89
135
  if (command === 'panel-screenshot') {
@@ -95,7 +141,7 @@ export function parseCliArgs(argv) {
95
141
  const heightRaw = getFlag(args, '--height');
96
142
  const width = widthRaw ? parseStrictInt(widthRaw, '--width') : undefined;
97
143
  const height = heightRaw ? parseStrictInt(heightRaw, '--height') : undefined;
98
- return { action: 'panel_screenshot', params: { tabId, ...(output ? { _output: output } : {}), ...(width ? { width } : {}), ...(height ? { height } : {}) }, session, json };
144
+ return finish('panel_screenshot', { tabId, ...(output ? { _output: output } : {}), ...(width ? { width } : {}), ...(height ? { height } : {}) });
99
145
  }
100
146
 
101
147
  if (command === 'raw') {
@@ -104,14 +150,14 @@ export function parseCliArgs(argv) {
104
150
  const cmd = JSON.parse(rawArg);
105
151
  // Reason: store action='raw' as a sentinel so run() can send the full cmd envelope
106
152
  // verbatim, letting the user control every field (including action) without reconstruction
107
- return { action: 'raw', params: cmd, session, json };
153
+ return finish('raw', cmd);
108
154
  }
109
155
 
110
156
  // Reason: Tool commands are extracted to tool-commands.mjs to keep parseCliArgs
111
157
  // and dassi.mjs within CLAUDE.md size limits (40 lines / 500 lines).
112
158
  const toolParams = parseToolCommand(command, args, getFlag, consumeFlag);
113
159
  if (toolParams) {
114
- return { action: 'tool_exec', params: toolParams, session, json };
160
+ return finish('tool_exec', toolParams);
115
161
  }
116
162
 
117
163
  throw new Error(`Unknown command: "${command}". Run: dassi --help`);
@@ -310,42 +356,7 @@ export async function waitForLogin(socketPath, optionsUrl) {
310
356
 
311
357
  // ─── Immediate actions ────────────────────────────────────────────────────────
312
358
 
313
- const HELP_TEXT =
314
- 'Usage: dassi [options] <command>\n\n' +
315
- 'Browser commands (each accepts --tab <id> | --group <id> | --group-title <name>):\n' +
316
- ' navigate <url> --tab <id> Navigate to URL\n' +
317
- ' click <ref> --tab <id> Click element by ref\n' +
318
- ' fill <ref> <text> --tab <id> Fill input with text (instant)\n' +
319
- ' type <ref> <text> --tab <id> Type text with keyboard events\n' +
320
- ' read-page --tab <id> Read page accessibility tree\n' +
321
- ' get-text --tab <id> Extract page text\n' +
322
- ' screenshot --tab <id> [-o file] Capture viewport screenshot\n' +
323
- ' panel-screenshot --tab <id> [-o f] [--width W] [--height H]\n' +
324
- ' Capture side panel UI (default 360x800)\n' +
325
- ' eval <code> --tab <id> Execute JavaScript\n' +
326
- ' tabs --tab <id> List tabs in same group\n' +
327
- ' open [url] --tab <id> Open new tab in group\n' +
328
- ' close --tab <id> Close tab\n\n' +
329
- 'Agent commands:\n' +
330
- ' run <prompt> --tab <id> | --group <id> | --group-title <name>\n' +
331
- ' Run AI agent on a tab or group (group = sequential)\n' +
332
- ' list-tabs [--all] List tabs in groups dassi has open (--all = every Chrome tab)\n' +
333
- ' list-groups [--all] List dassi-open tab groups (--all = every Chrome group)\n' +
334
- ' status Check extension status\n' +
335
- ' bug-report [-o file] Export debug logs from all contexts\n' +
336
- ' raw <json> Send raw JSON command\n\n' +
337
- 'Options:\n' +
338
- ' --tab <id> Chrome tab ID (use list-tabs to find)\n' +
339
- ' --all list-tabs/list-groups: include every Chrome tab/group\n' +
340
- ' --timeout <ms> Timeout for run command (default: 300000)\n' +
341
- ' --session <name> Daemon session name (default: "default")\n' +
342
- ' --json Output raw JSON\n' +
343
- ' --filter <type> Filter for read-page (interactive|all)\n' +
344
- ' --depth <n> Depth for read-page tree\n' +
345
- ' --await Await promise in eval\n' +
346
- ' -o, --output <f> Output file for screenshot\n' +
347
- ' --version Show version\n' +
348
- ' --help Show help';
359
+ // HELP_TEXT lives in ./help-text.mjs (extracted to keep this file within the size limit).
349
360
 
350
361
  /**
351
362
  * Handles --version and --help, which don't need a daemon. Returns true if handled.
@@ -417,9 +428,18 @@ 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
 
434
+ if (action === 'launch') {
435
+ await handleLaunch(params, { spawn: child_process.spawn, ensureDaemonRunning, getSocketPath, sendCommand, appDir: getAppDir(), launchesFile: getLaunchesFile() });
436
+ return;
437
+ }
438
+ if (action === 'launch_stop') {
439
+ handleStop(params, { launchesFile: getLaunchesFile() });
440
+ return;
441
+ }
442
+
423
443
  const socketPath = await ensureDaemonReady(session);
424
444
 
425
445
  const id = `cli_${Date.now()}`;
@@ -429,13 +449,16 @@ export async function run() {
429
449
  (action === 'run' || action === 'tool_exec') &&
430
450
  (params.groupId !== undefined || params.groupTitle !== undefined)
431
451
  ) {
432
- const allOk = await runWithGroupExpansion(socketPath, action, params, json, sendCommand, formatResponse);
452
+ const allOk = await runWithGroupExpansion(socketPath, action, params, json, sendCommand, formatResponse, profile);
433
453
  if (!allOk) process.exit(1);
434
454
  return;
435
455
  }
436
456
 
437
- // Reason: for 'raw' the user controls the full envelope — send params verbatim
438
- const command = action === 'raw' ? { id, ...params } : { id, action, ...params };
457
+ // Reason: for 'raw' the user controls the full envelope — send params verbatim (no target injection).
458
+ // For all other commands, include target only when --profile/--label was given so back-compat is exact.
459
+ const command = action === 'raw'
460
+ ? { id, ...params }
461
+ : { id, action, ...params, ...(profile ? { target: profile } : {}) };
439
462
  const response = await sendCommand(socketPath, command);
440
463
 
441
464
  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/help-text.mjs ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * `dassi --help` output. Extracted from dassi.mjs to keep that file within the
3
+ * CLAUDE.md size limit; this is pure presentation with no runtime dependencies.
4
+ */
5
+ export const HELP_TEXT =
6
+ 'Usage: dassi [options] <command>\n\n' +
7
+ 'Browser commands (each accepts --tab <id> | --group <id> | --group-title <name>):\n' +
8
+ ' navigate <url> --tab <id> Navigate to URL\n' +
9
+ ' click <ref> --tab <id> Click element by ref\n' +
10
+ ' fill <ref> <text> --tab <id> Fill input with text (instant)\n' +
11
+ ' type <ref> <text> --tab <id> Type text with keyboard events\n' +
12
+ ' read-page --tab <id> Read page accessibility tree\n' +
13
+ ' get-text --tab <id> Extract page text\n' +
14
+ ' screenshot --tab <id> [-o file] Capture viewport screenshot\n' +
15
+ ' panel-screenshot --tab <id> [-o f] [--width W] [--height H]\n' +
16
+ ' Capture side panel UI (default 360x800)\n' +
17
+ ' eval <code> --tab <id> Execute JavaScript\n' +
18
+ ' tabs --tab <id> List tabs in same group\n' +
19
+ ' open [url] --tab <id> Open new tab in group\n' +
20
+ ' close --tab <id> Close tab\n\n' +
21
+ 'Agent commands:\n' +
22
+ ' run <prompt> --tab <id> | --group <id> | --group-title <name>\n' +
23
+ ' Run AI agent on a tab or group (group = sequential)\n' +
24
+ ' list-tabs [--all] List tabs in groups dassi has open (--all = every Chrome tab)\n' +
25
+ ' list-groups [--all] List dassi-open tab groups (--all = every Chrome group)\n' +
26
+ ' list-profiles List connected profiles (Chrome instances)\n' +
27
+ ' status Check extension status\n' +
28
+ ' bug-report [-o file] Export debug logs from all contexts\n' +
29
+ ' raw <json> Send raw JSON command\n' +
30
+ ' launch [--label <name>] [--dist <path>]\n' +
31
+ ' Open Chrome with a dev dist loaded\n' +
32
+ ' launch --stop [label] | --stop-all\n' +
33
+ ' Stop a launched Chrome\n\n' +
34
+ 'Options:\n' +
35
+ ' --tab <id> Chrome tab ID (use list-tabs to find)\n' +
36
+ ' --all list-tabs/list-groups: include every Chrome tab/group\n' +
37
+ ' --timeout <ms> Timeout for run command (default: 300000)\n' +
38
+ ' --session <name> Daemon session name (default: "default")\n' +
39
+ ' --profile <label> Target a specific connected profile (alias: --label)\n' +
40
+ ' --json Output raw JSON\n' +
41
+ ' --filter <type> Filter for read-page (interactive|all)\n' +
42
+ ' --depth <n> Depth for read-page tree\n' +
43
+ ' --await Await promise in eval\n' +
44
+ ' -o, --output <f> Output file for screenshot\n' +
45
+ ' --version Show version\n' +
46
+ ' --help Show help';
package/launch.mjs ADDED
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Pure helpers for `dassi launch` — locating Chrome, resolving the dist,
3
+ * building Chrome launch args, and tracking launched instances.
4
+ */
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
6
+ import * as path from 'path';
7
+ import { DASSI_EXTENSION_ID, getDaemonBridgePort, DEFAULT_BRIDGE_PORT, isDaemonRunning as isDaemonRunningImpl } from './dassi-shared.mjs';
8
+
9
+ /**
10
+ * Resolve the path to an installed Chrome binary.
11
+ * @param {{ platform?: string; env?: Record<string,string|undefined>; override?: string|null; exists?: (p: string) => boolean }} [opts]
12
+ * @returns {string | null} The first existing Chrome path, the override, or null.
13
+ */
14
+ export function resolveChromePath({ platform = process.platform, env = process.env, override = null, exists = existsSync } = {}) {
15
+ if (override) return override;
16
+ const candidates = [];
17
+ if (platform === 'darwin') {
18
+ candidates.push('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome');
19
+ } else if (platform === 'win32') {
20
+ const pf = env['ProgramFiles'] ?? 'C:\\Program Files';
21
+ const pf86 = env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)';
22
+ candidates.push(`${pf}\\Google\\Chrome\\Application\\chrome.exe`, `${pf86}\\Google\\Chrome\\Application\\chrome.exe`);
23
+ if (env['LOCALAPPDATA']) candidates.push(`${env['LOCALAPPDATA']}\\Google\\Chrome\\Application\\chrome.exe`);
24
+ } else {
25
+ candidates.push('/opt/google/chrome/chrome', '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser');
26
+ }
27
+ return candidates.find((p) => exists(p)) ?? null;
28
+ }
29
+
30
+ /**
31
+ * Resolve and validate the extension dist directory to load.
32
+ * @param {{ dist?: string|null; cwd?: string; exists?: (p: string) => boolean }} [opts]
33
+ * @returns {string} Absolute path to the dist directory.
34
+ * @throws if the directory has no manifest.json.
35
+ */
36
+ export function resolveDistPath({ dist = null, cwd = process.cwd(), exists = existsSync } = {}) {
37
+ const base = dist ? path.resolve(cwd, dist) : path.resolve(cwd, 'extension/dist');
38
+ if (!exists(path.join(base, 'manifest.json'))) {
39
+ throw new Error(`No built extension at ${base} (manifest.json missing). Run \`pnpm build\`, or pass --dist <path>.`);
40
+ }
41
+ return base;
42
+ }
43
+
44
+ /**
45
+ * Build the Chrome command-line args that load the dev dist into an isolated
46
+ * profile and open the options page to seed the profile label.
47
+ * @param {{ distPath: string; profileDir: string; label: string; bridgePort: number }} opts
48
+ * bridgePort: the daemon port to seed (use getDaemonBridgePort() — no default
49
+ * here, to avoid duplicating its canonical 18790).
50
+ * @returns {string[]} Chrome args (the final entry is the seed URL to open).
51
+ */
52
+ export function buildLaunchArgs({ distPath, profileDir, label, bridgePort }) {
53
+ // Reason: seed BOTH label and bridgePort — the options page persists them so the
54
+ // launched extension connects to the daemon's actual port (matters when
55
+ // DASSI_BRIDGE_PORT moves the daemon off the default 18790; without it the
56
+ // extension falls back to 18790 and the isolated-port launch times out).
57
+ const seed = `options.html?label=${encodeURIComponent(label)}&bridgePort=${bridgePort}`;
58
+ return [
59
+ `--load-extension=${distPath}`,
60
+ `--disable-extensions-except=${distPath}`,
61
+ `--user-data-dir=${profileDir}`,
62
+ '--no-first-run',
63
+ '--no-default-browser-check',
64
+ `chrome-extension://${DASSI_EXTENSION_ID}/${seed}`,
65
+ ];
66
+ }
67
+
68
+ /**
69
+ * Read the launches registry. Returns [] if the file is missing or unparseable.
70
+ * @param {string} file
71
+ * @returns {Array<{label: string; pid: number; profileDir: string; startedAt: number}>}
72
+ */
73
+ export function readLaunches(file) {
74
+ try {
75
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
76
+ return Array.isArray(parsed) ? parsed : [];
77
+ } catch {
78
+ return [];
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Record a launched Chrome, replacing any prior entry for the same label.
84
+ *
85
+ * Reason: read-modify-write is not cross-process locked. For this dev-only CLI
86
+ * that's acceptable — concurrent `dassi launch`/`--stop` of *different* labels is
87
+ * rare, and the worst case (a lost entry) is recoverable via `--stop-all` + a
88
+ * manual Chrome close. A lock is intentionally omitted (YAGNI for a dev tool).
89
+ * @param {string} file
90
+ * @param {{label: string; pid: number; profileDir: string; startedAt: number}} entry
91
+ */
92
+ export function recordLaunch(file, entry) {
93
+ const list = readLaunches(file).filter((e) => e.label !== entry.label);
94
+ list.push(entry);
95
+ // Reason: ensure the dir exists so the write can't ENOENT after Chrome is already
96
+ // spawned+connected (which would orphan it with nothing recorded to --stop).
97
+ mkdirSync(path.dirname(file), { recursive: true });
98
+ writeFileSync(file, JSON.stringify(list, null, 2), { mode: 0o600 });
99
+ }
100
+
101
+ /**
102
+ * Remove the entry for a label. Returns the removed entry, or null if absent.
103
+ * @param {string} file
104
+ * @param {string} label
105
+ * @returns {object | null}
106
+ */
107
+ export function removeLaunch(file, label) {
108
+ const list = readLaunches(file);
109
+ const found = list.find((e) => e.label === label) ?? null;
110
+ if (found) writeFileSync(file, JSON.stringify(list.filter((e) => e.label !== label), null, 2), { mode: 0o600 });
111
+ return found;
112
+ }
113
+
114
+ // ─── Launch orchestrators (exported for testability; deps-injected to avoid circular imports) ───
115
+
116
+ /**
117
+ * Poll the daemon's list_profiles until `label` registers or the timeout elapses.
118
+ * @param {{ sendCommand: Function; socketPath: string; label: string; timeoutMs: number; sleep?: (ms:number)=>Promise<void> }} deps
119
+ * @returns {Promise<boolean>} true if the label connected in time.
120
+ */
121
+ export async function waitForProfile({ sendCommand, socketPath, label, timeoutMs, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
122
+ const deadline = Date.now() + timeoutMs;
123
+ while (Date.now() < deadline) {
124
+ try {
125
+ const resp = await sendCommand(socketPath, { id: `cli_lp_${Date.now()}`, action: 'list_profiles' });
126
+ if (resp.success && Array.isArray(resp.data) && resp.data.some((p) => p.label === label)) return true;
127
+ } catch { /* daemon still coming up — keep polling */ }
128
+ await sleep(500);
129
+ }
130
+ return false;
131
+ }
132
+
133
+ /**
134
+ * One-shot check: is a profile with `label` already connected to the daemon?
135
+ * Tolerates the daemon/socket not being up yet (returns false).
136
+ * @param {{ sendCommand: Function; socketPath: string; label: string }} deps
137
+ * @returns {Promise<boolean>}
138
+ */
139
+ async function isLabelConnected({ sendCommand, socketPath, label }) {
140
+ try {
141
+ const resp = await sendCommand(socketPath, { id: `cli_pre_${Date.now()}`, action: 'list_profiles' });
142
+ return resp.success && Array.isArray(resp.data) && resp.data.some((p) => p.label === label);
143
+ } catch {
144
+ return false;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Launch Chrome (detached) with the dev dist loaded under a dedicated profile,
150
+ * wait for the extension to register under `label`, then record it.
151
+ * @param {{label:string; dist:string|null; chrome:string|null; profileDir:string|null; timeoutMs:number}} opts
152
+ * @param {{ spawn: Function; ensureDaemonRunning: Function; getSocketPath: Function; sendCommand: Function;
153
+ * appDir: string; launchesFile: string;
154
+ * log?: Function; error?: Function; exit?: Function; mkdir?: Function }} deps
155
+ */
156
+ export async function handleLaunch(opts, deps) {
157
+ const {
158
+ spawn, ensureDaemonRunning, getSocketPath, sendCommand, appDir, launchesFile,
159
+ isDaemonRunning = isDaemonRunningImpl,
160
+ log = console.log, error = console.error, exit = process.exit,
161
+ mkdir = (d) => mkdirSync(d, { recursive: true }), kill = (pid) => process.kill(pid),
162
+ } = deps;
163
+
164
+ const target = resolveLaunchTarget(opts, { error, exit, appDir, mkdir });
165
+ if (!target) return; // resolution failed (exit already called)
166
+ const { distPath, chromePath, profileDir } = target;
167
+
168
+ // Reason: `dassi launch` reuses the 'default' daemon session, but the session PID
169
+ // doesn't tell us its port. If a non-default DASSI_BRIDGE_PORT is requested while a
170
+ // default daemon is already running, we can't confirm it's on that port — fail
171
+ // clearly rather than seed a port the extension can't reach (a silent timeout).
172
+ const bridgePort = getDaemonBridgePort();
173
+ if (bridgePort !== DEFAULT_BRIDGE_PORT && isDaemonRunning('default')) {
174
+ error(`❌ DASSI_BRIDGE_PORT=${bridgePort} is set, but a daemon is already running for the default session (it may be on a different port). Stop it first, or omit DASSI_BRIDGE_PORT to use the running daemon.`);
175
+ return exit(1);
176
+ }
177
+
178
+ // Reason: only SPAWN the daemon (non-blocking). NOT ensureDaemonReady — that waits
179
+ // for the ready file the daemon writes only after an extension connects; none is yet.
180
+ ensureDaemonRunning('default');
181
+ const socketPath = getSocketPath('default');
182
+
183
+ // Reason: a same-label Chrome already running makes waitForProfile falsely succeed
184
+ // (a second launch with the same --user-data-dir hands off to the existing process).
185
+ if (await isLabelConnected({ sendCommand, socketPath, label: opts.label })) {
186
+ error(`❌ A profile labelled "${opts.label}" is already connected. Run \`dassi launch --stop ${opts.label}\` first, or use a different --label.`);
187
+ return exit(1);
188
+ }
189
+
190
+ const pid = await spawnAndConnect({ spawn, kill, sendCommand, socketPath, chromePath, distPath, profileDir, bridgePort, opts, error });
191
+ if (pid === null) return exit(1);
192
+
193
+ recordLaunch(launchesFile, { label: opts.label, pid, profileDir, startedAt: Date.now() });
194
+ log(`✅ launched "${opts.label}" (pid ${pid}) — drive it with: dassi run "…" --profile ${opts.label}`);
195
+ }
196
+
197
+ /**
198
+ * Spawn the detached Chrome and wait for `label` to register on the daemon.
199
+ * @returns {Promise<number|null>} the Chrome pid on success, or null (after
200
+ * killing any orphan + logging) on a failed spawn or connect-timeout.
201
+ */
202
+ async function spawnAndConnect({ spawn, kill, sendCommand, socketPath, chromePath, distPath, profileDir, bridgePort, opts, error }) {
203
+ // Reason: bridgePort (the daemon's actual port) is seeded so the launched extension
204
+ // connects to it — the daemon inherits DASSI_BRIDGE_PORT via ensureDaemonRunning.
205
+ const child = spawn(chromePath, buildLaunchArgs({ distPath, profileDir, label: opts.label, bridgePort }), {
206
+ detached: true,
207
+ stdio: 'ignore',
208
+ });
209
+ // Reason: a missing/invalid binary leaves pid undefined and emits 'error' async —
210
+ // swallow the event (else it throws) and fast-fail on the synchronous pid check.
211
+ child.on('error', () => {});
212
+ child.unref();
213
+ if (typeof child.pid !== 'number') {
214
+ error('❌ Failed to launch Chrome — check the Chrome path (--chrome).');
215
+ return null;
216
+ }
217
+
218
+ error(`Launching Chrome (pid ${child.pid}) with ${distPath} — waiting for "${opts.label}" to connect…`);
219
+ if (await waitForProfile({ sendCommand, socketPath, label: opts.label, timeoutMs: opts.timeoutMs })) return child.pid;
220
+
221
+ // Reason: kill the detached Chrome we started so a connect-timeout never orphans a browser.
222
+ try { kill(child.pid); } catch { /* already gone */ }
223
+ error(`❌ "${opts.label}" did not connect within ${opts.timeoutMs}ms (Chrome already using ${profileDir}, bridge disabled, or dist too old).`);
224
+ return null;
225
+ }
226
+
227
+ /**
228
+ * Resolve the dist dir, Chrome binary, and profile dir for a launch.
229
+ * Calls `exit(1)` and returns null on any failure (extracted to keep
230
+ * handleLaunch within the function-length limit).
231
+ * @param {{label:string; dist:string|null; chrome:string|null; profileDir:string|null}} opts
232
+ * @param {{ error: Function; exit: Function; appDir: string; mkdir: Function }} deps
233
+ * @returns {{distPath:string; chromePath:string; profileDir:string} | null}
234
+ */
235
+ function resolveLaunchTarget(opts, { error, exit, appDir, mkdir }) {
236
+ let distPath;
237
+ try { distPath = resolveDistPath({ dist: opts.dist }); }
238
+ catch (err) { error(`❌ ${err.message}`); exit(1); return null; }
239
+
240
+ const chromePath = resolveChromePath({ override: opts.chrome });
241
+ if (!chromePath) {
242
+ error('❌ Could not find Chrome. Pass --chrome <path> to the Chrome/Chromium binary.');
243
+ exit(1);
244
+ return null;
245
+ }
246
+
247
+ const profileDir = opts.profileDir ?? path.join(appDir, `launch-${opts.label}`);
248
+ mkdir(profileDir);
249
+ return { distPath, chromePath, profileDir };
250
+ }
251
+
252
+ /**
253
+ * Kill the Chrome instance(s) recorded by `dassi launch`.
254
+ * @param {{label?: string; all?: boolean}} opts
255
+ * @param {{ launchesFile: string; kill?: (pid:number)=>void; log?: Function }} deps
256
+ */
257
+ export function handleStop(opts, deps) {
258
+ const { launchesFile, kill = (pid) => process.kill(pid), log = console.log } = deps;
259
+ const list = readLaunches(launchesFile);
260
+ const targets = opts.all ? list : list.filter((e) => e.label === opts.label);
261
+ if (targets.length === 0) {
262
+ log(opts.all ? 'No launched Chrome instances to stop.' : `Nothing to stop for "${opts.label}".`);
263
+ return;
264
+ }
265
+ for (const e of targets) {
266
+ // Reason: guard against corrupt launches.json entries with non-number pids, which
267
+ // would cause process.kill to throw a misleading TypeError instead of a clear skip.
268
+ if (typeof e.pid !== 'number') { log(`Skipping "${e.label}" — corrupt record (no pid).`); removeLaunch(launchesFile, e.label); continue; }
269
+ try { kill(e.pid); log(`Stopped "${e.label}" (pid ${e.pid}).`); }
270
+ catch { log(`"${e.label}" (pid ${e.pid}) was already gone.`); }
271
+ removeLaunch(launchesFile, e.label);
272
+ }
273
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dassi_ai/cli",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "CLI for the Dassi Chrome extension — run browser automation from the terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,6 +13,8 @@
13
13
  "tool-commands.mjs",
14
14
  "format-response.mjs",
15
15
  "group-expansion.mjs",
16
+ "launch.mjs",
17
+ "help-text.mjs",
16
18
  ".claude-plugin/",
17
19
  "skills/",
18
20
  "README.md"
@@ -37,6 +39,11 @@
37
39
  "scripts": {
38
40
  "test": "vitest run"
39
41
  },
40
- "keywords": ["dassi", "cli", "browser-automation", "chrome-extension"],
42
+ "keywords": [
43
+ "dassi",
44
+ "cli",
45
+ "browser-automation",
46
+ "chrome-extension"
47
+ ],
41
48
  "license": "MIT"
42
49
  }