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 +58 -0
- package/package.json +1 -1
- package/templates/skills/droid-mode/README.md +34 -0
- package/templates/skills/droid-mode/SKILL.md +40 -4
- package/templates/skills/droid-mode/bin/dm +95 -1
- package/templates/skills/droid-mode/lib/daemon.mjs +293 -0
- package/templates/skills/droid-mode/lib/daemon_client.mjs +165 -0
- package/templates/skills/droid-mode/lib/mcp_stdio.mjs +21 -3
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
|
@@ -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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
}
|