@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.
- package/dist/adapters/claude-code.js +14 -3
- package/dist/adapters/codex.js +13 -3
- package/dist/adapters/opencode.js +13 -3
- package/dist/adapters/pi-rust.js +13 -3
- package/dist/adapters/pi.js +13 -3
- package/dist/cli.js +55 -1
- package/dist/daemon/server.js +118 -17
- package/dist/daemon/session-tracker.d.ts +13 -0
- package/dist/daemon/session-tracker.js +73 -0
- package/dist/utils/daemon-env.d.ts +16 -0
- package/dist/utils/daemon-env.js +85 -0
- package/dist/utils/resolve-binary.d.ts +14 -0
- package/dist/utils/resolve-binary.js +66 -0
- package/package.json +1 -1
|
@@ -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 =
|
|
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
|
|
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
|
|
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() {
|
package/dist/adapters/codex.js
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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() {
|
package/dist/adapters/pi-rust.js
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
|
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() {
|
package/dist/adapters/pi.js
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
|
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")
|
package/dist/daemon/server.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
45
|
+
// 7. Load persisted state
|
|
35
46
|
const state = await StateManager.load(configDir);
|
|
36
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
72
|
+
// 11. Start session polling
|
|
60
73
|
sessionTracker.startPolling();
|
|
61
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
+
}
|