droid-mode 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,6 +22,61 @@ Droid Mode lets you:
22
22
  2. Access them **on-demand** through progressive discovery
23
23
  3. Run procedural workflows that call MCP tools **outside** the LLM loop
24
24
 
25
+ ## Performance: Daemon Mode
26
+
27
+ Droid Mode includes an optional daemon that maintains persistent MCP connections, dramatically reducing latency for repeated operations.
28
+
29
+ ### Benchmark Results
30
+
31
+ | Mode | 10 Operations | Per-Call (warm) | Speedup |
32
+ |------|---------------|-----------------|---------|
33
+ | Without Daemon | 29.9s | ~2,900ms | baseline |
34
+ | **With Daemon** | **10.2s** | **~620ms** | **4.7x faster** |
35
+
36
+ ### How It Works
37
+
38
+ Each `dm call` normally spawns a new process, initializes the MCP connection, executes the tool, and exits. This adds ~2.5s overhead per call.
39
+
40
+ The daemon maintains a connection pool. After the first call to a server, subsequent calls reuse the existing connection:
41
+
42
+ ```
43
+ ┌──────────────────────────────────────────────────┐
44
+ │ dm daemon (background) │
45
+ │ Connection Pool: │
46
+ │ ├── context-repo: connected (15 calls) │
47
+ │ ├── convex: idle │
48
+ │ └── firecrawl: connected (3 calls) │
49
+ └──────────────────────────────────────────────────┘
50
+
51
+ dm call --server X
52
+
53
+ ~620ms instead of ~2,900ms
54
+ ```
55
+
56
+ ### Usage
57
+
58
+ ```bash
59
+ # Start daemon (runs in background)
60
+ dm daemon start
61
+
62
+ # All dm call commands automatically use daemon
63
+ dm call list_collections --server context-repo # ~620ms
64
+
65
+ # Check connection status
66
+ dm daemon status
67
+
68
+ # Stop daemon when done
69
+ dm daemon stop
70
+ ```
71
+
72
+ ### When to Use Daemon
73
+
74
+ - **Multi-operation workflows** — Running several tool calls in sequence
75
+ - **Interactive development** — Testing and iterating with MCP tools
76
+ - **Benchmarking** — Accurate timing without spawn overhead
77
+
78
+ The daemon is optional. Without it, Droid Mode works exactly as before.
79
+
25
80
  ## Installation
26
81
 
27
82
  ```bash
@@ -113,6 +168,9 @@ dm run --server context-repo \
113
168
  | `dm call <tool> --server <name>` | Call a tool directly |
114
169
  | `dm run --workflow <file> --tools <a,b> --server <name>` | Execute workflow |
115
170
  | `dm doctor --server <name>` | Diagnose connection |
171
+ | `dm daemon start` | Start background daemon |
172
+ | `dm daemon stop` | Stop daemon |
173
+ | `dm daemon status` | Show connection pool status |
116
174
 
117
175
  ## Design Philosophy
118
176
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "droid-mode",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "Progressive Code-Mode MCP integration for Factory.ai Droid - access MCP tools without context bloat",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",
@@ -150,6 +150,40 @@ All artifacts written to `.factory/droid-mode/`:
150
150
 
151
151
  ---
152
152
 
153
+ ## Daemon Mode (Performance)
154
+
155
+ The daemon maintains persistent MCP connections, reducing call latency by ~5x.
156
+
157
+ ### Benchmark Results
158
+
159
+ | Mode | Per-Call (warm) | 10 Operations |
160
+ |------|-----------------|---------------|
161
+ | Without Daemon | ~2,900ms | 29.9s |
162
+ | **With Daemon** | **~620ms** | **10.2s** |
163
+
164
+ ### Usage
165
+
166
+ ```bash
167
+ # Start daemon (runs in background)
168
+ dm daemon start
169
+
170
+ # All dm call commands automatically use daemon
171
+ dm call list_collections --server context-repo
172
+
173
+ # Check connection status
174
+ dm daemon status
175
+
176
+ # Stop daemon
177
+ dm daemon stop
178
+
179
+ # Bypass daemon for a single call
180
+ dm call tool --server X --no-daemon
181
+ ```
182
+
183
+ The daemon is optional—without it, everything works as before.
184
+
185
+ ---
186
+
153
187
  ## Security Posture
154
188
 
155
189
  - Credentials remain in `mcp.json` env/headers, not in prompts
@@ -35,6 +35,15 @@ Servers with `disabled: true` in `mcp.json` are **fully available** to droid-mod
35
35
 
36
36
  This is the entire point of the skill.
37
37
 
38
+ ## Idempotency
39
+
40
+ All droid-mode commands are safe to rerun:
41
+ - `dm servers` / `dm index` — read-only discovery
42
+ - `dm hydrate` — overwrites previous hydration (timestamped)
43
+ - `dm run` — each run creates a new timestamped trace
44
+
45
+ No cleanup required between invocations.
46
+
38
47
  ## Workflow Example
39
48
 
40
49
  ```js
