droid-mode 0.0.5 → 0.0.7

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,22 +22,50 @@ 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
25
+ ## Benchmarks
26
26
 
27
- Droid Mode includes an optional daemon that maintains persistent MCP connections, dramatically reducing latency for repeated operations.
27
+ Independent benchmarks comparing Droid Mode against Native MCP (direct HTTP).
28
28
 
29
- ### Benchmark Results
29
+ ### Summary
30
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** |
31
+ | Configuration | Per-Tool Latency | vs Native MCP |
32
+ |---------------|------------------|---------------|
33
+ | **Droid Mode (Daemon)** | **616ms** | **11% faster** |
34
+ | Native MCP (HTTP) | 695ms | baseline |
35
+ | Droid Mode (No Daemon) | 2,365ms | 240% slower |
35
36
 
36
- ### How It Works
37
+ ### Single Tool Performance
38
+
39
+ | Call | No Daemon | Daemon | Native MCP |
40
+ |------|-----------|--------|------------|
41
+ | 1 | 2948ms | 845ms | 866ms |
42
+ | 2 | 2442ms | 610ms | 789ms |
43
+ | 3 | 2300ms | 613ms | 798ms |
44
+ | 4 | 2415ms | 706ms | 742ms |
45
+ | 5 | 2334ms | 617ms | 690ms |
46
+ | **Average** | **2488ms** | **678ms** | **777ms** |
47
+
48
+ ### Scale Performance (10 Tools)
49
+
50
+ | Metric | No Daemon | Daemon | Native MCP |
51
+ |--------|-----------|--------|------------|
52
+ | Total Time | 23.7s | **6.2s** | 6.9s |
53
+ | Per-Tool Avg | 2365ms | **616ms** | 695ms |
54
+
55
+ ### Key Findings
56
+
57
+ 1. **Daemon beats Native MCP** — 11-14% faster per-tool latency
58
+ 2. **Scales linearly** — No cumulative overhead at 10+ tools
59
+ 3. **Consistent** — ±262ms variance vs ±236ms for Native MCP
60
+ 4. **No-daemon is prohibitive** — 74% slower, only for one-off calls
61
+
62
+ *Benchmarks: macOS Darwin 25.2.0, Context Repo MCP server, January 2026*
63
+
64
+ ## Daemon Mode
37
65
 
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.
66
+ The daemon maintains persistent MCP connections via a Unix socket, eliminating stdio spawn overhead.
39
67
 
40
- The daemon maintains a connection pool. After the first call to a server, subsequent calls reuse the existing connection:
68
+ ### How It Works
41
69
 
42
70
  ```
43
71
  ┌──────────────────────────────────────────────────┐
@@ -56,26 +84,33 @@ The daemon maintains a connection pool. After the first call to a server, subseq
56
84
  ### Usage
57
85
 
58
86
  ```bash
59
- # Start daemon (runs in background)
60
- dm daemon start
87
+ # Daemon auto-starts on first dm call
88
+ dm call list_collections --server context-repo
61
89
 
62
- # All dm call commands automatically use daemon
63
- dm call list_collections --server context-repo # ~620ms
90
+ # Or start manually
91
+ dm daemon start
64
92
 
65
93
  # Check connection status
66
94
  dm daemon status
67
95
 
68
- # Stop daemon when done
96
+ # Pre-warm specific servers
97
+ dm daemon warm context-repo
98
+
99
+ # Stop daemon
69
100
  dm daemon stop
