@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.
- package/.claude-plugin/plugin.json +12 -0
- package/README.md +106 -0
- package/dassi-daemon.mjs +374 -0
- package/dassi-shared.mjs +182 -0
- package/dassi.mjs +425 -0
- package/format-response.mjs +134 -0
- package/group-expansion.mjs +157 -0
- package/package.json +42 -0
- package/skills/operate/SKILL.md +124 -0
- package/skills/operate/command-reference.md +50 -0
- package/skills/pick-tabs/SKILL.md +93 -0
- package/tool-commands.mjs +144 -0
|
@@ -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
|
package/dassi-daemon.mjs
ADDED
|
@@ -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
|
+
}
|
package/dassi-shared.mjs
ADDED
|
@@ -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
|
+
}
|