@dassi_ai/cli 0.5.0 → 0.7.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/.claude-plugin/plugin.json +8 -3
- package/README.md +116 -108
- package/daemon-client.mjs +39 -85
- package/dassi-daemon.mjs +106 -156
- package/dassi-shared.mjs +35 -172
- package/dassi.mjs +99 -60
- package/format-response.mjs +26 -11
- package/group-expansion.mjs +12 -3
- package/help-text.mjs +59 -48
- package/launch.mjs +1 -1
- package/package.json +2 -2
- package/setup.mjs +200 -0
- package/skills/dassi/SKILL.md +69 -0
- package/skills/dassi/scripts/dassi.mjs +3 -0
- package/tool-commands.mjs +48 -89
- package/skills/operate/SKILL.md +0 -124
- package/skills/operate/command-reference.md +0 -65
- package/skills/pick-tabs/SKILL.md +0 -93
package/dassi-daemon.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { CLI_PROTOCOL_VERSION } from './dassi-shared.mjs';
|
|
2
4
|
/**
|
|
3
5
|
* Dassi CLI Daemon
|
|
4
6
|
*
|
|
@@ -13,6 +15,7 @@
|
|
|
13
15
|
*/
|
|
14
16
|
|
|
15
17
|
import * as net from 'net';
|
|
18
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
16
19
|
import * as fs from 'fs';
|
|
17
20
|
import { pathToFileURL } from 'url';
|
|
18
21
|
import {
|
|
@@ -22,10 +25,9 @@ import {
|
|
|
22
25
|
getPidFile,
|
|
23
26
|
validateSession,
|
|
24
27
|
cleanupDaemonFiles,
|
|
25
|
-
buildReadyPayload,
|
|
26
|
-
createCommandQueue,
|
|
27
28
|
createConnectionRegistry,
|
|
28
29
|
getDaemonBridgePort,
|
|
30
|
+
unknownToolOutcome,
|
|
29
31
|
} from './dassi-shared.mjs';
|
|
30
32
|
|
|
31
33
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
@@ -33,8 +35,6 @@ import {
|
|
|
33
35
|
const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
34
36
|
const IDLE_CHECK_INTERVAL_MS = 5 * 60 * 1000;
|
|
35
37
|
|
|
36
|
-
// Reason: a registry of connected extensions keyed by profile label replaces
|
|
37
|
-
// the old single-socket model so multiple Chrome profiles can connect at once.
|
|
38
38
|
const registry = createConnectionRegistry();
|
|
39
39
|
|
|
40
40
|
// ─── WebSocket loader helper ──────────────────────────────────────────────────
|
|
@@ -72,7 +72,7 @@ function setupResponseRouter(entry) {
|
|
|
72
72
|
if (!pending) return;
|
|
73
73
|
entry.pending.delete(String(resp.id));
|
|
74
74
|
if (resp.error) {
|
|
75
|
-
pending.reject(new Error(resp.error.message ?? String(resp.error)));
|
|
75
|
+
pending.reject(Object.assign(new Error(resp.error.message ?? String(resp.error)), { taskId: resp.error.taskId, existingTaskId: resp.error.existingTaskId }));
|
|
76
76
|
} else {
|
|
77
77
|
pending.resolve(resp.result);
|
|
78
78
|
}
|
|
@@ -85,7 +85,7 @@ function setupResponseRouter(entry) {
|
|
|
85
85
|
*/
|
|
86
86
|
function drainPendingResponses(entry) {
|
|
87
87
|
for (const [id, pending] of entry.pending) {
|
|
88
|
-
pending.reject(new Error('Extension disconnected'));
|
|
88
|
+
pending.reject(Object.assign(new Error('Extension disconnected'), { outcome: 'unknown' }));
|
|
89
89
|
entry.pending.delete(id);
|
|
90
90
|
}
|
|
91
91
|
}
|
|
@@ -113,45 +113,12 @@ function acceptExtensionSocket(ws, identity, port) {
|
|
|
113
113
|
return entry;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
|
|
117
|
-
* Handles a registration message from a newly connected extension WebSocket.
|
|
118
|
-
* On first registration, resolves the startup promise with auth status.
|
|
119
|
-
* On subsequent registrations (reconnects), logs and continues silently.
|
|
120
|
-
* @param {import('ws').WebSocket} ws - The connected WebSocket.
|
|
121
|
-
* @param {Buffer} data - Raw message data.
|
|
122
|
-
* @param {{ resolved: boolean; timeout: ReturnType<typeof setTimeout> }} ctx - Shared state.
|
|
123
|
-
* @param {(value: unknown) => void} resolve - Promise resolve callback.
|
|
124
|
-
* @param {(reason: Error) => void} reject - Promise reject callback.
|
|
125
|
-
* @param {number} port - The port the WS server is bound to.
|
|
126
|
-
*/
|
|
127
|
-
async function handleRegistration(ws, data, ctx, resolve, reject, port) {
|
|
116
|
+
function handleRegistration(ws, data, port) {
|
|
128
117
|
let msg;
|
|
129
|
-
try { msg = JSON.parse(data.toString()); } catch { return; }
|
|
130
|
-
if (msg.type !== 'register' || msg.client !== 'dassi-extension') return;
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
ws,
|
|
134
|
-
{ label: typeof msg.label === 'string' ? msg.label : null, installId: String(msg.installId ?? '') },
|
|
135
|
-
port,
|
|
136
|
-
);
|
|
137
|
-
|
|
138
|
-
if (!ctx.resolved) {
|
|
139
|
-
clearTimeout(ctx.timeout);
|
|
140
|
-
try {
|
|
141
|
-
// Reason: route the startup status check to the connection that just registered.
|
|
142
|
-
const status = await dispatchToExtension({ id: 'init', action: 'status', target: entry.label });
|
|
143
|
-
ctx.resolved = true;
|
|
144
|
-
resolve(status);
|
|
145
|
-
} catch (err) {
|
|
146
|
-
// Reason: leave ctx.resolved false so a reconnecting extension can retry
|
|
147
|
-
// the startup status check rather than failing the daemon permanently.
|
|
148
|
-
console.error('[dassi-daemon] Initial status check failed:', err.message);
|
|
149
|
-
reject(err);
|
|
150
|
-
}
|
|
151
|
-
} else {
|
|
152
|
-
// Reason: daemon already started — this is a reconnect or an additional profile.
|
|
153
|
-
console.log('[dassi-daemon] Extension registered (daemon already started)');
|
|
154
|
-
}
|
|
118
|
+
try { msg = JSON.parse(data.toString()); } catch { ws.close(); return; }
|
|
119
|
+
if (msg.type !== 'register' || msg.client !== 'dassi-extension') { ws.close(); return; }
|
|
120
|
+
acceptExtensionSocket(ws, { label: typeof msg.label === 'string' ? msg.label : null,
|
|
121
|
+
installId: String(msg.installId ?? '') }, port);
|
|
155
122
|
}
|
|
156
123
|
|
|
157
124
|
// Reason: Port is configurable via DASSI_BRIDGE_PORT (single source of truth in
|
|
@@ -161,35 +128,15 @@ async function handleRegistration(ws, data, ctx, resolve, reject, port) {
|
|
|
161
128
|
// silently accept and bind to a random ephemeral port.
|
|
162
129
|
const BRIDGE_PORT = getDaemonBridgePort();
|
|
163
130
|
|
|
164
|
-
/**
|
|
165
|
-
* Starts a WebSocket server on the configured bridge port (default 18790).
|
|
166
|
-
* Stays alive for the daemon's lifetime. Waits for the first extension
|
|
167
|
-
* connection, then resolves with the auth status. Subsequent reconnections
|
|
168
|
-
* are handled transparently.
|
|
169
|
-
* @returns {Promise<{ authenticated: boolean; email: string | null; optionsUrl: string }>}
|
|
170
|
-
*/
|
|
131
|
+
/** Listen independently of whether Chrome is connected or signed in. */
|
|
171
132
|
async function startExtensionServer() {
|
|
172
133
|
const { WSServer } = await loadWebSocket();
|
|
173
|
-
|
|
174
134
|
return new Promise((resolve, reject) => {
|
|
175
135
|
const wss = new WSServer({ host: '127.0.0.1', port: BRIDGE_PORT });
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
ctx.timeout = setTimeout(() => {
|
|
179
|
-
wss.close();
|
|
180
|
-
reject(new Error('extension_not_installed'));
|
|
181
|
-
}, 30_000);
|
|
182
|
-
|
|
183
|
-
wss.on('error', (err) => {
|
|
184
|
-
if (!ctx.resolved) {
|
|
185
|
-
clearTimeout(ctx.timeout);
|
|
186
|
-
// Reason: EADDRINUSE means another daemon instance already owns the port
|
|
187
|
-
reject(err.code === 'EADDRINUSE' ? new Error('port_in_use') : new Error('extension_not_installed'));
|
|
188
|
-
}
|
|
189
|
-
});
|
|
190
|
-
|
|
136
|
+
wss.once('listening', () => resolve(wss));
|
|
137
|
+
wss.on('error', reject);
|
|
191
138
|
wss.on('connection', (ws) => {
|
|
192
|
-
ws.once('message', (data) => handleRegistration(ws, data,
|
|
139
|
+
ws.once('message', (data) => handleRegistration(ws, data, BRIDGE_PORT));
|
|
193
140
|
});
|
|
194
141
|
});
|
|
195
142
|
}
|
|
@@ -203,76 +150,94 @@ async function startExtensionServer() {
|
|
|
203
150
|
* @param {Record<string, unknown>} cmd - Command object with {id, action, target?, ...params}
|
|
204
151
|
* @returns {Promise<unknown>} The extension's result value (not wrapped in success/data).
|
|
205
152
|
*/
|
|
206
|
-
async function dispatchToExtension(cmd) {
|
|
153
|
+
export async function dispatchToExtension(cmd, reg = registry) {
|
|
207
154
|
const { id, action, target = null, ...params } = cmd;
|
|
208
|
-
const entry =
|
|
155
|
+
const entry = reg.resolve(target); // throws with a helpful message if ambiguous/unknown/none
|
|
209
156
|
if (!entry.ws || entry.ws.readyState !== 1 /* OPEN */) {
|
|
210
157
|
throw new Error('Extension not connected');
|
|
211
158
|
}
|
|
212
159
|
|
|
213
160
|
// Reason: the extension expects JSON-RPC {id, method, params}; `target` is a
|
|
214
161
|
// daemon-only routing field and must not leak into the extension params.
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const timeoutMs =
|
|
162
|
+
const correlationId = randomUUID();
|
|
163
|
+
const message = { id: correlationId, method: action ?? String(id), params };
|
|
164
|
+
// The canonical tool controls execution deadlines, including for dynamic plugins.
|
|
165
|
+
const timeoutMs = action === 'tool_exec' ? null : action === 'status' ? 10_000 : action === 'run' ? 65_000
|
|
166
|
+
: Number.isFinite(params.timeoutMs) && params.timeoutMs > 0 ? Math.min(params.timeoutMs + 10_000, 2147483647) : 65_000;
|
|
219
167
|
|
|
220
168
|
return new Promise((resolve, reject) => {
|
|
221
|
-
const timer = setTimeout(() => {
|
|
222
|
-
entry.pending.delete(
|
|
169
|
+
const timer = timeoutMs == null ? null : setTimeout(() => {
|
|
170
|
+
entry.pending.delete(correlationId);
|
|
223
171
|
reject(new Error(`Extension timeout after ${timeoutMs}ms`));
|
|
224
172
|
}, timeoutMs);
|
|
225
173
|
|
|
226
|
-
entry.pending.set(
|
|
174
|
+
entry.pending.set(correlationId, {
|
|
227
175
|
resolve: (result) => { clearTimeout(timer); resolve(result); },
|
|
228
176
|
reject: (err) => { clearTimeout(timer); reject(err); },
|
|
229
177
|
});
|
|
178
|
+
try { entry.ws.send(JSON.stringify(message)); } catch (error) {
|
|
179
|
+
entry.pending.delete(correlationId); clearTimeout(timer); reject(error);
|
|
180
|
+
}
|
|
230
181
|
});
|
|
231
182
|
}
|
|
232
183
|
|
|
233
|
-
/**
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
248
|
-
/**
|
|
249
|
-
* Refresh the ready file when the latest dispatched command was a `status` query.
|
|
250
|
-
*
|
|
251
|
-
* Reason: The daemon previously wrote the ready file exactly once on startup.
|
|
252
|
-
* When the user wasn't signed in at that moment, the file stayed `needs_login`
|
|
253
|
-
* even after waitForLogin's polling confirmed authentication via socket — so
|
|
254
|
-
* every subsequent CLI invocation re-read the stale file and re-triggered the
|
|
255
|
-
* login flow. Refreshing on every `status` response — which waitForLogin's
|
|
256
|
-
* poll loop drives naturally — converges the file to the truth without needing
|
|
257
|
-
* a separate signal channel.
|
|
258
|
-
*
|
|
259
|
-
* @param {string} readyFile - Absolute path to the daemon's ready file.
|
|
260
|
-
* @param {{ action?: string }} cmd - The command that was dispatched.
|
|
261
|
-
* @param {unknown} result - The dispatch result (extension's status payload).
|
|
262
|
-
* @returns {void}
|
|
263
|
-
*/
|
|
264
|
-
export function refreshReadyFileFromStatusResult(readyFile, cmd, result) {
|
|
265
|
-
if (cmd?.action !== 'status') return;
|
|
266
|
-
if (!result || typeof result !== 'object') return;
|
|
184
|
+
/** Discovery is independent of authentication and in-flight agent work. */
|
|
185
|
+
export async function routeCommand(cmd, reg = registry, dispatch = dispatchToExtension) {
|
|
186
|
+
let resolvedProfile;
|
|
187
|
+
const call = (command) => dispatch(command, reg);
|
|
267
188
|
try {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
189
|
+
if (['run', 'task_status', 'task_stop'].includes(cmd.action) && cmd.protocolVersion !== CLI_PROTOCOL_VERSION) throw new Error('Incompatible CLI. Update Dassi CLI and restart its daemon.');
|
|
190
|
+
let target = cmd.target ?? null;
|
|
191
|
+
let localTaskId = cmd.taskId;
|
|
192
|
+
if (cmd.action === 'task_status' || cmd.action === 'task_stop' || (cmd.action === 'run' && cmd.taskId)) {
|
|
193
|
+
const split = typeof cmd.taskId === 'string' ? cmd.taskId.indexOf('~') : -1;
|
|
194
|
+
if (split < 1) throw new Error('Invalid task ID. Copy the ID returned by dassi run.');
|
|
195
|
+
const taskProfile = cmd.taskId.slice(0, split);
|
|
196
|
+
if (target && reg.resolve(target).id !== taskProfile) throw new Error('Task belongs to another profile.');
|
|
197
|
+
target = taskProfile;
|
|
198
|
+
localTaskId = cmd.taskId.slice(split + 1);
|
|
199
|
+
}
|
|
200
|
+
const discovery = ['list_profiles', 'list_tabs', 'list_groups', 'status'].includes(cmd.action);
|
|
201
|
+
if (discovery) {
|
|
202
|
+
const profiles = target ? [reg.resolve(target)] : reg.list();
|
|
203
|
+
const profileOnly = cmd.action === 'list_profiles' || cmd.action === 'status';
|
|
204
|
+
const snapshots = await Promise.all(profiles.map(async (profile) => {
|
|
205
|
+
const [status, listing] = await Promise.allSettled([
|
|
206
|
+
call({ id: `${cmd.id}-${profile.id}-status`, action: 'status', target: profile.id }),
|
|
207
|
+
profileOnly ? Promise.resolve([]) : call({ ...cmd, id: `${cmd.id}-${profile.id}`, target: profile.id }),
|
|
208
|
+
]);
|
|
209
|
+
const info = { id: profile.id, label: profile.label,
|
|
210
|
+
...(status.status === 'fulfilled' ? status.value
|
|
211
|
+
: { [profileOnly ? 'error' : 'authError']: status.reason.message }),
|
|
212
|
+
...(listing.status === 'rejected' ? { error: listing.reason.message } : {}),
|
|
213
|
+
};
|
|
214
|
+
const items = listing.status === 'fulfilled' ? listing.value : [];
|
|
215
|
+
return { profile: info, items: items.map(item => ({ ...item, profileId: profile.id, profile: profile.label,
|
|
216
|
+
target: `${profile.id}:${cmd.action === 'list_tabs' ? item.tabId : item.id}` })) };
|
|
217
|
+
}));
|
|
218
|
+
return { id: cmd.id, success: true,
|
|
219
|
+
data: cmd.action === 'list_profiles' || cmd.action === 'status' ? snapshots.map(s => s.profile) : snapshots.flatMap(s => s.items),
|
|
220
|
+
profiles: snapshots.map(s => s.profile) };
|
|
221
|
+
}
|
|
222
|
+
const profile = reg.resolve(target);
|
|
223
|
+
resolvedProfile = profile;
|
|
224
|
+
if (cmd.action === 'run') {
|
|
225
|
+
const status = await call({ id: `${cmd.id}-auth`, action: 'status', target: profile.id });
|
|
226
|
+
if (status.cliProtocolVersion !== CLI_PROTOCOL_VERSION) throw new Error(`Update Dassi in profile "${profile.label}" to use task commands.`);
|
|
227
|
+
if (!status.authenticated) throw new Error(`Sign in to Dassi in profile "${profile.label}" (${profile.id}), then retry.`);
|
|
228
|
+
}
|
|
229
|
+
const data = await call({ ...cmd, target: profile.id, ...(localTaskId ? { taskId: localTaskId } : {}) });
|
|
230
|
+
return { id: cmd.id, success: true, data: data?.taskId
|
|
231
|
+
? { ...data, taskId: `${profile.id}~${data.taskId}`, profileId: profile.id, profile: profile.label } : data };
|
|
232
|
+
} catch (error) {
|
|
233
|
+
if (cmd.action === 'tool_exec' && error.outcome === 'unknown') {
|
|
234
|
+
const uncertain = unknownToolOutcome(error);
|
|
235
|
+
return { id: cmd.id, success: false, error: uncertain.message, outcome: uncertain.outcome };
|
|
236
|
+
}
|
|
237
|
+
const taskId = error.taskId && resolvedProfile ? `${resolvedProfile.id}~${error.taskId}` : undefined;
|
|
238
|
+
const existingTaskId = error.existingTaskId && resolvedProfile ? `${resolvedProfile.id}~${error.existingTaskId}` : undefined;
|
|
239
|
+
const checkId = taskId ?? existingTaskId;
|
|
240
|
+
return { id: cmd.id, success: false, ...(taskId ? { taskId } : {}), ...(existingTaskId ? { existingTaskId } : {}), error: `${error.message}${checkId ? ` Check: dassi status '${checkId}'` : ''}` };
|
|
276
241
|
}
|
|
277
242
|
}
|
|
278
243
|
|
|
@@ -298,7 +263,6 @@ function initDaemonProcess(session) {
|
|
|
298
263
|
cleanupDaemonFiles(session);
|
|
299
264
|
const pidFile = getPidFile(session);
|
|
300
265
|
fs.writeFileSync(pidFile, String(process.pid), { mode: 0o600 });
|
|
301
|
-
// Reason: see refreshReadyFileFromStatusResult — chmod normalizes pre-existing files.
|
|
302
266
|
try { fs.chmodSync(pidFile, 0o600); } catch { /* best-effort */ }
|
|
303
267
|
|
|
304
268
|
const shutdown = () => {
|
|
@@ -312,12 +276,12 @@ function initDaemonProcess(session) {
|
|
|
312
276
|
}
|
|
313
277
|
|
|
314
278
|
/**
|
|
315
|
-
* Creates
|
|
316
|
-
* @param {
|
|
279
|
+
* Creates a Unix socket server that dispatches independent NDJSON commands.
|
|
280
|
+
* @param {(cmd: Record<string, unknown>) => Promise<unknown>} dispatch
|
|
317
281
|
* @param {string} socketPath
|
|
318
282
|
* @returns {net.Server}
|
|
319
283
|
*/
|
|
320
|
-
function createSocketServer(
|
|
284
|
+
function createSocketServer(dispatch, socketPath) {
|
|
321
285
|
const server = net.createServer((socket) => {
|
|
322
286
|
let buffer = '';
|
|
323
287
|
|
|
@@ -337,11 +301,11 @@ function createSocketServer(queue, socketPath) {
|
|
|
337
301
|
continue;
|
|
338
302
|
}
|
|
339
303
|
|
|
340
|
-
|
|
304
|
+
dispatch(cmd)
|
|
341
305
|
.then((response) => {
|
|
342
306
|
if (!socket.destroyed) socket.write(JSON.stringify(response) + '\n');
|
|
343
307
|
})
|
|
344
|
-
.catch((err) => console.error('[dassi-daemon]
|
|
308
|
+
.catch((err) => console.error('[dassi-daemon] dispatch error:', err));
|
|
345
309
|
}
|
|
346
310
|
});
|
|
347
311
|
|
|
@@ -379,49 +343,35 @@ function startIdleShutdown(server, session, getLastCommandAt) {
|
|
|
379
343
|
// ─── Daemon entry point ───────────────────────────────────────────────────────
|
|
380
344
|
|
|
381
345
|
/**
|
|
382
|
-
*
|
|
383
|
-
* the extension to register, runs onboarding checks, starts the Unix socket
|
|
384
|
-
* server for CLI commands, and sets up idle shutdown.
|
|
346
|
+
* Starts the two local transports and idle shutdown. Browser tasks outlive this process.
|
|
385
347
|
* @returns {Promise<void>}
|
|
386
348
|
*/
|
|
387
349
|
export async function startDaemon() {
|
|
388
350
|
const session = validateSession(process.env.DASSI_SESSION ?? 'default');
|
|
389
351
|
const { socketPath, readyFile } = initDaemonProcess(session);
|
|
390
352
|
|
|
391
|
-
let statusData;
|
|
392
353
|
try {
|
|
393
|
-
|
|
394
|
-
} catch {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
fs.writeFileSync(readyFile, JSON.stringify({ status: 'extension_not_installed' }), { mode: 0o600 });
|
|
398
|
-
try { fs.chmodSync(readyFile, 0o600); } catch { /* best-effort */ }
|
|
354
|
+
await startExtensionServer();
|
|
355
|
+
} catch (error) {
|
|
356
|
+
fs.writeFileSync(readyFile, JSON.stringify({ status: 'error', error: error.code === 'EADDRINUSE'
|
|
357
|
+
? `Bridge port ${BRIDGE_PORT} is already in use. Use the existing daemon; --session does not select a browser.` : error.message }), { mode: 0o600 });
|
|
399
358
|
process.exit(1);
|
|
400
359
|
}
|
|
401
|
-
|
|
402
360
|
let lastCommandAt = Date.now();
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
// registry and intentionally skip extension dispatch + ready-file refresh.
|
|
407
|
-
const local = handleDaemonLocalCommand(cmd, registry);
|
|
408
|
-
if (local) return local;
|
|
361
|
+
let inFlight = 0;
|
|
362
|
+
const server = createSocketServer(async (cmd) => {
|
|
363
|
+
inFlight++;
|
|
409
364
|
try {
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
return { id: String(cmd.id ?? 'unknown'), success: false, error: err.message };
|
|
365
|
+
// Chrome reconnects every five seconds after an idle daemon exits.
|
|
366
|
+
const connectDeadline = Date.now() + 6000;
|
|
367
|
+
while (!registry.size() && Date.now() < connectDeadline) await sleep(100);
|
|
368
|
+
return await routeCommand(cmd);
|
|
415
369
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
fs.writeFileSync(readyFile, JSON.stringify(buildReadyPayload(statusData)), { mode: 0o600 });
|
|
422
|
-
try { fs.chmodSync(readyFile, 0o600); } catch { /* best-effort */ }
|
|
423
|
-
|
|
424
|
-
startIdleShutdown(server, session, () => lastCommandAt);
|
|
370
|
+
finally { inFlight--; lastCommandAt = Date.now(); }
|
|
371
|
+
}, socketPath);
|
|
372
|
+
await new Promise(resolve => server.listening ? resolve() : server.once('listening', resolve));
|
|
373
|
+
fs.writeFileSync(readyFile, JSON.stringify({ status: 'ok', protocolVersion: CLI_PROTOCOL_VERSION }), { mode: 0o600 });
|
|
374
|
+
startIdleShutdown(server, session, () => inFlight ? Date.now() : lastCommandAt);
|
|
425
375
|
}
|
|
426
376
|
|
|
427
377
|
// ── Entry point ───────────────────────────────────────────────────────────────
|
package/dassi-shared.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
+
export const CLI_PROTOCOL_VERSION = 3;
|
|
1
2
|
/**
|
|
2
3
|
* Dassi CLI Shared Utilities
|
|
3
4
|
*
|
|
4
5
|
* Pure helper functions shared between the CLI client (dassi.mjs) and the
|
|
5
6
|
* daemon process (dassi-daemon.mjs). Includes path resolution, session
|
|
6
|
-
* validation, daemon lifecycle helpers,
|
|
7
|
-
* the serialized command queue.
|
|
7
|
+
* validation, daemon lifecycle helpers, readiness, and browser identity.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import * as fs from 'fs';
|
|
@@ -19,6 +19,7 @@ import * as os from 'os';
|
|
|
19
19
|
* path — letting `dassi launch` open the extension's options page directly.
|
|
20
20
|
*/
|
|
21
21
|
export const DASSI_EXTENSION_ID = 'bjcngahpcjeililljmfegmlanlpgibdi';
|
|
22
|
+
export const CHROME_WEB_STORE_URL = `https://chromewebstore.google.com/detail/dassi-ai-browser-agent-fo/${DASSI_EXTENSION_ID}`;
|
|
22
23
|
|
|
23
24
|
// ─── Path helpers ─────────────────────────────────────────────────────────────
|
|
24
25
|
|
|
@@ -54,7 +55,7 @@ export function getPidFile(session) {
|
|
|
54
55
|
|
|
55
56
|
/**
|
|
56
57
|
* Returns the ready file path for the given session.
|
|
57
|
-
* Written
|
|
58
|
+
* Written when the local command transport is listening.
|
|
58
59
|
* @param {string} session
|
|
59
60
|
* @returns {string}
|
|
60
61
|
*/
|
|
@@ -132,205 +133,67 @@ export function isDaemonRunning(session) {
|
|
|
132
133
|
// Reason: signal 0 checks process existence without delivering a real signal
|
|
133
134
|
process.kill(pid, 0);
|
|
134
135
|
return true;
|
|
135
|
-
} catch {
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (error.code === 'EPERM') return true;
|
|
136
138
|
// Process does not exist — clean up stale files
|
|
137
139
|
cleanupDaemonFiles(session);
|
|
138
140
|
return false;
|
|
139
141
|
}
|
|
140
142
|
}
|
|
141
143
|
|
|
142
|
-
// ───
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Builds the ready file payload from an extension status response.
|
|
146
|
-
* @param {{ authenticated: boolean; email: string | null; optionsUrl: string }} status
|
|
147
|
-
* @returns {{ status: string; email?: string | null; optionsUrl?: string }}
|
|
148
|
-
*/
|
|
149
|
-
export function buildReadyPayload(status) {
|
|
150
|
-
if (status.authenticated) {
|
|
151
|
-
return { status: 'ok', email: status.email };
|
|
152
|
-
}
|
|
153
|
-
return { status: 'needs_login', optionsUrl: status.optionsUrl };
|
|
154
|
-
}
|
|
144
|
+
// ─── Readiness payload helpers ───────────────────────────────────────────────
|
|
155
145
|
|
|
156
146
|
/**
|
|
157
147
|
* Parses the ready file content into a status object.
|
|
158
|
-
*
|
|
148
|
+
* Throws for incomplete writes so startup can keep polling.
|
|
159
149
|
* @param {string} raw
|
|
160
150
|
* @returns {{ status: string; [key: string]: unknown }}
|
|
161
151
|
*/
|
|
162
152
|
export function parseReadyPayload(raw) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
return { status: 'extension_not_installed' };
|
|
167
|
-
}
|
|
153
|
+
const payload = JSON.parse(raw);
|
|
154
|
+
if (!payload || typeof payload.status !== 'string') throw new Error('Invalid daemon readiness file');
|
|
155
|
+
return payload;
|
|
168
156
|
}
|
|
169
157
|
|
|
170
|
-
// ───
|
|
158
|
+
// ─── Connected browser installations ──────────────────────────────────────────
|
|
171
159
|
|
|
172
|
-
/**
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
* socket clients are connected simultaneously. This prevents concurrent `run`
|
|
176
|
-
* calls from racing inside the extension.
|
|
177
|
-
*
|
|
178
|
-
* @param {(cmd: Record<string, unknown>) => Promise<{id: string; success: boolean; data?: unknown; error?: string}>} handler
|
|
179
|
-
* @returns {{ enqueue: (cmd: Record<string, unknown>) => Promise<{id: string; success: boolean; data?: unknown; error?: string}> }}
|
|
180
|
-
*/
|
|
181
|
-
export function createCommandQueue(handler) {
|
|
182
|
-
const queue = [];
|
|
183
|
-
let processing = false;
|
|
184
|
-
|
|
185
|
-
async function processNext() {
|
|
186
|
-
if (processing || queue.length === 0) return;
|
|
187
|
-
processing = true;
|
|
188
|
-
const { cmd, resolve } = queue.shift();
|
|
189
|
-
try {
|
|
190
|
-
const result = await handler(cmd);
|
|
191
|
-
resolve(result);
|
|
192
|
-
} catch (err) {
|
|
193
|
-
resolve({ id: String(cmd.id ?? 'unknown'), success: false, error: err.message ?? String(err) });
|
|
194
|
-
} finally {
|
|
195
|
-
processing = false;
|
|
196
|
-
// Reason: .catch() prevents unhandled rejection from crashing the daemon
|
|
197
|
-
// if the recursive call fails before reaching its own try/catch
|
|
198
|
-
processNext().catch(() => {});
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
return {
|
|
203
|
-
/**
|
|
204
|
-
* Enqueue a command and return a promise that resolves with the response.
|
|
205
|
-
* The returned Promise always resolves; errors are encoded as { success: false, error } values.
|
|
206
|
-
* @param {Record<string, unknown>} cmd
|
|
207
|
-
* @returns {Promise<{id: string; success: boolean; data?: unknown; error?: string}>}
|
|
208
|
-
*/
|
|
209
|
-
enqueue(cmd) {
|
|
210
|
-
return new Promise((resolve) => {
|
|
211
|
-
queue.push({ cmd, resolve });
|
|
212
|
-
processNext().catch(() => {});
|
|
213
|
-
});
|
|
214
|
-
},
|
|
215
|
-
};
|
|
160
|
+
/** A lost transport is not evidence that a browser action failed or stopped. */
|
|
161
|
+
export function unknownToolOutcome(error) {
|
|
162
|
+
return Object.assign(new Error(`${error.message} Tool outcome unknown; it may still be running. Inspect the browser before retrying.`), { outcome: 'unknown' });
|
|
216
163
|
}
|
|
217
164
|
|
|
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
165
|
export function createConnectionRegistry() {
|
|
235
|
-
/** @type {Map<string, { ws: any; label: string; installId: string; port: number; pending: Map<string, any> }>} */
|
|
236
166
|
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
167
|
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
168
|
add({ ws, label, installId, port }) {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
for (const [k, c] of conns) {
|
|
265
|
-
if (c.installId === installId) { conns.delete(k); break; }
|
|
266
|
-
}
|
|
169
|
+
const id = installId || `legacy-${port}`;
|
|
170
|
+
const old = conns.get(id);
|
|
171
|
+
if (old) {
|
|
172
|
+
for (const pending of old.pending.values()) pending.reject(Object.assign(new Error('Extension reconnected; check the existing task before retrying.'), { outcome: 'unknown' }));
|
|
173
|
+
old.ws.close();
|
|
267
174
|
}
|
|
268
|
-
|
|
269
|
-
|
|
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;
|
|
175
|
+
conns.set(id, { id, installId, label: label?.trim() || id, port, ws, pending: new Map() });
|
|
176
|
+
return id;
|
|
275
177
|
},
|
|
276
|
-
|
|
277
|
-
/**
|
|
278
|
-
* Remove the connection associated with the given WebSocket.
|
|
279
|
-
* @param {any} ws
|
|
280
|
-
* @returns {{ label: string; installId: string } | null}
|
|
281
|
-
*/
|
|
282
178
|
remove(ws) {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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;
|
|
179
|
+
const entry = this.getByWs(ws);
|
|
180
|
+
if (entry) conns.delete(entry.id);
|
|
181
|
+
return entry ?? null;
|
|
300
182
|
},
|
|
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
|
-
*/
|
|
183
|
+
getByWs(ws) { return [...conns.values()].find((entry) => entry.ws === ws) ?? null; },
|
|
309
184
|
resolve(target) {
|
|
310
|
-
if (conns.size
|
|
185
|
+
if (!conns.size) throw new Error('No extension connected. Open Dassi in Chrome, then retry.');
|
|
311
186
|
if (target == null) {
|
|
312
187
|
if (conns.size === 1) return [...conns.values()][0];
|
|
313
|
-
throw new Error(`Multiple profiles connected (${
|
|
188
|
+
throw new Error(`Multiple profiles connected (${[...conns.values()].map(e => e.label).join(', ')}). Copy a target from dassi list-tabs or use --profile <id>.`);
|
|
314
189
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
return
|
|
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;
|
|
190
|
+
if (conns.has(String(target))) return conns.get(String(target));
|
|
191
|
+
const matches = [...conns.values()].filter(entry => entry.label === target);
|
|
192
|
+
if (matches.length === 1) return matches[0];
|
|
193
|
+
if (matches.length > 1) throw new Error(`Profile name "${target}" is ambiguous. Use a profile ID from dassi list-profiles.`);
|
|
194
|
+
throw new Error(`Unknown profile "${target}". Connected: ${[...conns.values()].map(e => `${e.label} (${e.id})`).join(', ')}`);
|
|
334
195
|
},
|
|
196
|
+
list() { return [...conns.values()].map(({ id, label, port }) => ({ id, label, port })); },
|
|
197
|
+
size() { return conns.size; },
|
|
335
198
|
};
|
|
336
199
|
}
|