droid-mode 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -4
- package/package.json +1 -1
- package/templates/skills/droid-mode/README.md +32 -16
- package/templates/skills/droid-mode/bin/dm +72 -16
- package/templates/skills/droid-mode/lib/daemon.mjs +127 -11
- package/templates/skills/droid-mode/lib/daemon_client.mjs +34 -12
- package/templates/skills/droid-mode/lib/util.mjs +310 -0
package/README.md
CHANGED
|
@@ -74,13 +74,32 @@ flowchart LR
|
|
|
74
74
|
|
|
75
75
|
| Component | Purpose |
|
|
76
76
|
|-----------|---------|
|
|
77
|
-
| **
|
|
77
|
+
| **Per-Project Sockets** | Each project gets its own daemon at `~/.factory/run/dm-daemon-<hash>.sock` |
|
|
78
78
|
| **Connection Pool** | Lazy-initialized clients with automatic lifecycle management |
|
|
79
79
|
| **Auto-Warm** | Pre-connects frequently used servers on daemon start |
|
|
80
80
|
| **Idle Pruning** | Closes unused connections after 10 minutes (configurable) |
|
|
81
81
|
|
|
82
82
|
The daemon starts automatically on first `dm call`. Without it, each call spawns a fresh MCP process (~2.5s average). With the daemon, calls reuse pooled connections (~680ms average).
|
|
83
83
|
|
|
84
|
+
#### Multi-Project Isolation
|
|
85
|
+
|
|
86
|
+
When working across multiple projects, each project automatically gets its own daemon:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
# Project A - starts daemon bound to Project A's config
|
|
90
|
+
cd ~/projects/frontend && dm daemon start
|
|
91
|
+
# Socket: ~/.factory/run/dm-daemon-a1b2c3d4.sock
|
|
92
|
+
|
|
93
|
+
# Project B - starts separate daemon with Project B's config
|
|
94
|
+
cd ~/projects/backend && dm daemon start
|
|
95
|
+
# Socket: ~/.factory/run/dm-daemon-e5f6g7h8.sock
|
|
96
|
+
|
|
97
|
+
# List all running daemons
|
|
98
|
+
dm daemon status --all
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
This prevents configuration bleed between projects and ensures governance compliance when projects have different MCP server access policies.
|
|
102
|
+
|
|
84
103
|
---
|
|
85
104
|
|
|
86
105
|
## Performance Benchmarks
|
|
@@ -209,9 +228,11 @@ dm daemon stop # Stop daemon
|
|
|
209
228
|
|
|
210
229
|
| Command | Description |
|
|
211
230
|
|---------|-------------|
|
|
212
|
-
| `dm daemon start` | Start background daemon |
|
|
213
|
-
| `dm daemon stop` | Stop daemon |
|
|
214
|
-
| `dm daemon status` | Show
|
|
231
|
+
| `dm daemon start` | Start background daemon for current project |
|
|
232
|
+
| `dm daemon stop` | Stop current project's daemon |
|
|
233
|
+
| `dm daemon status` | Show current project's daemon status |
|
|
234
|
+
| `dm daemon status --all` | List all running daemons across projects |
|
|
235
|
+
| `dm daemon list` | Alias for `status --all` |
|
|
215
236
|
| `dm daemon warm [server]` | Pre-warm server connection(s) |
|
|
216
237
|
| `dm call ... --no-daemon` | Bypass daemon for single call |
|
|
217
238
|
|
package/package.json
CHANGED
|
@@ -152,7 +152,31 @@ All artifacts written to `.factory/droid-mode/`:
|
|
|
152
152
|
|
|
153
153
|
## Daemon Mode (Performance)
|
|
154
154
|
|
|
155
|
-
The daemon maintains persistent MCP connections, reducing call latency by ~5x.
|
|
155
|
+
The daemon maintains persistent MCP connections, reducing call latency by ~5x. Each project gets its own isolated daemon instance.
|
|
156
|
+
|
|
157
|
+
### Per-Project Isolation
|
|
158
|
+
|
|
159
|
+
Droid Mode automatically creates separate daemons for each project, preventing configuration cross-contamination:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
# Each project gets its own daemon
|
|
163
|
+
cd ~/project-a && dm daemon start # → dm-daemon-<hash-a>.sock
|
|
164
|
+
cd ~/project-b && dm daemon start # → dm-daemon-<hash-b>.sock
|
|
165
|
+
|
|
166
|
+
# View all daemons across projects
|
|
167
|
+
dm daemon status --all
|
|
168
|
+
|
|
169
|
+
# Output:
|
|
170
|
+
# project pid status started
|
|
171
|
+
# ------------------------- ----- ------- ----------
|
|
172
|
+
# /Users/me/project-a 12345 running 2026-01-04
|
|
173
|
+
# /Users/me/project-b 12346 running 2026-01-04
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
This ensures:
|
|
177
|
+
- **Configuration isolation**: Each daemon loads its project's `mcp.json`
|
|
178
|
+
- **Connection separation**: MCP server connections don't leak between projects
|
|
179
|
+
- **Governance compliance**: Projects with different access policies stay separated
|
|
156
180
|
|
|
157
181
|
### Benchmark Results
|
|
158
182
|
|
|
@@ -161,23 +185,15 @@ The daemon maintains persistent MCP connections, reducing call latency by ~5x.
|
|
|
161
185
|
| Without Daemon | ~2,900ms | 29.9s |
|
|
162
186
|
| **With Daemon** | **~620ms** | **10.2s** |
|
|
163
187
|
|
|
164
|
-
###
|
|
188
|
+
### Commands
|
|
165
189
|
|
|
166
190
|
```bash
|
|
167
|
-
# Start daemon
|
|
168
|
-
dm daemon
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
dm
|
|
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
|
|
191
|
+
dm daemon start # Start daemon for current project
|
|
192
|
+
dm daemon stop # Stop current project's daemon
|
|
193
|
+
dm daemon status # Check current project's daemon
|
|
194
|
+
dm daemon status --all # List ALL daemons across projects
|
|
195
|
+
dm daemon list # Alias for status --all
|
|
196
|
+
dm daemon warm [server] # Pre-warm connections
|
|
181
197
|
```
|
|
182
198
|
|
|
183
199
|
The daemon is optional—without it, everything works as before.
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
|
|
4
|
-
import { parseArgs, printTable } from "../lib/util.mjs";
|
|
4
|
+
import { parseArgs, printTable, listAllDaemons } from "../lib/util.mjs";
|
|
5
5
|
import { loadMcpConfigs, resolveServer, summarizeServerForDisplay, listAllServers } from "../lib/config.mjs";
|
|
6
6
|
import { McpClient } from "../lib/mcp_client.mjs";
|
|
7
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, ensureDaemonRunning, warmServers } from "../lib/daemon_client.mjs";
|
|
11
|
+
import { isDaemonRunning, callViaDaemon, getDaemonStatus, shutdownDaemon, startDaemon, ensureDaemonRunning, warmServers, getCurrentDaemonPaths } from "../lib/daemon_client.mjs";
|
|
12
12
|
import { setServerAutoWarm, getServerState } from "../lib/state.mjs";
|
|
13
13
|
|
|
14
14
|
function usage() {
|
|
@@ -22,13 +22,15 @@ Usage:
|
|
|
22
22
|
dm search "<query>" --server <name> [--limit 8] [--refresh] [--json]
|
|
23
23
|
dm hydrate <tool1> <tool2> ... --server <name> [--out <dir>] [--refresh] [--json]
|
|
24
24
|
dm run --workflow <file.js> --tools <a,b,...> --server <name> [--retries 3] [--timeout-ms 300000] [--json]
|
|
25
|
-
dm call <tool> --server <name> [--args
|
|
25
|
+
dm call <tool> --server <name> [--args '{...}'] [--args-file path.json] [--json] [--no-daemon]
|
|
26
26
|
|
|
27
27
|
Daemon (persistent connections for faster calls):
|
|
28
|
-
dm daemon start
|
|
29
|
-
dm daemon stop
|
|
30
|
-
dm daemon status [--json]
|
|
31
|
-
dm daemon
|
|
28
|
+
dm daemon start → Start background daemon for current project
|
|
29
|
+
dm daemon stop → Stop current project's daemon
|
|
30
|
+
dm daemon status [--json] → Show current project's daemon status
|
|
31
|
+
dm daemon status --all → List all running daemons
|
|
32
|
+
dm daemon list → Alias for status --all
|
|
33
|
+
dm daemon warm [<server>] → Pre-warm server(s)
|
|
32
34
|
|
|
33
35
|
Configuration:
|
|
34
36
|
dm config <server> autoWarm true|false → Enable/disable auto-warm for server
|
|
@@ -383,11 +385,11 @@ async function cmdCall(args) {
|
|
|
383
385
|
}
|
|
384
386
|
|
|
385
387
|
let argObj = {};
|
|
386
|
-
if (args.flags["args
|
|
388
|
+
if (args.flags["args"]) {
|
|
387
389
|
try {
|
|
388
|
-
argObj = JSON.parse(String(args.flags["args
|
|
390
|
+
argObj = JSON.parse(String(args.flags["args"]));
|
|
389
391
|
} catch (err) {
|
|
390
|
-
fail(`Invalid --args
|
|
392
|
+
fail(`Invalid --args JSON: ${err.message || err}`);
|
|
391
393
|
}
|
|
392
394
|
} else if (args.flags["args-file"]) {
|
|
393
395
|
const p = String(args.flags["args-file"]);
|
|
@@ -431,16 +433,26 @@ async function cmdCall(args) {
|
|
|
431
433
|
|
|
432
434
|
async function cmdDaemon(args) {
|
|
433
435
|
const subcmd = args._[1];
|
|
436
|
+
const { socket, projectRoot } = getCurrentDaemonPaths();
|
|
434
437
|
|
|
435
438
|
if (subcmd === "start") {
|
|
436
439
|
const running = await isDaemonRunning();
|
|
437
440
|
if (running) {
|
|
438
441
|
process.stdout.write("Daemon is already running.\n");
|
|
442
|
+
if (projectRoot) {
|
|
443
|
+
process.stdout.write(`Project: ${projectRoot}\n`);
|
|
444
|
+
}
|
|
439
445
|
return;
|
|
440
446
|
}
|
|
441
447
|
try {
|
|
442
|
-
const
|
|
443
|
-
process.stdout.write(`Daemon started (PID: ${pid})\n`);
|
|
448
|
+
const result = await startDaemon();
|
|
449
|
+
process.stdout.write(`Daemon started (PID: ${result.pid})\n`);
|
|
450
|
+
process.stdout.write(`Socket: ${result.socketPath}\n`);
|
|
451
|
+
if (result.projectRoot) {
|
|
452
|
+
process.stdout.write(`Project: ${result.projectRoot}\n`);
|
|
453
|
+
} else {
|
|
454
|
+
process.stdout.write(`Project: (global)\n`);
|
|
455
|
+
}
|
|
444
456
|
} catch (err) {
|
|
445
457
|
fail(`Failed to start daemon: ${err.message}`);
|
|
446
458
|
}
|
|
@@ -455,28 +467,72 @@ async function cmdDaemon(args) {
|
|
|
455
467
|
}
|
|
456
468
|
await shutdownDaemon();
|
|
457
469
|
process.stdout.write("Daemon stopped.\n");
|
|
470
|
+
if (projectRoot) {
|
|
471
|
+
process.stdout.write(`Project: ${projectRoot}\n`);
|
|
472
|
+
}
|
|
458
473
|
return;
|
|
459
474
|
}
|
|
460
475
|
|
|
461
|
-
if (subcmd === "status") {
|
|
476
|
+
if (subcmd === "status" || subcmd === "list") {
|
|
477
|
+
// --all flag shows all daemons across all projects
|
|
478
|
+
if (args.flags.all || subcmd === "list") {
|
|
479
|
+
const allDaemons = listAllDaemons();
|
|
480
|
+
|
|
481
|
+
if (args.flags.json) {
|
|
482
|
+
process.stdout.write(JSON.stringify({ daemons: allDaemons }, null, 2) + "\n");
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (!allDaemons.length) {
|
|
487
|
+
process.stdout.write("No daemons found.\n");
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
printTable(
|
|
492
|
+
allDaemons.map((d) => ({
|
|
493
|
+
project: d.projectPath || "(global)",
|
|
494
|
+
pid: d.pid,
|
|
495
|
+
status: d.alive ? "running" : (d.stale ? "stale" : "unknown"),
|
|
496
|
+
started: d.startedAt?.split("T")[0] || "-",
|
|
497
|
+
})),
|
|
498
|
+
["project", "pid", "status", "started"]
|
|
499
|
+
);
|
|
500
|
+
process.stdout.write(`\n${allDaemons.length} daemon(s) found.\n`);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Default: show current project's daemon
|
|
462
505
|
const running = await isDaemonRunning();
|
|
463
506
|
if (!running) {
|
|
464
507
|
if (args.flags.json) {
|
|
465
|
-
process.stdout.write(JSON.stringify({ running: false }, null, 2) + "\n");
|
|
508
|
+
process.stdout.write(JSON.stringify({ running: false, socket, projectRoot }, null, 2) + "\n");
|
|
466
509
|
} else {
|
|
467
510
|
process.stdout.write("Daemon is not running.\n");
|
|
511
|
+
process.stdout.write(`Socket: ${socket}\n`);
|
|
512
|
+
if (projectRoot) {
|
|
513
|
+
process.stdout.write(`Project: ${projectRoot}\n`);
|
|
514
|
+
} else {
|
|
515
|
+
process.stdout.write(`Project: (global)\n`);
|
|
516
|
+
}
|
|
468
517
|
}
|
|
469
518
|
return;
|
|
470
519
|
}
|
|
471
520
|
|
|
472
521
|
const status = await getDaemonStatus();
|
|
473
522
|
if (args.flags.json) {
|
|
474
|
-
process.stdout.write(JSON.stringify({ running: true, ...status }, null, 2) + "\n");
|
|
523
|
+
process.stdout.write(JSON.stringify({ running: true, socket, projectRoot, ...status }, null, 2) + "\n");
|
|
475
524
|
return;
|
|
476
525
|
}
|
|
477
526
|
|
|
478
527
|
const uptimeMin = Math.floor((status.uptime || 0) / 60000);
|
|
479
|
-
process.stdout.write(`Daemon running (uptime: ${uptimeMin}m)\n
|
|
528
|
+
process.stdout.write(`Daemon running (uptime: ${uptimeMin}m)\n`);
|
|
529
|
+
process.stdout.write(`Socket: ${socket}\n`);
|
|
530
|
+
if (projectRoot) {
|
|
531
|
+
process.stdout.write(`Project: ${projectRoot}\n`);
|
|
532
|
+
} else {
|
|
533
|
+
process.stdout.write(`Project: (global)\n`);
|
|
534
|
+
}
|
|
535
|
+
process.stdout.write("\n");
|
|
480
536
|
|
|
481
537
|
if (status.connections?.length) {
|
|
482
538
|
printTable(
|
|
@@ -1,14 +1,40 @@
|
|
|
1
1
|
import net from "node:net";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import crypto from "node:crypto";
|
|
4
5
|
import { McpClient } from "./mcp_client.mjs";
|
|
5
6
|
import { resolveServer, listAllServers, loadMcpConfigs } from "./config.mjs";
|
|
6
7
|
import { loadState, saveState, getServersToWarm, recordServerUsage } from "./state.mjs";
|
|
8
|
+
import { getDaemonPaths, findProjectRoot, getRunDir } from "./util.mjs";
|
|
7
9
|
|
|
8
|
-
const SOCKET_PATH = process.env.DM_DAEMON_SOCKET || "/tmp/dm-daemon.sock";
|
|
9
10
|
const IDLE_TIMEOUT_MS = parseInt(process.env.DM_DAEMON_IDLE_MS || "600000", 10); // 10 minutes default
|
|
10
11
|
const MAX_CONNECTIONS = 50;
|
|
11
12
|
|
|
13
|
+
// Daemon paths are set via env vars from client, or computed from cwd
|
|
14
|
+
function resolveDaemonConfig() {
|
|
15
|
+
// Client passes these via env when spawning daemon
|
|
16
|
+
if (process.env.DM_SOCKET_PATH && process.env.DM_META_PATH) {
|
|
17
|
+
return {
|
|
18
|
+
socketPath: process.env.DM_SOCKET_PATH,
|
|
19
|
+
metaPath: process.env.DM_META_PATH,
|
|
20
|
+
projectRoot: process.env.DM_PROJECT_ROOT || null,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
// Fallback: compute from current project root
|
|
24
|
+
const projectRoot = findProjectRoot();
|
|
25
|
+
const paths = getDaemonPaths(projectRoot);
|
|
26
|
+
return {
|
|
27
|
+
socketPath: paths.socket,
|
|
28
|
+
metaPath: paths.meta,
|
|
29
|
+
projectRoot,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Generate a random 128-bit token for PID reuse protection
|
|
34
|
+
function generateToken() {
|
|
35
|
+
return crypto.randomBytes(16).toString("hex");
|
|
36
|
+
}
|
|
37
|
+
|
|
12
38
|
/**
|
|
13
39
|
* Connection pool managing lazy-initialized MCP clients.
|
|
14
40
|
*/
|
|
@@ -194,6 +220,8 @@ export class DaemonServer {
|
|
|
194
220
|
this.pool = new ConnectionPool();
|
|
195
221
|
this.server = null;
|
|
196
222
|
this.startedAt = null;
|
|
223
|
+
this.config = resolveDaemonConfig();
|
|
224
|
+
this.token = generateToken();
|
|
197
225
|
}
|
|
198
226
|
|
|
199
227
|
/**
|
|
@@ -205,7 +233,13 @@ export class DaemonServer {
|
|
|
205
233
|
const { action, server, tool, args } = req;
|
|
206
234
|
|
|
207
235
|
if (action === "ping") {
|
|
208
|
-
return {
|
|
236
|
+
return {
|
|
237
|
+
ok: true,
|
|
238
|
+
pong: true,
|
|
239
|
+
uptime: Date.now() - this.startedAt,
|
|
240
|
+
token: this.token,
|
|
241
|
+
projectRoot: this.config.projectRoot,
|
|
242
|
+
};
|
|
209
243
|
}
|
|
210
244
|
|
|
211
245
|
if (action === "status") {
|
|
@@ -296,13 +330,81 @@ export class DaemonServer {
|
|
|
296
330
|
}
|
|
297
331
|
}
|
|
298
332
|
|
|
333
|
+
/**
|
|
334
|
+
* Write metadata file with daemon info for discovery and management.
|
|
335
|
+
*/
|
|
336
|
+
writeMetadata() {
|
|
337
|
+
const { metaPath, socketPath, projectRoot } = this.config;
|
|
338
|
+
const metadata = {
|
|
339
|
+
projectPath: projectRoot,
|
|
340
|
+
pid: process.pid,
|
|
341
|
+
token: this.token,
|
|
342
|
+
socketPath,
|
|
343
|
+
startedAt: new Date(this.startedAt).toISOString(),
|
|
344
|
+
version: "0.2.0",
|
|
345
|
+
};
|
|
346
|
+
// Atomic write: temp file then rename
|
|
347
|
+
const tmpPath = metaPath + ".tmp";
|
|
348
|
+
fs.writeFileSync(tmpPath, JSON.stringify(metadata, null, 2) + "\n", "utf-8");
|
|
349
|
+
fs.chmodSync(tmpPath, 0o600);
|
|
350
|
+
fs.renameSync(tmpPath, metaPath);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Remove metadata file on shutdown.
|
|
355
|
+
*/
|
|
356
|
+
removeMetadata() {
|
|
357
|
+
try {
|
|
358
|
+
fs.unlinkSync(this.config.metaPath);
|
|
359
|
+
} catch {}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Check if socket is stale (ECONNREFUSED) or alive.
|
|
364
|
+
* @returns {Promise<'stale'|'alive'|'missing'>}
|
|
365
|
+
*/
|
|
366
|
+
async checkSocketState() {
|
|
367
|
+
const { socketPath } = this.config;
|
|
368
|
+
if (!fs.existsSync(socketPath)) {
|
|
369
|
+
return "missing";
|
|
370
|
+
}
|
|
371
|
+
return new Promise((resolve) => {
|
|
372
|
+
const socket = net.createConnection(socketPath);
|
|
373
|
+
socket.on("connect", () => {
|
|
374
|
+
socket.end();
|
|
375
|
+
resolve("alive");
|
|
376
|
+
});
|
|
377
|
+
socket.on("error", (err) => {
|
|
378
|
+
if (err.code === "ECONNREFUSED" || err.code === "ENOENT") {
|
|
379
|
+
resolve("stale");
|
|
380
|
+
} else {
|
|
381
|
+
resolve("stale"); // Treat other errors as stale
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
setTimeout(() => {
|
|
385
|
+
socket.destroy();
|
|
386
|
+
resolve("stale");
|
|
387
|
+
}, 2000);
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
299
391
|
/**
|
|
300
392
|
* Start the daemon server.
|
|
301
393
|
*/
|
|
302
394
|
async start() {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
395
|
+
const { socketPath } = this.config;
|
|
396
|
+
|
|
397
|
+
// Check for existing daemon - don't blindly unlink
|
|
398
|
+
const socketState = await this.checkSocketState();
|
|
399
|
+
if (socketState === "alive") {
|
|
400
|
+
throw new Error(`Daemon already running on ${socketPath}`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Socket is stale or missing - safe to unlink before binding
|
|
404
|
+
if (socketState === "stale") {
|
|
405
|
+
try {
|
|
406
|
+
fs.unlinkSync(socketPath);
|
|
407
|
+
} catch {}
|
|
306
408
|
}
|
|
307
409
|
|
|
308
410
|
this.startedAt = Date.now();
|
|
@@ -338,9 +440,12 @@ export class DaemonServer {
|
|
|
338
440
|
|
|
339
441
|
this.server.on("error", reject);
|
|
340
442
|
|
|
341
|
-
this.server.listen(
|
|
443
|
+
this.server.listen(socketPath, async () => {
|
|
342
444
|
// Set socket permissions (user only)
|
|
343
|
-
fs.chmodSync(
|
|
445
|
+
fs.chmodSync(socketPath, 0o600);
|
|
446
|
+
|
|
447
|
+
// Write metadata file for discovery
|
|
448
|
+
this.writeMetadata();
|
|
344
449
|
|
|
345
450
|
// Start mcp.json watcher
|
|
346
451
|
this.startMcpWatcher();
|
|
@@ -367,9 +472,14 @@ export class DaemonServer {
|
|
|
367
472
|
this.server = null;
|
|
368
473
|
}
|
|
369
474
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
475
|
+
// Clean up socket and metadata files
|
|
476
|
+
const { socketPath } = this.config;
|
|
477
|
+
try {
|
|
478
|
+
if (fs.existsSync(socketPath)) {
|
|
479
|
+
fs.unlinkSync(socketPath);
|
|
480
|
+
}
|
|
481
|
+
} catch {}
|
|
482
|
+
this.removeMetadata();
|
|
373
483
|
}
|
|
374
484
|
}
|
|
375
485
|
|
|
@@ -391,8 +501,14 @@ export async function runDaemon() {
|
|
|
391
501
|
|
|
392
502
|
try {
|
|
393
503
|
await daemon.start();
|
|
394
|
-
|
|
504
|
+
const { socketPath, projectRoot } = daemon.config;
|
|
505
|
+
process.stdout.write(`Daemon started on ${socketPath}\n`);
|
|
395
506
|
process.stdout.write(`PID: ${process.pid}\n`);
|
|
507
|
+
if (projectRoot) {
|
|
508
|
+
process.stdout.write(`Project: ${projectRoot}\n`);
|
|
509
|
+
} else {
|
|
510
|
+
process.stdout.write(`Project: (global)\n`);
|
|
511
|
+
}
|
|
396
512
|
} catch (err) {
|
|
397
513
|
process.stderr.write(`Failed to start daemon: ${err.message}\n`);
|
|
398
514
|
process.exit(1);
|
|
@@ -3,20 +3,31 @@ import fs from "node:fs";
|
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { getDaemonPaths, findProjectRoot } from "./util.mjs";
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Get the current project's daemon paths.
|
|
10
|
+
* @returns {{ socket: string, meta: string, isOverride: boolean, projectRoot: string|null }}
|
|
11
|
+
*/
|
|
12
|
+
function getCurrentDaemonPaths() {
|
|
13
|
+
const projectRoot = findProjectRoot();
|
|
14
|
+
const paths = getDaemonPaths(projectRoot);
|
|
15
|
+
return { ...paths, projectRoot };
|
|
16
|
+
}
|
|
8
17
|
|
|
9
18
|
/**
|
|
10
19
|
* Check if daemon is running by pinging the socket.
|
|
20
|
+
* @param {string} [socketPath] - Optional socket path, defaults to current project's daemon
|
|
11
21
|
* @returns {Promise<boolean>}
|
|
12
22
|
*/
|
|
13
|
-
export async function isDaemonRunning() {
|
|
14
|
-
|
|
23
|
+
export async function isDaemonRunning(socketPath) {
|
|
24
|
+
const effectiveSocket = socketPath || getCurrentDaemonPaths().socket;
|
|
25
|
+
if (!fs.existsSync(effectiveSocket)) {
|
|
15
26
|
return false;
|
|
16
27
|
}
|
|
17
28
|
|
|
18
29
|
try {
|
|
19
|
-
const res = await sendRequest({ action: "ping" }, { timeoutMs: 2000 });
|
|
30
|
+
const res = await sendRequest({ action: "ping" }, { timeoutMs: 2000, socketPath: effectiveSocket });
|
|
20
31
|
return res?.ok === true && res?.pong === true;
|
|
21
32
|
} catch {
|
|
22
33
|
return false;
|
|
@@ -26,14 +37,15 @@ export async function isDaemonRunning() {
|
|
|
26
37
|
/**
|
|
27
38
|
* Send a request to the daemon.
|
|
28
39
|
* @param {object} req
|
|
29
|
-
* @param {{ timeoutMs?: number }} opts
|
|
40
|
+
* @param {{ timeoutMs?: number, socketPath?: string }} opts
|
|
30
41
|
* @returns {Promise<object>}
|
|
31
42
|
*/
|
|
32
43
|
export async function sendRequest(req, opts = {}) {
|
|
33
44
|
const timeoutMs = opts.timeoutMs || 65000; // Slightly longer than tool timeout
|
|
45
|
+
const effectiveSocket = opts.socketPath || getCurrentDaemonPaths().socket;
|
|
34
46
|
|
|
35
47
|
return new Promise((resolve, reject) => {
|
|
36
|
-
const socket = net.createConnection(
|
|
48
|
+
const socket = net.createConnection(effectiveSocket);
|
|
37
49
|
let buffer = "";
|
|
38
50
|
let resolved = false;
|
|
39
51
|
|
|
@@ -122,11 +134,13 @@ export async function shutdownDaemon() {
|
|
|
122
134
|
|
|
123
135
|
/**
|
|
124
136
|
* Start the daemon as a background process.
|
|
125
|
-
* @returns {Promise<{ pid: number }>}
|
|
137
|
+
* @returns {Promise<{ pid: number, socketPath: string, projectRoot: string|null }>}
|
|
126
138
|
*/
|
|
127
139
|
export async function startDaemon() {
|
|
140
|
+
const { socket, meta, projectRoot } = getCurrentDaemonPaths();
|
|
141
|
+
|
|
128
142
|
// Check if already running
|
|
129
|
-
if (await isDaemonRunning()) {
|
|
143
|
+
if (await isDaemonRunning(socket)) {
|
|
130
144
|
throw new Error("Daemon is already running");
|
|
131
145
|
}
|
|
132
146
|
|
|
@@ -135,11 +149,16 @@ export async function startDaemon() {
|
|
|
135
149
|
const daemonPath = path.join(__dirname, "daemon.mjs");
|
|
136
150
|
|
|
137
151
|
// Spawn fully detached process with no stdio connection
|
|
138
|
-
//
|
|
152
|
+
// Pass socket/meta paths via env so daemon knows where to bind
|
|
139
153
|
const child = spawn(process.execPath, [daemonPath, "run"], {
|
|
140
154
|
detached: true,
|
|
141
155
|
stdio: "ignore", // Fully detach stdio
|
|
142
|
-
env: {
|
|
156
|
+
env: {
|
|
157
|
+
...process.env,
|
|
158
|
+
DM_SOCKET_PATH: socket,
|
|
159
|
+
DM_META_PATH: meta,
|
|
160
|
+
DM_PROJECT_ROOT: projectRoot || "",
|
|
161
|
+
},
|
|
143
162
|
});
|
|
144
163
|
|
|
145
164
|
// Immediately unref so parent can exit
|
|
@@ -152,8 +171,8 @@ export async function startDaemon() {
|
|
|
152
171
|
|
|
153
172
|
while (Date.now() - start < maxWait) {
|
|
154
173
|
await new Promise((r) => setTimeout(r, pollInterval));
|
|
155
|
-
if (await isDaemonRunning()) {
|
|
156
|
-
return { pid: child.pid };
|
|
174
|
+
if (await isDaemonRunning(socket)) {
|
|
175
|
+
return { pid: child.pid, socketPath: socket, projectRoot };
|
|
157
176
|
}
|
|
158
177
|
}
|
|
159
178
|
|
|
@@ -195,3 +214,6 @@ export async function warmServers(serverName) {
|
|
|
195
214
|
server: serverName,
|
|
196
215
|
});
|
|
197
216
|
}
|
|
217
|
+
|
|
218
|
+
// Re-export for CLI use
|
|
219
|
+
export { getCurrentDaemonPaths };
|
|
@@ -3,6 +3,8 @@ import path from "node:path";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import crypto from "node:crypto";
|
|
5
5
|
|
|
6
|
+
const GLOBAL_ROOT_FALLBACK = path.join(os.homedir(), ".factory");
|
|
7
|
+
|
|
6
8
|
/** @returns {string} */
|
|
7
9
|
export function nowIsoCompact() {
|
|
8
10
|
const d = new Date();
|
|
@@ -226,3 +228,311 @@ export function redactEnvForDisplay(envObj) {
|
|
|
226
228
|
}
|
|
227
229
|
return out;
|
|
228
230
|
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Check if a directory is secure (owned by current user, not world/group writable).
|
|
234
|
+
* @param {string} dir
|
|
235
|
+
* @returns {boolean}
|
|
236
|
+
*/
|
|
237
|
+
function isSecureDir(dir) {
|
|
238
|
+
try {
|
|
239
|
+
const stat = fs.statSync(dir);
|
|
240
|
+
if (!stat.isDirectory()) return false;
|
|
241
|
+
|
|
242
|
+
const uid = process.getuid?.();
|
|
243
|
+
if (uid !== undefined) {
|
|
244
|
+
// POSIX: check ownership and permissions
|
|
245
|
+
return stat.uid === uid && (stat.mode & 0o077) === 0;
|
|
246
|
+
}
|
|
247
|
+
// Non-POSIX: just check writable
|
|
248
|
+
fs.accessSync(dir, fs.constants.W_OK);
|
|
249
|
+
return true;
|
|
250
|
+
} catch {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Ensure a directory exists with secure permissions (0700).
|
|
257
|
+
* @param {string} dir
|
|
258
|
+
* @returns {boolean} true if dir is now usable
|
|
259
|
+
*/
|
|
260
|
+
function ensureSecureDir(dir) {
|
|
261
|
+
try {
|
|
262
|
+
if (!fs.existsSync(dir)) {
|
|
263
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
264
|
+
// Explicit chmod to override umask (mkdir mode is affected by umask)
|
|
265
|
+
fs.chmodSync(dir, 0o700);
|
|
266
|
+
}
|
|
267
|
+
// Verify or fix permissions
|
|
268
|
+
const stat = fs.statSync(dir);
|
|
269
|
+
if (!stat.isDirectory()) return false;
|
|
270
|
+
|
|
271
|
+
const uid = process.getuid?.();
|
|
272
|
+
if (uid !== undefined && stat.uid === uid) {
|
|
273
|
+
// We own it, ensure perms are tight
|
|
274
|
+
if ((stat.mode & 0o077) !== 0) {
|
|
275
|
+
fs.chmodSync(dir, 0o700);
|
|
276
|
+
}
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
return isSecureDir(dir);
|
|
280
|
+
} catch {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Get the fallback runtime directory (~/.factory/run).
|
|
287
|
+
* @returns {string}
|
|
288
|
+
*/
|
|
289
|
+
function getFallbackRunDir() {
|
|
290
|
+
return path.join(os.homedir(), ".factory", "run");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Get all potential runtime directories to check for daemons.
|
|
295
|
+
* Returns fallback dir, XDG dir, and standard POSIX locations.
|
|
296
|
+
* @returns {string[]}
|
|
297
|
+
*/
|
|
298
|
+
export function getAllRunDirs() {
|
|
299
|
+
const dirs = new Set();
|
|
300
|
+
const fallback = getFallbackRunDir();
|
|
301
|
+
|
|
302
|
+
// Always include fallback if it exists
|
|
303
|
+
if (fs.existsSync(fallback)) {
|
|
304
|
+
dirs.add(fallback);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Include XDG if set and different from fallback
|
|
308
|
+
const xdgDir = process.env.XDG_RUNTIME_DIR;
|
|
309
|
+
if (xdgDir && xdgDir !== fallback && fs.existsSync(xdgDir)) {
|
|
310
|
+
dirs.add(xdgDir);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Probe standard POSIX locations for daemons started in other sessions
|
|
314
|
+
const uid = process.getuid?.();
|
|
315
|
+
if (uid !== undefined) {
|
|
316
|
+
const posixDirs = [
|
|
317
|
+
`/run/user/${uid}`,
|
|
318
|
+
`/var/run/user/${uid}`,
|
|
319
|
+
];
|
|
320
|
+
for (const dir of posixDirs) {
|
|
321
|
+
if (dir !== fallback && dir !== xdgDir && fs.existsSync(dir)) {
|
|
322
|
+
dirs.add(dir);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return Array.from(dirs);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Get the runtime directory for daemon sockets and metadata.
|
|
332
|
+
* Prefers ~/.factory/run for consistency, falls back to XDG_RUNTIME_DIR if needed.
|
|
333
|
+
* @returns {string}
|
|
334
|
+
* @throws {Error} if no secure directory can be found
|
|
335
|
+
*/
|
|
336
|
+
export function getRunDir() {
|
|
337
|
+
// Prefer fallback dir for consistency across shell sessions
|
|
338
|
+
const fallback = getFallbackRunDir();
|
|
339
|
+
if (ensureSecureDir(fallback)) {
|
|
340
|
+
return fallback;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Fallback failed (read-only HOME, wrong owner, etc.) - try XDG
|
|
344
|
+
const xdgDir = process.env.XDG_RUNTIME_DIR;
|
|
345
|
+
if (xdgDir && ensureSecureDir(xdgDir)) {
|
|
346
|
+
return xdgDir;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Try standard POSIX locations
|
|
350
|
+
const uid = process.getuid?.();
|
|
351
|
+
if (uid !== undefined) {
|
|
352
|
+
for (const dir of [`/run/user/${uid}`, `/var/run/user/${uid}`]) {
|
|
353
|
+
if (ensureSecureDir(dir)) {
|
|
354
|
+
return dir;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
throw new Error(
|
|
360
|
+
`Cannot find secure daemon directory. Tried:\n` +
|
|
361
|
+
` - ${fallback} (primary)\n` +
|
|
362
|
+
` - ${xdgDir || '(XDG_RUNTIME_DIR not set)'}\n` +
|
|
363
|
+
`Ensure one of these directories exists with mode 0700 and is owned by you.`
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* List all daemon metadata files across all potential run directories.
|
|
369
|
+
* Scans: ~/.factory/run, XDG_RUNTIME_DIR (if exists), and DM_DAEMON_SOCKET override.
|
|
370
|
+
*
|
|
371
|
+
* Note: The 'alive' check uses PID existence + socket file presence. This is a fast
|
|
372
|
+
* heuristic that covers 99% of cases. For absolute certainty, callers should ping
|
|
373
|
+
* the socket via daemon_client.isDaemonRunning(). We don't do socket ping here to
|
|
374
|
+
* avoid circular dependencies and keep the listing fast.
|
|
375
|
+
*
|
|
376
|
+
* @returns {Array<{ metaPath: string, socketPath: string, projectPath: string|null, pid: number, token: string, startedAt: string, version: string, alive: boolean, stale: boolean, isOverride?: boolean }>}
|
|
377
|
+
*/
|
|
378
|
+
export function listAllDaemons() {
|
|
379
|
+
const results = [];
|
|
380
|
+
const seenMeta = new Set();
|
|
381
|
+
|
|
382
|
+
// Helper to add a daemon from metadata
|
|
383
|
+
function addDaemonFromMeta(metaPath, isOverride = false) {
|
|
384
|
+
if (seenMeta.has(metaPath)) return;
|
|
385
|
+
seenMeta.add(metaPath);
|
|
386
|
+
|
|
387
|
+
try {
|
|
388
|
+
const content = fs.readFileSync(metaPath, "utf-8");
|
|
389
|
+
const meta = JSON.parse(content);
|
|
390
|
+
|
|
391
|
+
// Check if daemon is alive: both PID must exist AND socket file must exist
|
|
392
|
+
// PID-only check is unreliable due to PID reuse after daemon exit
|
|
393
|
+
let alive = false;
|
|
394
|
+
let stale = false;
|
|
395
|
+
const socketExists = meta.socketPath && fs.existsSync(meta.socketPath);
|
|
396
|
+
|
|
397
|
+
if (meta.pid && socketExists) {
|
|
398
|
+
try {
|
|
399
|
+
process.kill(meta.pid, 0); // Signal 0 = existence check
|
|
400
|
+
alive = true;
|
|
401
|
+
} catch {
|
|
402
|
+
// PID doesn't exist but socket file does = stale
|
|
403
|
+
stale = true;
|
|
404
|
+
}
|
|
405
|
+
} else if (meta.pid && !socketExists) {
|
|
406
|
+
// PID may exist but socket is gone = stale metadata
|
|
407
|
+
stale = true;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
results.push({
|
|
411
|
+
metaPath,
|
|
412
|
+
socketPath: meta.socketPath,
|
|
413
|
+
projectPath: meta.projectPath,
|
|
414
|
+
pid: meta.pid,
|
|
415
|
+
token: meta.token,
|
|
416
|
+
startedAt: meta.startedAt,
|
|
417
|
+
version: meta.version,
|
|
418
|
+
alive,
|
|
419
|
+
stale,
|
|
420
|
+
isOverride,
|
|
421
|
+
});
|
|
422
|
+
} catch {
|
|
423
|
+
// Skip malformed metadata files
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Scan a directory for daemon metadata files
|
|
428
|
+
function scanDir(dir) {
|
|
429
|
+
try {
|
|
430
|
+
const files = fs.readdirSync(dir);
|
|
431
|
+
for (const file of files) {
|
|
432
|
+
if (!file.startsWith("dm-daemon-") || !file.endsWith(".json")) continue;
|
|
433
|
+
addDaemonFromMeta(path.join(dir, file), false);
|
|
434
|
+
}
|
|
435
|
+
} catch {
|
|
436
|
+
// Dir doesn't exist or not readable
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Scan all potential run directories
|
|
441
|
+
const allDirs = getAllRunDirs();
|
|
442
|
+
for (const dir of allDirs) {
|
|
443
|
+
scanDir(dir);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Also scan the current run dir (in case it's new and not in allDirs yet)
|
|
447
|
+
scanDir(getRunDir());
|
|
448
|
+
|
|
449
|
+
// Also check for override daemon if DM_DAEMON_SOCKET is set
|
|
450
|
+
if (process.env.DM_DAEMON_SOCKET) {
|
|
451
|
+
// Resolve to absolute path (same logic as getDaemonPaths)
|
|
452
|
+
const socket = path.resolve(process.env.DM_DAEMON_SOCKET);
|
|
453
|
+
let metaPath = process.env.DM_META_PATH
|
|
454
|
+
? path.resolve(process.env.DM_META_PATH)
|
|
455
|
+
: null;
|
|
456
|
+
if (!metaPath) {
|
|
457
|
+
metaPath = socket.endsWith(".sock")
|
|
458
|
+
? socket.replace(/\.sock$/, ".json")
|
|
459
|
+
: socket + ".meta.json";
|
|
460
|
+
}
|
|
461
|
+
if (fs.existsSync(metaPath)) {
|
|
462
|
+
addDaemonFromMeta(metaPath, true);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return results;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Get daemon socket and metadata file paths for a project.
|
|
471
|
+
* Uses hash of UID + canonical project path for multi-user isolation.
|
|
472
|
+
*
|
|
473
|
+
* @param {string|null} projectRoot - Project root path, or null for global daemon
|
|
474
|
+
* @returns {{ socket: string, meta: string, isOverride: boolean }}
|
|
475
|
+
*/
|
|
476
|
+
export function getDaemonPaths(projectRoot) {
|
|
477
|
+
// Backwards compat: respect explicit socket/meta override
|
|
478
|
+
if (process.env.DM_DAEMON_SOCKET) {
|
|
479
|
+
// Always resolve to absolute path to avoid cwd-dependent behavior
|
|
480
|
+
const socket = path.resolve(process.env.DM_DAEMON_SOCKET);
|
|
481
|
+
// Derive meta path: use DM_META_PATH if set, otherwise append .meta.json
|
|
482
|
+
let meta = process.env.DM_META_PATH
|
|
483
|
+
? path.resolve(process.env.DM_META_PATH)
|
|
484
|
+
: null;
|
|
485
|
+
if (!meta) {
|
|
486
|
+
if (socket.endsWith(".sock")) {
|
|
487
|
+
meta = socket.replace(/\.sock$/, ".json");
|
|
488
|
+
} else {
|
|
489
|
+
meta = socket + ".meta.json";
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
// Sanity check: meta path must differ from socket path
|
|
493
|
+
if (meta === socket) {
|
|
494
|
+
meta = socket + ".meta.json";
|
|
495
|
+
}
|
|
496
|
+
return { socket, meta, isOverride: true };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Use global fallback when no project root
|
|
500
|
+
const root = projectRoot || GLOBAL_ROOT_FALLBACK;
|
|
501
|
+
|
|
502
|
+
// Canonicalize to resolve symlinks - ensures same physical project = same hash
|
|
503
|
+
// Handle ENOENT gracefully (common for global fallback on fresh install)
|
|
504
|
+
let canonical;
|
|
505
|
+
try {
|
|
506
|
+
// First ensure parent dirs exist for global fallback case
|
|
507
|
+
if (root === GLOBAL_ROOT_FALLBACK) {
|
|
508
|
+
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
509
|
+
}
|
|
510
|
+
canonical = fs.realpathSync.native(root);
|
|
511
|
+
} catch (err) {
|
|
512
|
+
// Fallback: canonicalize existing ancestor + resolve remaining path
|
|
513
|
+
if (err.code === "ENOENT") {
|
|
514
|
+
const parent = path.dirname(root);
|
|
515
|
+
const base = path.basename(root);
|
|
516
|
+
try {
|
|
517
|
+
canonical = path.join(fs.realpathSync.native(parent), base);
|
|
518
|
+
} catch {
|
|
519
|
+
canonical = path.resolve(root);
|
|
520
|
+
}
|
|
521
|
+
} else {
|
|
522
|
+
canonical = path.resolve(root);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Include UID for multi-user isolation on shared systems
|
|
527
|
+
// On Windows (no getuid), use homedir hash for stable per-user isolation
|
|
528
|
+
const uid = process.getuid?.() ?? sha256Hex(os.homedir()).slice(0, 8);
|
|
529
|
+
const hashInput = `${uid}:${canonical}`;
|
|
530
|
+
const hash = sha256Hex(hashInput).slice(0, 16);
|
|
531
|
+
|
|
532
|
+
const dir = getRunDir();
|
|
533
|
+
return {
|
|
534
|
+
socket: path.join(dir, `dm-daemon-${hash}.sock`),
|
|
535
|
+
meta: path.join(dir, `dm-daemon-${hash}.json`),
|
|
536
|
+
isOverride: false,
|
|
537
|
+
};
|
|
538
|
+
}
|