Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/adapters/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,7 +470,7 @@ export class ClaudeCodeAdapter implements AgentAdapter {
args.push("-p", opts.prompt);
}

const env = buildSpawnEnv(undefined, opts.env);
const env = buildSpawnEnv(opts.env);
const cwd = opts.cwd || process.cwd();

// Write stdout to a log file so we can extract the session ID
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ export class CodexAdapter implements AgentAdapter {
args.push("--", opts.prompt);
}

const env = buildSpawnEnv(undefined, opts.env);
const env = buildSpawnEnv(opts.env);

await fs.mkdir(this.sessionsMetaDir, { recursive: true });
const logPath = path.join(this.sessionsMetaDir, `launch-${Date.now()}.log`);
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ export class OpenCodeAdapter implements AgentAdapter {
args.push("--", opts.prompt);
}

const env = buildSpawnEnv(undefined, opts.env);
const env = buildSpawnEnv(opts.env);
const cwd = opts.cwd || process.cwd();

await fs.mkdir(this.sessionsMetaDir, { recursive: true });
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/pi-rust.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ export class PiRustAdapter implements AgentAdapter {
args.unshift("--append-system-prompt", text);
}

const env = buildSpawnEnv(undefined, opts.env);
const env = buildSpawnEnv(opts.env);
const cwd = opts.cwd || process.cwd();

// Write stdout to a log file so we can extract the session ID
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/pi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ export class PiAdapter implements AgentAdapter {
args.unshift("--model", opts.model);
}

const env = buildSpawnEnv(undefined, opts.env);
const env = buildSpawnEnv(opts.env);
const cwd = opts.cwd || process.cwd();

// Write stdout to a log file so we can extract the session ID
Expand Down
29 changes: 13 additions & 16 deletions src/daemon/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { PiAdapter } from "../adapters/pi.js";
import { PiRustAdapter } from "../adapters/pi-rust.js";
import type { AgentAdapter } from "../core/types.js";
import { migrateLocks } from "../migration/migrate-locks.js";
import { saveEnvironment } from "../utils/daemon-env.js";