@@ -57,13 +66,27 @@ After using droid-mode, verify:
57
66
  - [ ] Artifacts exist in `.factory/droid-mode/` (cache, hydrated, runs)
58
67
  - [ ] Workflow trace shows no errors (`runs/<server>/<ts>/run.json`)
59
68
 
69
+ ## Proof Artifacts
70
+
71
+ After completing a workflow, produce evidence:
72
+
73
+ - **Discovery proof**: Screenshot or paste of `dm index --server X` output
74
+ - **Hydration proof**: Confirm `types.d.ts` exists and compiles (`tsc --noEmit`)
75
+ - **Execution proof**: Link to `run.json` trace showing `error: false`
76
+ - **For PRs**: Include trace file or summary in PR description
77
+
60
78
  ## Success Criteria
61
79
 
62
- The skill completes successfully when:
80
+ The skill completes successfully when these artifacts exist:
63
81
 
64
- - Tool discovery returned results (`dm index` shows tools)
65
- - Hydrated schemas are valid (`types.d.ts` generated)
66
- - Workflow executed without sandbox errors (trace shows `error: false`)
82
+ | Artifact | Path | Content |
83
+ |----------|------|---------|
84
+ | Tool cache | `.factory/droid-mode/cache/<server>/tools.json` | Array of tool objects |
85
+ | Hydrated schemas | `.factory/droid-mode/hydrated/<server>/<ts>/schemas.json` | Full JSON schemas |
86
+ | TypeScript types | `.factory/droid-mode/hydrated/<server>/<ts>/types.d.ts` | Generated type definitions |
87
+ | Execution trace | `.factory/droid-mode/runs/<server>/<ts>/run.json` | `{ error: false, result: ... }` |
88
+
89
+ Verify with: `dm doctor --server X` (should exit 0)
67
90
 
68
91
  ## Fallbacks
69
92
 
@@ -88,6 +111,8 @@ All outputs written to `.factory/droid-mode/`:
88
111
  - `hydrated/<server>/<ts>/` — schemas + types
89
112
  - `runs/<server>/<ts>/run.json` — execution trace
90
113
 
114
+ All JSON artifacts are machine-parseable for downstream skill chaining. Workflows can read `tools.json` or `run.json` to inform subsequent steps.
115
+
91
116
  ## Supporting Files
92
117
 
93
118
  - `bin/dm` — CLI entry point
@@ -95,3 +120,14 @@ All outputs written to `.factory/droid-mode/`:
95
120
  - `examples/workflows/` — sample workflow files
96
121
  - `examples/hooks/` — PreToolUse hook examples
97
122
  - `README.md` — full documentation
