@dassi_ai/cli 0.4.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.
@@ -1,12 +1,17 @@
1
1
  {
2
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.3",
3
+ "description": "Use Dassi in Chrome: select a browser target, operate pages directly, or delegate a task and retrieve its result.",
4
+ "version": "0.4.0",
5
5
  "author": {
6
6
  "name": "Omnify Labs",
7
7
  "email": "team@dassi.ai"
8
8
  },
9
9
  "homepage": "https://dassi.ai",
10
10
  "license": "MIT",
11
- "keywords": ["browser", "automation", "chrome-extension", "agent"]
11
+ "keywords": [
12
+ "browser",
13
+ "automation",
14
+ "chrome-extension",
15
+ "agent"
16
+ ]
12
17
  }
package/README.md CHANGED
@@ -1,152 +1,163 @@
1
- # @dassi_ai/cli
1
+ # Dassi CLI
2
2
 
3
- Standalone CLI for the [Dassi](../extension/README.md) Chrome extension run browser automation from the terminal.
3
+ Control Dassi in Chrome from a terminal or a local agent such as Codex, Claude Code, or OpenCode. Requires Node ≥20.11 with npm, and Google Chrome on the same macOS or Linux machine.
4
4
 
5
- ## Quickstart
5
+ ## One-command setup
6
6
 
7
- ```bash
8
- # 1. Install the Dassi extension from the Chrome Web Store and sign in.
9
- # 2. Open the Dassi side panel once — this wakes the extension so the CLI can reach it.
10
- # 3. Run a command (no install needed):
11
- npx @dassi_ai/cli@latest list-tabs # find a TAB ID
12
- npx @dassi_ai/cli@latest run "summarize this page" --tab <id>
7
+ ```sh
8
+ npx --yes @dassi_ai/cli@latest setup
13
9
  ```
14
10
 
15
- Prefer a permanent `dassi` command? `npm install -g @dassi_ai/cli` (Node 20.11), then drop the `npx @dassi_ai/cli@latest` prefix.
11
+ Setup installs the CLI into npm's global location and registers its bundled skill in `~/.agents/skills/dassi` (Codex/OpenCode) and `~/.claude/skills/dassi` (Claude Code). It honors `CLAUDE_CONFIG_DIR` for Claude's location. The terminal command and agent launcher use that same npm package. An existing global install is reused; an npx invocation copies its package out of the temporary cache using npm's `--install-links`. Existing skills pointing elsewhere are preserved and reported for manual resolution.
16
12
 
17
- Two things that trip people up:
18
- - **`run` needs a target** — pass `--tab <id>` (or `--group <id>` / `--group-title <name>`). `--profile` only selects *which* connected Chrome, not the tab.
19
- - **Multiple Chrome windows/profiles connected?** Commands need `--profile <label>`; run `dassi list-profiles` to see the labels (one profile = no flag needed).
13
+ If you prefer installing the command first:
20
14
 
21
- ## Installation
15
+ ```sh
16
+ npm install -g @dassi_ai/cli@latest
17
+ dassi
18
+ ```
19
+
20
+ Running `dassi` without arguments in an interactive terminal starts the same setup flow. Scripts must request `setup` explicitly; bare noninteractive and JSON invocations return an actionable error. Setup requires a writable npm global location. If npm reports a permissions error, follow its [user-level installation guidance](https://docs.npmjs.com/resolving-eacces-permissions-errors-when-installing-packages-globally/), then rerun setup.
21
+
22
+ If Dassi is not connected, setup opens its Chrome Web Store listing. In the Chrome profile you want to use, click **Add to Chrome**, then **Add extension**. Chrome downloads and installs the extension. Setup waits for it to connect and verifies live tool discovery and tab access. With multiple connected profiles, an interactive terminal asks which one to use; scripts receive the available profile IDs instead of a guessed selection.
23
+
24
+ Browser tools work while signed out. Setup finishes after verifying the browser and does not open sign-in settings. Dassi sign-in is needed when you choose task delegation with `run`. JSON reports delegation separately; `data.delegationError` explains an unavailable sign-in check without blocking browser readiness. Setup retries temporary browser connection failures until its deadline. It does not run a model task as its connection check. The Web Store provides extension updates; a connection that lacks tool discovery receives update instructions.
22
25
 
23
- ```bash
24
- # Zero-install
25
- npx @dassi_ai/cli --help
26
+ Setup waits up to five minutes for Chrome by default. Rerun the same command to resume. For local agents and scripts:
26
27
 
