@orgloop/agentctl 1.2.0 → 1.3.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.
@@ -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
@@ -31,6 +33,44 @@ export class PiAdapter {
31
33
  this.getPids = opts?.getPids || getPiPids;
32
34
  this.isProcessAlive = opts?.isProcessAlive || defaultIsProcessAlive;
33
35
  }
36
+ async discover() {
37
+ const runningPids = await this.getPids();
38
+ const discovered = await this.discoverSessions();
39
+ const results = [];
40
+ for (const disc of discovered) {
41
+ const isRunning = await this.isSessionRunning(disc, runningPids);
42
+ const { model, tokens, cost } = await this.parseSessionTail(disc.filePath, disc.header);
43
+ results.push({
44
+ id: disc.sessionId,
45
+ status: isRunning ? "running" : "stopped",
46
+ adapter: this.id,
47
+ cwd: disc.header.cwd,
48
+ model,
49
+ startedAt: disc.created,
50
+ stoppedAt: isRunning ? undefined : disc.modified,
51
+ pid: isRunning
52
+ ? await this.findMatchingPid(disc, runningPids)
53
+ : undefined,
54
+ prompt: await this.getFirstPrompt(disc.filePath),
55
+ tokens,
56
+ cost,
57
+ nativeMetadata: {
58
+ provider: disc.header.provider,
59
+ thinkingLevel: disc.header.thinkingLevel,
60
+ version: disc.header.version,
61
+ cwdSlug: disc.cwdSlug,
62
+ },
63
+ });
64
+ }
65
+ return results;
66
+ }
67
+ async isAlive(sessionId) {
68
+ const runningPids = await this.getPids();
69
+ const disc = await this.findSession(sessionId);
70
+ if (!disc)
71
+ return false;
72
+ return this.isSessionRunning(disc, runningPids);
73
+ }
34
74
  async list(opts) {
35
75
  const runningPids = await this.getPids();
36
76
  const discovered = await this.discoverSessions();
@@ -163,18 +203,22 @@ export class PiAdapter {
163
203
  if (opts.model) {
164
204
  args.unshift("--model", opts.model);
165
205
  }
166
- const env = { ...process.env, ...opts.env };
206
+ const env = buildSpawnEnv(undefined, opts.env);
167
207
  const cwd = opts.cwd || process.cwd();
168
208
  // Write stdout to a log file so we can extract the session ID
169
209
  await fs.mkdir(this.sessionsMetaDir, { recursive: true });
170
210
  const logPath = path.join(this.sessionsMetaDir, `launch-${Date.now()}.log`);
171
211
  const logFd = await fs.open(logPath, "w");
172
- const child = spawn("pi", args, {
212
+ const piPath = await resolveBinaryPath("pi");
213
+ const child = spawn(piPath, args, {
173
214
  cwd,
174
215
  env,
175
216
  stdio: ["ignore", logFd.fd, logFd.fd],
176
217
  detached: true,
177
218
  });
219
+ child.on("error", (err) => {
220
+ console.error(`[pi] spawn error: ${err.message}`);
221
+ });
178
222
  // Fully detach: child runs in its own process group.
179
223
  child.unref();
180
224
  const pid = child.pid;
@@ -331,11 +375,15 @@ export class PiAdapter {
331
375
  // Launch a new pi session in the same cwd with the continuation message.
332
376
  const disc = await this.findSession(sessionId);
333
377
  const cwd = disc?.header.cwd || process.cwd();
334
- const child = spawn("pi", ["-p", message], {
378
+ const piPath = await resolveBinaryPath("pi");
379
+ const child = spawn(piPath, ["-p", message], {
335
380
  cwd,
336
381
  stdio: ["pipe", "pipe", "pipe"],
337
382
  detached: true,
338
383
  });
384
+ child.on("error", (err) => {
385
+ console.error(`[pi] resume spawn error: ${err.message}`);
386
+ });
339
387
  child.unref();
340
388
  }
341
389
  async *events() {
package/dist/cli.js CHANGED
@@ -18,7 +18,6 @@ import { DaemonClient } from "./client/daemon-client.js";
18
18
  import { runHook } from "./hooks.js";
19
19
  import { orchestrateLaunch, parseAdapterSlots, } from "./launch-orchestrator.js";
20
20
  import { expandMatrix, parseMatrixFile } from "./matrix-parser.js";
21
- import { mergeSession } from "./merge.js";
22
21
  import { createWorktree } from "./worktree.js";
23
22
  const adapters = {
24
23
  "claude-code": new ClaudeCodeAdapter(),
@@ -84,6 +83,7 @@ function formatSession(s, showGroup) {
84
83
  const row = {
85
84
  ID: s.id.slice(0, 8),
86
85
  Status: s.status,
86
+ Adapter: s.adapter || "-",
87
87
  Model: s.model || "-",
88
88
  };
89
89
  if (showGroup)
@@ -94,10 +94,23 @@ function formatSession(s, showGroup) {
94
94
  row.Prompt = (s.prompt || "-").slice(0, 60);
95
95
  return row;
96
96
  }
97
+ function formatDiscovered(s) {
98
+ return {
99
+ ID: s.id.slice(0, 8),
100
+ Adapter: s.adapter,
101
+ Status: s.status,
102
+ Model: s.model || "-",
103
+ CWD: s.cwd ? shortenPath(s.cwd) : "-",
104
+ PID: s.pid?.toString() || "-",
105
+ Started: s.startedAt ? timeAgo(s.startedAt) : "-",
106
+ Prompt: (s.prompt || "-").slice(0, 60),
107
+ };
108
+ }
97
109
  function formatRecord(s, showGroup) {
98
110
  const row = {
99
111
  ID: s.id.slice(0, 8),
100
112
  Status: s.status,
113
+ Adapter: s.adapter || "-",
101
114
  Model: s.model || "-",
102
115
  };
103
116
  if (showGroup)
@@ -176,6 +189,22 @@ function sessionToJson(s) {
176
189
  meta: s.meta,
177
190
  };
178
191
  }
192
+ function discoveredToJson(s) {
193
+ return {
194
+ id: s.id,
195
+ adapter: s.adapter,
196
+ status: s.status,
197
+ startedAt: s.startedAt?.toISOString(),
198
+ stoppedAt: s.stoppedAt?.toISOString(),
199
+ cwd: s.cwd,
200
+ model: s.model,
201
+ prompt: s.prompt,
202
+ tokens: s.tokens,
203
+ cost: s.cost,
204
+ pid: s.pid,
205
+ nativeMetadata: s.nativeMetadata,
206
+ };
207
+ }
179
208
  // --- CLI ---
180
209
  const program = new Command();
181
210
  program
@@ -212,25 +241,40 @@ program
212
241
  }
213
242
  return;
214
243
  }
215
- // Direct fallback
216
- const listOpts = { status: opts.status, all: opts.all };
217
- let sessions = [];
244
+ // Direct fallback — discover-first
245
+ let discovered = [];
218
246
  if (opts.adapter) {
219
247
  const adapter = getAdapter(opts.adapter);
220
- sessions = await adapter.list(listOpts);
248
+ discovered = await adapter.discover();
221
249
  }
222
250
  else {
223
251
  for (const adapter of getAllAdapters()) {
224
- const s = await adapter.list(listOpts);
225
- sessions.push(...s);
252
+ const d = await adapter.discover().catch(() => []);
253
+ discovered.push(...d);
226
254
  }
227
255
  }
256
+ // Apply status/all filters
257
+ if (opts.status) {
258
+ discovered = discovered.filter((s) => s.status === opts.status);
259
+ }
260
+ else if (!opts.all) {
261
+ discovered = discovered.filter((s) => s.status === "running");
262
+ }
263
+ // Sort: running first, then by most recent
264
+ discovered.sort((a, b) => {
265
+ if (a.status === "running" && b.status !== "running")
266
+ return -1;
267
+ if (b.status === "running" && a.status !== "running")
268
+ return 1;
269
+ const aTime = a.startedAt?.getTime() ?? 0;
270
+ const bTime = b.startedAt?.getTime() ?? 0;
271
+ return bTime - aTime;
272
+ });
228
273
  if (opts.json) {
229
- printJson(sessions.map(sessionToJson));
274
+ printJson(discovered.map(discoveredToJson));
230
275
  }
231
276
  else {
232
- const hasGroups = sessions.some((s) => s.group);
233
- printTable(sessions.map((s) => formatSession(s, hasGroups)));
277
+ printTable(discovered.map((s) => formatDiscovered(s)));
234
278
  }
235
279
  });
236
280
  // status
@@ -418,18 +462,14 @@ program
418
462
  .option("--matrix <file>", "YAML matrix file for advanced sweep launch")
419
463
  .option("--on-create <script>", "Hook: run after session is created")
420
464
  .option("--on-complete <script>", "Hook: run after session completes")
421
- .option("--pre-merge <script>", "Hook: run before merge")
422
- .option("--post-merge <script>", "Hook: run after merge")
423
465
  .allowUnknownOption() // Allow interleaved --adapter/--model for parseAdapterSlots
424
466
  .action(async (adapterName, opts) => {
425
467
  let cwd = opts.cwd ? path.resolve(opts.cwd) : process.cwd();
426
468
  // Collect hooks
427
- const hooks = opts.onCreate || opts.onComplete || opts.preMerge || opts.postMerge
469
+ const hooks = opts.onCreate || opts.onComplete
428
470
  ? {
429
471
  onCreate: opts.onCreate,
430
472
  onComplete: opts.onComplete,
431
- preMerge: opts.preMerge,
432
- postMerge: opts.postMerge,
433
473
  }
434
474
  : undefined;
435
475
  // --- Multi-adapter / matrix detection ---
@@ -621,85 +661,6 @@ program
621
661
  console.log(JSON.stringify(out));
622
662
  }
623
663
  });
624
- // --- Merge command (FEAT-4) ---
625
- program
626
- .command("merge <id>")
627
- .description("Commit, push, and open PR for a session's work")
628
- .option("-m, --message <text>", "Commit message")
629
- .option("--remove-worktree", "Remove worktree after merge")
630
- .option("--repo <path>", "Main repo path (for worktree removal)")
631
- .option("--pre-merge <script>", "Hook: run before merge")
632
- .option("--post-merge <script>", "Hook: run after merge")
633
- .action(async (id, opts) => {
634
- // Find session
635
- const daemonRunning = await ensureDaemon();
636
- let sessionCwd;
637
- let sessionAdapter;
638
- if (daemonRunning) {
639
- try {
640
- const session = await client.call("session.status", {
641
- id,
642
- });
643
- sessionCwd = session.cwd;
644
- sessionAdapter = session.adapter;
645
- }
646
- catch {
647
- // Fall through to adapter
648
- }
649
- }
650
- if (!sessionCwd) {
651
- const adapter = getAdapter();
652
- try {
653
- const session = await adapter.status(id);
654
- sessionCwd = session.cwd;
655
- sessionAdapter = session.adapter;
656
- }
657
- catch (err) {
658
- console.error(`Session not found: ${err.message}`);
659
- process.exit(1);
660
- }
661
- }
662
- if (!sessionCwd) {
663
- console.error("Cannot determine session working directory");
664
- process.exit(1);
665
- }
666
- const hooks = opts.preMerge || opts.postMerge
667
- ? { preMerge: opts.preMerge, postMerge: opts.postMerge }
668
- : undefined;
669
- // Pre-merge hook
670
- if (hooks?.preMerge) {
671
- await runHook(hooks, "preMerge", {
672
- sessionId: id,
673
- cwd: sessionCwd,
674
- adapter: sessionAdapter || "claude-code",
675
- });
676
- }
677
- const result = await mergeSession({
678
- cwd: sessionCwd,
679
- message: opts.message,
680
- removeWorktree: opts.removeWorktree,
681
- repoPath: opts.repo,
682
- });
683
- if (result.committed)
684
- console.log("Changes committed");
685
- if (result.pushed)
686
- console.log("Pushed to remote");
687
- if (result.prUrl)
688
- console.log(`PR: ${result.prUrl}`);
689
- if (result.worktreeRemoved)
690
- console.log("Worktree removed");
691
- if (!result.committed && !result.pushed) {
692
- console.log("No changes to commit or push");
693
- }
694
- // Post-merge hook
695
- if (hooks?.postMerge) {
696
- await runHook(hooks, "postMerge", {
697
- sessionId: id,
698
- cwd: sessionCwd,
699
- adapter: sessionAdapter || "claude-code",
700
- });
701
- }
702
- });
703
664
  // --- Worktree subcommand ---
704
665
  const worktreeCmd = new Command("worktree").description("Manage agentctl-created worktrees");
705
666
  worktreeCmd
@@ -844,7 +805,7 @@ program
844
805
  // --- Fuses command ---
845
806
  program
846
807
  .command("fuses")
847
- .description("List active Kind cluster fuse timers")
808
+ .description("List active fuse timers")
848
809
  .option("--json", "Output as JSON")
849
810
  .action(async (opts) => {
850
811
  try {
@@ -859,7 +820,7 @@ program
859
820
  }
860
821
  printTable(fuses.map((f) => ({
861
822
  Directory: shortenPath(f.directory),
862
- Cluster: f.clusterName,
823
+ Label: f.label || "-",
863
824
  "Expires In": formatDuration(new Date(f.expiresAt).getTime() - Date.now()),
864
825
  })));
865
826
  }
@@ -868,6 +829,25 @@ program
868
829
  process.exit(1);
869
830
  }
870
831
  });
832
+ // --- Prune command (#40) ---
833
+ program
834
+ .command("prune")
835
+ .description("Remove dead and stale sessions from daemon state")
836
+ .action(async () => {
837
+ const daemonRunning = await ensureDaemon();
838
+ if (!daemonRunning) {
839
+ console.error("Daemon not running. Start with: agentctl daemon start");
840
+ process.exit(1);
841
+ }
842
+ try {
843
+ const result = await client.call("session.prune");
844
+ console.log(`Pruned ${result.pruned} dead/stale sessions`);
845
+ }
846
+ catch (err) {
847
+ console.error(err.message);
848
+ process.exit(1);
849
+ }
850
+ });
871
851
  // --- Daemon subcommand ---
872
852
  const daemonCmd = new Command("daemon").description("Manage the agentctl daemon");
873
853
  daemonCmd
@@ -945,8 +925,9 @@ daemonCmd
945
925
  });