101
+
102
+ # Bypass daemon for direct call
103
+ dm call tool --server X --no-daemon
70
104
  ```
71
105
 
72
- ### When to Use Daemon
106
+ ### Configuration
73
107
 
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
108
+ ```bash
109
+ # Disable auto-warm for a server
110
+ dm config context-repo autoWarm false
111
+ ```
77
112
 
78
- The daemon is optional. Without it, Droid Mode works exactly as before.
113
+ The daemon is optional. Use `--no-daemon` to bypass it.
79
114
 
80
115
  ## Installation
81
116
 
@@ -171,6 +206,8 @@ dm run --server context-repo \
171
206
  | `dm daemon start` | Start background daemon |
172
207
  | `dm daemon stop` | Stop daemon |
173
208
  | `dm daemon status` | Show connection pool status |
209
+ | `dm daemon warm [server]` | Pre-warm server connections |
210
+ | `dm config <server> autoWarm false` | Disable auto-warm for server |
174
211
 
175
212
  ## Design Philosophy
176
213
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "droid-mode",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
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",
@@ -8,7 +8,8 @@ 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
+ import { isDaemonRunning, callViaDaemon, getDaemonStatus, shutdownDaemon, startDaemon, ensureDaemonRunning, warmServers } from "../lib/daemon_client.mjs";
12
+ import { setServerAutoWarm, getServerState } from "../lib/state.mjs";
12
13
 
13
14
  function usage() {
14
15
  return `
@@ -24,9 +25,13 @@ Usage:
24
25
  dm call <tool> --server <name> [--args-json '{...}'] [--args-file path.json] [--json] [--no-daemon]
25
26
 
26
27
  Daemon (persistent connections for faster calls):
27
- dm daemon start → Start background daemon
28
+ dm daemon start → Start background daemon (auto-warms servers)
28
29
  dm daemon stop → Stop daemon
29
30
  dm daemon status [--json] → Show connection pool status
31
+ dm daemon warm [<server>] → Pre-warm server(s)
32
+
33
+ Configuration:
34
+ dm config <server> autoWarm true|false → Enable/disable auto-warm for server
30
35
 
31
36
  Progressive Disclosure Flow:
32
37
  1. dm servers → Discover available MCP servers
@@ -394,19 +399,21 @@ async function cmdCall(args) {
394
399
  }
395
400
 
396
401
  // 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");
402
+ // Auto-start daemon if not running
403
+ if (!args.flags["no-daemon"]) {
404
+ const daemonReady = await ensureDaemonRunning();
405
+ if (daemonReady) {
406
+ const res = await callViaDaemon({ server: serverName, tool, args: argObj });
407
+ if (!res.ok) {
408
+ fail(`Daemon call failed: ${res.error}`);
409
+ }
410
+ if (args.flags.json) {
411
+ process.stdout.write(JSON.stringify(res, null, 2) + "\n");
412
+ return;
413
+ }
414
+ process.stdout.write(JSON.stringify(res.result, null, 2) + "\n");
406
415
  return;
407
416
  }
408
- process.stdout.write(JSON.stringify(res.result, null, 2) + "\n");
409
- return;
410
417
  }
411
418
 
412
419
  // Direct call (no daemon)
@@ -488,7 +495,65 @@ async function cmdDaemon(args) {
488
495
  return;
489
496
  }
490
497
 
491
- fail(`Unknown daemon subcommand: ${subcmd}\nUsage: dm daemon start|stop|status`);
498
+ if (subcmd === "warm") {
499
+ const running = await isDaemonRunning();
500
+ if (!running) {
501
+ // Auto-start daemon for warm command
502
+ try {
503
+ await startDaemon();
504
+ process.stdout.write("Daemon started.\n");
505
+ } catch (err) {
506
+ fail(`Failed to start daemon: ${err.message}`);
507
+ }
508
+ }
509
+
510
+ const serverName = args._[2]; // Optional: specific server
511
+ const result = await warmServers(serverName);
512
+
513
+ if (args.flags.json) {
514
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
515
+ return;
516
+ }
517
+
518
+ if (serverName) {
519
+ process.stdout.write(result.warmed ? `Warmed: ${serverName}\n` : `Already warm: ${serverName}\n`);
520
+ } else {
521
+ const { warmed = [], failed = [] } = result;
522
+ if (warmed.length) {
523
+ process.stdout.write(`Warmed ${warmed.length} server(s): ${warmed.join(", ")}\n`);
524
+ }
525
+ if (failed.length) {
526
+ process.stdout.write(`Failed: ${failed.join(", ")}\n`);
527
+ }
528
+ if (!warmed.length && !failed.length) {
529
+ process.stdout.write("All servers already warm or none configured for auto-warm.\n");
530
+ }
531
+ }
532
+ return;
533
+ }
534
+
535
+ fail(`Unknown daemon subcommand: ${subcmd}\nUsage: dm daemon start|stop|status|warm`);
536
+ }
537
+
538
+ async function cmdConfig(args) {
539
+ const serverName = args._[1];
540
+ const key = args._[2];
541
+ const value = args._[3];
542
+
543
+ if (!serverName || !key) {
544
+ fail("Usage: dm config <server> <key> <value>\n\nSupported keys:\n autoWarm true|false - Enable/disable auto-warm for server");
545
+ }
546
+
547
+ if (key === "autoWarm") {
548
+ if (value !== "true" && value !== "false") {
549
+ fail("autoWarm must be 'true' or 'false'");
550
+ }
551
+ setServerAutoWarm(serverName, value === "true");
552
+ process.stdout.write(`Set ${serverName}.autoWarm = ${value}\n`);
553
+ return;
554
+ }
555
+
556
+ fail(`Unknown config key: ${key}\nSupported: autoWarm`);
492
557
  }
