@dassi_ai/cli 0.1.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.
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "dassi",
3
+ "description": "Drive the Dassi Chrome extension from Claude Code. Pick tabs/groups, then run AI agent or browser tools against them.",
4
+ "version": "0.1.0",
5
+ "author": {
6
+ "name": "Omnify Labs",
7
+ "email": "team@dassi.ai"
8
+ },
9
+ "homepage": "https://dassi.ai",
10
+ "license": "MIT",
11
+ "keywords": ["browser", "automation", "chrome-extension", "agent"]
12
+ }
package/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # dassi-cli
2
+
3
+ Standalone CLI for the [Dassi](../extension/README.md) Chrome extension — run browser automation from the terminal.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ # Zero-install
9
+ npx dassi-cli --help
10
+
11
+ # Or install globally
12
+ npm install -g dassi-cli
13
+ ```
14
+
15
+ Or, for local development from a clone of this repo:
16
+
17
+ ```bash
18
+ cd cli
19
+ npm install
20
+ npm link # exposes `dassi` on PATH
21
+ ```
22
+
23
+ ## Prerequisites
24
+
25
+ - Node.js >= 20.11.1
26
+ - Dassi Chrome extension installed and running
27
+ - **External Bridge** enabled in Dassi Options (`options.html?dev`)
28
+
29
+ ## Usage
30
+
31
+ ```bash
32
+ # List all open Chrome tabs
33
+ dassi list-tabs
34
+
35
+ # Run an agent prompt on a specific tab
36
+ dassi run "summarize the top issues" --tab 456
37
+
38
+ # Run with a timeout (ms)
39
+ dassi run "check inbox" --tab 123 --timeout 60000
40
+
41
+ # Send a raw JSON command
42
+ dassi raw '{"id":"1","action":"list_tabs"}'
43
+
44
+ # Output as JSON (for scripting)
45
+ dassi list-tabs --json
46
+
47
+ # Use a named session
48
+ dassi list-tabs --session work
49
+
50
+ # Show version / help
51
+ dassi --version
52
+ dassi --help
53
+ ```
54
+
55
+ ### Tab groups
56
+
57
+ ```bash
58
+ # List all tab groups
59
+ dassi list-groups
60
+
61
+ # Run agent against every tab in a group (sequential)
62
+ dassi run "summarize each page" --group 7
63
+
64
+ # Use a group title instead of id (errors if title is ambiguous)
65
+ dassi run "extract prices" --group-title "Shopping"
66
+
67
+ # Tool commands also accept --group / --group-title
68
+ dassi screenshot --group-title "Research" -o shot.png
69
+ ```
70
+
71
+ When `--group`/`--group-title` is used, the CLI expands to member tab ids and runs them sequentially in one daemon session. True parallel execution is not currently supported — the daemon binds a fixed WebSocket port (`18790` by default, override with `DASSI_BRIDGE_PORT`), so multiple daemon processes can't coexist regardless of `--session`. Sequential dispatch is the only supported pattern.
72
+
73
+ ## Claude Code Plugin
74
+
75
+ This package also ships as a Claude Code plugin under the `dassi` namespace. After installation (via either `npm install -g dassi-cli` or `npm link` from this directory), Claude Code auto-discovers two skills:
76
+
77
+ - **`dassi:pick-tabs`** — a reusable tab/group picker. Lists open tabs and Chrome tab groups, asks the user to pick, returns the selected Chrome tab IDs.
78
+ - **`dassi:operate`** — main entry point. Translates natural-language browser asks ("summarize my Research group", "screenshot the active tab", etc.) into `dassi` CLI invocations.
79
+
80
+ The plugin manifest lives at `.claude-plugin/plugin.json`; skill content is under `skills/<name>/SKILL.md`. No additional configuration is required — installing the CLI is sufficient.
81
+
82
+ ## How It Works
83
+
84
+ The CLI (`dassi.mjs`) auto-spawns a background daemon (`dassi-daemon.mjs`) that:
85
+
86
+ - Hosts a WebSocket server on port 18790 for the Chrome extension to connect to
87
+ - Exposes a Unix socket (`~/.dassi/<session>.sock`) for CLI commands via NDJSON protocol
88
+ - Serializes concurrent `run` commands via a FIFO queue
89
+ - Handles first-run onboarding (extension install detection, login flow)
90
+ - Shuts down after 30 minutes of idle
91
+
92
+ ## Dependencies
93
+
94
+ - [`ws`](https://www.npmjs.com/package/ws) — WebSocket server for extension communication
95
+ - [`open`](https://www.npmjs.com/package/open) — Opens browser for onboarding/install flow
96
+
97
+ ## Development
98
+
99
+ ```bash
100
+ # Run tests
101
+ npm test
102
+ ```
103
+
104
+ ## License
105
+
106
+ MIT
@@ -0,0 +1,374 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Dassi CLI Daemon
4
+ *
5
+ * Persistent process that hosts a WebSocket server on port 18790 (override
6
+ * with DASSI_BRIDGE_PORT — the benchmark harness uses 18791 to isolate
7
+ * from a developer's real Chrome on the default port), waits for the Dassi
8
+ * Chrome extension to connect, and exposes a Unix socket for thin CLI
9
+ * clients to send NDJSON commands.
10
+ *
11
+ * Protocol is compatible with agent-browser: {id, action, ...} → {id, success, data/error}.
12
+ * Socket path: ~/.dassi/<session>.sock (env: DASSI_SESSION, default: "default")
13
+ */
14
+
15
+ import * as net from 'net';
16
+ import * as fs from 'fs';
17
+ import { pathToFileURL } from 'url';
18
+ import {
19
+ getAppDir,
20
+ getSocketPath,
21
+ getReadyFile,
22
+ getPidFile,
23
+ validateSession,
24
+ cleanupDaemonFiles,
25
+ buildReadyPayload,
26
+ createCommandQueue,
27
+ } from './dassi-shared.mjs';
28
+
29
+ // ─── Constants ────────────────────────────────────────────────────────────────
30
+
31
+ const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
32
+ const IDLE_CHECK_INTERVAL_MS = 5 * 60 * 1000;
33
+
34
+ // ─── Module-level extension connection state ──────────────────────────────────
35
+
36
+ /** Active WebSocket connection to the registered Dassi extension. */
37
+ let extensionSocket = null;
38
+ /** Pending response callbacks keyed by request ID. */
39
+ const pendingExtResponses = new Map();
40
+
41
+ // ─── WebSocket loader helper ──────────────────────────────────────────────────
42
+
43
+ /**
44
+ * Dynamically loads the `ws` package and returns both the WebSocket client
45
+ * class and the WebSocketServer class.
46
+ * @returns {Promise<{ WS: typeof import('ws').WebSocket; WSServer: typeof import('ws').WebSocketServer }>}
47
+ */
48
+ async function loadWebSocket() {
49
+ try {
50
+ const mod = await import('ws');
51
+ // Reason: ws may export default or named WebSocket depending on build
52
+ return { WS: mod.WebSocket ?? mod.default, WSServer: mod.WebSocketServer };
53
+ } catch {
54
+ // Fallback for environments where dynamic import of CJS doesn't work
55
+ const { createRequire } = await import('module');
56
+ const require = createRequire(import.meta.url);
57
+ const mod = require('ws');
58
+ return { WS: mod.WebSocket ?? mod.default, WSServer: mod.WebSocketServer };
59
+ }
60
+ }
61
+
62
+ // ─── Extension response routing ──────────────────────────────────────────────
63
+
64
+ /**
65
+ * Sets up the persistent message handler on the extension WebSocket that routes
66
+ * responses back to callers waiting in pendingExtResponses.
67
+ * @param {import('ws').WebSocket} ws
68
+ */
69
+ function setupResponseRouter(ws) {
70
+ ws.on('message', (responseData) => {
71
+ let resp;
72
+ try { resp = JSON.parse(responseData.toString()); } catch { return; }
73
+ const pending = pendingExtResponses.get(String(resp.id));
74
+ if (!pending) return;
75
+ pendingExtResponses.delete(String(resp.id));
76
+ if (resp.error) {
77
+ pending.reject(new Error(resp.error.message ?? String(resp.error)));
78
+ } else {
79
+ pending.resolve(resp.result);
80
+ }
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Reject all pending extension responses with a disconnection error.
86
+ * Called when the extension WebSocket closes so callers don't hang forever.
87
+ */
88
+ function drainPendingResponses() {
89
+ for (const [id, pending] of pendingExtResponses) {
90
+ pending.reject(new Error('Extension disconnected'));
91
+ pendingExtResponses.delete(id);
92
+ }
93
+ }
94
+
95
+ // ─── Extension server ─────────────────────────────────────────────────────────
96
+
97
+ /**
98
+ * Accepts a newly registered extension WebSocket: replaces any existing socket,
99
+ * drains pending responses from the old connection, and wires up the new one.
100
+ * @param {import('ws').WebSocket} ws - The new WebSocket connection.
101
+ */
102
+ function acceptExtensionSocket(ws) {
103
+ // Reason: if an old socket exists, close it cleanly before replacing
104
+ if (extensionSocket && extensionSocket !== ws) {
105
+ try { extensionSocket.close(); } catch { /* already closed */ }
106
+ }
107
+ drainPendingResponses();
108
+ extensionSocket = ws;
109
+ ws.on('close', () => {
110
+ if (extensionSocket === ws) {
111
+ extensionSocket = null;
112
+ drainPendingResponses();
113
+ console.log('[dassi-daemon] Extension disconnected, waiting for reconnect…');
114
+ }
115
+ });
116
+ setupResponseRouter(ws);
117
+ }
118
+
119
+ /**
120
+ * Handles a registration message from a newly connected extension WebSocket.
121
+ * On first registration, resolves the startup promise with auth status.
122
+ * On subsequent registrations (reconnects), logs and continues silently.
123
+ * @param {import('ws').WebSocket} ws - The connected WebSocket.
124
+ * @param {Buffer} data - Raw message data.
125
+ * @param {{ resolved: boolean; timeout: ReturnType<typeof setTimeout> }} ctx - Shared state.
126
+ * @param {import('ws').WebSocketServer} wss - The server (closed on fatal error).
127
+ * @param {(value: unknown) => void} resolve - Promise resolve callback.
128
+ * @param {(reason: Error) => void} reject - Promise reject callback.
129
+ */
130
+ async function handleRegistration(ws, data, ctx, wss, resolve, reject) {
131
+ let msg;
132
+ try { msg = JSON.parse(data.toString()); } catch { return; }
133
+ if (msg.type !== 'register' || msg.client !== 'dassi-extension') return;
134
+
135
+ // Reason: only accept a new registration when the current socket is dead.
136
+ // This prevents a rogue local process from hijacking the extension socket
137
+ // while a healthy connection exists.
138
+ if (extensionSocket && extensionSocket.readyState === 1 /* OPEN */) {
139
+ console.log('[dassi-daemon] Rejected registration — existing socket still healthy');
140
+ ws.close();
141
+ return;
142
+ }
143
+
144
+ acceptExtensionSocket(ws);
145
+
146
+ if (!ctx.resolved) {
147
+ clearTimeout(ctx.timeout);
148
+ try {
149
+ const status = await dispatchToExtension({ id: 'init', action: 'status' });
150
+ ctx.resolved = true;
151
+ resolve(status);
152
+ } catch (err) {
153
+ // Reason: don't close WSS — extension may reconnect and retry
154
+ console.error('[dassi-daemon] Initial status check failed:', err.message);
155
+ reject(err);
156
+ }
157
+ } else {
158
+ console.log('[dassi-daemon] Extension reconnected');
159
+ }
160
+ }
161
+
162
+ // Reason: Port is configurable via DASSI_BRIDGE_PORT so the benchmark harness
163
+ // can run a daemon that doesn't conflict with the developer's real Chrome
164
+ // daemon. Defaults to 18790 — the production port that the prod extension
165
+ // SW connects to.
166
+ // Validate so an empty/garbage env var (DASSI_BRIDGE_PORT="") doesn't yield NaN,
167
+ // which `new WSServer({ port: NaN })` would silently accept and bind to a random
168
+ // ephemeral port — leaving the extension's known-port connect attempt hanging.
169
+ const _rawPort = parseInt(process.env.DASSI_BRIDGE_PORT ?? '', 10);
170
+ const BRIDGE_PORT = Number.isInteger(_rawPort) && _rawPort > 0 && _rawPort <= 65535
171
+ ? _rawPort
172
+ : 18790;
173
+
174
+ /**
175
+ * Starts a WebSocket server on the configured bridge port (default 18790).
176
+ * Stays alive for the daemon's lifetime. Waits for the first extension
177
+ * connection, then resolves with the auth status. Subsequent reconnections
178
+ * are handled transparently.
179
+ * @returns {Promise<{ authenticated: boolean; email: string | null; optionsUrl: string }>}
180
+ */
181
+ async function startExtensionServer() {
182
+ const { WSServer } = await loadWebSocket();
183
+
184
+ return new Promise((resolve, reject) => {
185
+ const wss = new WSServer({ host: '127.0.0.1', port: BRIDGE_PORT });
186
+ const ctx = { resolved: false, timeout: null };
187
+
188
+ ctx.timeout = setTimeout(() => {
189
+ wss.close();
190
+ reject(new Error('extension_not_installed'));
191
+ }, 30_000);
192
+
193
+ wss.on('error', (err) => {
194
+ if (!ctx.resolved) {
195
+ clearTimeout(ctx.timeout);
196
+ // Reason: EADDRINUSE means another daemon instance already owns the port
197
+ reject(err.code === 'EADDRINUSE' ? new Error('port_in_use') : new Error('extension_not_installed'));
198
+ }
199
+ });
200
+
201
+ wss.on('connection', (ws) => {
202
+ ws.once('message', (data) => handleRegistration(ws, data, ctx, wss, resolve, reject));
203
+ });
204
+ });
205
+ }
206
+
207
+ // ─── Internal extension dispatcher ───────────────────────────────────────────
208
+
209
+ /**
210
+ * Dispatches a single command to the Dassi extension via the persistent WS
211
+ * connection, translating from the daemon's internal format to the extension's
212
+ * JSON-RPC-style protocol.
213
+ * @param {Record<string, unknown>} cmd - Command object with {id, action, ...params}
214
+ * @returns {Promise<unknown>} The extension's result value (not wrapped in success/data).
215
+ */
216
+ async function dispatchToExtension(cmd) {
217
+ if (!extensionSocket || extensionSocket.readyState !== 1 /* OPEN */) {
218
+ throw new Error('Extension not connected');
219
+ }
220
+
221
+ const { id, action, ...params } = cmd;
222
+ // Reason: the extension expects JSON-RPC style {id, method, params}, not {id, action, ...}
223
+ const message = { id, method: action ?? String(id), params };
224
+ extensionSocket.send(JSON.stringify(message));
225
+
226
+ const timeoutMs = typeof params.timeoutMs === 'number' ? params.timeoutMs + 10_000 : 65_000;
227
+
228
+ return new Promise((resolve, reject) => {
229
+ const timer = setTimeout(() => {
230
+ pendingExtResponses.delete(String(id));
231
+ reject(new Error(`Extension timeout after ${timeoutMs}ms`));
232
+ }, timeoutMs);
233
+
234
+ pendingExtResponses.set(String(id), {
235
+ resolve: (result) => { clearTimeout(timer); resolve(result); },
236
+ reject: (err) => { clearTimeout(timer); reject(err); },
237
+ });
238
+ });
239
+ }
240
+
241
+ // ─── Daemon setup helpers ─────────────────────────────────────────────────────
242
+
243
+ /**
244
+ * Initializes daemon process: creates app dir, writes PID, registers shutdown handlers.
245
+ * @param {string} session - Validated session name.
246
+ * @returns {{ socketPath: string; readyFile: string }}
247
+ */
248
+ function initDaemonProcess(session) {
249
+ const appDir = getAppDir();
250
+ const socketPath = getSocketPath(session);
251
+ const readyFile = getReadyFile(session);
252
+
253
+ fs.mkdirSync(appDir, { recursive: true, mode: 0o700 });
254
+ cleanupDaemonFiles(session);
255
+ fs.writeFileSync(getPidFile(session), String(process.pid));
256
+
257
+ const shutdown = () => {
258
+ cleanupDaemonFiles(session);
259
+ process.exit(0);
260
+ };
261
+ process.on('SIGTERM', shutdown);
262
+ process.on('SIGINT', shutdown);
263
+
264
+ return { socketPath, readyFile };
265
+ }
266
+
267
+ /**
268
+ * Creates and starts a Unix socket server that dispatches NDJSON commands to the queue.
269
+ * @param {{ enqueue: (cmd: Record<string, unknown>) => Promise<unknown> }} queue
270
+ * @param {string} socketPath
271
+ * @returns {net.Server}
272
+ */
273
+ function createSocketServer(queue, socketPath) {
274
+ const server = net.createServer((socket) => {
275
+ let buffer = '';
276
+
277
+ socket.on('data', (chunk) => {
278
+ buffer += chunk.toString();
279
+ const lines = buffer.split('\n');
280
+ // Reason: keep the incomplete last line in the buffer for the next data event
281
+ buffer = lines.pop() ?? '';
282
+
283
+ for (const line of lines) {
284
+ if (!line.trim()) continue;
285
+ let cmd;
286
+ try {
287
+ cmd = JSON.parse(line);
288
+ } catch {
289
+ socket.write(JSON.stringify({ id: 'unknown', success: false, error: 'Invalid JSON' }) + '\n');
290
+ continue;
291
+ }
292
+
293
+ queue.enqueue(cmd)
294
+ .then((response) => {
295
+ if (!socket.destroyed) socket.write(JSON.stringify(response) + '\n');
296
+ })
297
+ .catch((err) => console.error('[dassi-daemon] enqueue error:', err));
298
+ }
299
+ });
300
+
301
+ socket.on('error', () => { /* ignore client disconnects */ });
302
+ });
303
+
304
+ server.listen(socketPath);
305
+ return server;
306
+ }
307
+
308
+ /**
309
+ * Sets up an idle timer that shuts down the daemon after IDLE_TIMEOUT_MS of inactivity.
310
+ * @param {net.Server} server
311
+ * @param {string} session
312
+ * @param {() => number} getLastCommandAt - Returns timestamp of last command.
313
+ */
314
+ function startIdleShutdown(server, session, getLastCommandAt) {
315
+ // Reason: unref() prevents the timer from keeping the process alive if the
316
+ // server is closed; the server itself keeps the process alive while running
317
+ setInterval(() => {
318
+ if (Date.now() - getLastCommandAt() > IDLE_TIMEOUT_MS) {
319
+ server.close();
320
+ cleanupDaemonFiles(session);
321
+ process.exit(0);
322
+ }
323
+ }, IDLE_CHECK_INTERVAL_MS).unref();
324
+ }
325
+
326
+ // ─── Daemon entry point ───────────────────────────────────────────────────────
327
+
328
+ /**
329
+ * Main daemon entry point. Starts the extension WebSocket server, waits for
330
+ * the extension to register, runs onboarding checks, starts the Unix socket
331
+ * server for CLI commands, and sets up idle shutdown.
332
+ * @returns {Promise<void>}
333
+ */
334
+ export async function startDaemon() {
335
+ const session = validateSession(process.env.DASSI_SESSION ?? 'default');
336
+ const { socketPath, readyFile } = initDaemonProcess(session);
337
+
338
+ let statusData;
339
+ try {
340
+ statusData = await startExtensionServer();
341
+ } catch {
342
+ // Reason: write the ready file even on failure so the CLI can read the error state,
343
+ // then exit so the WSS doesn't keep the process alive as a zombie.
344
+ fs.writeFileSync(readyFile, JSON.stringify({ status: 'extension_not_installed' }));
345
+ process.exit(1);
346
+ }
347
+
348
+ let lastCommandAt = Date.now();
349
+ const queue = createCommandQueue(async (cmd) => {
350
+ lastCommandAt = Date.now();
351
+ try {
352
+ const result = await dispatchToExtension(cmd);
353
+ return { id: String(cmd.id ?? 'unknown'), success: true, data: result };
354
+ } catch (err) {
355
+ return { id: String(cmd.id ?? 'unknown'), success: false, error: err.message };
356
+ }
357
+ });
358
+
359
+ // Reason: start the socket server before writing the ready file so the CLI
360
+ // can connect immediately (e.g. for needs_login polling).
361
+ const server = createSocketServer(queue, socketPath);
362
+ fs.writeFileSync(readyFile, JSON.stringify(buildReadyPayload(statusData)));
363
+
364
+ startIdleShutdown(server, session, () => lastCommandAt);
365
+ }
366
+
367
+ // ── Entry point ───────────────────────────────────────────────────────────────
368
+ // Reason: guard allows this file to be imported by tests without starting the daemon
369
+ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
370
+ startDaemon().catch((err) => {
371
+ console.error('[dassi-daemon] fatal:', err.message);
372
+ process.exit(1);
373
+ });
374
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Dassi CLI Shared Utilities
3
+ *
4
+ * Pure helper functions shared between the CLI client (dassi.mjs) and the
5
+ * daemon process (dassi-daemon.mjs). Includes path resolution, session
6
+ * validation, daemon lifecycle helpers, ready-file payload handling, and
7
+ * the serialized command queue.
8
+ */
9
+
10
+ import * as fs from 'fs';
11
+ import * as path from 'path';
12
+ import * as os from 'os';
13
+
14
+ // ─── Path helpers ─────────────────────────────────────────────────────────────
15
+
16
+ /**
17
+ * Returns the base directory for dassi socket/pid/ready files.
18
+ * Priority: XDG_RUNTIME_DIR > ~/.dassi
19
+ * @returns {string}
20
+ */
21
+ export function getAppDir() {
22
+ if (process.env.XDG_RUNTIME_DIR) {
23
+ return path.join(process.env.XDG_RUNTIME_DIR, 'dassi');
24
+ }
25
+ return path.join(os.homedir(), '.dassi');
26
+ }
27
+
28
+ /**
29
+ * Returns the Unix socket path for the given session.
30
+ * @param {string} session
31
+ * @returns {string}
32
+ */
33
+ export function getSocketPath(session) {
34
+ return path.join(getAppDir(), `${session}.sock`);
35
+ }
36
+
37
+ /**
38
+ * Returns the PID file path for the given session.
39
+ * @param {string} session
40
+ * @returns {string}
41
+ */
42
+ export function getPidFile(session) {
43
+ return path.join(getAppDir(), `${session}.pid`);
44
+ }
45
+
46
+ /**
47
+ * Returns the ready file path for the given session.
48
+ * Written by the daemon once the extension WS is connected and auth is verified.
49
+ * @param {string} session
50
+ * @returns {string}
51
+ */
52
+ export function getReadyFile(session) {
53
+ return path.join(getAppDir(), `${session}.ready`);
54
+ }
55
+
56
+ // ─── Session validation ───────────────────────────────────────────────────────
57
+
58
+ /**
59
+ * Validates a session name to prevent path traversal.
60
+ * Only allows alphanumeric characters, hyphens, and underscores.
61
+ * @param {string} session
62
+ * @returns {string} The validated session name.
63
+ * @throws {Error} If the session name contains unsafe characters.
64
+ */
65
+ export function validateSession(session) {
66
+ if (!/^[a-zA-Z0-9_-]+$/.test(session)) {
67
+ throw new Error(`Invalid session name: "${session}". Only alphanumeric, hyphen, and underscore allowed.`);
68
+ }
69
+ return session;
70
+ }
71
+
72
+ // ─── Daemon lifecycle helpers ─────────────────────────────────────────────────
73
+
74
+ /**
75
+ * Removes socket, PID, and ready files for the given session.
76
+ * Called on stale daemon detection or on clean shutdown.
77
+ * @param {string} session
78
+ */
79
+ export function cleanupDaemonFiles(session) {
80
+ for (const filePath of [getSocketPath(session), getPidFile(session), getReadyFile(session)]) {
81
+ try { fs.unlinkSync(filePath); } catch { /* ignore missing files */ }
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Checks whether the daemon is already running for the given session.
87
+ * Reads the PID file and sends signal 0 to verify the process is alive.
88
+ * Cleans up stale files if the process is dead.
89
+ * @param {string} session
90
+ * @returns {boolean}
91
+ */
92
+ export function isDaemonRunning(session) {
93
+ const pidFile = getPidFile(session);
94
+ if (!fs.existsSync(pidFile)) return false;
95
+
96
+ try {
97
+ const pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
98
+ // Reason: signal 0 checks process existence without delivering a real signal
99
+ process.kill(pid, 0);
100
+ return true;
101
+ } catch {
102
+ // Process does not exist — clean up stale files
103
+ cleanupDaemonFiles(session);
104
+ return false;
105
+ }
106
+ }
107
+
108
+ // ─── Onboarding payload helpers ───────────────────────────────────────────────
109
+
110
+ /**
111
+ * Builds the ready file payload from an extension status response.
112
+ * @param {{ authenticated: boolean; email: string | null; optionsUrl: string }} status
113
+ * @returns {{ status: string; email?: string | null; optionsUrl?: string }}
114
+ */
115
+ export function buildReadyPayload(status) {
116
+ if (status.authenticated) {
117
+ return { status: 'ok', email: status.email };
118
+ }
119
+ return { status: 'needs_login', optionsUrl: status.optionsUrl };
120
+ }
121
+
122
+ /**
123
+ * Parses the ready file content into a status object.
124
+ * Returns extension_not_installed on parse failure as a safe fallback.
125
+ * @param {string} raw
126
+ * @returns {{ status: string; [key: string]: unknown }}
127
+ */
128
+ export function parseReadyPayload(raw) {
129
+ try {
130
+ return JSON.parse(raw);
131
+ } catch {
132
+ return { status: 'extension_not_installed' };
133
+ }
134
+ }
135
+
136
+ // ─── Serialized command queue ─────────────────────────────────────────────────
137
+
138
+ /**
139
+ * Creates a serialized command queue.
140
+ * Commands are executed one at a time in FIFO order regardless of how many
141
+ * socket clients are connected simultaneously. This prevents concurrent `run`
142
+ * calls from racing inside the extension.
143
+ *
144
+ * @param {(cmd: Record<string, unknown>) => Promise<{id: string; success: boolean; data?: unknown; error?: string}>} handler
145
+ * @returns {{ enqueue: (cmd: Record<string, unknown>) => Promise<{id: string; success: boolean; data?: unknown; error?: string}> }}
146
+ */
147
+ export function createCommandQueue(handler) {
148
+ const queue = [];
149
+ let processing = false;
150
+
151
+ async function processNext() {
152
+ if (processing || queue.length === 0) return;
153
+ processing = true;
154
+ const { cmd, resolve } = queue.shift();
155
+ try {
156
+ const result = await handler(cmd);
157
+ resolve(result);
158
+ } catch (err) {
159
+ resolve({ id: String(cmd.id ?? 'unknown'), success: false, error: err.message ?? String(err) });
160
+ } finally {
161
+ processing = false;
162
+ // Reason: .catch() prevents unhandled rejection from crashing the daemon
163
+ // if the recursive call fails before reaching its own try/catch
164
+ processNext().catch(() => {});
165
+ }
166
+ }
167
+
168
+ return {
169
+ /**
170
+ * Enqueue a command and return a promise that resolves with the response.
171
+ * The returned Promise always resolves; errors are encoded as { success: false, error } values.
172
+ * @param {Record<string, unknown>} cmd
173
+ * @returns {Promise<{id: string; success: boolean; data?: unknown; error?: string}>}
174
+ */
175
+ enqueue(cmd) {
176
+ return new Promise((resolve) => {
177
+ queue.push({ cmd, resolve });
178
+ processNext().catch(() => {});
179
+ });
180
+ },
181
+ };
182
+ }