@orgloop/agentctl 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +228 -0
- package/dist/adapters/claude-code.d.ts +83 -0
- package/dist/adapters/claude-code.js +783 -0
- package/dist/adapters/openclaw.d.ts +88 -0
- package/dist/adapters/openclaw.js +297 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +808 -0
- package/dist/client/daemon-client.d.ts +6 -0
- package/dist/client/daemon-client.js +81 -0
- package/dist/compat-shim.d.ts +2 -0
- package/dist/compat-shim.js +15 -0
- package/dist/core/types.d.ts +68 -0
- package/dist/core/types.js +2 -0
- package/dist/daemon/fuse-engine.d.ts +30 -0
- package/dist/daemon/fuse-engine.js +118 -0
- package/dist/daemon/launchagent.d.ts +7 -0
- package/dist/daemon/launchagent.js +49 -0
- package/dist/daemon/lock-manager.d.ts +16 -0
- package/dist/daemon/lock-manager.js +71 -0
- package/dist/daemon/metrics.d.ts +20 -0
- package/dist/daemon/metrics.js +72 -0
- package/dist/daemon/server.d.ts +33 -0
- package/dist/daemon/server.js +283 -0
- package/dist/daemon/session-tracker.d.ts +28 -0
- package/dist/daemon/session-tracker.js +121 -0
- package/dist/daemon/state.d.ts +61 -0
- package/dist/daemon/state.js +126 -0
- package/dist/daemon/supervisor.d.ts +24 -0
- package/dist/daemon/supervisor.js +79 -0
- package/dist/hooks.d.ts +19 -0
- package/dist/hooks.js +39 -0
- package/dist/merge.d.ts +24 -0
- package/dist/merge.js +65 -0
- package/dist/migration/migrate-locks.d.ts +5 -0
- package/dist/migration/migrate-locks.js +41 -0
- package/dist/worktree.d.ts +24 -0
- package/dist/worktree.js +65 -0
- package/package.json +60 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import net from "node:net";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { ClaudeCodeAdapter } from "../adapters/claude-code.js";
|
|
8
|
+
import { OpenClawAdapter } from "../adapters/openclaw.js";
|
|
9
|
+
import { migrateLocks } from "../migration/migrate-locks.js";
|
|
10
|
+
import { FuseEngine } from "./fuse-engine.js";
|
|
11
|
+
import { LockManager } from "./lock-manager.js";
|
|
12
|
+
import { MetricsRegistry } from "./metrics.js";
|
|
13
|
+
import { SessionTracker } from "./session-tracker.js";
|
|
14
|
+
import { StateManager } from "./state.js";
|
|
15
|
+
const startTime = Date.now();
|
|
16
|
+
export async function startDaemon(opts = {}) {
|
|
17
|
+
const configDir = opts.configDir || path.join(os.homedir(), ".agentctl");
|
|
18
|
+
await fs.mkdir(configDir, { recursive: true });
|
|
19
|
+
// 1. Check for existing daemon
|
|
20
|
+
const pidFilePath = path.join(configDir, "agentctl.pid");
|
|
21
|
+
const existingPid = await readPidFile(pidFilePath);
|
|
22
|
+
if (existingPid && isProcessAlive(existingPid)) {
|
|
23
|
+
throw new Error(`Daemon already running (PID ${existingPid})`);
|
|
24
|
+
}
|
|
25
|
+
// 2. Clean stale socket
|
|
26
|
+
const sockPath = path.join(configDir, "agentctl.sock");
|
|
27
|
+
await fs.rm(sockPath, { force: true });
|
|
28
|
+
// 3. Run migration (idempotent)
|
|
29
|
+
await migrateLocks(configDir).catch((err) => console.error("Migration warning:", err.message));
|
|
30
|
+
// 4. Load persisted state
|
|
31
|
+
const state = await StateManager.load(configDir);
|
|
32
|
+
// 5. Initialize subsystems
|
|
33
|
+
const adapters = opts.adapters || {
|
|
34
|
+
"claude-code": new ClaudeCodeAdapter(),
|
|
35
|
+
openclaw: new OpenClawAdapter(),
|
|
36
|
+
};
|
|
37
|
+
const lockManager = new LockManager(state);
|
|
38
|
+
const emitter = new EventEmitter();
|
|
39
|
+
const fuseEngine = new FuseEngine(state, {
|
|
40
|
+
defaultDurationMs: 10 * 60 * 1000,
|
|
41
|
+
emitter,
|
|
42
|
+
});
|
|
43
|
+
const sessionTracker = new SessionTracker(state, { adapters });
|
|
44
|
+
const metrics = new MetricsRegistry(sessionTracker, lockManager, fuseEngine);
|
|
45
|
+
// Wire up events
|
|
46
|
+
emitter.on("fuse.fired", () => {
|
|
47
|
+
metrics.recordFuseFired();
|
|
48
|
+
});
|
|
49
|
+
// 6. Resume fuse timers
|
|
50
|
+
fuseEngine.resumeTimers();
|
|
51
|
+
// 7. Start session polling
|
|
52
|
+
sessionTracker.startPolling();
|
|
53
|
+
// 8. Create request handler
|
|
54
|
+
const handleRequest = createRequestHandler({
|
|
55
|
+
sessionTracker,
|
|
56
|
+
lockManager,
|
|
57
|
+
fuseEngine,
|
|
58
|
+
metrics,
|
|
59
|
+
adapters,
|
|
60
|
+
state,
|
|
61
|
+
configDir,
|
|
62
|
+
sockPath,
|
|
63
|
+
});
|
|
64
|
+
// 9. Start Unix socket server
|
|
65
|
+
const socketServer = net.createServer((conn) => {
|
|
66
|
+
let buffer = "";
|
|
67
|
+
conn.on("data", (chunk) => {
|
|
68
|
+
buffer += chunk.toString();
|
|
69
|
+
const lines = buffer.split("\n");
|
|
70
|
+
buffer = lines.pop() ?? "";
|
|
71
|
+
for (const line of lines) {
|
|
72
|
+
if (!line.trim())
|
|
73
|
+
continue;
|
|
74
|
+
try {
|
|
75
|
+
const req = JSON.parse(line);
|
|
76
|
+
handleRequest(req).then((result) => {
|
|
77
|
+
const resp = { id: req.id, result };
|
|
78
|
+
conn.write(`${JSON.stringify(resp)}\n`);
|
|
79
|
+
}, (err) => {
|
|
80
|
+
const resp = {
|
|
81
|
+
id: req.id,
|
|
82
|
+
error: {
|
|
83
|
+
code: "ERR",
|
|
84
|
+
message: err.message,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
conn.write(`${JSON.stringify(resp)}\n`);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// Malformed JSON — ignore
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
await new Promise((resolve, reject) => {
|
|
97
|
+
socketServer.listen(sockPath, () => resolve());
|
|
98
|
+
socketServer.on("error", reject);
|
|
99
|
+
});
|
|
100
|
+
// 10. Start HTTP metrics server
|
|
101
|
+
const metricsPort = opts.metricsPort ?? 9200;
|
|
102
|
+
const httpServer = http.createServer((req, res) => {
|
|
103
|
+
if (req.url === "/metrics" && req.method === "GET") {
|
|
104
|
+
res.writeHead(200, {
|
|
105
|
+
"Content-Type": "text/plain; version=0.0.4",
|
|
106
|
+
});
|
|
107
|
+
res.end(metrics.generateMetrics());
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
res.writeHead(404);
|
|
111
|
+
res.end("Not Found\n");
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
await new Promise((resolve, reject) => {
|
|
115
|
+
httpServer.listen(metricsPort, "127.0.0.1", () => resolve());
|
|
116
|
+
httpServer.on("error", reject);
|
|
117
|
+
});
|
|
118
|
+
// 11. Write PID file
|
|
119
|
+
await fs.writeFile(pidFilePath, String(process.pid));
|
|
120
|
+
// Shutdown function
|
|
121
|
+
const shutdown = async () => {
|
|
122
|
+
sessionTracker.stopPolling();
|
|
123
|
+
fuseEngine.shutdown();
|
|
124
|
+
state.flush();
|
|
125
|
+
await state.persist();
|
|
126
|
+
socketServer.close();
|
|
127
|
+
httpServer.close();
|
|
128
|
+
await fs.rm(sockPath, { force: true });
|
|
129
|
+
await fs.rm(pidFilePath, { force: true });
|
|
130
|
+
};
|
|
131
|
+
// 12. Signal handlers
|
|
132
|
+
for (const sig of ["SIGTERM", "SIGINT"]) {
|
|
133
|
+
process.on(sig, async () => {
|
|
134
|
+
console.log(`Received ${sig}, shutting down...`);
|
|
135
|
+
await shutdown();
|
|
136
|
+
process.exit(0);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
console.log(`agentctl daemon started (PID ${process.pid})`);
|
|
140
|
+
console.log(` Socket: ${sockPath}`);
|
|
141
|
+
console.log(` Metrics: http://localhost:${metricsPort}/metrics`);
|
|
142
|
+
return { socketServer, httpServer, shutdown };
|
|
143
|
+
}
|
|
144
|
+
function createRequestHandler(ctx) {
|
|
145
|
+
return async (req) => {
|
|
146
|
+
const params = (req.params || {});
|
|
147
|
+
switch (req.method) {
|
|
148
|
+
case "session.list":
|
|
149
|
+
return ctx.sessionTracker.listSessions({
|
|
150
|
+
status: params.status,
|
|
151
|
+
all: params.all,
|
|
152
|
+
});
|
|
153
|
+
case "session.status": {
|
|
154
|
+
const session = ctx.sessionTracker.getSession(params.id);
|
|
155
|
+
if (!session)
|
|
156
|
+
throw new Error(`Session not found: ${params.id}`);
|
|
157
|
+
return session;
|
|
158
|
+
}
|
|
159
|
+
case "session.peek": {
|
|
160
|
+
const adapterName = params.adapter || "claude-code";
|
|
161
|
+
const adapter = ctx.adapters[adapterName];
|
|
162
|
+
if (!adapter)
|
|
163
|
+
throw new Error(`Unknown adapter: ${adapterName}`);
|
|
164
|
+
return adapter.peek(params.id, {
|
|
165
|
+
lines: params.lines,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
case "session.launch": {
|
|
169
|
+
const cwd = params.cwd;
|
|
170
|
+
// Check locks
|
|
171
|
+
const lock = ctx.lockManager.check(cwd);
|
|
172
|
+
if (lock && !params.force) {
|
|
173
|
+
if (lock.type === "manual") {
|
|
174
|
+
throw new Error(`Directory locked by ${lock.lockedBy}: ${lock.reason}. Use --force to override.`);
|
|
175
|
+
}
|
|
176
|
+
throw new Error(`Directory in use by session ${lock.sessionId?.slice(0, 8)}. Use --force to override.`);
|
|
177
|
+
}
|
|
178
|
+
// Cancel any pending fuse
|
|
179
|
+
if (cwd) {
|
|
180
|
+
ctx.fuseEngine.cancelFuse(cwd);
|
|
181
|
+
}
|
|
182
|
+
// Launch via adapter
|
|
183
|
+
const adapterName = params.adapter || "claude-code";
|
|
184
|
+
const adapter = ctx.adapters[adapterName];
|
|
185
|
+
if (!adapter)
|
|
186
|
+
throw new Error(`Unknown adapter: ${adapterName}`);
|
|
187
|
+
const session = await adapter.launch({
|
|
188
|
+
adapter: adapterName,
|
|
189
|
+
prompt: params.prompt,
|
|
190
|
+
cwd,
|
|
191
|
+
spec: params.spec,
|
|
192
|
+
model: params.model,
|
|
193
|
+
env: params.env,
|
|
194
|
+
adapterOpts: params.adapterOpts,
|
|
195
|
+
});
|
|
196
|
+
const record = ctx.sessionTracker.track(session, adapterName);
|
|
197
|
+
// Auto-lock
|
|
198
|
+
if (cwd) {
|
|
199
|
+
ctx.lockManager.autoLock(cwd, session.id);
|
|
200
|
+
}
|
|
201
|
+
return record;
|
|
202
|
+
}
|
|
203
|
+
case "session.stop": {
|
|
204
|
+
const session = ctx.sessionTracker.getSession(params.id);
|
|
205
|
+
if (!session)
|
|
206
|
+
throw new Error(`Session not found: ${params.id}`);
|
|
207
|
+
const adapter = ctx.adapters[session.adapter];
|
|
208
|
+
if (!adapter)
|
|
209
|
+
throw new Error(`Unknown adapter: ${session.adapter}`);
|
|
210
|
+
await adapter.stop(session.id, {
|
|
211
|
+
force: params.force,
|
|
212
|
+
});
|
|
213
|
+
// Remove auto-lock
|
|
214
|
+
ctx.lockManager.autoUnlock(session.id);
|
|
215
|
+
// Mark stopped and start fuse if applicable
|
|
216
|
+
const stopped = ctx.sessionTracker.onSessionExit(session.id);
|
|
217
|
+
if (stopped) {
|
|
218
|
+
ctx.fuseEngine.onSessionExit(stopped);
|
|
219
|
+
ctx.metrics.recordSessionStopped();
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
case "session.resume": {
|
|
224
|
+
const session = ctx.sessionTracker.getSession(params.id);
|
|
225
|
+
if (!session)
|
|
226
|
+
throw new Error(`Session not found: ${params.id}`);
|
|
227
|
+
const adapter = ctx.adapters[session.adapter];
|
|
228
|
+
if (!adapter)
|
|
229
|
+
throw new Error(`Unknown adapter: ${session.adapter}`);
|
|
230
|
+
await adapter.resume(session.id, params.message);
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
case "lock.list":
|
|
234
|
+
return ctx.lockManager.listAll();
|
|
235
|
+
case "lock.acquire":
|
|
236
|
+
return ctx.lockManager.manualLock(params.directory, params.by, params.reason);
|
|
237
|
+
case "lock.release":
|
|
238
|
+
ctx.lockManager.manualUnlock(params.directory);
|
|
239
|
+
return null;
|
|
240
|
+
case "fuse.list":
|
|
241
|
+
return ctx.fuseEngine.listActive();
|
|
242
|
+
case "fuse.cancel":
|
|
243
|
+
ctx.fuseEngine.cancelFuse(params.directory);
|
|
244
|
+
return null;
|
|
245
|
+
case "daemon.status":
|
|
246
|
+
return {
|
|
247
|
+
pid: process.pid,
|
|
248
|
+
uptime: Date.now() - startTime,
|
|
249
|
+
sessions: ctx.sessionTracker.activeCount(),
|
|
250
|
+
locks: ctx.lockManager.listAll().length,
|
|
251
|
+
fuses: ctx.fuseEngine.listActive().length,
|
|
252
|
+
};
|
|
253
|
+
case "daemon.shutdown":
|
|
254
|
+
// Graceful shutdown — defer so response can be sent first
|
|
255
|
+
setTimeout(async () => {
|
|
256
|
+
await ctx.state.persist();
|
|
257
|
+
process.exit(0);
|
|
258
|
+
}, 100);
|
|
259
|
+
return null;
|
|
260
|
+
default:
|
|
261
|
+
throw new Error(`Unknown method: ${req.method}`);
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
// --- Helpers ---
|
|
266
|
+
async function readPidFile(pidFilePath) {
|
|
267
|
+
try {
|
|
268
|
+
const raw = await fs.readFile(pidFilePath, "utf-8");
|
|
269
|
+
return Number.parseInt(raw.trim(), 10);
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
function isProcessAlive(pid) {
|
|
276
|
+
try {
|
|
277
|
+
process.kill(pid, 0);
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { AgentAdapter, AgentSession } from "../core/types.js";
|
|
2
|
+
import type { SessionRecord, StateManager } from "./state.js";
|
|
3
|
+
export interface SessionTrackerOpts {
|
|
4
|
+
adapters: Record<string, AgentAdapter>;
|
|
5
|
+
pollIntervalMs?: number;
|
|
6
|
+
}
|
|
7
|
+
export declare class SessionTracker {
|
|
8
|
+
private state;
|
|
9
|
+
private adapters;
|
|
10
|
+
private pollIntervalMs;
|
|
11
|
+
private pollHandle;
|
|
12
|
+
constructor(state: StateManager, opts: SessionTrackerOpts);
|
|
13
|
+
startPolling(): void;
|
|
14
|
+
stopPolling(): void;
|
|
15
|
+
private poll;
|
|
16
|
+
/** Track a newly launched session */
|
|
17
|
+
track(session: AgentSession, adapterName: string): SessionRecord;
|
|
18
|
+
/** Get session record by id (exact or prefix) */
|
|
19
|
+
getSession(id: string): SessionRecord | undefined;
|
|
20
|
+
/** List all tracked sessions */
|
|
21
|
+
listSessions(opts?: {
|
|
22
|
+
status?: string;
|
|
23
|
+
all?: boolean;
|
|
24
|
+
}): SessionRecord[];
|
|
25
|
+
activeCount(): number;
|
|
26
|
+
/** Called when a session stops — returns the cwd for fuse/lock processing */
|
|
27
|
+
onSessionExit(sessionId: string): SessionRecord | undefined;
|
|
28
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
export class SessionTracker {
|
|
2
|
+
state;
|
|
3
|
+
adapters;
|
|
4
|
+
pollIntervalMs;
|
|
5
|
+
pollHandle = null;
|
|
6
|
+
constructor(state, opts) {
|
|
7
|
+
this.state = state;
|
|
8
|
+
this.adapters = opts.adapters;
|
|
9
|
+
this.pollIntervalMs = opts.pollIntervalMs ?? 5000;
|
|
10
|
+
}
|
|
11
|
+
startPolling() {
|
|
12
|
+
if (this.pollHandle)
|
|
13
|
+
return;
|
|
14
|
+
// Initial poll
|
|
15
|
+
this.poll().catch((err) => console.error("Poll error:", err));
|
|
16
|
+
this.pollHandle = setInterval(() => {
|
|
17
|
+
this.poll().catch((err) => console.error("Poll error:", err));
|
|
18
|
+
}, this.pollIntervalMs);
|
|
19
|
+
}
|
|
20
|
+
stopPolling() {
|
|
21
|
+
if (this.pollHandle) {
|
|
22
|
+
clearInterval(this.pollHandle);
|
|
23
|
+
this.pollHandle = null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async poll() {
|
|
27
|
+
for (const [adapterName, adapter] of Object.entries(this.adapters)) {
|
|
28
|
+
try {
|
|
29
|
+
const sessions = await adapter.list({ all: true });
|
|
30
|
+
for (const session of sessions) {
|
|
31
|
+
const existing = this.state.getSession(session.id);
|
|
32
|
+
const record = sessionToRecord(session, adapterName);
|
|
33
|
+
if (!existing) {
|
|
34
|
+
this.state.setSession(session.id, record);
|
|
35
|
+
}
|
|
36
|
+
else if (existing.status !== record.status) {
|
|
37
|
+
// Status changed — update
|
|
38
|
+
this.state.setSession(session.id, {
|
|
39
|
+
...existing,
|
|
40
|
+
status: record.status,
|
|
41
|
+
stoppedAt: record.stoppedAt,
|
|
42
|
+
tokens: record.tokens,
|
|
43
|
+
cost: record.cost,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Adapter unavailable — skip
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Track a newly launched session */
|
|
54
|
+
track(session, adapterName) {
|
|
55
|
+
const record = sessionToRecord(session, adapterName);
|
|
56
|
+
this.state.setSession(session.id, record);
|
|
57
|
+
return record;
|
|
58
|
+
}
|
|
59
|
+
/** Get session record by id (exact or prefix) */
|
|
60
|
+
getSession(id) {
|
|
61
|
+
// Exact match
|
|
62
|
+
const exact = this.state.getSession(id);
|
|
63
|
+
if (exact)
|
|
64
|
+
return exact;
|
|
65
|
+
// Prefix match
|
|
66
|
+
const sessions = this.state.getSessions();
|
|
67
|
+
const matches = Object.entries(sessions).filter(([key]) => key.startsWith(id));
|
|
68
|
+
if (matches.length === 1)
|
|
69
|
+
return matches[0][1];
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
/** List all tracked sessions */
|
|
73
|
+
listSessions(opts) {
|
|
74
|
+
const sessions = Object.values(this.state.getSessions());
|
|
75
|
+
let filtered = sessions;
|
|
76
|
+
if (opts?.status) {
|
|
77
|
+
filtered = filtered.filter((s) => s.status === opts.status);
|
|
78
|
+
}
|
|
79
|
+
else if (!opts?.all) {
|
|
80
|
+
filtered = filtered.filter((s) => s.status === "running" || s.status === "idle");
|
|
81
|
+
}
|
|
82
|
+
return filtered.sort((a, b) => {
|
|
83
|
+
// Running first, then by recency
|
|
84
|
+
if (a.status === "running" && b.status !== "running")
|
|
85
|
+
return -1;
|
|
86
|
+
if (b.status === "running" && a.status !== "running")
|
|
87
|
+
return 1;
|
|
88
|
+
return new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime();
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
activeCount() {
|
|
92
|
+
return Object.values(this.state.getSessions()).filter((s) => s.status === "running" || s.status === "idle").length;
|
|
93
|
+
}
|
|
94
|
+
/** Called when a session stops — returns the cwd for fuse/lock processing */
|
|
95
|
+
onSessionExit(sessionId) {
|
|
96
|
+
const session = this.state.getSession(sessionId);
|
|
97
|
+
if (session) {
|
|
98
|
+
session.status = "stopped";
|
|
99
|
+
session.stoppedAt = new Date().toISOString();
|
|
100
|
+
this.state.setSession(sessionId, session);
|
|
101
|
+
}
|
|
102
|
+
return session;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function sessionToRecord(session, adapterName) {
|
|
106
|
+
return {
|
|
107
|
+
id: session.id,
|
|
108
|
+
adapter: adapterName,
|
|
109
|
+
status: session.status,
|
|
110
|
+
startedAt: session.startedAt.toISOString(),
|
|
111
|
+
stoppedAt: session.stoppedAt?.toISOString(),
|
|
112
|
+
cwd: session.cwd,
|
|
113
|
+
spec: session.spec,
|
|
114
|
+
model: session.model,
|
|
115
|
+
prompt: session.prompt,
|
|
116
|
+
tokens: session.tokens,
|
|
117
|
+
cost: session.cost,
|
|
118
|
+
pid: session.pid,
|
|
119
|
+
meta: session.meta,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export interface SessionRecord {
|
|
2
|
+
id: string;
|
|
3
|
+
adapter: string;
|
|
4
|
+
status: "running" | "idle" | "stopped" | "error" | "completed" | "failed" | "pending";
|
|
5
|
+
startedAt: string;
|
|
6
|
+
stoppedAt?: string;
|
|
7
|
+
cwd?: string;
|
|
8
|
+
spec?: string;
|
|
9
|
+
model?: string;
|
|
10
|
+
prompt?: string;
|
|
11
|
+
tokens?: {
|
|
12
|
+
in: number;
|
|
13
|
+
out: number;
|
|
14
|
+
};
|
|
15
|
+
cost?: number;
|
|
16
|
+
pid?: number;
|
|
17
|
+
exitCode?: number;
|
|
18
|
+
meta: Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
export interface Lock {
|
|
21
|
+
directory: string;
|
|
22
|
+
type: "auto" | "manual";
|
|
23
|
+
sessionId?: string;
|
|
24
|
+
lockedBy?: string;
|
|
25
|
+
reason?: string;
|
|
26
|
+
lockedAt: string;
|
|
27
|
+
}
|
|
28
|
+
export interface FuseTimer {
|
|
29
|
+
directory: string;
|
|
30
|
+
clusterName: string;
|
|
31
|
+
branch: string;
|
|
32
|
+
expiresAt: string;
|
|
33
|
+
sessionId: string;
|
|
34
|
+
}
|
|
35
|
+
export interface PersistedState {
|
|
36
|
+
sessions: Record<string, SessionRecord>;
|
|
37
|
+
locks: Lock[];
|
|
38
|
+
fuses: FuseTimer[];
|
|
39
|
+
version: number;
|
|
40
|
+
}
|
|
41
|
+
export declare class StateManager {
|
|
42
|
+
private state;
|
|
43
|
+
private configDir;
|
|
44
|
+
private persistTimer;
|
|
45
|
+
constructor(configDir: string, state?: PersistedState);
|
|
46
|
+
static load(configDir: string): Promise<StateManager>;
|
|
47
|
+
persist(): Promise<void>;
|
|
48
|
+
markDirty(): void;
|
|
49
|
+
getSessions(): Record<string, SessionRecord>;
|
|
50
|
+
getSession(id: string): SessionRecord | undefined;
|
|
51
|
+
setSession(id: string, session: SessionRecord): void;
|
|
52
|
+
removeSession(id: string): void;
|
|
53
|
+
getLocks(): Lock[];
|
|
54
|
+
addLock(lock: Lock): void;
|
|
55
|
+
removeLocks(predicate: (lock: Lock) => boolean): void;
|
|
56
|
+
getFuses(): FuseTimer[];
|
|
57
|
+
addFuse(fuse: FuseTimer): void;
|
|
58
|
+
removeFuse(directory: string): void;
|
|
59
|
+
/** Flush pending timer (for clean shutdown) */
|
|
60
|
+
flush(): void;
|
|
61
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const SCHEMA_VERSION = 1;
|
|
4
|
+
export class StateManager {
|
|
5
|
+
state;
|
|
6
|
+
configDir;
|
|
7
|
+
persistTimer = null;
|
|
8
|
+
constructor(configDir, state) {
|
|
9
|
+
this.configDir = configDir;
|
|
10
|
+
this.state = state || {
|
|
11
|
+
sessions: {},
|
|
12
|
+
locks: [],
|
|
13
|
+
fuses: [],
|
|
14
|
+
version: SCHEMA_VERSION,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
static async load(configDir) {
|
|
18
|
+
await fs.mkdir(configDir, { recursive: true });
|
|
19
|
+
const state = {
|
|
20
|
+
sessions: {},
|
|
21
|
+
locks: [],
|
|
22
|
+
fuses: [],
|
|
23
|
+
version: SCHEMA_VERSION,
|
|
24
|
+
};
|
|
25
|
+
// Load state.json
|
|
26
|
+
try {
|
|
27
|
+
const raw = await fs.readFile(path.join(configDir, "state.json"), "utf-8");
|
|
28
|
+
const parsed = JSON.parse(raw);
|
|
29
|
+
if (parsed.sessions)
|
|
30
|
+
state.sessions = parsed.sessions;
|
|
31
|
+
if (parsed.version)
|
|
32
|
+
state.version = parsed.version;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// First run or missing file
|
|
36
|
+
}
|
|
37
|
+
// Load locks.json
|
|
38
|
+
try {
|
|
39
|
+
const raw = await fs.readFile(path.join(configDir, "locks.json"), "utf-8");
|
|
40
|
+
state.locks = JSON.parse(raw);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// First run or missing file
|
|
44
|
+
}
|
|
45
|
+
// Load fuses.json
|
|
46
|
+
try {
|
|
47
|
+
const raw = await fs.readFile(path.join(configDir, "fuses.json"), "utf-8");
|
|
48
|
+
state.fuses = JSON.parse(raw);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// First run or missing file
|
|
52
|
+
}
|
|
53
|
+
return new StateManager(configDir, state);
|
|
54
|
+
}
|
|
55
|
+
async persist() {
|
|
56
|
+
if (this.persistTimer) {
|
|
57
|
+
clearTimeout(this.persistTimer);
|
|
58
|
+
this.persistTimer = null;
|
|
59
|
+
}
|
|
60
|
+
await fs.mkdir(this.configDir, { recursive: true });
|
|
61
|
+
// Atomic writes via write-to-tmp + rename
|
|
62
|
+
await atomicWrite(path.join(this.configDir, "state.json"), JSON.stringify({ sessions: this.state.sessions, version: this.state.version }, null, 2));
|
|
63
|
+
await atomicWrite(path.join(this.configDir, "locks.json"), JSON.stringify(this.state.locks, null, 2));
|
|
64
|
+
await atomicWrite(path.join(this.configDir, "fuses.json"), JSON.stringify(this.state.fuses, null, 2));
|
|
65
|
+
}
|
|
66
|
+
markDirty() {
|
|
67
|
+
if (!this.persistTimer) {
|
|
68
|
+
this.persistTimer = setTimeout(() => {
|
|
69
|
+
this.persist().catch((err) => console.error("Failed to persist state:", err));
|
|
70
|
+
}, 1000);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// --- Session accessors ---
|
|
74
|
+
getSessions() {
|
|
75
|
+
return this.state.sessions;
|
|
76
|
+
}
|
|
77
|
+
getSession(id) {
|
|
78
|
+
return this.state.sessions[id];
|
|
79
|
+
}
|
|
80
|
+
setSession(id, session) {
|
|
81
|
+
this.state.sessions[id] = session;
|
|
82
|
+
this.markDirty();
|
|
83
|
+
}
|
|
84
|
+
removeSession(id) {
|
|
85
|
+
delete this.state.sessions[id];
|
|
86
|
+
this.markDirty();
|
|
87
|
+
}
|
|
88
|
+
// --- Lock accessors ---
|
|
89
|
+
getLocks() {
|
|
90
|
+
return [...this.state.locks];
|
|
91
|
+
}
|
|
92
|
+
addLock(lock) {
|
|
93
|
+
this.state.locks.push(lock);
|
|
94
|
+
this.markDirty();
|
|
95
|
+
}
|
|
96
|
+
removeLocks(predicate) {
|
|
97
|
+
this.state.locks = this.state.locks.filter((l) => !predicate(l));
|
|
98
|
+
this.markDirty();
|
|
99
|
+
}
|
|
100
|
+
// --- Fuse accessors ---
|
|
101
|
+
getFuses() {
|
|
102
|
+
return [...this.state.fuses];
|
|
103
|
+
}
|
|
104
|
+
addFuse(fuse) {
|
|
105
|
+
// Remove existing fuse for same directory first
|
|
106
|
+
this.state.fuses = this.state.fuses.filter((f) => f.directory !== fuse.directory);
|
|
107
|
+
this.state.fuses.push(fuse);
|
|
108
|
+
this.markDirty();
|
|
109
|
+
}
|
|
110
|
+
removeFuse(directory) {
|
|
111
|
+
this.state.fuses = this.state.fuses.filter((f) => f.directory !== directory);
|
|
112
|
+
this.markDirty();
|
|
113
|
+
}
|
|
114
|
+
/** Flush pending timer (for clean shutdown) */
|
|
115
|
+
flush() {
|
|
116
|
+
if (this.persistTimer) {
|
|
117
|
+
clearTimeout(this.persistTimer);
|
|
118
|
+
this.persistTimer = null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async function atomicWrite(filePath, data) {
|
|
123
|
+
const tmpPath = `${filePath}.tmp`;
|
|
124
|
+
await fs.writeFile(tmpPath, data, "utf-8");
|
|
125
|
+
await fs.rename(tmpPath, filePath);
|
|
126
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface SupervisorOpts {
|
|
2
|
+
/** Path to Node.js executable */
|
|
3
|
+
nodePath: string;
|
|
4
|
+
/** Path to the CLI entry point */
|
|
5
|
+
cliPath: string;
|
|
6
|
+
/** Metrics port */
|
|
7
|
+
metricsPort: number;
|
|
8
|
+
/** Config directory (~/.agentctl) */
|
|
9
|
+
configDir: string;
|
|
10
|
+
/** Minimum backoff delay in ms (default: 1000) */
|
|
11
|
+
minBackoffMs?: number;
|
|
12
|
+
/** Maximum backoff delay in ms (default: 300000 = 5min) */
|
|
13
|
+
maxBackoffMs?: number;
|
|
14
|
+
/** Reset backoff after this many ms of uptime (default: 60000 = 1min) */
|
|
15
|
+
stableUptimeMs?: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Daemon supervisor — launches the daemon in foreground mode and
|
|
19
|
+
* restarts it on crash with exponential backoff (1s, 2s, 4s... cap 5min).
|
|
20
|
+
* Resets backoff after stable uptime.
|
|
21
|
+
*/
|
|
22
|
+
export declare function runSupervisor(opts: SupervisorOpts): Promise<void>;
|
|
23
|
+
/** Read the supervisor PID from disk and check if it's alive */
|
|
24
|
+
export declare function getSupervisorPid(configDir?: string): Promise<number | null>;
|