@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 +19 -0
- package/dassi-daemon.mjs +79 -68
- package/dassi-shared.mjs +154 -0
- package/dassi.mjs +75 -52
- package/format-response.mjs +10 -0
- package/group-expansion.mjs +15 -7
- package/help-text.mjs +46 -0
- package/launch.mjs +273 -0
- package/package.json +9 -2
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
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
*
|
|
66
|
-
*
|
|
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(
|
|
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 =
|
|
71
|
+
const pending = entry.pending.get(String(resp.id));
|
|
74
72
|
if (!pending) return;
|
|
75
|
-
|
|
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
|
|
86
|
-
*
|
|
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
|
|
86
|
+
function drainPendingResponses(entry) {
|
|
87
|
+
for (const [id, pending] of entry.pending) {
|
|
90
88
|
pending.reject(new Error('Extension disconnected'));
|
|
91
|
-
|
|
89
|
+
entry.pending.delete(id);
|
|
92
90
|
}
|
|
93
91
|
}
|
|
94
92
|
|
|
95
93
|
// ─── Extension server ─────────────────────────────────────────────────────────
|
|
96
94
|
|
|
97
95
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* @param {
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
222
|
+
entry.pending.delete(String(id));
|
|
231
223
|
reject(new Error(`Extension timeout after ${timeoutMs}ms`));
|
|
232
224
|
}, timeoutMs);
|
|
233
225
|
|
|
234
|
-
|
|
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 =
|
|
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
|
|
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
|
|
77
|
+
return finish('list_groups', all ? { all: true } : {});
|
|
68
78
|
}
|
|
69
79
|
|
|
70
80
|
if (command === 'status') {
|
|
71
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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));
|
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/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.
|
|
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": [
|
|
42
|
+
"keywords": [
|
|
43
|
+
"dassi",
|
|
44
|
+
"cli",
|
|
45
|
+
"browser-automation",
|
|
46
|
+
"chrome-extension"
|
|
47
|
+
],
|
|
41
48
|
"license": "MIT"
|
|
42
49
|
}
|