946
926
  daemonCmd
947
927
  .command("status")
948
- .description("Show daemon status")
928
+ .description("Show daemon status and all daemon-related processes")
949
929
  .action(async () => {
930
+ // Show daemon status
950
931
  try {
951
932
  const status = await client.call("daemon.status");
952
933
  console.log(`Daemon running (PID ${status.pid})`);
@@ -958,6 +939,38 @@ daemonCmd
958
939
  catch {
959
940
  console.log("Daemon not running");
960
941
  }
942
+ // Show all daemon-related processes (#39)
943
+ const configDir = path.join(os.homedir(), ".agentctl");
944
+ const { getSupervisorPid } = await import("./daemon/supervisor.js");
945
+ const supPid = await getSupervisorPid();
946
+ let daemonPid = null;
947
+ try {
948
+ const raw = await fs.readFile(path.join(configDir, "agentctl.pid"), "utf-8");
949
+ const pid = Number.parseInt(raw.trim(), 10);
950
+ try {
951
+ process.kill(pid, 0);
952
+ daemonPid = pid;
953
+ }
954
+ catch {
955
+ // PID file is stale
956
+ }
957
+ }
958
+ catch {
959
+ // No PID file
960
+ }
961
+ console.log("\nDaemon-related processes:");
962
+ if (supPid) {
963
+ console.log(` Supervisor: PID ${supPid} (alive)`);
964
+ }
965
+ else {
966
+ console.log(" Supervisor: not running");
967
+ }
968
+ if (daemonPid) {
969
+ console.log(` Daemon: PID ${daemonPid} (alive)`);
970
+ }
971
+ else {
972
+ console.log(" Daemon: not running");
973
+ }
961
974
  });
962
975
  daemonCmd
963
976
  .command("restart")
@@ -1,5 +1,31 @@
1
+ /**
2
+ * Session discovered by an adapter's runtime — the ground truth for "what exists."
3
+ * This is the source of truth for session lifecycle in the discover-first model.
4
+ */
5
+ export interface DiscoveredSession {
6
+ id: string;
7
+ status: "running" | "stopped";
8
+ adapter: string;
9
+ cwd?: string;
10
+ model?: string;
11
+ startedAt?: Date;
12
+ stoppedAt?: Date;
13
+ pid?: number;
14
+ prompt?: string;
15
+ tokens?: {
16
+ in: number;
17
+ out: number;
18
+ };
19
+ cost?: number;
20
+ /** Adapter-native fields — whatever the runtime provides */
21
+ nativeMetadata?: Record<string, unknown>;
22
+ }
1
23
  export interface AgentAdapter {
2
24
  id: string;
25
+ /** Find all sessions currently managed by this adapter's runtime. */
26
+ discover(): Promise<DiscoveredSession[]>;
27
+ /** Check if a specific session is still alive. */
28
+ isAlive(sessionId: string): Promise<boolean>;
3
29
  list(opts?: ListOpts): Promise<AgentSession[]>;
4
30
  peek(sessionId: string, opts?: PeekOpts): Promise<string>;
5
31
  status(sessionId: string): Promise<AgentSession>;
@@ -64,6 +90,4 @@ export interface LaunchOpts {
64
90
  export interface LifecycleHooks {
65
91
  onCreate?: string;
66
92
  onComplete?: string;
67
- preMerge?: string;
68
- postMerge?: string;
69
93
  }
@@ -1,28 +1,31 @@
1
1
  import type { EventEmitter } from "node:events";
2
- import type { FuseTimer, SessionRecord, StateManager } from "./state.js";
2
+ import type { FuseTimer, StateManager } from "./state.js";
3
3
  export interface FuseEngineOpts {
4
4
  defaultDurationMs: number;
5
5
  emitter?: EventEmitter;
6
6
  }
7
+ export interface SetFuseOpts {
8
+ directory: string;
9
+ sessionId: string;
10
+ ttlMs?: number;
11
+ onExpire?: FuseTimer["onExpire"];
12
+ label?: string;
13
+ }
7
14
  export declare class FuseEngine {
8
15
  private timers;
9
16
  private state;
10
17
  private defaultDurationMs;
11
18
  private emitter?;
12
19
  constructor(state: StateManager, opts: FuseEngineOpts);
13
- /** Derive cluster name from worktree directory. Returns null if not a mono worktree. */
14
- static deriveClusterName(directory: string): {
15
- clusterName: string;
16
- branch: string;
17
- } | null;
18
- /** Called when a session exits. Starts fuse if applicable. */
19
- onSessionExit(session: SessionRecord): void;
20
- private startFuse;
20
+ /** Set a fuse for a directory. Called when a session exits or explicitly via API. */
21
+ setFuse(opts: SetFuseOpts): void;
22
+ /** Extend an existing fuse's TTL. Resets the timer to a new duration. */
23
+ extendFuse(directory: string, ttlMs?: number): boolean;
21
24
  /** Cancel fuse for a directory. */
22
25
  cancelFuse(directory: string, persist?: boolean): boolean;
23
26
  /** Resume fuses from persisted state after daemon restart. */
24
27
  resumeTimers(): void;
25
- /** Fire a fuse — delete the Kind cluster. */
28
+ /** Fire a fuse — execute the configured on-expire action. */
26
29
  private fireFuse;
27
30
  listActive(): FuseTimer[];
28
31
  /** Clear all timers (for clean shutdown) */
@@ -1,6 +1,4 @@
1
1
  import { exec } from "node:child_process";
2
- import os from "node:os";
3
- import path from "node:path";
4
2
  import { promisify } from "node:util";
5
3
  const execAsync = promisify(exec);
6
4
  export class FuseEngine {
@@ -13,43 +11,45 @@ export class FuseEngine {
13
11
  this.defaultDurationMs = opts.defaultDurationMs;
14
12
  this.emitter = opts.emitter;
15
13
  }
16
- /** Derive cluster name from worktree directory. Returns null if not a mono worktree. */
17
- static deriveClusterName(directory) {
18
- const home = os.homedir();
19
- const monoPrefix = path.join(home, "code", "mono-");
20
- if (!directory.startsWith(monoPrefix))
21
- return null;
22
- const branch = directory.slice(monoPrefix.length);
23
- if (!branch)
24
- return null;
25
- return {
26
- clusterName: `kindo-charlie-${branch}`,
27
- branch,
14
+ /** Set a fuse for a directory. Called when a session exits or explicitly via API. */
15
+ setFuse(opts) {
16
+ const ttlMs = opts.ttlMs ?? this.defaultDurationMs;
17
+ // Cancel existing fuse for same directory
18
+ this.cancelFuse(opts.directory, false);
19
+ const expiresAt = new Date(Date.now() + ttlMs);
20
+ const fuse = {
21
+ directory: opts.directory,
22
+ ttlMs,
23
+ expiresAt: expiresAt.toISOString(),
24
+ sessionId: opts.sessionId,
25
+ onExpire: opts.onExpire,
26
+ label: opts.label,
28
27
  };
28
+ this.state.addFuse(fuse);
29
+ const timeout = setTimeout(() => this.fireFuse(fuse), ttlMs);
30
+ this.timers.set(opts.directory, timeout);
31
+ this.emitter?.emit("fuse.set", fuse);
29
32
  }
30
- /** Called when a session exits. Starts fuse if applicable. */
31
- onSessionExit(session) {
32
- if (!session.cwd)
33
- return;
34
- const derived = FuseEngine.deriveClusterName(session.cwd);
35
- if (!derived)
36
- return;
37
- this.startFuse(session.cwd, derived.clusterName, derived.branch, session.id);
38
- }
39
- startFuse(directory, clusterName, branch, sessionId) {
40
- // Cancel existing fuse for same directory
33
+ /** Extend an existing fuse's TTL. Resets the timer to a new duration. */
34
+ extendFuse(directory, ttlMs) {
35
+ const existing = this.state
36
+ .getFuses()
37
+ .find((f) => f.directory === directory);
38
+ if (!existing)
39
+ return false;
40
+ const duration = ttlMs ?? existing.ttlMs;
41
41
  this.cancelFuse(directory, false);
42
- const expiresAt = new Date(Date.now() + this.defaultDurationMs);
42
+ const expiresAt = new Date(Date.now() + duration);
43
43
  const fuse = {
44
- directory,
45
- clusterName,
46
- branch,
44
+ ...existing,
45
+ ttlMs: duration,
47
46
  expiresAt: expiresAt.toISOString(),
48
- sessionId,
49
47
  };
50
48
  this.state.addFuse(fuse);
51
- const timeout = setTimeout(() => this.fireFuse(fuse), this.defaultDurationMs);
49
+ const timeout = setTimeout(() => this.fireFuse(fuse), duration);
52
50
  this.timers.set(directory, timeout);
51
+ this.emitter?.emit("fuse.extended", fuse);
52
+ return true;
53
53
  }
54
54
  /** Cancel fuse for a directory. */
55
55
  cancelFuse(directory, persist = true) {
@@ -79,30 +79,53 @@ export class FuseEngine {
79
79
  }
80
80
  }
81
81
  }
82
- /** Fire a fuse — delete the Kind cluster. */
82
+ /** Fire a fuse — execute the configured on-expire action. */
83
83
  async fireFuse(fuse) {
84
84
  this.timers.delete(fuse.directory);
85
85
  this.state.removeFuse(fuse.directory);
86
- console.log(`Fuse fired: deleting cluster ${fuse.clusterName}`);
87
- try {
88
- // Best effort: yarn local:down first
86
+ const label = fuse.label || fuse.directory;
87
+ console.log(`Fuse expired: ${label}`);
88
+ this.emitter?.emit("fuse.expired", fuse);
89
+ const action = fuse.onExpire;
90
+ if (!action)
91
+ return;
92
+ // Execute on-expire script
93
+ if (action.script) {
89
94
  try {
90
- await execAsync("yarn local:down", {
95
+ await execAsync(action.script, {
91
96
  cwd: fuse.directory,
92
- timeout: 60_000,
97
+ timeout: 120_000,
98
+ });
99
+ console.log(`Fuse action completed: ${label}`);
100
+ }
101
+ catch (err) {
102
+ console.error(`Fuse action failed for ${label}:`, err);
103
+ }
104
+ }
105
+ // POST to webhook
106
+ if (action.webhook) {
107
+ try {
108
+ const body = JSON.stringify({
109
+ type: "fuse.expired",
110
+ directory: fuse.directory,
111
+ sessionId: fuse.sessionId,
112
+ label: fuse.label,
113
+ expiredAt: new Date().toISOString(),
114
+ });
115
+ await fetch(action.webhook, {
116
+ method: "POST",
117
+ headers: { "Content-Type": "application/json" },
118
+ body,
119
+ signal: AbortSignal.timeout(30_000),
93
120
  });
94
121
  }
95
- catch {
96
- // Ignore
122
+ catch (err) {
123
+ console.error(`Fuse webhook failed for ${label}:`, err);
97
124
  }
98
- await execAsync(`kind delete cluster --name ${fuse.clusterName}`, {
99
- timeout: 120_000,
100
- });
101
- console.log(`Cluster ${fuse.clusterName} deleted`);
102
- this.emitter?.emit("fuse.fired", fuse);
103
125
  }
104
- catch (err) {
105
- console.error(`Failed to delete cluster ${fuse.clusterName}:`, err);
126
+ // Emit named event
127
+ if (action.event) {
128
+ this.emitter?.emit(action.event, fuse);
106
129
  }
107
130
  }
108
131
  listActive() {
@@ -8,13 +8,12 @@ export declare class MetricsRegistry {
8
8
  sessionsTotalCompleted: number;
9
9
  sessionsTotalFailed: number;
10
10
  sessionsTotalStopped: number;
11
- fusesFiredTotal: number;
12
- clustersDeletedTotal: number;
11
+ fusesExpiredTotal: number;
13
12
  sessionDurations: number[];
14
13
  constructor(sessionTracker: SessionTracker, lockManager: LockManager, fuseEngine: FuseEngine);
15
14
  recordSessionCompleted(durationSeconds?: number): void;
16
15
  recordSessionFailed(durationSeconds?: number): void;
17
16
  recordSessionStopped(durationSeconds?: number): void;
18
- recordFuseFired(): void;
17
+ recordFuseExpired(): void;
19
18
  generateMetrics(): string;
20
19
  }