123
+
124
+ ## References
125
+
126
+ For project-specific conventions, see:
127
+ - `AGENTS.md` — project-wide agent guidance (if present)
128
+ - `mcp.json` — MCP server configuration
129
+ - `.factory/skills/*/SKILL.md` — related skills that may chain with droid-mode
130
+
131
+ For droid-mode internals:
132
+ - `README.md` — full CLI documentation
133
+ - `examples/` — sample workflows and hooks
@@ -8,6 +8,7 @@ import { getToolsCached, compactToolSummaries } from "../lib/tool_index.mjs";
8
8
  import { searchTools } from "../lib/search.mjs";
9
9
  import { hydrateTools } from "../lib/hydrate.mjs";
10
10
  import { createToolApi, executeWorkflow, writeRunArtifact } from "../lib/run.mjs";
11
+ import { isDaemonRunning, callViaDaemon, getDaemonStatus, shutdownDaemon, startDaemon } from "../lib/daemon_client.mjs";
11
12
 
12
13
  function usage() {
13
14
  return `
@@ -20,7 +21,12 @@ Usage:
20
21
  dm search "<query>" --server <name> [--limit 8] [--refresh] [--json]
21
22
  dm hydrate <tool1> <tool2> ... --server <name> [--out <dir>] [--refresh] [--json]
22
23
  dm run --workflow <file.js> --tools <a,b,...> --server <name> [--retries 3] [--timeout-ms 300000] [--json]
23
- dm call <tool> --server <name> [--args-json '{...}'] [--args-file path.json] [--json]
24
+ dm call <tool> --server <name> [--args-json '{...}'] [--args-file path.json] [--json] [--no-daemon]
25
+
26
+ Daemon (persistent connections for faster calls):
27
+ dm daemon start → Start background daemon
28
+ dm daemon stop → Stop daemon
29
+ dm daemon status [--json] → Show connection pool status
24
30
 
25
31
  Progressive Disclosure Flow:
26
32
  1. dm servers → Discover available MCP servers
@@ -37,6 +43,7 @@ Notes:
37
43
  - Servers with 'disabled: true' in mcp.json are fully available here.
38
44
  That flag only prevents Droid from loading tools into context.
39
45
  - Tool inventory is cached under .factory/droid-mode/cache/<server>/tools.json
46
+ - When daemon is running, 'dm call' uses it automatically (~5x faster).
40
47
  `.trim();
41
48
  }
42
49
 
@@ -386,6 +393,23 @@ async function cmdCall(args) {
386
393
  }
387
394
  }
388
395
 