import { clearBinaryCache } from "../utils/resolve-binary.js";
import { FuseEngine } from "./fuse-engine.js";
import { LockManager } from "./lock-manager.js";
Expand Down Expand Up @@ -77,21 +77,18 @@ export async function startDaemon(opts: DaemonStartOpts = {}): Promise<{
// 3. Clean stale socket file
await fs.rm(sockPath, { force: true });

// 4. Save shell environment for subprocess spawning (#42)
await saveEnvironment(configDir);

// 5. Clear binary cache on restart (#41 — pick up moved/updated binaries)
// 4. Clear binary cache on restart (#41 — pick up moved/updated binaries)
clearBinaryCache();

// 6. Run migration (idempotent)
// 5. Run migration (idempotent)
await migrateLocks(configDir).catch((err) =>
console.error("Migration warning:", err.message),
);

// 7. Load persisted state
// 6. Load persisted state
const state = await StateManager.load(configDir);

// 8. Initialize subsystems
// 7. Initialize subsystems
const adapters: Record<string, AgentAdapter> = opts.adapters || {
"claude-code": new ClaudeCodeAdapter(),
codex: new CodexAdapter(),
Expand All @@ -115,7 +112,7 @@ export async function startDaemon(opts: DaemonStartOpts = {}): Promise<{
metrics.recordFuseExpired();
});

// 9. Initial PID liveness cleanup for daemon-launched sessions
// 8. Initial PID liveness cleanup for daemon-launched sessions
// (replaces the old validateAllSessions — much simpler, only checks launches)
const initialDead = sessionTracker.cleanupDeadLaunches();
if (initialDead.length > 0) {
Expand All @@ -125,15 +122,15 @@ export async function startDaemon(opts: DaemonStartOpts = {}): Promise<{
);
}

// 10. Resume fuse timers
// 9. Resume fuse timers
fuseEngine.resumeTimers();

// 11. Start periodic PID liveness check for lock cleanup (30s interval)
// 10. Start periodic PID liveness check for lock cleanup (30s interval)
sessionTracker.startLaunchCleanup((deadId) => {
lockManager.autoUnlock(deadId);
});

// 12. Create request handler
// 11. Create request handler
const handleRequest = createRequestHandler({
sessionTracker,
lockManager,
Expand All @@ -145,7 +142,7 @@ export async function startDaemon(opts: DaemonStartOpts = {}): Promise<{
sockPath,
});

// 13. Start Unix socket server
// 12. Start Unix socket server
const socketServer = net.createServer((conn) => {
let buffer = "";
conn.on("data", (chunk) => {
Expand Down Expand Up @@ -184,10 +181,10 @@ export async function startDaemon(opts: DaemonStartOpts = {}): Promise<{
socketServer.on("error", reject);
});

// 14. Write PID file (after socket is listening — acts as "lock acquired")
// 13. Write PID file (after socket is listening — acts as "lock acquired")
await fs.writeFile(pidFilePath, String(process.pid));

// 15. Start HTTP metrics server
// 14. Start HTTP metrics server
const metricsPort = opts.metricsPort ?? 9200;
const httpServer = http.createServer((req, res) => {
if (req.url === "/metrics" && req.method === "GET") {
Expand Down Expand Up @@ -218,7 +215,7 @@ export async function startDaemon(opts: DaemonStartOpts = {}): Promise<{
await fs.rm(pidFilePath, { force: true });
};

// 16. Signal handlers
// 15. Signal handlers
for (const sig of ["SIGTERM", "SIGINT"] as const) {
process.on(sig, async () => {
console.log(`Received ${sig}, shutting down...`);
Expand Down
115 changes: 115 additions & 0 deletions src/utils/daemon-env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { execFileSync } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, type Mock, vi } from "vitest";
import { buildSpawnEnv, getCommonBinDirs } from "./daemon-env.js";

vi.mock("node:child_process", () => ({
execFileSync: vi.fn(),
}));

const mockedExecFileSync = execFileSync as Mock;

afterEach(() => {
vi.restoreAllMocks();
});

describe("getCommonBinDirs", () => {
it("returns an array of common bin directories", () => {
const dirs = getCommonBinDirs();
expect(dirs).toBeInstanceOf(Array);
expect(dirs.length).toBeGreaterThan(0);
expect(dirs).toContain("/usr/local/bin");
expect(dirs).toContain("/usr/bin");
expect(dirs).toContain("/opt/homebrew/bin");
expect(dirs.some((d) => d.includes(".cargo/bin"))).toBe(true);
});
});

describe("buildSpawnEnv", () => {
it("sources ~/.zshenv and returns env with augmented PATH", () => {
const fakeEnv = `HOME=/Users/test\0PATH=/usr/bin:/bin\0EDITOR=vim\0`;
mockedExecFileSync.mockReturnValue(fakeEnv);

const env = buildSpawnEnv();

expect(mockedExecFileSync).toHaveBeenCalledWith(
"/bin/zsh",
["-c", expect.stringContaining("source")],
expect.objectContaining({ encoding: "utf-8", timeout: 5000 }),
);
expect(env.HOME).toBe("/Users/test");
expect(env.EDITOR).toBe("vim");
// PATH should include original dirs + common bin dirs
expect(env.PATH).toContain("/usr/bin");
expect(env.PATH).toContain("/opt/homebrew/bin");
});

it("preserves process.env-only vars when ~/.zshenv sourcing succeeds", () => {
const processOnlyKey = "AGENTCTL_PROCESS_ONLY_TEST_VAR";
const previous = process.env[processOnlyKey];
process.env[processOnlyKey] = "from-process";
mockedExecFileSync.mockReturnValue(
`HOME=/Users/test\0PATH=/usr/bin:/bin\0`,
);

try {
const env = buildSpawnEnv();

expect(env[processOnlyKey]).toBe("from-process");
expect(env.HOME).toBe("/Users/test");
} finally {
if (previous === undefined) {
delete process.env[processOnlyKey];
} else {
process.env[processOnlyKey] = previous;
}
}
});

it("falls back to process.env when sourcing fails", () => {
mockedExecFileSync.mockImplementation(() => {
throw new Error("zsh not found");
});

const env = buildSpawnEnv();

// Should still have process.env PATH augmented with common dirs
expect(env.PATH).toBeDefined();
expect(env.PATH).toContain("/opt/homebrew/bin");
});

it("applies extra env overrides", () => {
const fakeEnv = `HOME=/Users/test\0PATH=/usr/bin\0`;
mockedExecFileSync.mockReturnValue(fakeEnv);

const env = buildSpawnEnv({ MY_VAR: "hello", PATH: "/custom/bin" });

expect(env.MY_VAR).toBe("hello");
// Extra env PATH override should win
expect(env.PATH).toBe("/custom/bin");
});

it("does not duplicate existing PATH entries", () => {
const home = os.homedir();
const cargoDir = path.join(home, ".cargo", "bin");
const fakeEnv = `PATH=/usr/bin:${cargoDir}\0`;
mockedExecFileSync.mockReturnValue(fakeEnv);

const env = buildSpawnEnv();

// Count occurrences of cargoDir in PATH
const pathDirs = (env.PATH ?? "").split(":");
const count = pathDirs.filter((d) => d === cargoDir).length;
expect(count).toBe(1);
});

it("handles empty output from zsh gracefully", () => {
mockedExecFileSync.mockReturnValue("");

const env = buildSpawnEnv();

// Should fall back to process.env
expect(env.PATH).toBeDefined();
});
});
68 changes: 31 additions & 37 deletions src/utils/daemon-env.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import * as fs from "node:fs/promises";
import { execFileSync } from "node:child_process";
import * as os from "node:os";
import * as path from "node:path";

const ENV_FILE = "daemon-env.json";

/**
* Common bin directories that should be in PATH when spawning subprocesses.
* These cover the usual locations for various package managers and tools.
*/
function getCommonBinDirs(): string[] {
export function getCommonBinDirs(): string[] {
const home = os.homedir();
return [
path.join(home, ".local", "bin"),
Expand All @@ -25,55 +23,51 @@ function getCommonBinDirs(): string[] {
}

/**
* Save the current process environment to disk.
* Called at daemon start time when we still have the user's shell env.
* Source ~/.zshenv (or other shell init) and capture the resulting environment.
* Returns undefined if the file doesn't exist or sourcing fails.
*/
export async function saveEnvironment(configDir: string): Promise<void> {
const envPath = path.join(configDir, ENV_FILE);
function sourceZshEnv(): Record<string, string> | undefined {
const zshenv = path.join(os.homedir(), ".zshenv");
try {
const tmpPath = `${envPath}.tmp`;
await fs.writeFile(tmpPath, JSON.stringify(process.env));
await fs.rename(tmpPath, envPath);
} catch (err) {
console.error(
`Warning: could not save environment: ${(err as Error).message}`,
const output = execFileSync(
"/bin/zsh",
["-c", `source "${zshenv}" 2>/dev/null; env -0`],
{
encoding: "utf-8",
timeout: 5000,
env: { HOME: os.homedir(), PATH: "/usr/bin:/bin" },
},
);
}
}

/**
* Load the saved environment from disk.
* Returns undefined if the env file doesn't exist or is corrupt.
*/
export async function loadSavedEnvironment(
configDir: string,
): Promise<Record<string, string> | undefined> {
const envPath = path.join(configDir, ENV_FILE);
try {
const raw = await fs.readFile(envPath, "utf-8");
const parsed = JSON.parse(raw);
if (typeof parsed === "object" && parsed !== null) {
return parsed as Record<string, string>;
const env: Record<string, string> = {};
for (const entry of output.split("\0")) {
if (!entry) continue;
const idx = entry.indexOf("=");
if (idx > 0) {
env[entry.slice(0, idx)] = entry.slice(idx + 1);
}
}
return Object.keys(env).length > 0 ? env : undefined;
} catch {
// File doesn't exist or is corrupt
return undefined;
}
return undefined;
}

/**
* Build an augmented environment for spawning subprocesses.
* Merges the saved daemon env with common bin paths to ensure
* binaries are findable even when the daemon is detached from the shell.
* Starts with process.env, overlays ~/.zshenv at call time when available,
* then augments PATH with common bin directories.
*/
export function buildSpawnEnv(
savedEnv?: Record<string, string>,
extraEnv?: Record<string, string>,
): Record<string, string> {
const base: Record<string, string> = {};
const source = savedEnv || (process.env as Record<string, string>);
const zshEnv = sourceZshEnv();
const source = {
...(process.env as Record<string, string | undefined>),
...(zshEnv ?? {}),
};

// Copy source env
// Copy merged env
for (const [k, v] of Object.entries(source)) {
if (v !== undefined) base[k] = v;
}
Expand Down