@orgloop/agentctl 1.2.0 → 1.2.1

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,7 +5,9 @@ import * as fs from "node:fs/promises";
5
5
  import * as os from "node:os";
6
6
  import * as path from "node:path";
7
7
  import { promisify } from "node:util";
8
+ import { buildSpawnEnv } from "../utils/daemon-env.js";
8
9
  import { readHead, readTail } from "../utils/partial-read.js";
10
+ import { resolveBinaryPath } from "../utils/resolve-binary.js";
9
11
  const execFileAsync = promisify(execFile);
10
12
  const DEFAULT_CLAUDE_DIR = path.join(os.homedir(), ".claude");
11
13
  // Default: only show stopped sessions from the last 7 days
@@ -123,7 +125,7 @@ export class ClaudeCodeAdapter {
123
125
  args.push("--model", opts.model);
124
126
  }
125
127
  args.push("-p", opts.prompt);
126
- const env = { ...process.env, ...opts.env };
128
+ const env = buildSpawnEnv(undefined, opts.env);
127
129
  const cwd = opts.cwd || process.cwd();
128
130
  // Write stdout to a log file so we can extract the session ID
129
131
  // without keeping a pipe open (which would prevent full detachment).
@@ -131,12 +133,17 @@ export class ClaudeCodeAdapter {
131
133
  const logPath = path.join(this.sessionsMetaDir, `launch-${Date.now()}.log`);
132
134
  const logFd = await fs.open(logPath, "w");
133
135
  // Capture stderr to the same log file for debugging launch failures
134
- const child = spawn("claude", args, {
136
+ const claudePath = await resolveBinaryPath("claude");
137
+ const child = spawn(claudePath, args, {
135
138
  cwd,
136
139
  env,
137
140
  stdio: ["ignore", logFd.fd, logFd.fd],
138
141
  detached: true,
139
142
  });
143
+ // Handle spawn errors (e.g. ENOENT) gracefully instead of crashing the daemon
144
+ child.on("error", (err) => {
145
+ console.error(`[claude-code] spawn error: ${err.message}`);
146
+ });
140
147
  // Fully detach: child runs in its own process group.
141
148
  // When the wrapper gets SIGTERM, the child keeps running.
142
149
  child.unref();
@@ -250,11 +257,15 @@ export class ClaudeCodeAdapter {
250
257
  ];
251
258
  const session = await this.status(sessionId).catch(() => null);
252
259
  const cwd = session?.cwd || process.cwd();
253
- const child = spawn("claude", args, {
260
+ const claudePath = await resolveBinaryPath("claude");
261
+ const child = spawn(claudePath, args, {
254
262
  cwd,
255
263
  stdio: ["pipe", "pipe", "pipe"],
256
264
  detached: true,
257
265
  });
266
+ child.on("error", (err) => {
267
+ console.error(`[claude-code] resume spawn error: ${err.message}`);
268
+ });
258
269
  child.unref();
259
270
  }
260
271
  async *events() {
@@ -5,6 +5,8 @@ import * as fs from "node:fs/promises";
5
5
  import * as os from "node:os";
6
6
  import * as path from "node:path";
7
7
  import { promisify } from "node:util";
8
+ import { buildSpawnEnv } from "../utils/daemon-env.js";
9
+ import { resolveBinaryPath } from "../utils/resolve-binary.js";
8
10
  const execFileAsync = promisify(execFile);
9
11
  const DEFAULT_CODEX_DIR = path.join(os.homedir(), ".codex");
10
12
  // Default: only show stopped sessions from the last 7 days
@@ -102,16 +104,20 @@ export class CodexAdapter {
102
104
  const cwd = opts.cwd || process.cwd();
103
105
  args.push("--cd", cwd);
104
106
  args.push(opts.prompt);
105
- const env = { ...process.env, ...opts.env };
107
+ const env = buildSpawnEnv(undefined, opts.env);
106
108
  await fs.mkdir(this.sessionsMetaDir, { recursive: true });
107
109
  const logPath = path.join(this.sessionsMetaDir, `launch-${Date.now()}.log`);
108
110
  const logFd = await fs.open(logPath, "w");
109
- const child = spawn("codex", args, {
111
+ const codexPath = await resolveBinaryPath("codex");
112
+ const child = spawn(codexPath, args, {
110
113
  cwd,
111
114
  env,
112
115
  stdio: ["ignore", logFd.fd, "ignore"],
113
116
  detached: true,
114
117
  });
118
+ child.on("error", (err) => {
119
+ console.error(`[codex] spawn error: ${err.message}`);
120
+ });
115
121
  child.unref();
116
122
  const pid = child.pid;
117
123
  const now = new Date();
@@ -219,11 +225,15 @@ export class CodexAdapter {
219
225
  sessionId,
220
226
  message,
221
227
  ];
222
- const child = spawn("codex", args, {
228
+ const codexPath = await resolveBinaryPath("codex");
229
+ const child = spawn(codexPath, args, {
223
230
  cwd,
224
231
  stdio: ["pipe", "pipe", "pipe"],
225
232
  detached: true,
226
233
  });
234
+ child.on("error", (err) => {
235
+ console.error(`[codex] resume spawn error: ${err.message}`);
236
+ });
227
237
  child.unref();
228
238
  }
229
239
  async *events() {
@@ -5,6 +5,8 @@ import * as fs from "node:fs/promises";
5
5
  import * as os from "node:os";
6
6
  import * as path from "node:path";
7
7
  import { promisify } from "node:util";
8
+ import { buildSpawnEnv } from "../utils/daemon-env.js";
9
+ import { resolveBinaryPath } from "../utils/resolve-binary.js";
8
10
  const execFileAsync = promisify(execFile);
9
11
  const DEFAULT_STORAGE_DIR = path.join(os.homedir(), ".local", "share", "opencode", "storage");
10
12
  // Default: only show stopped sessions from the last 7 days
@@ -116,15 +118,19 @@ export class OpenCodeAdapter {
116
118
  }
117
119
  async launch(opts) {
118
120
  const args = ["run", opts.prompt];
119
- const env = { ...process.env, ...opts.env };
121
+ const env = buildSpawnEnv(undefined, opts.env);
120
122
  const cwd = opts.cwd || process.cwd();
121
123
  await fs.mkdir(this.sessionsMetaDir, { recursive: true });
122
- const child = spawn("opencode", args, {
124
+ const opencodePath = await resolveBinaryPath("opencode");
125
+ const child = spawn(opencodePath, args, {
123
126
  cwd,
124
127
  env,
125
128
  stdio: ["ignore", "pipe", "pipe"],
126
129
  detached: true,
127
130
  });
131
+ child.on("error", (err) => {
132
+ console.error(`[opencode] spawn error: ${err.message}`);
133
+ });
128
134
  child.unref();
129
135
  const pid = child.pid;
130
136
  const now = new Date();
@@ -183,11 +189,15 @@ export class OpenCodeAdapter {
183
189
  if (!resolved)
184
190
  throw new Error(`Session not found for resume: ${sessionId}`);
185
191
  const cwd = resolved.directory || process.cwd();
186
- const child = spawn("opencode", ["run", message], {
192
+ const opencodePath = await resolveBinaryPath("opencode");
193
+ const child = spawn(opencodePath, ["run", message], {
187
194
  cwd,
188
195
  stdio: ["ignore", "pipe", "pipe"],
189
196
  detached: true,
190
197
  });
198
+ child.on("error", (err) => {
199
+ console.error(`[opencode] resume spawn error: ${err.message}`);
200
+ });
191
201
  child.unref();
192
202
  }
193
203
  async *events() {
@@ -5,6 +5,8 @@ import * as fs from "node:fs/promises";
5
5
  import * as os from "node:os";
6
6
  import * as path from "node:path";
7
7
  import { promisify } from "node:util";
8
+ import { buildSpawnEnv } from "../utils/daemon-env.js";
9
+ import { resolveBinaryPath } from "../utils/resolve-binary.js";
8
10
  const execFileAsync = promisify(execFile);
9
11
  const DEFAULT_SESSION_DIR = path.join(os.homedir(), ".pi", "agent", "sessions");
10
12
  // Default: only show stopped sessions from the last 7 days
@@ -129,18 +131,22 @@ export class PiRustAdapter {
129
131
  if (opts.model) {
130
132
  args.unshift("--model", opts.model);
131
133
  }
132
- const env = { ...process.env, ...opts.env };
134
+ const env = buildSpawnEnv(undefined, opts.env);
133
135
  const cwd = opts.cwd || process.cwd();
134
136
  // Write stdout to a log file so we can extract the session ID
135
137
  await fs.mkdir(this.sessionsMetaDir, { recursive: true });
136
138
  const logPath = path.join(this.sessionsMetaDir, `launch-${Date.now()}.log`);
137
139
  const logFd = await fs.open(logPath, "w");
138
- const child = spawn("pi-rust", args, {
140
+ const piRustPath = await resolveBinaryPath("pi-rust");
141
+ const child = spawn(piRustPath, args, {
139
142
  cwd,
140
143
  env,
141
144
  stdio: ["ignore", logFd.fd, "ignore"],
142
145
  detached: true,
143
146
  });
147
+ child.on("error", (err) => {
148
+ console.error(`[pi-rust] spawn error: ${err.message}`);
149
+ });
144
150
  child.unref();
145
151
  const pid = child.pid;
146
152
  const now = new Date();
@@ -251,11 +257,15 @@ export class PiRustAdapter {
251
257
  else {
252
258
  args.unshift("--continue");
253
259
  }
254
- const child = spawn("pi-rust", args, {
260
+ const piRustPath = await resolveBinaryPath("pi-rust");
261
+ const child = spawn(piRustPath, args, {
255
262
  cwd,
256
263
  stdio: ["pipe", "pipe", "pipe"],
257
264
  detached: true,
258
265
  });
266
+ child.on("error", (err) => {
267
+ console.error(`[pi-rust] resume spawn error: ${err.message}`);
268
+ });
259
269
  child.unref();
260
270
  }
261
271
  async *events() {
@@ -5,6 +5,8 @@ import * as fs from "node:fs/promises";
5
5
  import * as os from "node:os";
6
6
  import * as path from "node:path";
7
7
  import { promisify } from "node:util";
8
+ import { buildSpawnEnv } from "../utils/daemon-env.js";
9
+ import { resolveBinaryPath } from "../utils/resolve-binary.js";
8
10
  const execFileAsync = promisify(execFile);
9
11
  const DEFAULT_PI_DIR = path.join(os.homedir(), ".pi");
10
12
  // Default: only show stopped sessions from the last 7 days
@@ -163,18 +165,22 @@ export class PiAdapter {
163
165
  if (opts.model) {
164
166
  args.unshift("--model", opts.model);
165
167
  }
166
- const env = { ...process.env, ...opts.env };
168
+ const env = buildSpawnEnv(undefined, opts.env);
167
169
  const cwd = opts.cwd || process.cwd();
168
170
  // Write stdout to a log file so we can extract the session ID
169
171
  await fs.mkdir(this.sessionsMetaDir, { recursive: true });
170
172
  const logPath = path.join(this.sessionsMetaDir, `launch-${Date.now()}.log`);
171
173
  const logFd = await fs.open(logPath, "w");
172
- const child = spawn("pi", args, {
174
+ const piPath = await resolveBinaryPath("pi");
175
+ const child = spawn(piPath, args, {
173
176
  cwd,
174
177
  env,
175
178
  stdio: ["ignore", logFd.fd, logFd.fd],
176
179
  detached: true,
177
180
  });
181
+ child.on("error", (err) => {
182
+ console.error(`[pi] spawn error: ${err.message}`);
183
+ });
178
184
  // Fully detach: child runs in its own process group.
179
185
  child.unref();
180
186
  const pid = child.pid;
@@ -331,11 +337,15 @@ export class PiAdapter {
331
337
  // Launch a new pi session in the same cwd with the continuation message.
332
338
  const disc = await this.findSession(sessionId);
333
339
  const cwd = disc?.header.cwd || process.cwd();
334
- const child = spawn("pi", ["-p", message], {
340
+ const piPath = await resolveBinaryPath("pi");
341
+ const child = spawn(piPath, ["-p", message], {
335
342
  cwd,
336
343
  stdio: ["pipe", "pipe", "pipe"],
337
344
  detached: true,
338
345
  });
346
+ child.on("error", (err) => {
347
+ console.error(`[pi] resume spawn error: ${err.message}`);
348
+ });
339
349
  child.unref();
340
350
  }
341
351
  async *events() {
package/dist/cli.js CHANGED
@@ -84,6 +84,7 @@ function formatSession(s, showGroup) {
84
84
  const row = {
85
85
  ID: s.id.slice(0, 8),
86
86
  Status: s.status,
87
+ Adapter: s.adapter || "-",
87
88
  Model: s.model || "-",
88
89
  };
89
90
  if (showGroup)
@@ -98,6 +99,7 @@ function formatRecord(s, showGroup) {
98
99
  const row = {
99
100
  ID: s.id.slice(0, 8),
100
101
  Status: s.status,
102
+ Adapter: s.adapter || "-",
101
103
  Model: s.model || "-",
102
104
  };
103
105
  if (showGroup)
@@ -868,6 +870,25 @@ program
868
870
  process.exit(1);
869
871
  }
870
872
  });
873
+ // --- Prune command (#40) ---
874
+ program
875
+ .command("prune")
876
+ .description("Remove dead and stale sessions from daemon state")
877
+ .action(async () => {
878
+ const daemonRunning = await ensureDaemon();
879
+ if (!daemonRunning) {
880
+ console.error("Daemon not running. Start with: agentctl daemon start");
881
+ process.exit(1);
882
+ }
883
+ try {
884
+ const result = await client.call("session.prune");
885
+ console.log(`Pruned ${result.pruned} dead/stale sessions`);
886
+ }
887
+ catch (err) {
888
+ console.error(err.message);
889
+ process.exit(1);
890
+ }
891
+ });
871
892
  // --- Daemon subcommand ---
872
893
  const daemonCmd = new Command("daemon").description("Manage the agentctl daemon");
873
894
  daemonCmd
@@ -945,8 +966,9 @@ daemonCmd
945
966
  });
946
967
  daemonCmd
947
968
  .command("status")
948
- .description("Show daemon status")
969
+ .description("Show daemon status and all daemon-related processes")
949
970
  .action(async () => {
971
+ // Show daemon status
950
972
  try {
951
973
  const status = await client.call("daemon.status");
952
974
  console.log(`Daemon running (PID ${status.pid})`);
@@ -958,6 +980,38 @@ daemonCmd
958
980
  catch {
959
981
  console.log("Daemon not running");
960
982
  }
983
+ // Show all daemon-related processes (#39)
984
+ const configDir = path.join(os.homedir(), ".agentctl");
985
+ const { getSupervisorPid } = await import("./daemon/supervisor.js");
986
+ const supPid = await getSupervisorPid();
987
+ let daemonPid = null;
988
+ try {
989
+ const raw = await fs.readFile(path.join(configDir, "agentctl.pid"), "utf-8");
990
+ const pid = Number.parseInt(raw.trim(), 10);
991
+ try {
992
+ process.kill(pid, 0);
993
+ daemonPid = pid;
994
+ }
995
+ catch {
996
+ // PID file is stale
997
+ }
998
+ }
999
+ catch {
1000
+ // No PID file
1001
+ }
1002
+ console.log("\nDaemon-related processes:");
1003
+ if (supPid) {
1004
+ console.log(` Supervisor: PID ${supPid} (alive)`);
1005
+ }
1006
+ else {
1007
+ console.log(" Supervisor: not running");
1008
+ }
1009
+ if (daemonPid) {
1010
+ console.log(` Daemon: PID ${daemonPid} (alive)`);
1011
+ }
1012
+ else {
1013
+ console.log(" Daemon: not running");
1014
+ }
961
1015
  });
962
1016
  daemonCmd
963
1017
  .command("restart")
@@ -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(),
@@ -54,11 +65,13 @@ export async function startDaemon(opts = {}) {
54
65
  emitter.on("fuse.fired", () => {
55
66
  metrics.recordFuseFired();
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 || {});
@@ -260,6 +353,11 @@ function createRequestHandler(ctx) {
260
353
  await adapter.resume(session.id, params.message);
261
354
  return null;
262
355
  }
356
+ // --- Prune command (#40) ---
357
+ case "session.prune": {
358
+ const pruned = ctx.sessionTracker.pruneDeadSessions();
359
+ return { pruned };
360
+ }
263
361
  case "lock.list":
264
362
  return ctx.lockManager.listAll();
265
363
  case "lock.acquire":
@@ -311,3 +409,6 @@ function isProcessAlive(pid) {
311
409
  return false;
312
410
  }
313
411
  }
412
+ function sleep(ms) {
413
+ return new Promise((resolve) => setTimeout(resolve, ms));
414
+ }
@@ -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.
@@ -113,6 +113,79 @@ export class SessionTracker {
113
113
  }
114
114
  }
115
115
  }
116
+ /**
117
+ * Validate all sessions on daemon startup (#40).
118
+ * Any session marked as "running" or "idle" whose PID is dead gets
119
+ * immediately marked as "stopped". This prevents unbounded growth of
120
+ * ghost sessions across daemon restarts.
121
+ */
122
+ validateAllSessions() {
123
+ const sessions = this.state.getSessions();
124
+ let cleaned = 0;
125
+ for (const [id, record] of Object.entries(sessions)) {
126
+ if (record.status !== "running" && record.status !== "idle")
127
+ continue;
128
+ if (record.pid) {
129
+ if (!this.isProcessAlive(record.pid)) {
130
+ this.state.setSession(id, {
131
+ ...record,
132
+ status: "stopped",
133
+ stoppedAt: new Date().toISOString(),
134
+ });
135
+ cleaned++;
136
+ }
137
+ }
138
+ else {
139
+ // No PID recorded — can't verify, mark as stopped
140
+ this.state.setSession(id, {
141
+ ...record,
142
+ status: "stopped",
143
+ stoppedAt: new Date().toISOString(),
144
+ });
145
+ cleaned++;
146
+ }
147
+ }
148
+ if (cleaned > 0) {
149
+ console.error(`Validated sessions on startup: marked ${cleaned} dead sessions as stopped`);
150
+ }
151
+ }
152
+ /**
153
+ * Aggressively prune all clearly-dead sessions (#40).
154
+ * Returns the number of sessions pruned.
155
+ * Called via `agentctl prune` command.
156
+ */
157
+ pruneDeadSessions() {
158
+ const sessions = this.state.getSessions();
159
+ let pruned = 0;
160
+ for (const [id, record] of Object.entries(sessions)) {
161
+ // Remove stopped/completed/failed sessions older than 24h
162
+ if (record.status === "stopped" ||
163
+ record.status === "completed" ||
164
+ record.status === "failed") {
165
+ const stoppedAt = record.stoppedAt
166
+ ? new Date(record.stoppedAt).getTime()
167
+ : new Date(record.startedAt).getTime();
168
+ const age = Date.now() - stoppedAt;
169
+ if (age > 24 * 60 * 60 * 1000) {
170
+ this.state.removeSession(id);
171
+ pruned++;
172
+ }
173
+ continue;
174
+ }
175
+ // Remove running/idle sessions whose PID is dead
176
+ if (record.status === "running" || record.status === "idle") {
177
+ if (record.pid && !this.isProcessAlive(record.pid)) {
178
+ this.state.removeSession(id);
179
+ pruned++;
180
+ }
181
+ else if (!record.pid) {
182
+ this.state.removeSession(id);
183
+ pruned++;
184
+ }
185
+ }
186
+ }
187
+ return pruned;
188
+ }
116
189
  /**
117
190
  * Remove stopped sessions from state that have been stopped for more than 7 days.
118
191
  * This reduces overhead from accumulating hundreds of historical sessions.
@@ -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.2.1",
4
4
  "description": "Universal agent supervision interface — monitor and control AI coding agents from a single CLI",
5
5
  "type": "module",
6
6
  "bin": {