396
+ // Try daemon first (unless --no-daemon flag is set)
397
+ const useDaemon = !args.flags["no-daemon"] && await isDaemonRunning();
398
+
399
+ if (useDaemon) {
400
+ const res = await callViaDaemon({ server: serverName, tool, args: argObj });
401
+ if (!res.ok) {
402
+ fail(`Daemon call failed: ${res.error}`);
403
+ }
404
+ if (args.flags.json) {
405
+ process.stdout.write(JSON.stringify(res, null, 2) + "\n");
406
+ return;
407
+ }
408
+ process.stdout.write(JSON.stringify(res.result, null, 2) + "\n");
409
+ return;
410
+ }
411
+
412
+ // Direct call (no daemon)
389
413
  const out = await withClient(serverName, entry, async (client) => {
390
414
  const res = await client.callTool({ name: tool, arguments: argObj, timeoutMs: 60_000 });
391
415
  return res;
@@ -398,6 +422,75 @@ async function cmdCall(args) {
398
422
  process.stdout.write(JSON.stringify(out.structured ?? out.text ?? out.raw, null, 2) + "\n");
399
423
  }
400
424
 
425
+ async function cmdDaemon(args) {
426
+ const subcmd = args._[1];
427
+
428
+ if (subcmd === "start") {
429
+ const running = await isDaemonRunning();
430
+ if (running) {
431
+ process.stdout.write("Daemon is already running.\n");
432
+ return;
433
+ }
434
+ try {
435
+ const { pid } = await startDaemon();
436
+ process.stdout.write(`Daemon started (PID: ${pid})\n`);
437
+ } catch (err) {
438
+ fail(`Failed to start daemon: ${err.message}`);
439
+ }
440
+ return;
441
+ }
442
+
443
+ if (subcmd === "stop") {
444
+ const running = await isDaemonRunning();
445
+ if (!running) {
446
+ process.stdout.write("Daemon is not running.\n");
447
+ return;
448
+ }
449
+ await shutdownDaemon();
450
+ process.stdout.write("Daemon stopped.\n");
451
+ return;
452
+ }
453
+
454
+ if (subcmd === "status") {
455
+ const running = await isDaemonRunning();
456
+ if (!running) {
457
+ if (args.flags.json) {
458
+ process.stdout.write(JSON.stringify({ running: false }, null, 2) + "\n");
459
+ } else {
460
+ process.stdout.write("Daemon is not running.\n");
461
+ }
462
+ return;
463
+ }
464
+
465
+ const status = await getDaemonStatus();
466
+ if (args.flags.json) {
467
+ process.stdout.write(JSON.stringify({ running: true, ...status }, null, 2) + "\n");
468
+ return;
469
+ }
470
+
471
+ const uptimeMin = Math.floor((status.uptime || 0) / 60000);
472
+ process.stdout.write(`Daemon running (uptime: ${uptimeMin}m)\n\n`);
473
+
474
+ if (status.connections?.length) {
475
+ printTable(
476
+ status.connections.map((c) => ({
477
+ server: c.name,
478
+ type: c.type,
479
+ state: c.state,
480
+ uptime: c.uptime || "-",
481
+ calls: c.callCount,
482
+ })),
483
+ ["server", "type", "state", "uptime", "calls"]
484
+ );
485
+ } else {
486
+ process.stdout.write("No server connections.\n");
487
+ }
488
+ return;
489
+ }
490
+
491
+ fail(`Unknown daemon subcommand: ${subcmd}\nUsage: dm daemon start|stop|status`);
492
+ }
493
+
401
494
  async function main() {
402
495
  const args = parseArgs(process.argv.slice(2));
403
496
  const cmd = args._[0];
@@ -415,6 +508,7 @@ async function main() {
415
508
  if (cmd === "hydrate") return await cmdHydrate(args);
416
509
  if (cmd === "run") return await cmdRun(args);
417
510
  if (cmd === "call") return await cmdCall(args);
511
+ if (cmd === "daemon") return await cmdDaemon(args);
418
512
  fail(`Unknown command: ${cmd}\n\n${usage()}`);
419
513
  } catch (err) {
420
514
  fail(err.message || err);
@@ -0,0 +1,293 @@
1
+ import net from "node:net";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { McpClient } from "./mcp_client.mjs";
5
+ import { resolveServer, listAllServers } from "./config.mjs";
6
+
7
+ const SOCKET_PATH = process.env.DM_DAEMON_SOCKET || "/tmp/dm-daemon.sock";
8
+ const IDLE_TIMEOUT_MS = parseInt(process.env.DM_DAEMON_IDLE_MS || "600000", 10); // 10 minutes default
9
+
10
+ /**
11
+ * Connection pool managing lazy-initialized MCP clients.
12
+ */
13
+ class ConnectionPool {
14
+ constructor() {
15
+ /** @type {Map<string, { client: McpClient, connectedAt: Date, lastUsed: Date, callCount: number }>} */
16
+ this.connections = new Map();
17
+ this.idleCheckInterval = null;
18
+ }
19
+
20
+ /**
21
+ * Get or create a client for the given server.
22
+ * @param {string} serverName
23
+ * @returns {Promise<McpClient>}
24
+ */
25
+ async getClient(serverName) {
26
+ const existing = this.connections.get(serverName);
27
+ if (existing) {
28
+ existing.lastUsed = new Date();
29
+ existing.callCount++;
30
+ return existing.client;
31
+ }
32
+
33
+ // Lazy init: resolve from mcp.json, connect, cache
34
+ const { entry } = resolveServer({ server: serverName });
35
+ const client = new McpClient({ serverName, entry });
36
+ await client.init();
37
+
38
+ this.connections.set(serverName, {
39
+ client,
40
+ connectedAt: new Date(),
41
+ lastUsed: new Date(),
42
+ callCount: 1,
43
+ });
44
+
45
+ return client;
46
+ }
47
+
48
+ /**
49
+ * Get status of all connections.
50
+ */
51
+ getStatus() {
52
+ const servers = listAllServers();
53
+ const status = [];
54
+
55
+ for (const server of servers) {
56
+ const conn = this.connections.get(server.name);
57
+ if (conn) {
58
+ const uptimeMs = Date.now() - conn.connectedAt.getTime();
59
+ const uptimeMin = Math.floor(uptimeMs / 60000);
60
+ status.push({
61
+ name: server.name,
62
+ type: server.type,
63
+ state: "connected",
64
+ uptime: `${uptimeMin}m`,
65
+ callCount: conn.callCount,
66
+ });
67
+ } else {
68
+ status.push({
69
+ name: server.name,
70
+ type: server.type,
71
+ state: "idle",
72
+ uptime: null,
73
+ callCount: 0,
74
+ });
75
+ }
76
+ }
77
+
78
+ return status;
79
+ }
80
+
81
+ /**
82
+ * Close idle connections that haven't been used recently.
83
+ */
84
+ async pruneIdle() {
85
+ const now = Date.now();
86
+ for (const [name, conn] of this.connections.entries()) {
87
+ const idleMs = now - conn.lastUsed.getTime();
88
+ if (idleMs > IDLE_TIMEOUT_MS) {
89
+ try {
90
+ await conn.client.close();
91
+ } catch {}
92
+ this.connections.delete(name);
93
+ if (process.env.DM_DEBUG === "1") {
94
+ process.stderr.write(`[daemon] Closed idle connection: ${name}\n`);
95
+ }
96
+ }
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Start periodic idle checking.
102
+ */
103
+ startIdleCheck() {
104
+ if (this.idleCheckInterval) return;
105
+ this.idleCheckInterval = setInterval(() => this.pruneIdle(), 60000); // Check every minute
106
+ }
107
+
108
+ /**
109
+ * Close all connections.
110
+ */
111
+ async closeAll() {
112
+ if (this.idleCheckInterval) {
113
+ clearInterval(this.idleCheckInterval);
114
+ this.idleCheckInterval = null;
115
+ }
116
+ for (const [name, conn] of this.connections.entries()) {
117
+ try {
118
+ await conn.client.close();
119
+ } catch {}
120
+ }
121
+ this.connections.clear();
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Daemon server that listens on Unix socket and handles requests.
127
+ */
128
+ export class DaemonServer {
129
+ constructor() {
130
+ this.pool = new ConnectionPool();
131
+ this.server = null;
132
+ this.startedAt = null;
133
+ }
134
+
135
+ /**
136
+ * Handle a single request.
137
+ * @param {object} req
138
+ * @returns {Promise<object>}
139
+ */
140
+ async handleRequest(req) {
141
+ const { action, server, tool, args } = req;
142
+
143
+ if (action === "ping") {
144
+ return { ok: true, pong: true, uptime: Date.now() - this.startedAt };
145
+ }
146
+
147
+ if (action === "status") {
148
+ return {
149
+ ok: true,
150
+ uptime: Date.now() - this.startedAt,
151
+ connections: this.pool.getStatus(),
152
+ };
153
+ }
154
+
155
+ if (action === "call") {
156
+ if (!server || !tool) {
157
+ return { ok: false, error: "Missing server or tool" };
158
+ }
159
+
160
+ const start = Date.now();
161
+ try {
162
+ const client = await this.pool.getClient(server);
163
+ const result = await client.callTool({
164
+ name: tool,
165
+ arguments: args || {},
166
+ timeoutMs: 60000,
167
+ });
168
+ const durationMs = Date.now() - start;
169
+ return {
170
+ ok: true,
171
+ server,
172
+ tool,
173
+ result: result.structured ?? result.text ?? result.raw,
174
+ durationMs,
175
+ fromPool: this.pool.connections.has(server),
176
+ };
177
+ } catch (err) {
178
+ return {
179
+ ok: false,
180
+ server,
181
+ tool,
182
+ error: err.message || String(err),
183
+ durationMs: Date.now() - start,
184
+ };
185
+ }
186
+ }
187
+
188
+ if (action === "shutdown") {
189
+ // Graceful shutdown
190
+ setImmediate(() => this.stop());
191
+ return { ok: true, message: "Shutting down" };
192
+ }
193
+
194
+ return { ok: false, error: `Unknown action: ${action}` };
195
+ }
196
+
197
+ /**
198
+ * Start the daemon server.
199
+ */
200
+ async start() {
201
+ // Remove stale socket
202
+ if (fs.existsSync(SOCKET_PATH)) {
203
+ fs.unlinkSync(SOCKET_PATH);
204
+ }
205
+
206
+ this.startedAt = Date.now();
207
+ this.pool.startIdleCheck();
208
+
209
+ return new Promise((resolve, reject) => {
210
+ this.server = net.createServer((socket) => {
211
+ let buffer = "";
212
+
213
+ socket.on("data", async (data) => {
214
+ buffer += data.toString();
215
+
216
+ // Handle newline-delimited JSON
217
+ let idx;
218
+ while ((idx = buffer.indexOf("\n")) !== -1) {
219
+ const line = buffer.slice(0, idx);
220
+ buffer = buffer.slice(idx + 1);
221
+
222
+ if (!line.trim()) continue;
223
+
224
+ try {
225
+ const req = JSON.parse(line);
226
+ const res = await this.handleRequest(req);
227
+ socket.write(JSON.stringify(res) + "\n");
228
+ } catch (err) {
229
+ socket.write(JSON.stringify({ ok: false, error: err.message }) + "\n");
230
+ }
231
+ }
232
+ });
233
+
234
+ socket.on("error", () => {});
235
+ });
236
+
237
+ this.server.on("error", reject);
238
+
239
+ this.server.listen(SOCKET_PATH, () => {
240
+ // Set socket permissions (user only)
241
+ fs.chmodSync(SOCKET_PATH, 0o600);
242
+ resolve();
243
+ });
244
+ });
245
+ }
246
+
247
+ /**
248
+ * Stop the daemon server.
249
+ */
250
+ async stop() {
251
+ await this.pool.closeAll();
252
+
253
+ if (this.server) {
254
+ this.server.close();
255
+ this.server = null;
256
+ }
257
+
258
+ if (fs.existsSync(SOCKET_PATH)) {
259
+ fs.unlinkSync(SOCKET_PATH);
260
+ }
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Run daemon as main process.
266
+ */
267
+ export async function runDaemon() {
268
+ const daemon = new DaemonServer();
269
+
270
+ process.on("SIGTERM", async () => {
271
+ await daemon.stop();
272
+ process.exit(0);
273
+ });
274
+
275
+ process.on("SIGINT", async () => {
276
+ await daemon.stop();
277
+ process.exit(0);
278
+ });
279
+
280
+ try {
281
+ await daemon.start();
282
+ process.stdout.write(`Daemon started on ${SOCKET_PATH}\n`);
283
+ process.stdout.write(`PID: ${process.pid}\n`);
284
+ } catch (err) {
285
+ process.stderr.write(`Failed to start daemon: ${err.message}\n`);
286
+ process.exit(1);
287
+ }
288
+ }
289
+
290
+ // Allow running directly: node daemon.mjs
291
+ if (process.argv[1]?.endsWith("daemon.mjs") && process.argv[2] === "run") {
292
+ runDaemon();
293
+ }
@@ -0,0 +1,165 @@
1
+ import net from "node:net";
2
+ import fs from "node:fs";
3
+ import { spawn } from "node:child_process";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const SOCKET_PATH = process.env.DM_DAEMON_SOCKET || "/tmp/dm-daemon.sock";
8
+
9
+ /**
10
+ * Check if daemon is running by pinging the socket.
11
+ * @returns {Promise<boolean>}
12
+ */
13
+ export async function isDaemonRunning() {
14
+ if (!fs.existsSync(SOCKET_PATH)) {
15
+ return false;
16
+ }
17
+
18
+ try {
19
+ const res = await sendRequest({ action: "ping" }, { timeoutMs: 2000 });
20
+ return res?.ok === true && res?.pong === true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Send a request to the daemon.
28
+ * @param {object} req
29
+ * @param {{ timeoutMs?: number }} opts
30
+ * @returns {Promise<object>}
31
+ */
32
+ export async function sendRequest(req, opts = {}) {
33
+ const timeoutMs = opts.timeoutMs || 65000; // Slightly longer than tool timeout
34
+
35
+ return new Promise((resolve, reject) => {
36
+ const socket = net.createConnection(SOCKET_PATH);
37
+ let buffer = "";
38
+ let resolved = false;
39
+
40
+ const timeout = setTimeout(() => {
41
+ if (!resolved) {
42
+ resolved = true;
43
+ socket.destroy();
44
+ reject(new Error("Daemon request timed out"));
45
+ }
46
+ }, timeoutMs);
47
+
48
+ socket.on("connect", () => {
49
+ socket.write(JSON.stringify(req) + "\n");
50
+ });
51
+
52
+ socket.on("data", (data) => {
53
+ buffer += data.toString();
54
+ const idx = buffer.indexOf("\n");
55
+ if (idx !== -1) {
56
+ const line = buffer.slice(0, idx);
57
+ if (!resolved) {
58
+ resolved = true;
59
+ clearTimeout(timeout);
60
+ socket.end();
61
+ try {
62
+ resolve(JSON.parse(line));
63
+ } catch (err) {
64
+ reject(new Error(`Invalid daemon response: ${line}`));
65
+ }
66
+ }
67
+ }
68
+ });
69
+
70
+ socket.on("error", (err) => {
71
+ if (!resolved) {
72
+ resolved = true;
73
+ clearTimeout(timeout);
74
+ reject(err);
75
+ }
76
+ });
77
+
78
+ socket.on("close", () => {
79
+ if (!resolved) {
80
+ resolved = true;
81
+ clearTimeout(timeout);
82
+ reject(new Error("Daemon connection closed unexpectedly"));
83
+ }
84
+ });
85
+ });
86
+ }
87
+
88
+ /**
89
+ * Call a tool through the daemon.
90
+ * @param {{ server: string, tool: string, args?: object }} opts
91
+ * @returns {Promise<object>}
92
+ */
93
+ export async function callViaDaemon(opts) {
94
+ return sendRequest({
95
+ action: "call",
96
+ server: opts.server,
97
+ tool: opts.tool,
98
+ args: opts.args || {},
99
+ });
100
+ }
101
+
102
+ /**
103
+ * Get daemon status.
104
+ * @returns {Promise<object>}
105
+ */
106
+ export async function getDaemonStatus() {
107
+ return sendRequest({ action: "status" });
108
+ }
109
+
110
+ /**
111
+ * Request daemon shutdown.
112
+ * @returns {Promise<object>}
113
+ */
114
+ export async function shutdownDaemon() {
115
+ try {
116
+ return await sendRequest({ action: "shutdown" }, { timeoutMs: 5000 });
117
+ } catch {
118
+ // Daemon may have already exited
119
+ return { ok: true, message: "Daemon stopped" };
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Start the daemon as a background process.
125
+ * @returns {Promise<{ pid: number }>}
126
+ */
127
+ export async function startDaemon() {
128
+ // Check if already running
129
+ if (await isDaemonRunning()) {
130
+ throw new Error("Daemon is already running");
131
+ }
132
+
133
+ // Find daemon.mjs path
134
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
135
+ const daemonPath = path.join(__dirname, "daemon.mjs");
136
+
137
+ // Spawn fully detached process with no stdio connection
138
+ // This prevents the daemon from exiting when the parent exits
139
+ const child = spawn(process.execPath, [daemonPath, "run"], {
140
+ detached: true,
141
+ stdio: "ignore", // Fully detach stdio
142
+ env: { ...process.env },
143
+ });
144
+
145
+ // Immediately unref so parent can exit
146
+ child.unref();
147
+
148
+ // Wait for daemon to be ready by polling the socket
149
+ const maxWait = 10000;
150
+ const pollInterval = 100;
151
+ const start = Date.now();
152
+
153
+ while (Date.now() - start < maxWait) {
154
+ await new Promise((r) => setTimeout(r, pollInterval));
155
+ if (await isDaemonRunning()) {
156
+ return { pid: child.pid };
157
+ }
158
+ }
159
+
160
+ // If we get here, daemon didn't start
161
+ try {
162
+ child.kill();
163
+ } catch {}
164
+ throw new Error("Daemon failed to start within timeout");
165
+ }
@@ -45,20 +45,38 @@ export class McpStdioTransport {
45
45
  // Avoid noisy output; keep it available for debugging.
46
46
  // You can enable by setting DM_DEBUG=1.
47
47
  if (process.env.DM_DEBUG === "1") {
48
- process.stderr.write(String(buf));
48
+ process.stderr.write(`[dm:stderr] ${String(buf)}`);
49
49
  }
50
50
  });
51
51
 
52
+ // Handle stdout closing unexpectedly (e.g., server crashes or exits without response)
53
+ this.proc.stdout.on("close", () => {
54
+ if (process.env.DM_DEBUG === "1" && this.pending.size > 0) {
55
+ process.stderr.write(`[dm:stdio] stdout closed with ${this.pending.size} pending request(s)\n`);
56
+ }
57
+ for (const [id, p] of this.pending.entries()) {
58
+ clearTimeout(p.timeout);
59
+ p.reject(new Error(`MCP stdio stdout closed while waiting for response id=${id}`));
60
+ }
61
+ this.pending.clear();
62
+ });
63
+
52
64
  this.rl = readline.createInterface({ input: this.proc.stdout });
53
65
 
54
66
  this.rl.on("line", (line) => {
55
67
  if (!line) return;
68
+ if (process.env.DM_DEBUG === "1") {
69
+ process.stderr.write(`[dm:stdio] << ${line.slice(0, 500)}${line.length > 500 ? '...' : ''}\n`);
70
+ }
56
71
  let msg;
57
72
  try {
58
73
  msg = JSON.parse(line);
59
74
  } catch (err) {
60
- if (process.env.DM_DEBUG === "1") {
61
- process.stderr.write(`[dm] Failed to parse JSON from server: ${line}\n`);
75
+ // Log parse failures even without DM_DEBUG if we have pending requests
76
+ if (this.pending.size > 0) {
77
+ process.stderr.write(`[dm:stdio] JSON parse error with ${this.pending.size} pending: ${line.slice(0, 200)}\n`);
78
+ } else if (process.env.DM_DEBUG === "1") {
79
+ process.stderr.write(`[dm:stdio] Ignoring non-JSON line: ${line.slice(0, 100)}\n`);
62
80
  }
63
81
  return;
64
82
  }