@orgloop/agentctl 1.2.0 → 1.3.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.
@@ -5,8 +5,7 @@ export class MetricsRegistry {
5
5
  sessionsTotalCompleted = 0;
6
6
  sessionsTotalFailed = 0;
7
7
  sessionsTotalStopped = 0;
8
- fusesFiredTotal = 0;
9
- clustersDeletedTotal = 0;
8
+ fusesExpiredTotal = 0;
10
9
  sessionDurations = []; // seconds
11
10
  constructor(sessionTracker, lockManager, fuseEngine) {
12
11
  this.sessionTracker = sessionTracker;
@@ -28,9 +27,8 @@ export class MetricsRegistry {
28
27
  if (durationSeconds != null)
29
28
  this.sessionDurations.push(durationSeconds);
30
29
  }
31
- recordFuseFired() {
32
- this.fusesFiredTotal++;
33
- this.clustersDeletedTotal++;
30
+ recordFuseExpired() {
31
+ this.fusesExpiredTotal++;
34
32
  }
35
33
  generateMetrics() {
36
34
  const lines = [];
@@ -54,8 +52,7 @@ export class MetricsRegistry {
54
52
  c("agentctl_sessions_total", "Total sessions by status", this.sessionsTotalCompleted, 'status="completed"');
55
53
  c("agentctl_sessions_total", "Total sessions by status", this.sessionsTotalFailed, 'status="failed"');
56
54
  c("agentctl_sessions_total", "Total sessions by status", this.sessionsTotalStopped, 'status="stopped"');
57
- c("agentctl_fuses_fired_total", "Total fuses fired", this.fusesFiredTotal);
58
- c("agentctl_kind_clusters_deleted_total", "Total Kind clusters deleted", this.clustersDeletedTotal);
55
+ c("agentctl_fuses_expired_total", "Total fuses expired", this.fusesExpiredTotal);
59
56
  // Histogram (session duration)
60
57
  lines.push("# HELP agentctl_session_duration_seconds Session duration histogram");
61
58
  lines.push("# TYPE agentctl_session_duration_seconds histogram");
@@ -1,9 +1,11 @@
1
+ import { execFile } from "node:child_process";
1
2
  import { EventEmitter } from "node:events";
2
3
  import fs from "node:fs/promises";
3
4
  import http from "node:http";
4
5
  import net from "node:net";
5
6
  import os from "node:os";
6
7
  import path from "node:path";
8
+ import { promisify } from "node:util";
7
9
  import { ClaudeCodeAdapter } from "../adapters/claude-code.js";
8
10
  import { CodexAdapter } from "../adapters/codex.js";
9
11
  import { OpenClawAdapter } from "../adapters/openclaw.js";
@@ -11,29 +13,38 @@ import { OpenCodeAdapter } from "../adapters/opencode.js";
11
13
  import { PiAdapter } from "../adapters/pi.js";
12
14
  import { PiRustAdapter } from "../adapters/pi-rust.js";
13
15
  import { migrateLocks } from "../migration/migrate-locks.js";
16
+ import { saveEnvironment } from "../utils/daemon-env.js";
17
+ import { clearBinaryCache } from "../utils/resolve-binary.js";
14
18
  import { FuseEngine } from "./fuse-engine.js";
15
19
  import { LockManager } from "./lock-manager.js";
16
20
  import { MetricsRegistry } from "./metrics.js";
17
21
  import { SessionTracker } from "./session-tracker.js";
18
22
  import { StateManager } from "./state.js";
23
+ const execFileAsync = promisify(execFile);
19
24
  const startTime = Date.now();
20
25
  export async function startDaemon(opts = {}) {
21
26
  const configDir = opts.configDir || path.join(os.homedir(), ".agentctl");
22
27
  await fs.mkdir(configDir, { recursive: true });
23
- // 1. Check for existing daemon
24
28
  const pidFilePath = path.join(configDir, "agentctl.pid");
25
- const existingPid = await readPidFile(pidFilePath);
26
- if (existingPid && isProcessAlive(existingPid)) {
27
- throw new Error(`Daemon already running (PID ${existingPid})`);
28
- }
29
- // 2. Clean stale socket
30
29
  const sockPath = path.join(configDir, "agentctl.sock");
30
+ // 1. Kill stale daemon/supervisor processes before anything else (#39)
31
+ await killStaleDaemons(configDir);
32
+ // 2. Verify no daemon is actually running by trying to connect to socket
33
+ const socketAlive = await isSocketAlive(sockPath);
34
+ if (socketAlive) {
35
+ throw new Error("Daemon already running (socket responsive)");
36
+ }
37
+ // 3. Clean stale socket file
31
38
  await fs.rm(sockPath, { force: true });
32
- // 3. Run migration (idempotent)
39
+ // 4. Save shell environment for subprocess spawning (#42)
40
+ await saveEnvironment(configDir);
41
+ // 5. Clear binary cache on restart (#41 — pick up moved/updated binaries)
42
+ clearBinaryCache();
43
+ // 6. Run migration (idempotent)
33
44
  await migrateLocks(configDir).catch((err) => console.error("Migration warning:", err.message));
34
- // 4. Load persisted state
45
+ // 7. Load persisted state
35
46
  const state = await StateManager.load(configDir);
36
- // 5. Initialize subsystems
47
+ // 8. Initialize subsystems
37
48
  const adapters = opts.adapters || {
38
49
  "claude-code": new ClaudeCodeAdapter(),
39
50
  codex: new CodexAdapter(),
@@ -51,14 +62,16 @@ export async function startDaemon(opts = {}) {
51
62
  const sessionTracker = new SessionTracker(state, { adapters });
52
63
  const metrics = new MetricsRegistry(sessionTracker, lockManager, fuseEngine);
53
64
  // Wire up events
54
- emitter.on("fuse.fired", () => {
55
- metrics.recordFuseFired();
65
+ emitter.on("fuse.expired", () => {
66
+ metrics.recordFuseExpired();
56
67
  });
57
- // 6. Resume fuse timers
68
+ // 9. Validate all sessions on startup — mark dead ones as stopped (#40)
69
+ sessionTracker.validateAllSessions();
70
+ // 10. Resume fuse timers
58
71
  fuseEngine.resumeTimers();
59
- // 7. Start session polling
72
+ // 11. Start session polling
60
73
  sessionTracker.startPolling();
61
- // 8. Create request handler
74
+ // 12. Create request handler
62
75
  const handleRequest = createRequestHandler({
63
76
  sessionTracker,
64
77
  lockManager,
@@ -69,7 +82,7 @@ export async function startDaemon(opts = {}) {
69
82
  configDir,
70
83
  sockPath,
71
84
  });
72
- // 9. Start Unix socket server
85
+ // 13. Start Unix socket server
73
86
  const socketServer = net.createServer((conn) => {
74
87
  let buffer = "";
75
88
  conn.on("data", (chunk) => {
@@ -105,7 +118,9 @@ export async function startDaemon(opts = {}) {
105
118
  socketServer.listen(sockPath, () => resolve());
106
119
  socketServer.on("error", reject);
107
120
  });
108
- // 10. Start HTTP metrics server
121
+ // 14. Write PID file (after socket is listening — acts as "lock acquired")
122
+ await fs.writeFile(pidFilePath, String(process.pid));
123
+ // 15. Start HTTP metrics server
109
124
  const metricsPort = opts.metricsPort ?? 9200;
110
125
  const httpServer = http.createServer((req, res) => {
111
126
  if (req.url === "/metrics" && req.method === "GET") {
@@ -123,8 +138,6 @@ export async function startDaemon(opts = {}) {
123
138
  httpServer.listen(metricsPort, "127.0.0.1", () => resolve());
124
139
  httpServer.on("error", reject);
125
140
  });
126
- // 11. Write PID file
127
- await fs.writeFile(pidFilePath, String(process.pid));
128
141
  // Shutdown function
129
142
  const shutdown = async () => {
130
143
  sessionTracker.stopPolling();
@@ -136,7 +149,7 @@ export async function startDaemon(opts = {}) {
136
149
  await fs.rm(sockPath, { force: true });
137
150
  await fs.rm(pidFilePath, { force: true });
138
151
  };
139
- // 12. Signal handlers
152
+ // 16. Signal handlers
140
153
  for (const sig of ["SIGTERM", "SIGINT"]) {
141
154
  process.on(sig, async () => {
142
155
  console.log(`Received ${sig}, shutting down...`);
@@ -149,6 +162,86 @@ export async function startDaemon(opts = {}) {
149
162
  console.log(` Metrics: http://localhost:${metricsPort}/metrics`);
150
163
  return { socketServer, httpServer, shutdown };
151
164
  }
165
+ // --- Stale daemon cleanup (#39) ---
166
+ /**
167
+ * Find and kill ALL stale agentctl daemon/supervisor processes.
168
+ * This ensures singleton enforcement even after unclean shutdowns.
169
+ */
170
+ async function killStaleDaemons(configDir) {
171
+ // 1. Kill processes recorded in PID files
172
+ for (const pidFile of ["agentctl.pid", "supervisor.pid"]) {
173
+ const p = path.join(configDir, pidFile);
174
+ const pid = await readPidFile(p);
175
+ if (pid && pid !== process.pid && isProcessAlive(pid)) {
176
+ try {
177
+ process.kill(pid, "SIGTERM");
178
+ // Wait briefly for clean shutdown
179
+ await sleep(500);
180
+ if (isProcessAlive(pid)) {
181
+ process.kill(pid, "SIGKILL");
182
+ }
183
+ }
184
+ catch {
185
+ // Already gone
186
+ }
187
+ }
188
+ // Clean up stale PID file
189
+ await fs.rm(p, { force: true }).catch(() => { });
190
+ }
191
+ // 2. Scan ps for any remaining agentctl daemon processes
192
+ try {
193
+ const { stdout } = await execFileAsync("ps", ["aux"]);
194
+ for (const line of stdout.split("\n")) {
195
+ if (!line.includes("agentctl") || !line.includes("daemon"))
196
+ continue;
197
+ if (line.includes("grep"))
198
+ continue;
199
+ const fields = line.trim().split(/\s+/);
200
+ if (fields.length < 2)
201
+ continue;
202
+ const pid = Number.parseInt(fields[1], 10);
203
+ if (Number.isNaN(pid) || pid === process.pid)
204
+ continue;
205
+ // Also skip our parent process (supervisor)
206
+ if (pid === process.ppid)
207
+ continue;
208
+ try {
209
+ process.kill(pid, "SIGTERM");
210
+ await sleep(200);
211
+ if (isProcessAlive(pid)) {
212
+ process.kill(pid, "SIGKILL");
213
+ }
214
+ }
215
+ catch {
216
+ // Already gone
217
+ }
218
+ }
219
+ }
220
+ catch {
221
+ // ps failed — best effort
222
+ }
223
+ }
224
+ /**
225
+ * Check if a Unix socket is actually responsive (not just a stale file).
226
+ */
227
+ async function isSocketAlive(sockPath) {
228
+ return new Promise((resolve) => {
229
+ const socket = net.createConnection(sockPath);
230
+ const timeout = setTimeout(() => {
231
+ socket.destroy();
232
+ resolve(false);
233
+ }, 1000);
234
+ socket.on("connect", () => {
235
+ clearTimeout(timeout);
236
+ socket.destroy();
237
+ resolve(true);
238
+ });
239
+ socket.on("error", () => {
240
+ clearTimeout(timeout);
241
+ resolve(false);
242
+ });
243
+ });
244
+ }
152
245
  function createRequestHandler(ctx) {
153
246
  return async (req) => {
154
247
  const params = (req.params || {});
@@ -242,10 +335,9 @@ function createRequestHandler(ctx) {
242
335
  });
243
336
  // Remove auto-lock
244
337
  ctx.lockManager.autoUnlock(session.id);
245
- // Mark stopped and start fuse if applicable
338
+ // Mark stopped
246
339
  const stopped = ctx.sessionTracker.onSessionExit(session.id);
247
340
  if (stopped) {
248
- ctx.fuseEngine.onSessionExit(stopped);
249
341
  ctx.metrics.recordSessionStopped();
250
342
  }
251
343
  return null;
@@ -260,6 +352,11 @@ function createRequestHandler(ctx) {
260
352
  await adapter.resume(session.id, params.message);
261
353
  return null;
262
354
  }
355
+ // --- Prune command (#40) ---
356
+ case "session.prune": {
357
+ const pruned = ctx.sessionTracker.pruneDeadSessions();
358
+ return { pruned };
359
+ }
263
360
  case "lock.list":
264
361
  return ctx.lockManager.listAll();
265
362
  case "lock.acquire":
@@ -269,6 +366,21 @@ function createRequestHandler(ctx) {
269
366
  return null;
270
367
  case "fuse.list":
271
368
  return ctx.fuseEngine.listActive();
369
+ case "fuse.set":
370
+ ctx.fuseEngine.setFuse({
371
+ directory: params.directory,
372
+ sessionId: params.sessionId,
373
+ ttlMs: params.ttlMs,
374
+ onExpire: params.onExpire,
375
+ label: params.label,
376
+ });
377
+ return null;
378
+ case "fuse.extend": {
379
+ const extended = ctx.fuseEngine.extendFuse(params.directory, params.ttlMs);
380
+ if (!extended)
381
+ throw new Error(`No active fuse for directory: ${params.directory}`);
382
+ return null;
383
+ }
272
384
  case "fuse.cancel":
273
385
  ctx.fuseEngine.cancelFuse(params.directory);
274
386
  return null;
@@ -311,3 +423,6 @@ function isProcessAlive(pid) {
311
423
  return false;
312
424
  }
313
425
  }
426
+ function sleep(ms) {
427
+ return new Promise((resolve) => setTimeout(resolve, ms));
428
+ }
@@ -25,6 +25,19 @@ export declare class SessionTracker {
25
25
  * - Any "running"/"idle" session in state whose PID is dead → mark stopped
26
26
  */
27
27
  private reapStaleEntries;
28
+ /**
29
+ * Validate all sessions on daemon startup (#40).
30
+ * Any session marked as "running" or "idle" whose PID is dead gets
31
+ * immediately marked as "stopped". This prevents unbounded growth of
32
+ * ghost sessions across daemon restarts.
33
+ */
34
+ validateAllSessions(): void;
35
+ /**
36
+ * Aggressively prune all clearly-dead sessions (#40).
37
+ * Returns the number of sessions pruned.
38
+ * Called via `agentctl prune` command.
39
+ */
40
+ pruneDeadSessions(): number;
28
41
  /**
29
42
  * Remove stopped sessions from state that have been stopped for more than 7 days.
30
43
  * This reduces overhead from accumulating hundreds of historical sessions.
@@ -42,24 +42,25 @@ export class SessionTracker {
42
42
  }
43
43
  }
44
44
  async poll() {
45
- // Collect PIDs from all adapter-returned sessions (the source of truth)
45
+ // Collect PIDs from all adapter-discovered sessions (the source of truth)
46
46
  const adapterPidToId = new Map();
47
47
  for (const [adapterName, adapter] of Object.entries(this.adapters)) {
48
48
  try {
49
- const sessions = await adapter.list({ all: true });
50
- for (const session of sessions) {
51
- if (session.pid) {
52
- adapterPidToId.set(session.pid, session.id);
49
+ // Discover-first: adapter.discover() is the ground truth
50
+ const discovered = await adapter.discover();
51
+ for (const disc of discovered) {
52
+ if (disc.pid) {
53
+ adapterPidToId.set(disc.pid, disc.id);
53
54
  }
54
- const existing = this.state.getSession(session.id);
55
- const record = sessionToRecord(session, adapterName);
55
+ const existing = this.state.getSession(disc.id);
56
+ const record = discoveredToRecord(disc, adapterName);
56
57
  if (!existing) {
57
- this.state.setSession(session.id, record);
58
+ this.state.setSession(disc.id, record);
58
59
  }
59
60
  else if (existing.status !== record.status ||
60
61
  (!existing.model && record.model)) {
61
- // Status changed or model resolved — update
62
- this.state.setSession(session.id, {
62
+ // Status changed or model resolved — update, preserving metadata
63
+ this.state.setSession(disc.id, {
63
64
  ...existing,
64
65
  status: record.status,
65
66
  stoppedAt: record.stoppedAt,
@@ -67,6 +68,7 @@ export class SessionTracker {
67
68
  tokens: record.tokens,
68
69
  cost: record.cost,
69
70
  prompt: record.prompt || existing.prompt,
71
+ pid: record.pid,
70
72
  });
71
73
  }
72
74
  }
@@ -113,6 +115,79 @@ export class SessionTracker {
113
115
  }
114
116
  }
115
117
  }
118
+ /**
119
+ * Validate all sessions on daemon startup (#40).
120
+ * Any session marked as "running" or "idle" whose PID is dead gets
121
+ * immediately marked as "stopped". This prevents unbounded growth of
122
+ * ghost sessions across daemon restarts.
123
+ */
124
+ validateAllSessions() {
125
+ const sessions = this.state.getSessions();
126
+ let cleaned = 0;
127
+ for (const [id, record] of Object.entries(sessions)) {
128
+ if (record.status !== "running" && record.status !== "idle")
129
+ continue;
130
+ if (record.pid) {
131
+ if (!this.isProcessAlive(record.pid)) {
132
+ this.state.setSession(id, {
133
+ ...record,
134
+ status: "stopped",
135
+ stoppedAt: new Date().toISOString(),
136
+ });
137
+ cleaned++;
138
+ }
139
+ }
140
+ else {
141
+ // No PID recorded — can't verify, mark as stopped
142
+ this.state.setSession(id, {
143
+ ...record,
144
+ status: "stopped",
145
+ stoppedAt: new Date().toISOString(),
146
+ });
147
+ cleaned++;
148
+ }
149
+ }
150
+ if (cleaned > 0) {
151
+ console.error(`Validated sessions on startup: marked ${cleaned} dead sessions as stopped`);
152
+ }
153
+ }
154
+ /**
155
+ * Aggressively prune all clearly-dead sessions (#40).
156
+ * Returns the number of sessions pruned.
157
+ * Called via `agentctl prune` command.
158
+ */
159
+ pruneDeadSessions() {
160
+ const sessions = this.state.getSessions();
161
+ let pruned = 0;
162
+ for (const [id, record] of Object.entries(sessions)) {
163
+ // Remove stopped/completed/failed sessions older than 24h
164
+ if (record.status === "stopped" ||
165
+ record.status === "completed" ||
166
+ record.status === "failed") {
167
+ const stoppedAt = record.stoppedAt
168
+ ? new Date(record.stoppedAt).getTime()
169
+ : new Date(record.startedAt).getTime();
170
+ const age = Date.now() - stoppedAt;
171
+ if (age > 24 * 60 * 60 * 1000) {
172
+ this.state.removeSession(id);
173
+ pruned++;
174
+ }
175
+ continue;
176
+ }
177
+ // Remove running/idle sessions whose PID is dead
178
+ if (record.status === "running" || record.status === "idle") {
179
+ if (record.pid && !this.isProcessAlive(record.pid)) {
180
+ this.state.removeSession(id);
181
+ pruned++;
182
+ }
183
+ else if (!record.pid) {
184
+ this.state.removeSession(id);
185
+ pruned++;
186
+ }
187
+ }
188
+ }
189
+ return pruned;
190
+ }
116
191
  /**
117
192
  * Remove stopped sessions from state that have been stopped for more than 7 days.
118
193
  * This reduces overhead from accumulating hundreds of historical sessions.
@@ -265,3 +340,20 @@ function sessionToRecord(session, adapterName) {
265
340
  meta: session.meta,
266
341
  };
267
342
  }
343
+ /** Convert a DiscoveredSession (adapter ground truth) to a SessionRecord for state */
344
+ function discoveredToRecord(disc, adapterName) {
345
+ return {
346
+ id: disc.id,
347
+ adapter: adapterName,
348
+ status: disc.status,
349
+ startedAt: disc.startedAt?.toISOString() ?? new Date().toISOString(),
350
+ stoppedAt: disc.stoppedAt?.toISOString(),
351
+ cwd: disc.cwd,
352
+ model: disc.model,
353
+ prompt: disc.prompt,
354
+ tokens: disc.tokens,
355
+ cost: disc.cost,
356
+ pid: disc.pid,
357
+ meta: disc.nativeMetadata ?? {},
358
+ };
359
+ }
@@ -28,10 +28,20 @@ export interface Lock {
28
28
  }
29
29
  export interface FuseTimer {
30
30
  directory: string;
31
- clusterName: string;
32
- branch: string;
31
+ ttlMs: number;
33
32
  expiresAt: string;
34
33
  sessionId: string;
34
+ /** On-expire action: shell command, webhook URL, or event name */
35
+ onExpire?: FuseAction;
36
+ label?: string;
37
+ }
38
+ export interface FuseAction {
39
+ /** Shell script to run when fuse expires. CWD is the fuse directory. */
40
+ script?: string;
41
+ /** Webhook URL to POST to when fuse expires */
42
+ webhook?: string;
43
+ /** Event name to emit when fuse expires */
44
+ event?: string;
35
45
  }
36
46
  export interface PersistedState {
37
47
  sessions: Record<string, SessionRecord>;
package/dist/hooks.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { LifecycleHooks } from "./core/types.js";
2
- export type HookPhase = "onCreate" | "onComplete" | "preMerge" | "postMerge";
2
+ export type HookPhase = "onCreate" | "onComplete";
3
3
  export interface HookContext {
4
4
  sessionId: string;
5
5
  cwd: string;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Save the current process environment to disk.
3
+ * Called at daemon start time when we still have the user's shell env.
4
+ */
5
+ export declare function saveEnvironment(configDir: string): Promise<void>;
6
+ /**
7
+ * Load the saved environment from disk.
8
+ * Returns undefined if the env file doesn't exist or is corrupt.
9
+ */
10
+ export declare function loadSavedEnvironment(configDir: string): Promise<Record<string, string> | undefined>;
11
+ /**
12
+ * Build an augmented environment for spawning subprocesses.
13
+ * Merges the saved daemon env with common bin paths to ensure
14
+ * binaries are findable even when the daemon is detached from the shell.
15
+ */
16
+ export declare function buildSpawnEnv(savedEnv?: Record<string, string>, extraEnv?: Record<string, string>): Record<string, string>;
@@ -0,0 +1,85 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ const ENV_FILE = "daemon-env.json";
5
+ /**
6
+ * Common bin directories that should be in PATH when spawning subprocesses.
7
+ * These cover the usual locations for various package managers and tools.
8
+ */
9
+ function getCommonBinDirs() {
10
+ const home = os.homedir();
11
+ return [
12
+ path.join(home, ".local", "bin"),
13
+ "/usr/local/bin",
14
+ "/usr/bin",
15
+ "/bin",
16
+ "/usr/sbin",
17
+ "/sbin",
18
+ "/opt/homebrew/bin",
19
+ path.join(home, ".npm-global", "bin"),
20
+ path.join(home, ".local", "share", "mise", "shims"),
21
+ path.join(home, ".cargo", "bin"),
22
+ ];
23
+ }
24
+ /**
25
+ * Save the current process environment to disk.
26
+ * Called at daemon start time when we still have the user's shell env.
27
+ */
28
+ export async function saveEnvironment(configDir) {
29
+ const envPath = path.join(configDir, ENV_FILE);
30
+ try {
31
+ const tmpPath = `${envPath}.tmp`;
32
+ await fs.writeFile(tmpPath, JSON.stringify(process.env));
33
+ await fs.rename(tmpPath, envPath);
34
+ }
35
+ catch (err) {
36
+ console.error(`Warning: could not save environment: ${err.message}`);
37
+ }
38
+ }
39
+ /**
40
+ * Load the saved environment from disk.
41
+ * Returns undefined if the env file doesn't exist or is corrupt.
42
+ */
43
+ export async function loadSavedEnvironment(configDir) {
44
+ const envPath = path.join(configDir, ENV_FILE);
45
+ try {
46
+ const raw = await fs.readFile(envPath, "utf-8");
47
+ const parsed = JSON.parse(raw);
48
+ if (typeof parsed === "object" && parsed !== null) {
49
+ return parsed;
50
+ }
51
+ }
52
+ catch {
53
+ // File doesn't exist or is corrupt
54
+ }
55
+ return undefined;
56
+ }
57
+ /**
58
+ * Build an augmented environment for spawning subprocesses.
59
+ * Merges the saved daemon env with common bin paths to ensure
60
+ * binaries are findable even when the daemon is detached from the shell.
61
+ */
62
+ export function buildSpawnEnv(savedEnv, extraEnv) {
63
+ const base = {};
64
+ const source = savedEnv || process.env;
65
+ // Copy source env
66
+ for (const [k, v] of Object.entries(source)) {
67
+ if (v !== undefined)
68
+ base[k] = v;
69
+ }
70
+ // Augment PATH with common bin directories
71
+ const existingPath = base.PATH || "";
72
+ const existingDirs = new Set(existingPath.split(":").filter(Boolean));
73
+ const commonDirs = getCommonBinDirs();
74
+ const newDirs = commonDirs.filter((d) => !existingDirs.has(d));
75
+ if (newDirs.length > 0) {
76
+ base.PATH = [...existingPath.split(":").filter(Boolean), ...newDirs].join(":");
77
+ }
78
+ // Apply extra env overrides
79
+ if (extraEnv) {
80
+ for (const [k, v] of Object.entries(extraEnv)) {
81
+ base[k] = v;
82
+ }
83
+ }
84
+ return base;
85
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Resolve the absolute path to a binary, checking known locations first,
3
+ * then falling back to `which`. Results are cached per binary name.
4
+ *
5
+ * @param name - Binary name (e.g., "claude", "codex", "pi")
6
+ * @param knownLocations - Additional absolute paths to check first
7
+ * @returns Resolved absolute path, or bare name as last resort
8
+ */
9
+ export declare function resolveBinaryPath(name: string, knownLocations?: string[]): Promise<string>;
10
+ /**
11
+ * Clear the resolved path cache. Call this when binaries may have been
12
+ * updated (e.g., on daemon restart).
13
+ */
14
+ export declare function clearBinaryCache(): void;
@@ -0,0 +1,66 @@
1
+ import { execFile } from "node:child_process";
2
+ import * as fs from "node:fs/promises";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { promisify } from "node:util";
6
+ const execFileAsync = promisify(execFile);
7
+ /** Cache of resolved binary paths: name → absolute path */
8
+ const resolvedCache = new Map();
9
+ /**
10
+ * Resolve the absolute path to a binary, checking known locations first,
11
+ * then falling back to `which`. Results are cached per binary name.
12
+ *
13
+ * @param name - Binary name (e.g., "claude", "codex", "pi")
14
+ * @param knownLocations - Additional absolute paths to check first
15
+ * @returns Resolved absolute path, or bare name as last resort
16
+ */
17
+ export async function resolveBinaryPath(name, knownLocations = []) {
18
+ const cached = resolvedCache.get(name);
19
+ if (cached)
20
+ return cached;
21
+ const home = os.homedir();
22
+ // Default well-known locations for common toolchains
23
+ const defaultLocations = [
24
+ path.join(home, ".local", "bin", name),
25
+ `/usr/local/bin/${name}`,
26
+ `/opt/homebrew/bin/${name}`, // Homebrew Apple Silicon
27
+ path.join(home, ".npm-global", "bin", name),
28
+ path.join(home, ".local", "share", "mise", "shims", name),
29
+ path.join(home, ".cargo", "bin", name),
30
+ ];
31
+ const candidates = [...knownLocations, ...defaultLocations];
32
+ for (const c of candidates) {
33
+ try {
34
+ await fs.access(c, fs.constants.X_OK);
35
+ // Resolve symlinks to get the actual binary path
36
+ const resolved = await fs.realpath(c);
37
+ await fs.access(resolved, fs.constants.X_OK);
38
+ resolvedCache.set(name, resolved);
39
+ return resolved;
40
+ }
41
+ catch {
42
+ // Try next
43
+ }
44
+ }
45
+ // Try `which <name>` as fallback
46
+ try {
47
+ const { stdout } = await execFileAsync("which", [name]);
48
+ const p = stdout.trim();
49
+ if (p) {
50
+ resolvedCache.set(name, p);
51
+ return p;
52
+ }
53
+ }
54
+ catch {
55
+ // Fall through
56
+ }
57
+ // Last resort: bare name (let PATH resolve it at spawn time)
58
+ return name;
59
+ }
60
+ /**
61
+ * Clear the resolved path cache. Call this when binaries may have been
62
+ * updated (e.g., on daemon restart).
63
+ */
64
+ export function clearBinaryCache() {
65
+ resolvedCache.clear();
66
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orgloop/agentctl",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Universal agent supervision interface — monitor and control AI coding agents from a single CLI",
5
5
  "type": "module",
6
6
  "bin": {