@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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +228 -0
  3. package/dist/adapters/claude-code.d.ts +83 -0
  4. package/dist/adapters/claude-code.js +783 -0
  5. package/dist/adapters/openclaw.d.ts +88 -0
  6. package/dist/adapters/openclaw.js +297 -0
  7. package/dist/cli.d.ts +2 -0
  8. package/dist/cli.js +808 -0
  9. package/dist/client/daemon-client.d.ts +6 -0
  10. package/dist/client/daemon-client.js +81 -0
  11. package/dist/compat-shim.d.ts +2 -0
  12. package/dist/compat-shim.js +15 -0
  13. package/dist/core/types.d.ts +68 -0
  14. package/dist/core/types.js +2 -0
  15. package/dist/daemon/fuse-engine.d.ts +30 -0
  16. package/dist/daemon/fuse-engine.js +118 -0
  17. package/dist/daemon/launchagent.d.ts +7 -0
  18. package/dist/daemon/launchagent.js +49 -0
  19. package/dist/daemon/lock-manager.d.ts +16 -0
  20. package/dist/daemon/lock-manager.js +71 -0
  21. package/dist/daemon/metrics.d.ts +20 -0
  22. package/dist/daemon/metrics.js +72 -0
  23. package/dist/daemon/server.d.ts +33 -0
  24. package/dist/daemon/server.js +283 -0
  25. package/dist/daemon/session-tracker.d.ts +28 -0
  26. package/dist/daemon/session-tracker.js +121 -0
  27. package/dist/daemon/state.d.ts +61 -0
  28. package/dist/daemon/state.js +126 -0
  29. package/dist/daemon/supervisor.d.ts +24 -0
  30. package/dist/daemon/supervisor.js +79 -0
  31. package/dist/hooks.d.ts +19 -0
  32. package/dist/hooks.js +39 -0
  33. package/dist/merge.d.ts +24 -0
  34. package/dist/merge.js +65 -0
  35. package/dist/migration/migrate-locks.d.ts +5 -0
  36. package/dist/migration/migrate-locks.js +41 -0
  37. package/dist/worktree.d.ts +24 -0
  38. package/dist/worktree.js +65 -0
  39. 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>;