@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 +7 -0
- package/dassi-daemon.mjs +72 -57
- package/dassi-shared.mjs +120 -0
- package/dassi.mjs +29 -15
- package/format-response.mjs +10 -0
- package/group-expansion.mjs +15 -7
- package/package.json +7 -2
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
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
*
|
|
66
|
-
*
|
|
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(
|
|
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 =
|
|
70
|
+
const pending = entry.pending.get(String(resp.id));
|
|
74
71
|
if (!pending) return;
|
|
75
|
-
|
|
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
|
|
86
|
-
*
|
|
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
|
|
85
|
+
function drainPendingResponses(entry) {
|
|
86
|
+
for (const [id, pending] of entry.pending) {
|
|
90
87
|
pending.reject(new Error('Extension disconnected'));
|
|
91
|
-
|
|
88
|
+
entry.pending.delete(id);
|
|
92
89
|
}
|
|
93
90
|
}
|
|
94
91
|
|
|
95
92
|
// ─── Extension server ─────────────────────────────────────────────────────────
|
|
96
93
|
|
|
97
94
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* @param {
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
226
|
+
entry.pending.delete(String(id));
|
|
231
227
|
reject(new Error(`Extension timeout after ${timeoutMs}ms`));
|
|
232
228
|
}, timeoutMs);
|
|
233
229
|
|
|
234
|
-
|
|
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
|
|
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
|
|
72
|
+
return finish('list_groups', all ? { all: true } : {});
|
|
68
73
|
}
|
|
69
74
|
|
|
70
75
|
if (command === 'status') {
|
|
71
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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));
|
package/format-response.mjs
CHANGED
|
@@ -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
|
/**
|
package/group-expansion.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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": [
|
|
40
|
+
"keywords": [
|
|
41
|
+
"dassi",
|
|
42
|
+
"cli",
|
|
43
|
+
"browser-automation",
|
|
44
|
+
"chrome-extension"
|
|
45
|
+
],
|
|
41
46
|
"license": "MIT"
|
|
42
47
|
}
|