droid-mode 0.2.0 → 0.2.1

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
@@ -17,6 +17,16 @@ Access MCP tools on-demand without loading schemas into your context window.
17
17
 
18
18
  ---
19
19
 
20
+ ## A Personal Note
21
+
22
+ > I spend most of my time in Factory.ai's Droid CLI, and I'm a big believer in MCP - it's the right abstraction for giving AI agents access to external tools. But I kept bumping into an interesting constraint. Every MCP server adds its tool schemas to the context window, which means adding servers costs tokens whether you use those tools or not. I wanted ten servers available; I didn't want to pay for ten servers in every prompt. This seemed like a solvable inefficiency, and I was curious enough to dig in.
23
+ >
24
+ > Droid Mode is what emerged: a Skill that provides daemon-backed MCP access with progressive disclosure. Built on 28 years of Linux system administration experience, it treats MCP servers as infrastructure - persistent connections, lazy schema loading, code-mode execution outside the context window. The result is zero token overhead and 13% better latency than native MCP. Per-project daemon isolation handles governance, and everything is traced for accountability. It's experimental research software, but I've tested it thoroughly and use it daily. I'm sharing it as an early AI explorer who believes we're just scratching the surface of what performant agent architectures can look like.
25
+ >
26
+ > \- [GitMaxd](https://github.com/Gitmaxd)
27
+
28
+ ---
29
+
20
30
  ## The Problem
21
31
 
22
32
  When you configure MCP servers in Factory.ai Droid, **all tool schemas get injected into every prompt** ([source](https://www.anthropic.com/engineering/code-execution-with-mcp)). This creates a compounding cost:
package/dist/cli.js CHANGED
@@ -83,7 +83,7 @@ var init = defineCommand({
83
83
  console.error("\u274C Templates directory not found. Package may be corrupted.");
84
84
  process.exit(1);
85
85
  }
86
- if (await pathExists(skillDir)) {
86
+ if (await pathExists(skillDir) && !args.force) {
87
87
  console.log("\u26A0\uFE0F .factory/skills/droid-mode already exists");
88
88
  console.log(" Scaffolding new files only (existing files will NOT be overwritten)\n");
89
89
  }
@@ -100,8 +100,10 @@ var init = defineCommand({
100
100
  return true;
101
101
  }
102
102
  if (await pathExists(dest)) {
103
- const relativePath2 = dest.replace(targetDir, ".");
104
- console.log(`\u23ED\uFE0F Skipped: ${relativePath2} (already exists)`);
103
+ if (!args.force) {
104
+ const relativePath2 = dest.replace(targetDir, ".");
105
+ console.log(`\u23ED\uFE0F Skipped: ${relativePath2} (already exists)`);
106
+ }
105
107
  skipped++;
106
108
  return false;
107
109
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "droid-mode",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
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",
@@ -2,9 +2,19 @@
2
2
 
3
3
  Progressive Code-Mode MCP integration for Factory Droid. Access MCP tools without loading them into context.
4
4
 
5
+ ---
6
+
7
+ > I spend most of my time in Factory.ai's Droid CLI, and I'm a big believer in MCP - it's the right abstraction for giving AI agents access to external tools. But I kept bumping into an interesting constraint. Every MCP server adds its tool schemas to the context window, which means adding servers costs tokens whether you use those tools or not. I wanted ten servers available; I didn't want to pay for ten servers in every prompt. This seemed like a solvable inefficiency, and I was curious enough to dig in.
8
+ >
9
+ > Droid Mode is what emerged: a Skill that provides daemon-backed MCP access with progressive disclosure. Built on 28 years of Linux system administration experience, it treats MCP servers as infrastructure - persistent connections, lazy schema loading, code-mode execution outside the context window. The result is zero token overhead and 13% better latency than native MCP. Per-project daemon isolation handles governance, and everything is traced for accountability. It's experimental research software, but I've tested it thoroughly and use it daily. I'm sharing it as an early AI explorer who believes we're just scratching the surface of what performant agent architectures can look like.
10
+ >
11
+ > \- [GitMaxd](https://github.com/Gitmaxd)
12
+
13
+ ---
14
+
5
15
  ## Installation
6
16
 
7
- ### Option A Project skill (recommended for teams)
17
+ ### Option A - Project skill (recommended for teams)
8
18
 
9
19
  Copy this directory to `<repo>/.factory/skills/droid-mode/`
10
20
 
@@ -12,7 +22,7 @@ Copy this directory to `<repo>/.factory/skills/droid-mode/`
12
22
  ./.factory/skills/droid-mode/bin/dm --help
13
23
  ```
14
24
 
15
- ### Option B User skill (personal)
25
+ ### Option B - User skill (personal)
16
26
 
17
27
  Copy to `~/.factory/skills/droid-mode/`
18
28
 
@@ -20,7 +30,7 @@ Copy to `~/.factory/skills/droid-mode/`
20
30
  ~/.factory/skills/droid-mode/bin/dm --help
21
31
  ```
22
32
 
23
- ### Option C npx (scaffolding)
33
+ ### Option C - npx (scaffolding)
24
34
 
25
35
  ```bash
26
36
  npx droid-mode init
@@ -76,7 +86,7 @@ For CI or quick experiments:
76
86
 
77
87
  ## Commands
78
88
 
79
- ### `dm servers` List available MCP servers
89
+ ### `dm servers` - List available MCP servers
80
90
 
81
91
  ```bash
82
92
  dm servers
@@ -84,7 +94,7 @@ dm servers
84
94
 
85
95
  Shows all servers from `mcp.json`. Servers with `disabled: true` show as "disabled (good!)".
86
96
 
87
- ### `dm doctor` Sanity check
97
+ ### `dm doctor` - Sanity check
88
98
 
89
99
  ```bash
90
100
  dm doctor --server contextrepo
@@ -92,7 +102,7 @@ dm doctor --server contextrepo
92
102
 
93
103
  Checks: finds `mcp.json`, resolves server, attempts `initialize` + `tools/list`.
94
104
 
95
- ### `dm index` List tools (compact)
105
+ ### `dm index` - List tools (compact)
96
106
 
97
107
  ```bash
98
108
  dm index --server contextrepo
@@ -101,7 +111,7 @@ dm index --server contextrepo --json
101
111
 
102
112
  Outputs a compact table (name + description + required params).
103
113
 
104
- ### `dm search` Find tools by keyword
114
+ ### `dm search` - Find tools by keyword
105
115
 
106
116
  ```bash
107
117
  dm search "semantic search over docs" --limit 8 --server contextrepo
@@ -109,18 +119,18 @@ dm search "semantic search over docs" --limit 8 --server contextrepo
109
119
 
110
120
  Searches the cached index.
111
121
 
112
- ### `dm hydrate` Get full schemas
122
+ ### `dm hydrate` - Get full schemas
113
123
 
114
124
  ```bash
115
125
  dm hydrate search_documents get_document --server contextrepo
116
126
  ```
117
127
 
118
128
  Writes to `.factory/droid-mode/hydrated/<server>/<timestamp>/`:
119
- - `tools.json` full tool definitions
120
- - `types.d.ts` TypeScript types
121
- - `toolmap.json` safe JS identifiers → tool names
129
+ - `tools.json` - full tool definitions
130
+ - `types.d.ts` - TypeScript types
131
+ - `toolmap.json` - safe JS identifiers → tool names
122
132
 
123
- ### `dm run` Execute workflow
133
+ ### `dm run` - Execute workflow
124
134
 
125
135
  ```bash
126
136
  dm run --server contextrepo --tools search_documents,get_document --workflow workflow.js
@@ -196,7 +206,7 @@ dm daemon list # Alias for status --all
196
206
  dm daemon warm [server] # Pre-warm connections
197
207
  ```
198
208
 
199
- The daemon is optionalwithout it, everything works as before.
209
+ The daemon is optional-without it, everything works as before.
200
210
 
201
211
  ---
202
212
 
@@ -213,4 +223,4 @@ Optional: Add a **PreToolUse hook** to block direct `mcp__*` calls. See `example
213
223
 
214
224
  ## Design Philosophy
215
225
 
216
- Treat MCP as infrastructure. Treat Skills as capability boundaries. Treat code as a reasoning amplifier not as authority.
226
+ Treat MCP as infrastructure. Treat Skills as capability boundaries. Treat code as a reasoning amplifier - not as authority.
@@ -56,9 +56,9 @@ This is the entire point of the skill.
56
56
  ## Idempotency
57
57
 
58
58
  All droid-mode commands are safe to rerun:
59
- - `dm servers` / `dm index` read-only discovery
60
- - `dm hydrate` overwrites previous hydration (timestamped)
61
- - `dm run` each run creates a new timestamped trace
59
+ - `dm servers` / `dm index`: read-only discovery
60
+ - `dm hydrate`: overwrites previous hydration (timestamped)
61
+ - `dm run`: each run creates a new timestamped trace
62
62
 
63
63
  No cleanup required between invocations.
64
64
 
@@ -192,19 +192,19 @@ Command should exit 0.
192
192
 
193
193
  All outputs written to `.factory/droid-mode/`:
194
194
 
195
- - `cache/<server>/tools.json` tool inventory
196
- - `hydrated/<server>/<ts>/` schemas + types
197
- - `runs/<server>/<ts>/run.json` execution trace
195
+ - `cache/<server>/tools.json`: tool inventory
196
+ - `hydrated/<server>/<ts>/`: schemas + types
197
+ - `runs/<server>/<ts>/run.json`: execution trace
198
198
 
199
199
  All JSON artifacts are machine-parseable for downstream skill chaining. Workflows can read `tools.json` or `run.json` to inform subsequent steps.
200
200
 
201
201
  ## Supporting Files
202
202
 
203
- - `bin/dm` CLI entry point
204
- - `lib/` implementation modules
205
- - `examples/workflows/` sample workflow files
206
- - `examples/hooks/` PreToolUse hook examples
207
- - `README.md` full documentation
203
+ - `bin/dm`: CLI entry point
204
+ - `lib/`: implementation modules
205
+ - `examples/workflows/`: sample workflow files
206
+ - `examples/hooks/`: PreToolUse hook examples
207
+ - `README.md`: full documentation
208
208
 
209
209
  ## Quick Reference
210
210
 
@@ -230,10 +230,10 @@ For personal skill, replace `./.factory/` with `~/.factory/`.
230
230
  ## References
231
231
 
232
232
  For project-specific conventions, see:
233
- - `AGENTS.md` project-wide agent guidance (if present)
234
- - `mcp.json` MCP server configuration
235
- - `.factory/skills/*/SKILL.md` related skills that may chain with droid-mode
233
+ - `AGENTS.md`: project-wide agent guidance (if present)
234
+ - `mcp.json`: MCP server configuration
235
+ - `.factory/skills/*/SKILL.md`: related skills that may chain with droid-mode
236
236
 
237
237
  For droid-mode internals:
238
- - `README.md` full CLI documentation
239
- - `examples/` sample workflows and hooks
238
+ - `README.md`: full CLI documentation
239
+ - `examples/`: sample workflows and hooks
@@ -13,7 +13,7 @@ import { setServerAutoWarm, getServerState } from "../lib/state.mjs";
13
13
 
14
14
  function usage() {
15
15
  return `
16
- Droid Mode (dm) Progressive CodeMode MCP workflows for Factory Droid
16
+ Droid Mode (dm) - Progressive Code-Mode MCP workflows for Factory Droid
17
17
 
18
18
  Usage:
19
19
  dm servers [--json]
@@ -21,7 +21,7 @@ Usage:
21
21
  dm index --server <name> [--refresh] [--json]
22
22
  dm search "<query>" --server <name> [--limit 8] [--refresh] [--json]
23
23
  dm hydrate <tool1> <tool2> ... --server <name> [--out <dir>] [--refresh] [--json]
24
- dm run --workflow <file.js> --tools <a,b,...> --server <name> [--retries 3] [--timeout-ms 300000] [--json]
24
+ dm run --workflow <file.js> --tools <a,b,...> --server <name> [--retries 3] [--timeout-ms 300000]
25
25
  dm call <tool> --server <name> [--args '{...}'] [--args-file path.json] [--json] [--no-daemon]
26
26
 
27
27
  Daemon (persistent connections for faster calls):
@@ -0,0 +1,248 @@
1
+ import { stripCommentsAndStrings, validateWorkflowSource } from "../run.mjs";
2
+
3
+ let passed = 0;
4
+ let failed = 0;
5
+
6
+ function test(name, fn) {
7
+ try {
8
+ fn();
9
+ passed++;
10
+ console.log(` ✓ ${name}`);
11
+ } catch (err) {
12
+ failed++;
13
+ console.log(` ✗ ${name}`);
14
+ console.log(` ${err.message}`);
15
+ }
16
+ }
17
+
18
+ function assertEqual(actual, expected, msg = "") {
19
+ if (actual !== expected) {
20
+ throw new Error(`${msg}\n Expected: ${JSON.stringify(expected)}\n Got: ${JSON.stringify(actual)}`);
21
+ }
22
+ }
23
+
24
+ function assertNoThrow(fn, msg = "") {
25
+ try {
26
+ fn();
27
+ } catch (err) {
28
+ throw new Error(`${msg} - Unexpected error: ${err.message}`);
29
+ }
30
+ }
31
+
32
+ function assertThrows(fn, msg = "") {
33
+ try {
34
+ fn();
35
+ throw new Error(`${msg} - Expected to throw but did not`);
36
+ } catch (err) {
37
+ if (err.message.includes("Expected to throw")) throw err;
38
+ }
39
+ }
40
+
41
+ console.log("\n=== stripCommentsAndStrings ===\n");
42
+
43
+ test("strips single-line comments", () => {
44
+ const input = 'const x = 1; // process is here';
45
+ const result = stripCommentsAndStrings(input);
46
+ assertEqual(result.includes("process"), false, "Should strip comment");
47
+ assertEqual(result.includes("const x = 1;"), true, "Should keep code");
48
+ });
49
+
50
+ test("strips multi-line comments", () => {
51
+ const input = 'const x = 1; /* process\n is here */ const y = 2;';
52
+ const result = stripCommentsAndStrings(input);
53
+ assertEqual(result.includes("process"), false, "Should strip comment");
54
+ assertEqual(result.includes("const x = 1;"), true, "Should keep code before");
55
+ assertEqual(result.includes("const y = 2;"), true, "Should keep code after");
56
+ });
57
+
58
+ test("strips double-quoted strings", () => {
59
+ const input = 'log("process completed");';
60
+ const result = stripCommentsAndStrings(input);
61
+ assertEqual(result.includes("process"), false, "Should strip string");
62
+ assertEqual(result.includes("log"), true, "Should keep function name");
63
+ });
64
+
65
+ test("strips single-quoted strings", () => {
66
+ const input = "log('process completed');";
67
+ const result = stripCommentsAndStrings(input);
68
+ assertEqual(result.includes("process"), false, "Should strip string");
69
+ });
70
+
71
+ test("strips template literals but keeps expressions", () => {
72
+ const input = 'const x = `prefix ${process.env} suffix`;';
73
+ const result = stripCommentsAndStrings(input);
74
+ assertEqual(result.includes("prefix"), false, "Should strip literal text");
75
+ assertEqual(result.includes("suffix"), false, "Should strip literal text");
76
+ assertEqual(result.includes("process"), true, "Should keep expression code");
77
+ });
78
+
79
+ test("handles escaped quotes", () => {
80
+ const input = 'const x = "hello \\"process\\" world";';
81
+ const result = stripCommentsAndStrings(input);
82
+ assertEqual(result.includes("process"), false, "Should strip escaped content");
83
+ });
84
+
85
+ test("handles regex literals", () => {
86
+ const input = 'const re = /process/g;';
87
+ const result = stripCommentsAndStrings(input);
88
+ assertEqual(result.includes("process"), false, "Should strip regex content");
89
+ assertEqual(result.includes("const re ="), true, "Should keep assignment");
90
+ });
91
+
92
+ test("preserves actual code", () => {
93
+ const input = 'const process = {}; process.start();';
94
+ const result = stripCommentsAndStrings(input);
95
+ assertEqual(result.includes("process"), true, "Should keep actual code");
96
+ });
97
+
98
+ console.log("\n=== validateWorkflowSource ===\n");
99
+
100
+ // Cases that should PASS (false positives we're fixing)
101
+ const passCases = [
102
+ ['log("process completed")', "string with process"],
103
+ ['// process this data', "comment with process"],
104
+ ["/* process */", "block comment with process"],
105
+ ['const x = "require this"', "string with require"],
106
+ ['`processing ${data}`', "template literal with process text"],
107
+ ['const re = /process/', "regex with process"],
108
+ ['obj.process.start()', "property named process"],
109
+ ['log("use fetch API")', "string with fetch"],
110
+ ['// import something', "comment with import"],
111
+ ['const msg = "eval this"', "string with eval"],
112
+ ];
113
+
114
+ for (const [code, desc] of passCases) {
115
+ test(`passes: ${desc}`, () => {
116
+ assertNoThrow(() => validateWorkflowSource(code), desc);
117
+ });
118
+ }
119
+
120
+ // Cases that should FAIL (real violations)
121
+ const failCases = [
122
+ ['process.exit()', "actual process access"],
123
+ ['require("fs")', "actual require call"],
124
+ ['import fs from "fs"', "static import"],
125
+ ['import("fs")', "dynamic import"],
126
+ ['eval("code")', "eval call"],
127
+ ['new Function("code")', "Function constructor"],
128
+ ['fetch("url")', "fetch call"],
129
+ ['const { exit } = process', "process destructuring"],
130
+ ['child_process.spawn()', "child_process access"],
131
+ ['WebAssembly.compile()', "WebAssembly access"],
132
+ ];
133
+
134
+ for (const [code, desc] of failCases) {
135
+ test(`blocks: ${desc}`, () => {
136
+ assertThrows(() => validateWorkflowSource(code), desc);
137
+ });
138
+ }
139
+
140
+ // Edge cases
141
+ console.log("\n=== Edge Cases ===\n");
142
+
143
+ test("handles nested template expressions", () => {
144
+ const input = 'const x = `outer ${inner ? `nested ${process}` : "no"} end`;';
145
+ const result = stripCommentsAndStrings(input);
146
+ assertEqual(result.includes("process"), true, "Should keep nested expression");
147
+ });
148
+
149
+ test("handles empty strings", () => {
150
+ assertNoThrow(() => validateWorkflowSource('const x = "";'));
151
+ });
152
+
153
+ test("handles complex workflow", () => {
154
+ const workflow = `
155
+ // This workflow processes data
156
+ workflow = async () => {
157
+ const msg = "Processing started";
158
+ log(msg);
159
+ const result = await t.someApiCall({ query: "process" });
160
+ /* Multi-line
161
+ comment about process */
162
+ return { ok: true, data: "process complete" };
163
+ };
164
+ `;
165
+ assertNoThrow(() => validateWorkflowSource(workflow), "Complex workflow should pass");
166
+ });
167
+
168
+ test("blocks process even with tricky formatting", () => {
169
+ assertThrows(() => validateWorkflowSource('process\n.exit()'), "newline between process and method");
170
+ });
171
+
172
+ test("allowUnsafe option bypasses validation", () => {
173
+ assertNoThrow(() => validateWorkflowSource('process.exit()', { allowUnsafe: true }));
174
+ });
175
+
176
+ // Review-identified edge cases
177
+ console.log("\n=== Review Edge Cases (Critical/Major fixes) ===\n");
178
+
179
+ test("CRITICAL: brace in string inside template expression doesn't break parsing", () => {
180
+ // This was bypassing the scanner because "}" inside the string closed braceDepth
181
+ const code = 'const x = `${"}"}` + process.exit()';
182
+ assertThrows(() => validateWorkflowSource(code), "Should still catch process.exit()");
183
+ });
184
+
185
+ test("CRITICAL: brace bypass attempt with actual dangerous code", () => {
186
+ const code = '`${"}" + process.exit()}`';
187
+ assertThrows(() => validateWorkflowSource(code), "Should catch process in expression");
188
+ });
189
+
190
+ test("MAJOR: strings inside template expressions are stripped", () => {
191
+ // String "process" inside ${} should not cause false positive
192
+ const code = '`${log("process finished")}`';
193
+ assertNoThrow(() => validateWorkflowSource(code), "String in template expr should be stripped");
194
+ });
195
+
196
+ test("MAJOR: comments inside template expressions are stripped", () => {
197
+ const code = '`${/*process*/ 42}`';
198
+ assertNoThrow(() => validateWorkflowSource(code), "Comment in template expr should be stripped");
199
+ });
200
+
201
+ test("MAJOR: regex after return is detected", () => {
202
+ const code = 'return /process/';
203
+ const result = stripCommentsAndStrings(code);
204
+ assertEqual(result.includes("process"), false, "Regex after return should be stripped");
205
+ });
206
+
207
+ test("MAJOR: regex after case is detected", () => {
208
+ const code = 'case /process/: break;';
209
+ const result = stripCommentsAndStrings(code);
210
+ assertEqual(result.includes("process"), false, "Regex after case should be stripped");
211
+ });
212
+
213
+ test("MAJOR: regex after throw is detected", () => {
214
+ const code = 'throw /process/';
215
+ const result = stripCommentsAndStrings(code);
216
+ assertEqual(result.includes("process"), false, "Regex after throw should be stripped");
217
+ });
218
+
219
+ test("MAJOR: regex after typeof is detected", () => {
220
+ const code = 'typeof /process/';
221
+ const result = stripCommentsAndStrings(code);
222
+ assertEqual(result.includes("process"), false, "Regex after typeof should be stripped");
223
+ });
224
+
225
+ test("handles regex with character class containing slash", () => {
226
+ const code = 'const re = /[/]/';
227
+ const result = stripCommentsAndStrings(code);
228
+ assertEqual(result.includes("const re ="), true, "Should parse regex with char class");
229
+ });
230
+
231
+ test("deeply nested template with dangerous code is caught", () => {
232
+ const code = '`outer ${`inner ${process.exit()}`}`';
233
+ assertThrows(() => validateWorkflowSource(code), "Nested template with process should fail");
234
+ });
235
+
236
+ test("safe deeply nested template passes", () => {
237
+ const code = '`outer ${`inner ${"process"}`}`';
238
+ assertNoThrow(() => validateWorkflowSource(code), "Nested template with string should pass");
239
+ });
240
+
241
+ // Summary
242
+ console.log(`\n${"=".repeat(40)}`);
243
+ console.log(`Results: ${passed} passed, ${failed} failed`);
244
+ console.log("=".repeat(40) + "\n");
245
+
246
+ if (failed > 0) {
247
+ process.exit(1);
248
+ }
@@ -3,9 +3,29 @@ import path from "node:path";
3
3
  import os from "node:os";
4
4
  import { findProjectRoot, readJsonFileIfExists, redactEnvForDisplay } from "./util.mjs";
5
5
 
6
+ // Module-level cache for mcp.json configs (invalidated by mtime changes)
7
+ let _configCache = null;
8
+
9
+ function getMtime(filePath) {
10
+ if (!filePath) return null;
11
+ try {
12
+ return fs.statSync(filePath).mtimeMs;
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
17
+
18
+ function isCacheValid() {
19
+ if (!_configCache) return false;
20
+ const projectMtime = getMtime(_configCache.projectPath);
21
+ const userMtime = getMtime(_configCache.userPath);
22
+ return projectMtime === _configCache.projectMtime && userMtime === _configCache.userMtime;
23
+ }
24
+
6
25
  /**
7
26
  * Read project and user mcp.json, merge according to Factory's layering rules:
8
27
  * - user servers override project servers with the same name
28
+ * - Results are cached and invalidated when file mtimes change
9
29
  * @returns {{
10
30
  * projectPath: string|null,
11
31
  * userPath: string,
@@ -15,6 +35,10 @@ import { findProjectRoot, readJsonFileIfExists, redactEnvForDisplay } from "./ut
15
35
  * }}
16
36
  */
17
37
  export function loadMcpConfigs() {
38
+ if (isCacheValid()) {
39
+ return _configCache.result;
40
+ }
41
+
18
42
  const projectRoot = findProjectRoot();
19
43
  const projectPath = projectRoot ? path.join(projectRoot, ".factory", "mcp.json") : null;
20
44
  const userPath = path.join(os.homedir(), ".factory", "mcp.json");
@@ -26,7 +50,17 @@ export function loadMcpConfigs() {
26
50
  const userServers = (user && user.mcpServers) || {};
27
51
 
28
52
  const merged = { ...projectServers, ...userServers };
29
- return { projectPath, userPath, project, user, mergedServers: merged };
53
+ const result = { projectPath, userPath, project, user, mergedServers: merged };
54
+
55
+ _configCache = {
56
+ projectPath,
57
+ userPath,
58
+ projectMtime: getMtime(projectPath),
59
+ userMtime: getMtime(userPath),
60
+ result,
61
+ };
62
+
63
+ return result;
30
64
  }
31
65
 
32
66
  /**
@@ -9,6 +9,7 @@ import { getDaemonPaths, findProjectRoot, getRunDir } from "./util.mjs";
9
9
 
10
10
  const IDLE_TIMEOUT_MS = parseInt(process.env.DM_DAEMON_IDLE_MS || "600000", 10); // 10 minutes default
11
11
  const MAX_CONNECTIONS = 50;
12
+ const WARM_CONCURRENCY = 4; // Max parallel server warm operations
12
13
 
13
14
  // Daemon paths are set via env vars from client, or computed from cwd
14
15
  function resolveDaemonConfig() {
@@ -114,13 +115,18 @@ class ConnectionPool {
114
115
  const warmed = [];
115
116
  const failed = [];
116
117
 
117
- // Warm in parallel with concurrency limit
118
- const results = await Promise.allSettled(
119
- toWarm.map(async (name) => {
120
- const success = await this.warmServer(name);
121
- return { name, success };
122
- })
123
- );
118
+ // Warm in batches with concurrency limit
119
+ const results = [];
120
+ for (let i = 0; i < toWarm.length; i += WARM_CONCURRENCY) {
121
+ const batch = toWarm.slice(i, i + WARM_CONCURRENCY);
122
+ const batchResults = await Promise.allSettled(
123
+ batch.map(async (name) => {
124
+ const success = await this.warmServer(name);
125
+ return { name, success };
126
+ })
127
+ );
128
+ results.push(...batchResults);
129
+ }
124
130
 
125
131
  for (const result of results) {
126
132
  if (result.status === "fulfilled" && result.value.success) {
@@ -6,28 +6,261 @@ import { nowIsoCompact, sha256Hex, uniqueSafeToolMap, ensureDir, getDroidModeDat
6
6
  /**
7
7
  * Static, best-effort disallow list for sandboxed workflows.
8
8
  * This is not meant to be perfect security; it is intended to prevent accidental escalation.
9
+ * The vm sandbox is the real security boundary - these checks provide clearer error messages.
9
10
  */
10
11
  const DEFAULT_BANNED_PATTERNS = [
11
12
  /\brequire\s*\(/,
12
13
  /\bimport\s+/,
13
- /\bimport\s*\(/, // dynamic import
14
- /\bprocess\b/,
14
+ /\bimport\s*\(/,
15
+ /(?<![.])process\b/, // process but not .process (property access is ok)
15
16
  /\bchild_process\b/,
16
- /node:(?:fs|http|https|net)\b/, // Node built-in modules (allows plain URLs)
17
- /\bfetch\b/,
17
+ /node:(?:fs|http|https|net)\b/,
18
+ /(?<![.])fetch\b/, // fetch but not .fetch (property access is ok)
18
19
  /\beval\s*\(/,
19
20
  /\bFunction\s*\(/,
20
21
  /\bWebAssembly\b/,
21
22
  ];
22
23
 
24
+ /**
25
+ * Check if we're in a regex-allowed context by looking at the last meaningful token.
26
+ * Returns true if a `/` at this position would start a regex literal, not division.
27
+ * @param {string} codeSoFar - Code processed so far (with literals stripped)
28
+ * @returns {boolean}
29
+ */
30
+ function isRegexContext(codeSoFar) {
31
+ const trimmed = codeSoFar.trimEnd();
32
+ if (!trimmed) return true;
33
+
34
+ // Check for keywords that precede regex (return /x/, case /x/:, throw /x/, etc.)
35
+ const keywordMatch = trimmed.match(/\b(return|case|throw|typeof|void|delete|in|instanceof|new|else|do)\s*$/);
36
+ if (keywordMatch) return true;
37
+
38
+ // Check for operators and punctuation that precede regex
39
+ const lastChar = trimmed.slice(-1);
40
+ return ["=", "(", ",", "[", "!", "&", "|", ":", ";", "{", "}", "?", "\n", "+", "-", "*", "%", "<", ">", "~", "^"].includes(lastChar);
41
+ }
42
+
43
+ /**
44
+ * Extract a template expression ${...} handling nested braces, strings, and templates correctly.
45
+ * @param {string} source - Full source
46
+ * @param {number} start - Position of the `$` in `${`
47
+ * @returns {{ content: string, endIndex: number }} - Expression content and position after closing `}`
48
+ */
49
+ function extractTemplateExpression(source, start) {
50
+ let i = start + 2; // Skip `${`
51
+ let content = "";
52
+ let braceDepth = 1;
53
+ const len = source.length;
54
+
55
+ while (i < len && braceDepth > 0) {
56
+ // Handle nested template literals
57
+ if (source[i] === "`") {
58
+ content += "`";
59
+ i++;
60
+ while (i < len && source[i] !== "`") {
61
+ if (source[i] === "$" && source[i + 1] === "{") {
62
+ const nested = extractTemplateExpression(source, i);
63
+ content += "${" + nested.content + "}";
64
+ i = nested.endIndex;
65
+ } else if (source[i] === "\\") {
66
+ content += source.slice(i, i + 2);
67
+ i += 2;
68
+ } else {
69
+ content += source[i];
70
+ i++;
71
+ }
72
+ }
73
+ if (i < len) {
74
+ content += "`";
75
+ i++;
76
+ }
77
+ continue;
78
+ }
79
+
80
+ // Handle strings inside expression (so } inside strings don't count)
81
+ if (source[i] === '"' || source[i] === "'") {
82
+ const quote = source[i];
83
+ content += quote;
84
+ i++;
85
+ while (i < len && source[i] !== quote) {
86
+ if (source[i] === "\\") {
87
+ content += source.slice(i, i + 2);
88
+ i += 2;
89
+ } else {
90
+ content += source[i];
91
+ i++;
92
+ }
93
+ }
94
+ if (i < len) {
95
+ content += quote;
96
+ i++;
97
+ }
98
+ continue;
99
+ }
100
+
101
+ // Handle comments
102
+ if (source[i] === "/" && source[i + 1] === "/") {
103
+ while (i < len && source[i] !== "\n") {
104
+ content += source[i];
105
+ i++;
106
+ }
107
+ continue;
108
+ }
109
+ if (source[i] === "/" && source[i + 1] === "*") {
110
+ content += "/*";
111
+ i += 2;
112
+ while (i < len && !(source[i] === "*" && source[i + 1] === "/")) {
113
+ content += source[i];
114
+ i++;
115
+ }
116
+ if (i < len) {
117
+ content += "*/";
118
+ i += 2;
119
+ }
120
+ continue;
121
+ }
122
+
123
+ // Track braces
124
+ if (source[i] === "{") braceDepth++;
125
+ else if (source[i] === "}") braceDepth--;
126
+
127
+ if (braceDepth > 0) {
128
+ content += source[i];
129
+ i++;
130
+ }
131
+ }
132
+
133
+ return { content, endIndex: i + 1 }; // +1 to skip the closing `}`
134
+ }
135
+
136
+ /**
137
+ * Strip comments and string literals from source code to avoid false positives
138
+ * when scanning for banned patterns. Replaces content with whitespace to preserve positions.
139
+ * Template expressions ${...} are recursively processed to strip their literals too.
140
+ * @param {string} source
141
+ * @returns {string}
142
+ */
143
+ export function stripCommentsAndStrings(source) {
144
+ let result = "";
145
+ let i = 0;
146
+ const len = source.length;
147
+
148
+ while (i < len) {
149
+ // Single-line comment
150
+ if (source[i] === "/" && source[i + 1] === "/") {
151
+ const start = i;
152
+ i += 2;
153
+ while (i < len && source[i] !== "\n") i++;
154
+ result += " ".repeat(i - start);
155
+ continue;
156
+ }
157
+
158
+ // Multi-line comment
159
+ if (source[i] === "/" && source[i + 1] === "*") {
160
+ const start = i;
161
+ i += 2;
162
+ while (i < len && !(source[i] === "*" && source[i + 1] === "/")) i++;
163
+ i += 2;
164
+ result += " ".repeat(i - start);
165
+ continue;
166
+ }
167
+
168
+ // Template literal (backtick) - preserve ${...} expressions but strip their literals
169
+ if (source[i] === "`") {
170
+ result += " ";
171
+ i++;
172
+ while (i < len && source[i] !== "`") {
173
+ if (source[i] === "$" && source[i + 1] === "{") {
174
+ const expr = extractTemplateExpression(source, i);
175
+ // Recursively strip literals from the expression content
176
+ const strippedExpr = stripCommentsAndStrings(expr.content);
177
+ result += "${" + strippedExpr + "}";
178
+ i = expr.endIndex;
179
+ } else if (source[i] === "\\") {
180
+ result += " ";
181
+ i += 2;
182
+ } else {
183
+ result += " ";
184
+ i++;
185
+ }
186
+ }
187
+ if (i < len) {
188
+ result += " ";
189
+ i++;
190
+ }
191
+ continue;
192
+ }
193
+
194
+ // Double-quoted string
195
+ if (source[i] === '"') {
196
+ const start = i;
197
+ i++;
198
+ while (i < len && source[i] !== '"') {
199
+ if (source[i] === "\\") i++;
200
+ i++;
201
+ }
202
+ i++;
203
+ result += " ".repeat(i - start);
204
+ continue;
205
+ }
206
+
207
+ // Single-quoted string
208
+ if (source[i] === "'") {
209
+ const start = i;
210
+ i++;
211
+ while (i < len && source[i] !== "'") {
212
+ if (source[i] === "\\") i++;
213
+ i++;
214
+ }
215
+ i++;
216
+ result += " ".repeat(i - start);
217
+ continue;
218
+ }
219
+
220
+ // Regex literal - detect using context analysis
221
+ if (source[i] === "/" && i > 0 && source[i + 1] !== "/" && source[i + 1] !== "*") {
222
+ if (isRegexContext(result)) {
223
+ const start = i;
224
+ i++;
225
+ while (i < len && source[i] !== "/") {
226
+ if (source[i] === "\\") i++;
227
+ if (source[i] === "[") {
228
+ // Character class - / doesn't end regex here
229
+ i++;
230
+ while (i < len && source[i] !== "]") {
231
+ if (source[i] === "\\") i++;
232
+ i++;
233
+ }
234
+ }
235
+ i++;
236
+ }
237
+ i++;
238
+ // Skip flags
239
+ while (i < len && /[gimsuy]/.test(source[i])) i++;
240
+ result += " ".repeat(i - start);
241
+ continue;
242
+ }
243
+ }
244
+
245
+ result += source[i];
246
+ i++;
247
+ }
248
+
249
+ return result;
250
+ }
251
+
23
252
  /**
24
253
  * @param {string} source
25
254
  * @param {{ allowUnsafe?: boolean }} opts
26
255
  */
27
256
  export function validateWorkflowSource(source, opts = {}) {
28
257
  if (opts.allowUnsafe) return;
258
+
259
+ // Strip comments and strings to avoid false positives like log("process completed")
260
+ const codeOnly = stripCommentsAndStrings(source);
261
+
29
262
  for (const re of DEFAULT_BANNED_PATTERNS) {
30
- if (re.test(source)) {
263
+ if (re.test(codeOnly)) {
31
264
  throw new Error(
32
265
  `Workflow source contains a disallowed pattern (${re}).\n` +
33
266
  `This runner executes workflows in a restricted sandbox; ` +
@@ -1,19 +1,40 @@
1
1
  import path from "node:path";
2
2
  import { getDroidModeDataDir, readJsonFileIfExists, writeJson, serverNameToDirName } from "./util.mjs";
3
3
 
4
+ const DEFAULT_MAX_PAGES = 100;
5
+ const DEFAULT_MAX_TOOLS = 1000;
6
+
4
7
  /**
5
8
  * @param {import("./mcp_client.mjs").McpClient} client
9
+ * @param {{ maxPages?: number, maxTools?: number }} opts
10
+ * @returns {Promise<{ tools: any[], truncated: boolean, truncationReason?: string }>}
6
11
  */
7
- export async function fetchAllTools(client) {
12
+ export async function fetchAllTools(client, opts = {}) {
13
+ const maxPages = opts.maxPages ?? DEFAULT_MAX_PAGES;
14
+ const maxTools = opts.maxTools ?? DEFAULT_MAX_TOOLS;
8
15
  const tools = [];
9
16
  let cursor = undefined;
10
- for (let i = 0; i < 10_000; i++) {
17
+ let truncated = false;
18
+ let truncationReason;
19
+
20
+ for (let i = 0; i < maxPages; i++) {
11
21
  const res = await client.listTools({ cursor, timeoutMs: 60_000 });
12
22
  if (Array.isArray(res?.tools)) tools.push(...res.tools);
13
23
  cursor = res?.nextCursor;
24
+
25
+ if (tools.length >= maxTools) {
26
+ truncated = true;
27
+ truncationReason = `max_tools (${maxTools})`;
28
+ break;
29
+ }
14
30
  if (!cursor) break;
31
+ if (i === maxPages - 1 && cursor) {
32
+ truncated = true;
33
+ truncationReason = `max_pages (${maxPages})`;
34
+ }
15
35
  }
16
- return tools;
36
+
37
+ return { tools, truncated, truncationReason };
17
38
  }
18
39
 
19
40
  /**
@@ -43,7 +64,7 @@ export async function getToolsCached(opts) {
43
64
 
44
65
  // Refresh
45
66
  await opts.client.init();
46
- const tools = await fetchAllTools(opts.client);
67
+ const { tools, truncated, truncationReason } = await fetchAllTools(opts.client);
47
68
  const payload = {
48
69
  fetchedAt: new Date().toISOString(),
49
70
  serverName: opts.serverName,
@@ -51,6 +72,9 @@ export async function getToolsCached(opts) {
51
72
  protocolVersion: opts.client.negotiatedProtocolVersion,
52
73
  serverInfo: opts.client.serverInfo,
53
74
  capabilities: opts.client.serverCapabilities,
75
+ truncated,
76
+ truncationReason,
77
+ toolCount: tools.length,
54
78
  tools,
55
79
  };
56
80
  writeJson(cacheFile, payload);