493
558
 
494
559
  async function main() {
@@ -509,6 +574,7 @@ async function main() {
509
574
  if (cmd === "run") return await cmdRun(args);
510
575
  if (cmd === "call") return await cmdCall(args);
511
576
  if (cmd === "daemon") return await cmdDaemon(args);
577
+ if (cmd === "config") return await cmdConfig(args);
512
578
  fail(`Unknown command: ${cmd}\n\n${usage()}`);
513
579
  } catch (err) {
514
580
  fail(err.message || err);
@@ -2,10 +2,12 @@ import net from "node:net";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { McpClient } from "./mcp_client.mjs";
5
- import { resolveServer, listAllServers } from "./config.mjs";
5
+ import { resolveServer, listAllServers, loadMcpConfigs } from "./config.mjs";
6
+ import { loadState, saveState, getServersToWarm, recordServerUsage } from "./state.mjs";
6
7
 
7
8
  const SOCKET_PATH = process.env.DM_DAEMON_SOCKET || "/tmp/dm-daemon.sock";
8
9
  const IDLE_TIMEOUT_MS = parseInt(process.env.DM_DAEMON_IDLE_MS || "600000", 10); // 10 minutes default
10
+ const MAX_CONNECTIONS = 50;
9
11
 
10
12
  /**
11
13
  * Connection pool managing lazy-initialized MCP clients.
@@ -30,6 +32,11 @@ class ConnectionPool {
30
32
  return existing.client;
31
33
  }
32
34
 
35
+ // Enforce max connections limit
36
+ if (this.connections.size >= MAX_CONNECTIONS) {
37
+ throw new Error(`Connection pool full (max ${MAX_CONNECTIONS}). Close unused connections or increase limit.`);
38
+ }
39
+
33
40
  // Lazy init: resolve from mcp.json, connect, cache
34
41
  const { entry } = resolveServer({ server: serverName });
35
42
  const client = new McpClient({ serverName, entry });
@@ -42,9 +49,66 @@ class ConnectionPool {
42
49
  callCount: 1,
43
50
  });
44
51
 
52
+ // Record usage for auto-warm state
53
+ recordServerUsage(serverName);
54
+
45
55
  return client;
46
56
  }
47
57
 
58
+ /**
59
+ * Pre-warm a specific server connection.
60
+ * @param {string} serverName
61
+ * @returns {Promise<boolean>} true if warmed, false if already warm or failed
62
+ */
63
+ async warmServer(serverName) {
64
+ if (this.connections.has(serverName)) {
65
+ return false; // Already warm
66
+ }
67
+
68
+ try {
69
+ await this.getClient(serverName);
70
+ return true;
71
+ } catch (err) {
72
+ if (process.env.DM_DEBUG === "1") {
73
+ process.stderr.write(`[daemon] Failed to warm ${serverName}: ${err.message}\n`);
74
+ }
75
+ return false;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Pre-warm all servers that should be auto-warmed.
81
+ * @returns {Promise<{ warmed: string[], failed: string[] }>}
82
+ */
83
+ async warmAll() {
84
+ const servers = listAllServers();
85
+ const serverNames = servers.map((s) => s.name);
86
+ const toWarm = getServersToWarm(serverNames);
87
+
88
+ const warmed = [];
89
+ const failed = [];
90
+
91
+ // Warm in parallel with concurrency limit
92
+ const results = await Promise.allSettled(
93
+ toWarm.map(async (name) => {
94
+ const success = await this.warmServer(name);
95
+ return { name, success };
96
+ })
97
+ );
98
+
99
+ for (const result of results) {
100
+ if (result.status === "fulfilled" && result.value.success) {
101
+ warmed.push(result.value.name);
102
+ } else if (result.status === "fulfilled") {
103
+ // Already warm, don't count as failed
104
+ } else {
105
+ failed.push(result.reason?.name || "unknown");
106
+ }
107
+ }
108
+
109
+ return { warmed, failed };
110
+ }
111
+
48
112
  /**
49
113
  * Get status of all connections.
50
114
  */
@@ -185,6 +249,18 @@ export class DaemonServer {
185
249
  }
186
250
  }
187
251
 
252
+ if (action === "warm") {
253
+ if (server) {
254
+ // Warm specific server
255
+ const success = await this.pool.warmServer(server);
256
+ return { ok: true, server, warmed: success };
257
+ } else {
258
+ // Warm all auto-warm servers
259
+ const result = await this.pool.warmAll();
260
+ return { ok: true, ...result };
261
+ }
262
+ }
263
+
188
264
  if (action === "shutdown") {
189
265
  // Graceful shutdown
190
266
  setImmediate(() => this.stop());
@@ -194,6 +270,32 @@ export class DaemonServer {
194
270
  return { ok: false, error: `Unknown action: ${action}` };
195
271
  }
196
272
 
273
+ /**
274
+ * Start watching mcp.json for changes.
275
+ */
276
+ startMcpWatcher() {
277
+ const { projectPath, userPath } = loadMcpConfigs();
278
+ const watchPaths = [userPath, projectPath].filter(Boolean);
279
+
280
+ for (const watchPath of watchPaths) {
281
+ if (!fs.existsSync(watchPath)) continue;
282
+
283
+ try {
284
+ fs.watch(watchPath, { persistent: false }, async (eventType) => {
285
+ if (eventType === "change") {
286
+ if (process.env.DM_DEBUG === "1") {
287
+ process.stderr.write(`[daemon] mcp.json changed, warming new servers...\n`);
288
+ }
289
+ // Re-warm any new servers
290
+ await this.pool.warmAll();
291
+ }
292
+ });
293
+ } catch (err) {
294
+ // Ignore watch errors (file might not exist yet)
295
+ }
296
+ }
297
+ }
298
+
197
299
  /**
198
300
  * Start the daemon server.
199
301
  */
@@ -236,9 +338,19 @@ export class DaemonServer {
236
338
 
237
339
  this.server.on("error", reject);
238
340
 
239
- this.server.listen(SOCKET_PATH, () => {
341
+ this.server.listen(SOCKET_PATH, async () => {
240
342
  // Set socket permissions (user only)
241
343
  fs.chmodSync(SOCKET_PATH, 0o600);
344
+
345
+ // Start mcp.json watcher
346
+ this.startMcpWatcher();
347
+
348
+ // Auto-warm servers on startup
349
+ const { warmed, failed } = await this.pool.warmAll();
350
+ if (process.env.DM_DEBUG === "1" && warmed.length > 0) {
351
+ process.stderr.write(`[daemon] Pre-warmed ${warmed.length} server(s): ${warmed.join(", ")}\n`);
352
+ }
353
+
242
354
  resolve();
243
355
  });
244
356
  });
@@ -163,3 +163,35 @@ export async function startDaemon() {
163
163
  } catch {}
164
164
  throw new Error("Daemon failed to start within timeout");
165
165
  }
166
+
167
+ /**
168
+ * Ensure daemon is running, starting it if necessary.
169
+ * @returns {Promise<boolean>} true if daemon is now running
170
+ */
171
+ export async function ensureDaemonRunning() {
172
+ if (await isDaemonRunning()) {
173
+ return true;
174
+ }
175
+
176
+ try {
177
+ await startDaemon();
178
+ return true;
179
+ } catch (err) {
180
+ if (process.env.DM_DEBUG === "1") {
181
+ process.stderr.write(`[dm] Failed to auto-start daemon: ${err.message}\n`);
182
+ }
183
+ return false;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Request daemon to warm a specific server or all servers.
189
+ * @param {string} [serverName] - If provided, warm only this server. Otherwise warm all.
190
+ * @returns {Promise<object>}
191
+ */
192
+ export async function warmServers(serverName) {
193
+ return sendRequest({
194
+ action: "warm",
195
+ server: serverName,
196
+ });
197
+ }
@@ -0,0 +1,105 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { getDroidModeDataDir, ensureDir, readJsonFileIfExists, writeJson } from "./util.mjs";
4
+
5
+ const STATE_FILE = "daemon-state.json";
6
+ const STATE_VERSION = 1;
7
+
8
+ /**
9
+ * Get the path to the daemon state file.
10
+ */
11
+ export function getStateFilePath() {
12
+ return path.join(getDroidModeDataDir(), STATE_FILE);
13
+ }
14
+
15
+ /**
16
+ * Load daemon state from disk.
17
+ * @returns {{ version: number, servers: Record<string, ServerState> }}
18
+ */
19
+ export function loadState() {
20
+ const filePath = getStateFilePath();
21
+ const data = readJsonFileIfExists(filePath);
22
+
23
+ if (!data || data.version !== STATE_VERSION) {
24
+ return { version: STATE_VERSION, servers: {} };
25
+ }
26
+
27
+ return data;
28
+ }
29
+
30
+ /**
31
+ * Save daemon state to disk.
32
+ * @param {{ version: number, servers: Record<string, ServerState> }} state
33
+ */
34
+ export function saveState(state) {
35
+ const filePath = getStateFilePath();
36
+ ensureDir(path.dirname(filePath));
37
+ writeJson(filePath, { ...state, version: STATE_VERSION });
38
+ }
39
+
40
+ /**
41
+ * Get server state, creating default if not exists.
42
+ * @param {string} serverName
43
+ * @returns {{ autoWarm: boolean, lastUsed: string|null, usageCount: number }}
44
+ */
45
+ export function getServerState(serverName) {
46
+ const state = loadState();
47
+ return state.servers[serverName] || {
48
+ autoWarm: true, // Default: opt-out (auto-warm enabled)
49
+ lastUsed: null,
50
+ usageCount: 0,
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Update server state after use.
56
+ * @param {string} serverName
57
+ */
58
+ export function recordServerUsage(serverName) {
59
+ const state = loadState();
60
+ const server = state.servers[serverName] || {
61
+ autoWarm: true,
62
+ lastUsed: null,
63
+ usageCount: 0,
64
+ };
65
+
66
+ server.lastUsed = new Date().toISOString();
67
+ server.usageCount = (server.usageCount || 0) + 1;
68
+ state.servers[serverName] = server;
69
+
70
+ saveState(state);
71
+ }
72
+
73
+ /**
74
+ * Set autoWarm for a server.
75
+ * @param {string} serverName
76
+ * @param {boolean} autoWarm
77
+ */
78
+ export function setServerAutoWarm(serverName, autoWarm) {
79
+ const state = loadState();
80
+ const server = state.servers[serverName] || {
81
+ autoWarm: true,
82
+ lastUsed: null,
83
+ usageCount: 0,
84
+ };
85
+
86
+ server.autoWarm = autoWarm;
87
+ state.servers[serverName] = server;
88
+
89
+ saveState(state);
90
+ }
91
+
92
+ /**
93
+ * Get list of servers that should be auto-warmed.
94
+ * @param {string[]} availableServers - List of server names from mcp.json
95
+ * @returns {string[]}
96
+ */
97
+ export function getServersToWarm(availableServers) {
98
+ const state = loadState();
99
+
100
+ return availableServers.filter((name) => {
101
+ const serverState = state.servers[name];
102
+ // Default to true if no state exists (opt-out model)
103
+ return serverState?.autoWarm !== false;
104
+ });
105
+ }