@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
package/dist/cli.js ADDED
@@ -0,0 +1,808 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { Command } from "commander";
8
+ import { ClaudeCodeAdapter } from "./adapters/claude-code.js";
9
+ import { OpenClawAdapter } from "./adapters/openclaw.js";
10
+ import { DaemonClient } from "./client/daemon-client.js";
11
+ import { runHook } from "./hooks.js";
12
+ import { mergeSession } from "./merge.js";
13
+ import { createWorktree } from "./worktree.js";
14
+ const adapters = {
15
+ "claude-code": new ClaudeCodeAdapter(),
16
+ openclaw: new OpenClawAdapter(),
17
+ };
18
+ const client = new DaemonClient();
19
+ /**
20
+ * Ensure the daemon is running. Auto-starts it if not.
21
+ * Returns true if daemon is available after the call.
22
+ */
23
+ async function ensureDaemon() {
24
+ if (await client.isRunning())
25
+ return true;
26
+ // Auto-start daemon in background
27
+ try {
28
+ const __filename = fileURLToPath(import.meta.url);
29
+ const logDir = path.join(os.homedir(), ".agentctl");
30
+ await fs.mkdir(logDir, { recursive: true });
31
+ const child = spawn(process.execPath, [__filename, "daemon", "start", "--supervised"], {
32
+ detached: true,
33
+ stdio: [
34
+ "ignore",
35
+ (await fs.open(path.join(logDir, "daemon.stdout.log"), "a")).fd,
36
+ (await fs.open(path.join(logDir, "daemon.stderr.log"), "a")).fd,
37
+ ],
38
+ });
39
+ child.unref();
40
+ // Wait briefly for daemon to be ready (up to 3s)
41
+ for (let i = 0; i < 30; i++) {
42
+ await new Promise((r) => setTimeout(r, 100));
43
+ if (await client.isRunning())
44
+ return true;
45
+ }
46
+ }
47
+ catch {
48
+ // Failed to auto-start — fall through to direct mode
49
+ }
50
+ return false;
51
+ }
52
+ function getAdapter(name) {
53
+ if (!name) {
54
+ return adapters["claude-code"];
55
+ }
56
+ const adapter = adapters[name];
57
+ if (!adapter) {
58
+ console.error(`Unknown adapter: ${name}`);
59
+ process.exit(1);
60
+ }
61
+ return adapter;
62
+ }
63
+ function getAllAdapters() {
64
+ return Object.values(adapters);
65
+ }
66
+ // --- Formatters ---
67
+ function formatSession(s) {
68
+ return {
69
+ ID: s.id.slice(0, 8),
70
+ Status: s.status,
71
+ Model: s.model || "-",
72
+ CWD: s.cwd ? shortenPath(s.cwd) : "-",
73
+ PID: s.pid?.toString() || "-",
74
+ Started: timeAgo(s.startedAt),
75
+ Prompt: (s.prompt || "-").slice(0, 60),
76
+ };
77
+ }
78
+ function formatRecord(s) {
79
+ return {
80
+ ID: s.id.slice(0, 8),
81
+ Status: s.status,
82
+ Model: s.model || "-",
83
+ CWD: s.cwd ? shortenPath(s.cwd) : "-",
84
+ PID: s.pid?.toString() || "-",
85
+ Started: timeAgo(new Date(s.startedAt)),
86
+ Prompt: (s.prompt || "-").slice(0, 60),
87
+ };
88
+ }
89
+ function shortenPath(p) {
90
+ const home = process.env.HOME || "";
91
+ if (p.startsWith(home))
92
+ return `~${p.slice(home.length)}`;
93
+ return p;
94
+ }
95
+ function timeAgo(date) {
96
+ const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
97
+ if (seconds < 60)
98
+ return `${seconds}s ago`;
99
+ const minutes = Math.floor(seconds / 60);
100
+ if (minutes < 60)
101
+ return `${minutes}m ago`;
102
+ const hours = Math.floor(minutes / 60);
103
+ if (hours < 24)
104
+ return `${hours}h ago`;
105
+ const days = Math.floor(hours / 24);
106
+ return `${days}d ago`;
107
+ }
108
+ function formatDuration(ms) {
109
+ if (ms < 0)
110
+ return "expired";
111
+ const seconds = Math.floor(ms / 1000);
112
+ if (seconds < 60)
113
+ return `${seconds}s`;
114
+ const minutes = Math.floor(seconds / 60);
115
+ if (minutes < 60)
116
+ return `${minutes}m`;
117
+ const hours = Math.floor(minutes / 60);
118
+ return `${hours}h ${minutes % 60}m`;
119
+ }
120
+ function printTable(rows) {
121
+ if (rows.length === 0) {
122
+ console.log("No sessions found.");
123
+ return;
124
+ }
125
+ const keys = Object.keys(rows[0]);
126
+ const widths = keys.map((k) => Math.max(k.length, ...rows.map((r) => (r[k] || "").length)));
127
+ const header = keys.map((k, i) => k.padEnd(widths[i])).join(" ");
128
+ console.log(header);
129
+ console.log(widths.map((w) => "-".repeat(w)).join(" "));
130
+ for (const row of rows) {
131
+ const line = keys
132
+ .map((k, i) => (row[k] || "").padEnd(widths[i]))
133
+ .join(" ");
134
+ console.log(line);
135
+ }
136
+ }
137
+ function printJson(data) {
138
+ console.log(JSON.stringify(data, null, 2));
139
+ }
140
+ function sessionToJson(s) {
141
+ return {
142
+ id: s.id,
143
+ adapter: s.adapter,
144
+ status: s.status,
145
+ startedAt: s.startedAt.toISOString(),
146
+ stoppedAt: s.stoppedAt?.toISOString(),
147
+ cwd: s.cwd,
148
+ model: s.model,
149
+ prompt: s.prompt,
150
+ tokens: s.tokens,
151
+ cost: s.cost,
152
+ pid: s.pid,
153
+ meta: s.meta,
154
+ };
155
+ }
156
+ // --- CLI ---
157
+ const program = new Command();
158
+ program
159
+ .name("agentctl")
160
+ .description("Universal agent supervision interface")
161
+ .version("0.3.0");
162
+ // list
163
+ program
164
+ .command("list")
165
+ .description("List agent sessions")
166
+ .option("--adapter <name>", "Filter by adapter")
167
+ .option("--status <status>", "Filter by status (running|stopped|idle|error)")
168
+ .option("-a, --all", "Include stopped sessions (last 7 days)")
169
+ .option("--json", "Output as JSON")
170
+ .action(async (opts) => {
171
+ const daemonRunning = await ensureDaemon();
172
+ if (daemonRunning) {
173
+ const sessions = await client.call("session.list", {
174
+ status: opts.status,
175
+ all: opts.all,
176
+ });
177
+ if (opts.json) {
178
+ printJson(sessions);
179
+ }
180
+ else {
181
+ printTable(sessions.map(formatRecord));
182
+ }
183
+ return;
184
+ }
185
+ // Direct fallback
186
+ const listOpts = { status: opts.status, all: opts.all };
187
+ let sessions = [];
188
+ if (opts.adapter) {
189
+ const adapter = getAdapter(opts.adapter);
190
+ sessions = await adapter.list(listOpts);
191
+ }
192
+ else {
193
+ for (const adapter of getAllAdapters()) {
194
+ const s = await adapter.list(listOpts);
195
+ sessions.push(...s);
196
+ }
197
+ }
198
+ if (opts.json) {
199
+ printJson(sessions.map(sessionToJson));
200
+ }
201
+ else {
202
+ printTable(sessions.map(formatSession));
203
+ }
204
+ });
205
+ // status
206
+ program
207
+ .command("status <id>")
208
+ .description("Show detailed session status")
209
+ .option("--adapter <name>", "Adapter to use")
210
+ .option("--json", "Output as JSON")
211
+ .action(async (id, opts) => {
212
+ const daemonRunning = await ensureDaemon();
213
+ if (daemonRunning) {
214
+ try {
215
+ const session = await client.call("session.status", {
216
+ id,
217
+ });
218
+ if (opts.json) {
219
+ printJson(session);
220
+ }
221
+ else {
222
+ const fmt = formatRecord(session);
223
+ for (const [k, v] of Object.entries(fmt)) {
224
+ console.log(`${k.padEnd(10)} ${v}`);
225
+ }
226
+ if (session.tokens) {
227
+ console.log(`Tokens in: ${session.tokens.in}, out: ${session.tokens.out}`);
228
+ }
229
+ }
230
+ return;
231
+ }
232
+ catch (err) {
233
+ console.error(err.message);
234
+ process.exit(1);
235
+ }
236
+ }
237
+ // Direct fallback
238
+ const adapter = getAdapter(opts.adapter);
239
+ try {
240
+ const session = await adapter.status(id);
241
+ if (opts.json) {
242
+ printJson(sessionToJson(session));
243
+ }
244
+ else {
245
+ const fmt = formatSession(session);
246
+ for (const [k, v] of Object.entries(fmt)) {
247
+ console.log(`${k.padEnd(10)} ${v}`);
248
+ }
249
+ if (session.tokens) {
250
+ console.log(`Tokens in: ${session.tokens.in}, out: ${session.tokens.out}`);
251
+ }
252
+ }
253
+ }
254
+ catch (err) {
255
+ console.error(err.message);
256
+ process.exit(1);
257
+ }
258
+ });
259
+ // peek
260
+ program
261
+ .command("peek <id>")
262
+ .description("Peek at recent output from a session")
263
+ .option("-n, --lines <n>", "Number of recent messages", "20")
264
+ .option("--adapter <name>", "Adapter to use")
265
+ .action(async (id, opts) => {
266
+ const daemonRunning = await ensureDaemon();
267
+ if (daemonRunning) {
268
+ try {
269
+ const output = await client.call("session.peek", {
270
+ id,
271
+ lines: Number.parseInt(opts.lines, 10),
272
+ });
273
+ console.log(output);
274
+ return;
275
+ }
276
+ catch (err) {
277
+ console.error(err.message);
278
+ process.exit(1);
279
+ }
280
+ }
281
+ const adapter = getAdapter(opts.adapter);
282
+ try {
283
+ const output = await adapter.peek(id, {
284
+ lines: Number.parseInt(opts.lines, 10),
285
+ });
286
+ console.log(output);
287
+ }
288
+ catch (err) {
289
+ console.error(err.message);
290
+ process.exit(1);
291
+ }
292
+ });
293
+ // stop
294
+ program
295
+ .command("stop <id>")
296
+ .description("Stop a running session")
297
+ .option("--force", "Force kill (SIGINT then SIGKILL)")
298
+ .option("--adapter <name>", "Adapter to use")
299
+ .action(async (id, opts) => {
300
+ const daemonRunning = await ensureDaemon();
301
+ if (daemonRunning) {
302
+ try {
303
+ await client.call("session.stop", {
304
+ id,
305
+ force: opts.force,
306
+ });
307
+ console.log(`Stopped session ${id.slice(0, 8)}`);
308
+ return;
309
+ }
310
+ catch (err) {
311
+ console.error(err.message);
312
+ process.exit(1);
313
+ }
314
+ }
315
+ const adapter = getAdapter(opts.adapter);
316
+ try {
317
+ await adapter.stop(id, { force: opts.force });
318
+ console.log(`Stopped session ${id.slice(0, 8)}`);
319
+ }
320
+ catch (err) {
321
+ console.error(err.message);
322
+ process.exit(1);
323
+ }
324
+ });
325
+ // resume
326
+ program
327
+ .command("resume <id> <message>")
328
+ .description("Resume a session with a new message")
329
+ .option("--adapter <name>", "Adapter to use")
330
+ .action(async (id, message, opts) => {
331
+ const daemonRunning = await ensureDaemon();
332
+ if (daemonRunning) {
333
+ try {
334
+ await client.call("session.resume", { id, message });
335
+ console.log(`Resumed session ${id.slice(0, 8)}`);
336
+ return;
337
+ }
338
+ catch (err) {
339
+ console.error(err.message);
340
+ process.exit(1);
341
+ }
342
+ }
343
+ const adapter = getAdapter(opts.adapter);
344
+ try {
345
+ await adapter.resume(id, message);
346
+ console.log(`Resumed session ${id.slice(0, 8)}`);
347
+ }
348
+ catch (err) {
349
+ console.error(err.message);
350
+ process.exit(1);
351
+ }
352
+ });
353
+ // launch
354
+ program
355
+ .command("launch [adapter]")
356
+ .description("Launch a new agent session")
357
+ .requiredOption("-p, --prompt <text>", "Prompt to send")
358
+ .option("--spec <path>", "Spec file path")
359
+ .option("--cwd <dir>", "Working directory")
360
+ .option("--model <model>", "Model to use (e.g. sonnet, opus)")
361
+ .option("--force", "Override directory locks")
362
+ .option("--worktree <repo>", "Auto-create git worktree from this repo before launch")
363
+ .option("--branch <name>", "Branch name for --worktree")
364
+ .option("--on-create <script>", "Hook: run after session is created")
365
+ .option("--on-complete <script>", "Hook: run after session completes")
366
+ .option("--pre-merge <script>", "Hook: run before merge")
367
+ .option("--post-merge <script>", "Hook: run after merge")
368
+ .action(async (adapterName, opts) => {
369
+ let cwd = opts.cwd ? path.resolve(opts.cwd) : process.cwd();
370
+ const name = adapterName || "claude-code";
371
+ // FEAT-1: Worktree lifecycle
372
+ let worktreeInfo;
373
+ if (opts.worktree) {
374
+ if (!opts.branch) {
375
+ console.error("--branch is required when using --worktree");
376
+ process.exit(1);
377
+ }
378
+ try {
379
+ worktreeInfo = await createWorktree({
380
+ repo: opts.worktree,
381
+ branch: opts.branch,
382
+ });
383
+ cwd = worktreeInfo.path;
384
+ console.log(`Worktree created: ${worktreeInfo.path}`);
385
+ }
386
+ catch (err) {
387
+ console.error(`Failed to create worktree: ${err.message}`);
388
+ process.exit(1);
389
+ }
390
+ }
391
+ // Collect hooks
392
+ const hooks = opts.onCreate || opts.onComplete || opts.preMerge || opts.postMerge
393
+ ? {
394
+ onCreate: opts.onCreate,
395
+ onComplete: opts.onComplete,
396
+ preMerge: opts.preMerge,
397
+ postMerge: opts.postMerge,
398
+ }
399
+ : undefined;
400
+ const daemonRunning = await ensureDaemon();
401
+ if (daemonRunning) {
402
+ try {
403
+ const session = await client.call("session.launch", {
404
+ adapter: name,
405
+ prompt: opts.prompt,
406
+ cwd,
407
+ spec: opts.spec,
408
+ model: opts.model,
409
+ force: opts.force,
410
+ worktree: worktreeInfo
411
+ ? { repo: worktreeInfo.repo, branch: worktreeInfo.branch }
412
+ : undefined,
413
+ hooks,
414
+ });
415
+ console.log(`Launched session ${session.id.slice(0, 8)} (PID: ${session.pid})`);
416
+ // Run onCreate hook
417
+ if (hooks?.onCreate) {
418
+ await runHook(hooks, "onCreate", {
419
+ sessionId: session.id,
420
+ cwd,
421
+ adapter: name,
422
+ branch: opts.branch,
423
+ });
424
+ }
425
+ return;
426
+ }
427
+ catch (err) {
428
+ console.error(err.message);
429
+ process.exit(1);
430
+ }
431
+ }
432
+ // Direct fallback
433
+ if (!opts.force) {
434
+ console.error("Warning: Daemon not running, launching without lock protection");
435
+ }
436
+ const adapter = getAdapter(name);
437
+ try {
438
+ const session = await adapter.launch({
439
+ adapter: name,
440
+ prompt: opts.prompt,
441
+ spec: opts.spec,
442
+ cwd,
443
+ model: opts.model,
444
+ hooks,
445
+ });
446
+ console.log(`Launched session ${session.id.slice(0, 8)} (PID: ${session.pid})`);
447
+ // Run onCreate hook
448
+ if (hooks?.onCreate) {
449
+ await runHook(hooks, "onCreate", {
450
+ sessionId: session.id,
451
+ cwd,
452
+ adapter: name,
453
+ branch: opts.branch,
454
+ });
455
+ }
456
+ }
457
+ catch (err) {
458
+ console.error(err.message);
459
+ process.exit(1);
460
+ }
461
+ });
462
+ // events
463
+ program
464
+ .command("events")
465
+ .description("Stream lifecycle events")
466
+ .option("--json", "Output as NDJSON (default)")
467
+ .action(async () => {
468
+ const adapter = getAdapter("claude-code");
469
+ for await (const event of adapter.events()) {
470
+ const out = {
471
+ type: event.type,
472
+ adapter: event.adapter,
473
+ sessionId: event.sessionId,
474
+ timestamp: event.timestamp.toISOString(),
475
+ session: sessionToJson(event.session),
476
+ meta: event.meta,
477
+ };
478
+ console.log(JSON.stringify(out));
479
+ }
480
+ });
481
+ // --- Merge command (FEAT-4) ---
482
+ program
483
+ .command("merge <id>")
484
+ .description("Commit, push, and open PR for a session's work")
485
+ .option("-m, --message <text>", "Commit message")
486
+ .option("--remove-worktree", "Remove worktree after merge")
487
+ .option("--repo <path>", "Main repo path (for worktree removal)")
488
+ .option("--pre-merge <script>", "Hook: run before merge")
489
+ .option("--post-merge <script>", "Hook: run after merge")
490
+ .action(async (id, opts) => {
491
+ // Find session
492
+ const daemonRunning = await ensureDaemon();
493
+ let sessionCwd;
494
+ let sessionAdapter;
495
+ if (daemonRunning) {
496
+ try {
497
+ const session = await client.call("session.status", {
498
+ id,
499
+ });
500
+ sessionCwd = session.cwd;
501
+ sessionAdapter = session.adapter;
502
+ }
503
+ catch {
504
+ // Fall through to adapter
505
+ }
506
+ }
507
+ if (!sessionCwd) {
508
+ const adapter = getAdapter();
509
+ try {
510
+ const session = await adapter.status(id);
511
+ sessionCwd = session.cwd;
512
+ sessionAdapter = session.adapter;
513
+ }
514
+ catch (err) {
515
+ console.error(`Session not found: ${err.message}`);
516
+ process.exit(1);
517
+ }
518
+ }
519
+ if (!sessionCwd) {
520
+ console.error("Cannot determine session working directory");
521
+ process.exit(1);
522
+ }
523
+ const hooks = opts.preMerge || opts.postMerge
524
+ ? { preMerge: opts.preMerge, postMerge: opts.postMerge }
525
+ : undefined;
526
+ // Pre-merge hook
527
+ if (hooks?.preMerge) {
528
+ await runHook(hooks, "preMerge", {
529
+ sessionId: id,
530
+ cwd: sessionCwd,
531
+ adapter: sessionAdapter || "claude-code",
532
+ });
533
+ }
534
+ const result = await mergeSession({
535
+ cwd: sessionCwd,
536
+ message: opts.message,
537
+ removeWorktree: opts.removeWorktree,
538
+ repoPath: opts.repo,
539
+ });
540
+ if (result.committed)
541
+ console.log("Changes committed");
542
+ if (result.pushed)
543
+ console.log("Pushed to remote");
544
+ if (result.prUrl)
545
+ console.log(`PR: ${result.prUrl}`);
546
+ if (result.worktreeRemoved)
547
+ console.log("Worktree removed");
548
+ if (!result.committed && !result.pushed) {
549
+ console.log("No changes to commit or push");
550
+ }
551
+ // Post-merge hook
552
+ if (hooks?.postMerge) {
553
+ await runHook(hooks, "postMerge", {
554
+ sessionId: id,
555
+ cwd: sessionCwd,
556
+ adapter: sessionAdapter || "claude-code",
557
+ });
558
+ }
559
+ });
560
+ // --- Lock commands ---
561
+ program
562
+ .command("lock <directory>")
563
+ .description("Manually lock a directory")
564
+ .option("--by <name>", "Who is locking", os.userInfo().username)
565
+ .option("--reason <reason>", "Why")
566
+ .action(async (directory, opts) => {
567
+ const absDir = path.resolve(directory);
568
+ try {
569
+ await client.call("lock.acquire", {
570
+ directory: absDir,
571
+ by: opts.by,
572
+ reason: opts.reason,
573
+ });
574
+ console.log(`Locked: ${absDir}`);
575
+ }
576
+ catch (err) {
577
+ console.error(err.message);
578
+ process.exit(1);
579
+ }
580
+ });
581
+ program
582
+ .command("unlock <directory>")
583
+ .description("Unlock a manually locked directory")
584
+ .action(async (directory) => {
585
+ const absDir = path.resolve(directory);
586
+ try {
587
+ await client.call("lock.release", { directory: absDir });
588
+ console.log(`Unlocked: ${absDir}`);
589
+ }
590
+ catch (err) {
591
+ console.error(err.message);
592
+ process.exit(1);
593
+ }
594
+ });
595
+ program
596
+ .command("locks")
597
+ .description("List all directory locks")
598
+ .option("--json", "Output as JSON")
599
+ .action(async (opts) => {
600
+ try {
601
+ const locks = await client.call("lock.list");
602
+ if (opts.json) {
603
+ printJson(locks);
604
+ return;
605
+ }
606
+ if (locks.length === 0) {
607
+ console.log("No active locks");
608
+ return;
609
+ }
610
+ printTable(locks.map((l) => ({
611
+ Directory: shortenPath(l.directory),
612
+ Type: l.type,
613
+ "Locked By": l.lockedBy || l.sessionId?.slice(0, 8) || "-",
614
+ Reason: l.reason || "-",
615
+ Since: timeAgo(new Date(l.lockedAt)),
616
+ })));
617
+ }
618
+ catch (err) {
619
+ console.error(err.message);
620
+ process.exit(1);
621
+ }
622
+ });
623
+ // --- Fuses command ---
624
+ program
625
+ .command("fuses")
626
+ .description("List active Kind cluster fuse timers")
627
+ .option("--json", "Output as JSON")
628
+ .action(async (opts) => {
629
+ try {
630
+ const fuses = await client.call("fuse.list");
631
+ if (opts.json) {
632
+ printJson(fuses);
633
+ return;
634
+ }
635
+ if (fuses.length === 0) {
636
+ console.log("No active fuses");
637
+ return;
638
+ }
639
+ printTable(fuses.map((f) => ({
640
+ Directory: shortenPath(f.directory),
641
+ Cluster: f.clusterName,
642
+ "Expires In": formatDuration(new Date(f.expiresAt).getTime() - Date.now()),
643
+ })));
644
+ }
645
+ catch (err) {
646
+ console.error(err.message);
647
+ process.exit(1);
648
+ }
649
+ });
650
+ // --- Daemon subcommand ---
651
+ const daemonCmd = new Command("daemon").description("Manage the agentctl daemon");
652
+ daemonCmd
653
+ .command("start")
654
+ .description("Start the daemon")
655
+ .option("--foreground", "Run in foreground (don't daemonize)")
656
+ .option("--supervised", "Run under supervisor (auto-restart on crash)")
657
+ .option("--metrics-port <port>", "Prometheus metrics port", "9200")
658
+ .action(async (opts) => {
659
+ if (opts.foreground) {
660
+ // Foreground mode — import and start directly
661
+ const { startDaemon } = await import("./daemon/server.js");
662
+ await startDaemon({
663
+ metricsPort: Number(opts.metricsPort),
664
+ });
665
+ return;
666
+ }
667
+ if (opts.supervised) {
668
+ // Supervised mode — run supervisor loop in foreground (launched detached)
669
+ const { runSupervisor } = await import("./daemon/supervisor.js");
670
+ const __filename = fileURLToPath(import.meta.url);
671
+ await runSupervisor({
672
+ nodePath: process.execPath,
673
+ cliPath: __filename,
674
+ metricsPort: Number(opts.metricsPort),
675
+ configDir: path.join(os.homedir(), ".agentctl"),
676
+ });
677
+ return;
678
+ }
679
+ // Default: launch supervisor in background (detached)
680
+ const __filename = fileURLToPath(import.meta.url);
681
+ const logDir = path.join(os.homedir(), ".agentctl");
682
+ await fs.mkdir(logDir, { recursive: true });
683
+ const child = spawn(process.execPath, [
684
+ __filename,
685
+ "daemon",
686
+ "start",
687
+ "--supervised",
688
+ "--metrics-port",
689
+ opts.metricsPort,
690
+ ], {
691
+ detached: true,
692
+ stdio: [
693
+ "ignore",
694
+ (await fs.open(path.join(logDir, "daemon.stdout.log"), "a")).fd,
695
+ (await fs.open(path.join(logDir, "daemon.stderr.log"), "a")).fd,
696
+ ],
697
+ });
698
+ child.unref();
699
+ console.log(`Daemon started with supervisor (PID ${child.pid})`);
700
+ });
701
+ daemonCmd
702
+ .command("stop")
703
+ .description("Stop the daemon")
704
+ .action(async () => {
705
+ // Stop supervisor first (prevents auto-restart)
706
+ const { getSupervisorPid } = await import("./daemon/supervisor.js");
707
+ const supPid = await getSupervisorPid();
708
+ if (supPid) {
709
+ try {
710
+ process.kill(supPid, "SIGTERM");
711
+ }
712
+ catch {
713
+ // Already gone
714
+ }
715
+ }
716
+ try {
717
+ await client.call("daemon.shutdown");
718
+ console.log("Daemon stopped");
719
+ }
720
+ catch (err) {
721
+ console.error(err.message);
722
+ process.exit(1);
723
+ }
724
+ });
725
+ daemonCmd
726
+ .command("status")
727
+ .description("Show daemon status")
728
+ .action(async () => {
729
+ try {
730
+ const status = await client.call("daemon.status");
731
+ console.log(`Daemon running (PID ${status.pid})`);
732
+ console.log(` Uptime: ${formatDuration(status.uptime)}`);
733
+ console.log(` Active sessions: ${status.sessions}`);
734
+ console.log(` Active locks: ${status.locks}`);
735
+ console.log(` Active fuses: ${status.fuses}`);
736
+ }
737
+ catch {
738
+ console.log("Daemon not running");
739
+ }
740
+ });
741
+ daemonCmd
742
+ .command("restart")
743
+ .description("Restart the daemon")
744
+ .action(async () => {
745
+ try {
746
+ await client.call("daemon.shutdown");
747
+ }
748
+ catch {
749
+ // Daemon wasn't running — that's fine
750
+ }
751
+ // Also kill supervisor if running
752
+ const { getSupervisorPid } = await import("./daemon/supervisor.js");
753
+ const supPid = await getSupervisorPid();
754
+ if (supPid) {
755
+ try {
756
+ process.kill(supPid, "SIGTERM");
757
+ }
758
+ catch {
759
+ // Already gone
760
+ }
761
+ }
762
+ // Wait for old processes to exit
763
+ await new Promise((r) => setTimeout(r, 500));
764
+ // Start new daemon with supervisor
765
+ const __filename = fileURLToPath(import.meta.url);
766
+ const logDir = path.join(os.homedir(), ".agentctl");
767
+ await fs.mkdir(logDir, { recursive: true });
768
+ const child = spawn(process.execPath, [__filename, "daemon", "start", "--supervised"], {
769
+ detached: true,
770
+ stdio: [
771
+ "ignore",
772
+ (await fs.open(path.join(logDir, "daemon.stdout.log"), "a")).fd,
773
+ (await fs.open(path.join(logDir, "daemon.stderr.log"), "a")).fd,
774
+ ],
775
+ });
776
+ child.unref();
777
+ console.log(`Daemon restarted with supervisor (PID ${child.pid})`);
778
+ });
779
+ daemonCmd
780
+ .command("install")
781
+ .description("Install LaunchAgent (auto-start on login)")
782
+ .action(async () => {
783
+ const { generatePlist } = await import("./daemon/launchagent.js");
784
+ const plistPath = path.join(os.homedir(), "Library/LaunchAgents/com.agentctl.daemon.plist");
785
+ const plistContent = generatePlist();
786
+ await fs.mkdir(path.dirname(plistPath), { recursive: true });
787
+ await fs.writeFile(plistPath, plistContent);
788
+ const { execSync } = await import("node:child_process");
789
+ execSync(`launchctl load ${plistPath}`);
790
+ console.log("LaunchAgent installed. Daemon will start on login.");
791
+ });
792
+ daemonCmd
793
+ .command("uninstall")
794
+ .description("Remove LaunchAgent")
795
+ .action(async () => {
796
+ const plistPath = path.join(os.homedir(), "Library/LaunchAgents/com.agentctl.daemon.plist");
797
+ const { execSync } = await import("node:child_process");
798
+ try {
799
+ execSync(`launchctl unload ${plistPath}`);
800
+ }
801
+ catch {
802
+ // Already unloaded
803
+ }
804
+ await fs.rm(plistPath, { force: true });
805
+ console.log("LaunchAgent removed.");
806
+ });
807
+ program.addCommand(daemonCmd);
808
+ program.parse();