27
- # Or install globally
28
- npm install -g @dassi_ai/cli
28
+ ```sh
29
+ npx --yes @dassi_ai/cli@latest setup --wait 0 --json
30
+ npx --yes @dassi_ai/cli@latest setup --profile '<profile-id>' --wait 30s --json
29
31
  ```
30
32
 
31
- Or, for local development from a clone of this repo:
33
+ Read `data.ready`, `data.browserTools`, and `data.delegation`. An incomplete setup exits nonzero and includes the next action; it preserves the installation already completed. `--no-open` prints instructions without opening Chrome. Chrome installation confirmation and account sign-in remain user actions. Setup's observer deadline does not stop browser work.
32
34
 
33
- ```bash
34
- cd cli
35
- npm install
36
- npm link # exposes `dassi` on PATH
35
+ When setup says **“You're ready,”** open your agent and ask **“Use Dassi to show my open browser tabs.”** Restart an already-running agent to load the skill. Its bundled launcher works without PATH configuration. Installation paths and delegation diagnostics remain available in `setup --json`.
36
+
37
+ ## Update and remove
38
+
39
+ Update the terminal command and its bundled agent skill together:
40
+
41
+ ```sh
42
+ npm install -g @dassi_ai/cli@latest
37
43
  ```
38
44
 
39
- ## Prerequisites
45
+ You can also rerun `npx --yes @dassi_ai/cli@latest setup` to update and check Chrome. Restart running agents to load updated skill instructions. If you change Node versions or npm prefixes, rerun setup; registrations pointing elsewhere are reported for manual resolution.
40
46
 
41
- - Node.js >= 20.11.1
42
- - Dassi Chrome extension installed and running
43
- - **External Bridge** enabled in Dassi Options (`options.html?dev`)
47
+ To remove Dassi from local agents and uninstall the CLI:
44
48
 
45
- ## Usage
49
+ ```sh
50
+ dassi skill --remove
51
+ npm uninstall -g @dassi_ai/cli
52
+ ```
46
53
 
47
- ```bash
48
- # List all open Chrome tabs
49
- dassi list-tabs
54
+ Skill removal deletes only symlinks to this npm installation, preserving custom skills and other installations. npm owns package removal. Chrome's extension, browser conversations, and runtime files are separate; remove the extension through Chrome if desired.
50
55
 
51
- # Run an agent prompt on a specific tab
52
- dassi run "summarize the top issues" --tab 456
56
+ ## Use from a local agent
53
57
 
54
- # Run with a timeout (ms)
55
- dassi run "check inbox" --tab 123 --timeout 60000
58
+ The shared skill includes `scripts/dassi.mjs`, a launcher for the matching CLI version:
56
59
 
