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 CHANGED
@@ -74,13 +74,32 @@ flowchart LR
74
74
 
75
75
  | Component | Purpose |
76
76
  |-----------|---------|
77
- | **Unix Socket** | IPC channel at `/tmp/dm-daemon.sock` for low-latency communication |
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 connection pool status |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "droid-mode",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
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",
@@ -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
- ### Usage
188
+ ### Commands
165
189
 
166
190
  ```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
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-json '{...}'] [--args-file path.json] [--json] [--no-daemon]
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 → Start background daemon (auto-warms servers)
29
- dm daemon stop → Stop daemon
30
- dm daemon status [--json] → Show connection pool status
31
- dm daemon warm [<server>] Pre-warm server(s)
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-json"]) {
388
+ if (args.flags["args"]) {
387
389
  try {
388
- argObj = JSON.parse(String(args.flags["args-json"]));
390
+ argObj = JSON.parse(String(args.flags["args"]));
389
391
  } catch (err) {
390
- fail(`Invalid --args-json: ${err.message || err}`);
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 { pid } = await startDaemon();
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\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 { ok: true, pong: true, uptime: Date.now() - this.startedAt };
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
- // Remove stale socket
304
- if (fs.existsSync(SOCKET_PATH)) {
305
- fs.unlinkSync(SOCKET_PATH);
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(SOCKET_PATH, async () => {
443
+ this.server.listen(socketPath, async () => {
342
444
  // Set socket permissions (user only)
343
- fs.chmodSync(SOCKET_PATH, 0o600);
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
- if (fs.existsSync(SOCKET_PATH)) {
371
- fs.unlinkSync(SOCKET_PATH);
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
- process.stdout.write(`Daemon started on ${SOCKET_PATH}\n`);
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
- const SOCKET_PATH = process.env.DM_DAEMON_SOCKET || "/tmp/dm-daemon.sock";
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
- if (!fs.existsSync(SOCKET_PATH)) {
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(SOCKET_PATH);
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
- // This prevents the daemon from exiting when the parent exits
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: { ...process.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
+ }