57
- # Send a raw JSON command
58
- dassi raw '{"id":"1","action":"list_tabs"}'
60
+ ```sh
61
+ node '<installed-skill-directory>/scripts/dassi.mjs' list-tabs --json
62
+ ```
63
+
64
+ Examples below use `dassi` as shorthand for that invocation or the globally installed command. `dassi skill` prints the bundled workflow without requiring Chrome; `dassi skill --path` locates its directory. The skill covers profile selection, tool discovery, delegation, bounded polling and recovery without resubmitting accepted work.
65
+
66
+ Native registration follows the documented [Codex](https://learn.chatgpt.com/docs/build-skills), [Claude Code](https://code.claude.com/docs/en/skills), and [OpenCode](https://opencode.ai/docs/skills) discovery locations. Other shell agents can load the same skill and launcher. Shell and local browser connectivity remain required.
67
+
68
+ ## Discover and call browser tools
59
69
 
60
- # Output as JSON (for scripting)
70
+ The connected extension owns tool names, instructions and parameter schemas. The CLI has no browser-name translation table or cached catalog.
71
+
72
+ ```sh
61
73
  dassi list-tabs --json
74
+ # Copy a target from the intended profile.
75
+ dassi tools --tab '<target>' --json
76
+ dassi tools '<name-from-discovery>' --tab '<target>' --json
77
+ dassi call '<name-from-discovery>' --tab '<target>' --args '{"parameter":"value"}' --json
78
+ ```
62
79
 
63
- # Use a named session
64
- dassi list-tabs --session work
80
+ `tools` lists short descriptions; `tools <name>` returns the full description and JSON schema. Discovery does not group tabs or claim a conversation. You can also use `tools --profile '<profile-id>'` without a tab. It requires one identifiable profile and does not merge catalogs from different installations.
65
81
 
66
- # List connected profiles (multiple Chrome profiles can connect at once)
67
- dassi list-profiles
82
+ `call` forwards the tool name and JSON object unchanged. The extension applies defaults, validates the parameters, checks its existing direct-call restrictions and executes under the existing CLI ownership rules. It supplies the outer `--tab` target to tools whose schema declares `tabId`, overriding any different tab ID in the JSON. Other schemas receive no added argument. Tools that require agent-run, automation or human-reply context (including `ask_user`) are excluded from both discovery and direct calls.
68
83
 
69
- # Target a specific profile by label when more than one is connected
70
- dassi list-tabs --profile dev
71
- dassi run "summarize" --tab 456 --profile dev # alias: --label
72
-
73
- # Launch a fresh Chrome with a locally-built dev dist (for testing extension changes)
74
- pnpm build # build extension/dist first
75
- dassi launch # loads extension/dist as profile "dev"
76
- dassi launch --label qa --dist some/dist # custom label + dist
77
- # Drive the launched Chrome run still needs a tab/group target; --profile selects which Chrome:
78
- dassi list-tabs --profile dev # find a tab id in the launched profile
79
- dassi run "summarize this page" --tab <id> --profile dev
80
- dassi launch --stop # close the "dev" Chrome (or --stop-all)
81
- # Note: launch reuses the default daemon (port 18790). To launch on an isolated
82
- # port (DASSI_BRIDGE_PORT=18791 dassi launch), no default daemon may be running —
83
- # it errors clearly otherwise, since it can't confirm the running daemon's port.
84
- #
85
- # How the extension is loaded (auto-detected per Chrome binary):
86
- # • Branded Google Chrome (137+) disabled the `--load-extension` flag, so launch
87
- # installs the dist at runtime via the `Extensions.loadUnpacked` CDP command over
88
- # `--remote-debugging-pipe`. Because such an extension lives only as long as the
89
- # debugging pipe, launch spawns a detached `__launch-hold` helper that keeps the
90
- # pipe open; `--stop` kills the helper, which closes the pipe and its Chrome.
91
- # • Chrome for Testing / Chromium still honour `--load-extension` (persistent), so
92
- # launch uses that directly there — no helper. Point at one with `--chrome <path>`.
93
- # • The mode is auto-detected from the binary; override with `--load-mode auto|pipe|flag`
94
- # (e.g. `dassi launch --chrome <cft> --load-mode pipe` to exercise the pipe path on CfT).
95
-
96
- # Show version / help
97
- dassi --version
98
- dassi --help
84
+ Direct calls wait for the capability's result or error; the CLI and daemon impose no additional execution deadline. Use the timeout parameter from that tool's schema when available. Closing the CLI does not cancel a direct call. A lost connection after submission reports `outcome: "unknown"`; inspect the browser before retrying because the action may still be running. Actual tool errors retain their original meaning.
85
+
86
+ To save returned images, use `call ... -o /tmp/dassi-page.jpg` without `--json`. JSON returns raw image content. `panel-screenshot` still captures Dassi's own UI, and `open [url] --window <id>` creates an ungrouped background tab (`--foreground` activates it).
87
+
88
+ CLI 0.7 replaces the handwritten browser aliases (`read-page`, `get-text`, `click`, `fill`, `type`, `navigate`, `screenshot`, `eval`, `tabs`, `close`, and grouped `open`) with `tools` and `call`. Scripts must discover the current tool and use its schema. `list-tabs`, `run`, `status`, `stop`, and the raw bridge interface retain their roles.
89
+
90
+ ## Delegate a task
91
+
92
+ Copy the `TARGET` from the listing, then start a task:
93
+
94
+ ```sh
95
+ dassi run 'Review these applications' --tab '<target>'
96
+ # Returns a task ID after the browser accepts the work.
97
+ dassi status '<task-id>'
98
+ dassi run 'Now compare the strongest two' --task '<task-id>'
99
+ dassi stop '<task-id>'
99
100
  ```
100
101
 
101
- ### Tab groups
102
+ A task can run for more than 30 minutes. Closing the terminal, a disconnected CLI, or an expired wait does not cancel it. To wait for a result in the current command:
103
+
104
+ ```sh
105
+ dassi run 'Summarize this page' --tab '<target>' --wait 30s --json
106
+ dassi status '<task-id>' --wait 10m --json
107
+ ```
102
108
 
103
- ```bash
104
- # List all tab groups
105
- dassi list-groups
109
+ Successful `stop` commands exit zero. `run` and task `status` exit nonzero for failed or stopped tasks.
106
110
 
107
- # Run agent against every tab in a group (sequential)
108
- dassi run "summarize each page" --group 7
111
+ When the wait ends, inspect `data.status`: `running` or `stopping` means check the **same task** again. `completed`, `failed`, and `stopped` are final. Each task is one conversation. A fresh `run --tab` starts a new conversation, even on the same tab. `run --task` explicitly continues that conversation and returns the same ID; status shows its latest reply and run outcome. A follow-up submitted while running uses the native follow-up queue. It has no separate result ID. Status reports persisted messages, so an in-progress model turn may not yet appear. Keep the full opaque task ID; it includes its browser identity. Tasks and their transcripts remain available until their conversation history is deleted. Follow-ups use the original browser tab; if it has closed, start a new task on another tab.
109
112
 
110
- # Use a group title instead of id (errors if title is ambiguous)
111
- dassi run "extract prices" --group-title "Shopping"
113
+ ## Multiple Chrome profiles
112
114
 
113
- # Tool commands also accept --group / --group-title
114
- dassi screenshot --group-title "Research" -o shot.png
115
+ `list-tabs`, `list-groups`, `list-profiles`, and bare `status` discover all connected profiles. Listings include profile labels, stable installation IDs, and sign-in status. A target combines the installation ID and the Chrome tab/group ID. Always copy it; tab IDs alone do not identify a browser profile.
116
+
117
+ For an existing numeric tab ID, `--profile <id-or-unique-name>` remains available. Duplicate names produce an ambiguity error. Profile selection does not fall back to another installation after disconnect. Sign-in is checked in the profile receiving a task, and discovery works while signed out.
118
+
119
+ ```sh
120
+ dassi list-profiles
121
+ dassi list-tabs --profile '<profile-id>'
122
+ dassi run 'Summarize' --tab 42 --profile '<profile-id>'
115
123
  ```
116
124
 
117
- 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.
125
+ ## Browser groups
118
126
 
119
- ## Claude Code Plugin
127
+ `run --group <target>` submits **one prompt for the whole group**, using the group as its browser workspace. `call --group <target>` executes sequentially once per member tab; use a single tab for an action intended to happen once. A fresh `run` on a busy group is refused with the existing task ID. Wait, explicitly continue that task, stop it, or use another tab.
120
128
 
121
- This package also ships as a Claude Code plugin under the `dassi` namespace. After installation (via either `npm install -g @dassi_ai/cli` or `npm link` from this directory), Claude Code auto-discovers two skills:
129
+ ## Development and upgrades
122
130
 
123
- - **`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.
124
- - **`dassi:operate`** — main entry point. Translates natural-language browser asks ("summarize my Research group", "screenshot the active tab", etc.) into `dassi` CLI invocations.
131
+ ```sh
132
+ # From the repository root:
133
+ pnpm --dir extension build
134
+ dassi launch --label dev
135
+ dassi list-tabs --profile dev
136
+ dassi launch --stop dev
137
+ ```
125
138
 
126
- 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.
139
+ Launch supports `--dist`, `--chrome`, `--profile-dir`, `--load-mode auto|pipe|flag`, and Chrome arguments after `--`. Branded Chrome uses a detached helper holding a CDP pipe for the unpacked extension; Chromium/Chrome for Testing can use `--load-extension` directly.
127
140
 
128
- ## How It Works
141
+ CLI 0.7 requires the current extension with live tool discovery, and CLI protocol 3 for task submission. Incompatible clients, daemons, and extensions fail before a task is submitted, in either upgrade order. After upgrading the CLI, finish any old CLI calls and restart an older daemon if prompted (its PID is in `~/.dassi/default.pid`). Browser conversations survive a daemon restart. This package is not auto-published by a source change.
129
142
 
130
- The CLI (`dassi.mjs`) auto-spawns a background daemon (`dassi-daemon.mjs`) that:
143
+ `run --timeout` is rejected with migration instructions. Use `--wait` to observe and `stop` to cancel. Scripts must check task status and explicitly stop a task if they impose an execution budget. `--session`/`DASSI_SESSION` are retained for development compatibility; they select a daemon socket, not a browser or conversation. Normal use needs neither. `DASSI_BRIDGE_PORT` configures an isolated development bridge.
131
144
 
132
- - Hosts a WebSocket server on port 18790 for the Chrome extension to connect to
133
- - Exposes a Unix socket (`~/.dassi/<session>.sock`) for CLI commands via NDJSON protocol
134
- - Serializes concurrent `run` commands via a FIFO queue
135
- - Handles first-run onboarding (extension install detection, login flow)
136
- - Shuts down after 30 minutes of idle
145
+ The daemon routes short requests. Native runtime admission controls each conversation; the daemon does not serialize unrelated profiles or hold a connection for a task's lifetime. Startup requires no sign-in. The native conversation owns admission, cancellation, recovery, and transcript. Status reads its transcript and state in one storage transaction; a single last-run summary survives later compaction. There is no per-prompt task ledger.
137
146
 
138
- ## Dependencies
147
+ Before promoting a CLI release to npm `latest`, make its compatible extension available in the Chrome Web Store. Check the packed npm artifact in a fresh global prefix, then verify setup against that store installation: extension confirmation, signed-out browser access, profile choice, agent discovery, an initial tab listing, and resume after interruption. Local builds and injected browser responses do not establish that store-to-npm release path.
139
148
 
140
- - [`ws`](https://www.npmjs.com/package/ws) — WebSocket server for extension communication
141
- - [`open`](https://www.npmjs.com/package/open) — Opens browser for onboarding/install flow
149
+ ## Output for scripts
142
150
 
143
- ## Development
151
+ Browser and task commands with `--json` write their response to stdout, including argument, setup, and connection failures:
144
152
 
145
- ```bash
146
- # Run tests
147
- npm test
153
+ ```json
154
+ {"success": false, "error": "An older Dassi daemon is running. Finish active tasks, restart the daemon, then retry with this CLI."}
148
155
  ```
149
156
 
150
- ## License
157
+ Parse stdout even on a nonzero exit. Successful responses contain `data`; failures contain `error`. Direct group tools return an array of `{tabId, response}`. Task status, rather than exit zero alone, tells you whether delegated work finished. `--help`, `--version`, and `skill` also support JSON, with a string in `data`. Development `launch` commands use terminal output.
158
+
159
+ An error's top-level `taskId` means that submission may have been accepted: check it before retrying. `existingTaskId` refers to other work already using the browser workspace. Do not treat it as permission to cancel that work.
160
+
161
+ The shared skill ships in `skills/dassi`. It replaces the former `operate` and `pick-tabs` skills. The Claude Code plugin manifest remains at `.claude-plugin/plugin.json`; its skill command is now `/dassi:dassi` when loaded as a plugin. Installing the npm package alone does not register a plugin in an agent.
151
162
 
152
- MIT
163
+ Run CLI tests with `pnpm --dir cli test`.
@@ -0,0 +1,181 @@
1
+ /** CLI transport, daemon startup, and bounded observation of browser-owned tasks. */
2
+
3
+ import * as net from 'net';
4
+ import * as fs from 'fs';
5
+ import * as path from 'path';
6
+ import * as child_process from 'child_process';
7
+ import { fileURLToPath } from 'url';
8
+ import {
9
+ getSocketPath,
10
+ CLI_PROTOCOL_VERSION,
11
+ getReadyFile,
12
+ isDaemonRunning,
13
+ parseReadyPayload,
14
+ CHROME_WEB_STORE_URL,
15
+ unknownToolOutcome,
16
+ } from './dassi-shared.mjs';
17
+
18
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
+ const DAEMON_SCRIPT = path.join(__dirname, 'dassi-daemon.mjs');
20
+ const READY_POLL_MS = 100;
21
+ const READY_TIMEOUT_MS = 30_000;
22
+
23
+ // ─── Daemon management ────────────────────────────────────────────────────────
24
+
25
+ /**
26
+ * Ensures the daemon is running for the given session.
27
+ * Spawns it as a detached background process if the PID file is missing or stale.
28
+ * @param {string} session
29
+ */
30
+ export function ensureDaemonRunning(session) {
31
+ if (isDaemonRunning(session)) return;
32
+
33
+ // Reason: detached + unref means the daemon outlives the CLI process
34
+ const daemon = child_process.spawn(process.execPath, [DAEMON_SCRIPT], {
35
+ detached: true,
36
+ stdio: 'ignore',
37
+ env: { ...process.env, DASSI_SESSION: session },
38
+ });
39
+ daemon.unref();
40
+ }
41
+
42
+ /**
43
+ * Polls for the ready file until it appears or the timeout elapses.
44
+ * Resolves with the parsed ready payload.
45
+ * @param {string} readyFile
46
+ * @param {number} [timeoutMs]
47
+ * @returns {Promise<{ status: string; [key: string]: unknown }>}
48
+ */
49
+ export function waitForReady(readyFile, timeoutMs = READY_TIMEOUT_MS) {
50
+ return new Promise((resolve, reject) => {
51
+ const deadline = Date.now() + timeoutMs;
52
+ const check = () => {
53
+ if (fs.existsSync(readyFile)) {
54
+ try {
55
+ resolve(parseReadyPayload(fs.readFileSync(readyFile, 'utf8')));
56
+ return;
57
+ } catch { /* file may be partially written — keep polling */ }
58
+ }
59
+ if (Date.now() >= deadline) {
60
+ const seconds = Math.round(timeoutMs / 1000);
61
+ reject(new Error(`Timed out waiting for Dassi daemon to start (${seconds}s)`));
62
+ return;
63
+ }
64
+ setTimeout(check, READY_POLL_MS);
65
+ };
66
+ check();
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Connects to the daemon Unix socket, sends one NDJSON command, and reads the response.
72
+ * @param {string} socketPath - Unix socket path.
73
+ * @param {object} command - Command object to send (will be JSON-stringified).
74
+ * @returns {Promise<object>} Parsed response object.
75
+ */
76
+ export function sendCommand(socketPath, command, {
77
+ // Direct tools own their deadlines; a plugin need not expose a timeout parameter.
78
+ timeoutMs = command.action === 'tool_exec' ? null : command.action !== 'run' && Number.isFinite(command.timeoutMs) && command.timeoutMs > 0
79
+ ? Math.min(command.timeoutMs + 15_000, 2147483647)
80
+ : 70_000,
81
+ } = {}) {
82
+ return new Promise((resolve, reject) => {
83
+ const socket = net.createConnection(socketPath);
84
+ const timer = timeoutMs == null ? null : setTimeout(() => socket.destroy(new Error('Daemon did not respond. Check the existing task before retrying.')), timeoutMs);
85
+ let buffer = '';
86
+ let settled = false;
87
+ let submitted = false;
88
+
89
+ // Reason: guard against double-settlement since 'close' fires after socket.destroy()
90
+ // in the normal data path, and we don't want the close handler to re-resolve
91
+ const settle = (fn, val) => {
92
+ if (settled) return;
93
+ settled = true;
94
+ clearTimeout(timer);
95
+ fn(fn === reject && submitted && command.action === 'tool_exec' ? unknownToolOutcome(val) : val);
96
+ };
97
+
98
+ socket.on('connect', () => {
99
+ submitted = true;
100
+ socket.write(JSON.stringify(command) + '\n');
101
+ });
102
+
103
+ socket.on('data', (chunk) => {
104
+ buffer += chunk.toString();
105
+ const nl = buffer.indexOf('\n');
106
+ if (nl !== -1) {
107
+ const line = buffer.slice(0, nl);
108
+ socket.destroy();
109
+ try { settle(resolve, JSON.parse(line)); }
110
+ catch { settle(reject, new Error(`Invalid JSON from daemon: ${line}`)); }
111
+ }
112
+ });
113
+
114
+ socket.on('error', (err) => settle(reject, err));
115
+
116
+ socket.on('close', () => {
117
+ if (settled) return;
118
+ // Reason: attempt to parse whatever arrived before the socket closed unexpectedly
119
+ if (buffer.trim()) {
120
+ try { settle(resolve, JSON.parse(buffer.trim())); }
121
+ catch { settle(reject, new Error(`Invalid JSON from daemon: ${buffer.trim()}`)); }
122
+ } else {
123
+ settle(reject, new Error('Daemon closed connection without a response'));
124
+ }
125
+ });
126
+ });
127
+ }
128
+
129
+ // ─── Daemon readiness ─────────────────────────────────────────────────────────
130
+
131
+ /**
132
+ * Ensures the local command transport is ready.
133
+ * @param {string} session
134
+ * @returns {Promise<string>} The daemon's Unix socket path.
135
+ */
136
+ export async function ensureDaemonReady(session) {
137
+ const socketPath = getSocketPath(session);
138
+ const readyFile = getReadyFile(session);
139
+
140
+ // Reason: delete any stale ready file from a previous run so the new daemon
141
+ // writes a fresh one. Skip if a daemon is already running to avoid racing.
142
+ if (!isDaemonRunning(session)) {
143
+ try { fs.unlinkSync(readyFile); } catch { /* ok if missing */ }
144
+ }
145
+
146
+ ensureDaemonRunning(session);
147
+
148
+ const ready = await waitForReady(readyFile);
149
+
150
+ if (ready.status === 'extension_not_installed') {
151
+ throw new Error(
152
+ `Dassi extension not detected.\n\n` +
153
+ ` Install it from:\n ${CHROME_WEB_STORE_URL}\n\n` +
154
+ ` Then run this command again.`
155
+ );
156
+ }
157
+
158
+ if (ready.status === 'error') throw new Error(ready.error);
159
+ if (ready.protocolVersion !== CLI_PROTOCOL_VERSION) throw new Error('An older Dassi daemon is running. Finish active tasks, restart the daemon, then retry with this CLI.');
160
+
161
+ return socketPath;
162
+ }
163
+
164
+ /** Waiting expires locally; it never cancels the browser-owned task. */
165
+ export async function sendAndWait(socketPath, command, send = sendCommand, now = Date.now, sleep = ms => new Promise(resolve => setTimeout(resolve, ms))) {
166
+ const { waitMs = 0, ...wire } = command;
167
+ if (['run', 'task_status', 'task_stop'].includes(wire.action)) wire.protocolVersion = CLI_PROTOCOL_VERSION;
168
+ let response = await send(socketPath, wire);
169
+ const deadline = now() + waitMs;
170
+ while (response.success && ['running', 'stopping'].includes(response.data?.status) && now() < deadline) {
171
+ await sleep(Math.min(1000, deadline - now()));
172
+ if (now() >= deadline) break;
173
+ const taskId = response.data.taskId;
174
+ try {
175
+ const next = await send(socketPath, { id: `${wire.id}-status-${now()}`, action: 'task_status', taskId, protocolVersion: CLI_PROTOCOL_VERSION }, { timeoutMs: Math.max(1, deadline - now()) });
176
+ if (!next.success) return { ...next, taskId, error: `${next.error} Check task ${taskId}; do not resubmit.` };
177
+ response = next;
178
+ } catch (error) { if (now() >= deadline) return response; return { success: false, taskId, error: `${error.message} Check task ${taskId}; do not resubmit.` }; }
179
+ }
180
+ return response;
181
+ }