@orgloop/agentctl 1.2.1 → 1.4.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/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(),
@@ -95,6 +94,18 @@ function formatSession(s, showGroup) {
95
94
  row.Prompt = (s.prompt || "-").slice(0, 60);
96
95
  return row;
97
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
+ }
98
109
  function formatRecord(s, showGroup) {
99
110
  const row = {
100
111
  ID: s.id.slice(0, 8),
@@ -178,6 +189,22 @@ function sessionToJson(s) {
178
189
  meta: s.meta,
179
190
  };
180
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
+ }
181
208
  // --- CLI ---
182
209
  const program = new Command();
183
210
  program
@@ -214,25 +241,40 @@ program
214
241
  }
215
242
  return;
216
243
  }
217
- // Direct fallback
218
- const listOpts = { status: opts.status, all: opts.all };
219
- let sessions = [];
244
+ // Direct fallback — discover-first
245
+ let discovered = [];
220
246
  if (opts.adapter) {
221
247
  const adapter = getAdapter(opts.adapter);
222
- sessions = await adapter.list(listOpts);
248
+ discovered = await adapter.discover();
223
249
  }
224
250
  else {
225
251
  for (const adapter of getAllAdapters()) {
226
- const s = await adapter.list(listOpts);
227
- sessions.push(...s);
252
+ const d = await adapter.discover().catch(() => []);
253
+ discovered.push(...d);
228
254
  }
229
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
+ });
230
273
  if (opts.json) {
231
- printJson(sessions.map(sessionToJson));
274
+ printJson(discovered.map(discoveredToJson));
232
275
  }
233
276
  else {
234
- const hasGroups = sessions.some((s) => s.group);
235
- printTable(sessions.map((s) => formatSession(s, hasGroups)));
277
+ printTable(discovered.map((s) => formatDiscovered(s)));
236
278
  }
237
279
  });
238
280
  // status
@@ -420,18 +462,14 @@ program
420
462
  .option("--matrix <file>", "YAML matrix file for advanced sweep launch")
421
463
  .option("--on-create <script>", "Hook: run after session is created")
422
464
  .option("--on-complete <script>", "Hook: run after session completes")
423
- .option("--pre-merge <script>", "Hook: run before merge")
424
- .option("--post-merge <script>", "Hook: run after merge")
425
465
  .allowUnknownOption() // Allow interleaved --adapter/--model for parseAdapterSlots
426
466
  .action(async (adapterName, opts) => {
427
467
  let cwd = opts.cwd ? path.resolve(opts.cwd) : process.cwd();
428
468
  // Collect hooks
429
- const hooks = opts.onCreate || opts.onComplete || opts.preMerge || opts.postMerge
469
+ const hooks = opts.onCreate || opts.onComplete
430
470
  ? {
431
471
  onCreate: opts.onCreate,
432
472
  onComplete: opts.onComplete,
433
- preMerge: opts.preMerge,
434
- postMerge: opts.postMerge,
435
473
  }
436
474
  : undefined;
437
475
  // --- Multi-adapter / matrix detection ---
@@ -623,85 +661,6 @@ program
623
661
  console.log(JSON.stringify(out));
624
662
  }
625
663
  });
626
- // --- Merge command (FEAT-4) ---
627
- program
628
- .command("merge <id>")
629
- .description("Commit, push, and open PR for a session's work")
630
- .option("-m, --message <text>", "Commit message")
631
- .option("--remove-worktree", "Remove worktree after merge")
632
- .option("--repo <path>", "Main repo path (for worktree removal)")
633
- .option("--pre-merge <script>", "Hook: run before merge")
634
- .option("--post-merge <script>", "Hook: run after merge")
635
- .action(async (id, opts) => {
636
- // Find session
637
- const daemonRunning = await ensureDaemon();
638
- let sessionCwd;
639
- let sessionAdapter;
640
- if (daemonRunning) {
641
- try {
642
- const session = await client.call("session.status", {
643
- id,
644
- });
645
- sessionCwd = session.cwd;
646
- sessionAdapter = session.adapter;
647
- }
648
- catch {
649
- // Fall through to adapter
650
- }
651
- }
652
- if (!sessionCwd) {
653
- const adapter = getAdapter();
654
- try {
655
- const session = await adapter.status(id);
656
- sessionCwd = session.cwd;
657
- sessionAdapter = session.adapter;
658
- }
659
- catch (err) {
660
- console.error(`Session not found: ${err.message}`);
661
- process.exit(1);
662
- }
663
- }
664
- if (!sessionCwd) {
665
- console.error("Cannot determine session working directory");
666
- process.exit(1);
667
- }
668
- const hooks = opts.preMerge || opts.postMerge
669
- ? { preMerge: opts.preMerge, postMerge: opts.postMerge }
670
- : undefined;
671
- // Pre-merge hook
672
- if (hooks?.preMerge) {
673
- await runHook(hooks, "preMerge", {
674
- sessionId: id,
675
- cwd: sessionCwd,
676
- adapter: sessionAdapter || "claude-code",
677
- });
678
- }
679
- const result = await mergeSession({
680
- cwd: sessionCwd,
681
- message: opts.message,
682
- removeWorktree: opts.removeWorktree,
683
- repoPath: opts.repo,
684
- });
685
- if (result.committed)
686
- console.log("Changes committed");
687
- if (result.pushed)
688
- console.log("Pushed to remote");
689
- if (result.prUrl)
690
- console.log(`PR: ${result.prUrl}`);
691
- if (result.worktreeRemoved)
692
- console.log("Worktree removed");
693
- if (!result.committed && !result.pushed) {
694
- console.log("No changes to commit or push");
695
- }
696
- // Post-merge hook
697
- if (hooks?.postMerge) {
698
- await runHook(hooks, "postMerge", {
699
- sessionId: id,
700
- cwd: sessionCwd,
701
- adapter: sessionAdapter || "claude-code",
702
- });
703
- }
704
- });
705
664
  // --- Worktree subcommand ---
706
665
  const worktreeCmd = new Command("worktree").description("Manage agentctl-created worktrees");
707
666
  worktreeCmd
@@ -846,7 +805,7 @@ program
846
805
  // --- Fuses command ---
847
806
  program
848
807
  .command("fuses")
849
- .description("List active Kind cluster fuse timers")
808
+ .description("List active fuse timers")
850
809
  .option("--json", "Output as JSON")
851
810
  .action(async (opts) => {
852
811
  try {
@@ -861,7 +820,7 @@ program
861
820
  }
862
821
  printTable(fuses.map((f) => ({
863
822
  Directory: shortenPath(f.directory),
864
- Cluster: f.clusterName,
823
+ Label: f.label || "-",
865
824
  "Expires In": formatDuration(new Date(f.expiresAt).getTime() - Date.now()),
866
825
  })));
867
826
  }
@@ -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() {
@@ -1,20 +1,22 @@
1
1
  import type { FuseEngine } from "./fuse-engine.js";
2
2
  import type { LockManager } from "./lock-manager.js";
3
- import type { SessionTracker } from "./session-tracker.js";
4
3
  export declare class MetricsRegistry {
5
- private sessionTracker;
6
4
  private lockManager;
7
5
  private fuseEngine;
8
6
  sessionsTotalCompleted: number;
9
7
  sessionsTotalFailed: number;
10
8
  sessionsTotalStopped: number;
11
- fusesFiredTotal: number;
12
- clustersDeletedTotal: number;
9
+ fusesExpiredTotal: number;
13
10
  sessionDurations: number[];
14
- constructor(sessionTracker: SessionTracker, lockManager: LockManager, fuseEngine: FuseEngine);
11
+ /** Last-known active session count, updated by session.list fan-out */
12
+ private _activeSessionCount;
13
+ constructor(lockManager: LockManager, fuseEngine: FuseEngine);
14
+ /** Update the active session gauge (called after session.list fan-out) */
15
+ setActiveSessionCount(count: number): void;
16
+ get activeSessionCount(): number;
15
17
  recordSessionCompleted(durationSeconds?: number): void;
16
18
  recordSessionFailed(durationSeconds?: number): void;
17
19
  recordSessionStopped(durationSeconds?: number): void;
18
- recordFuseFired(): void;
20
+ recordFuseExpired(): void;
19
21
  generateMetrics(): string;
20
22
  }
@@ -1,18 +1,24 @@
1
1
  export class MetricsRegistry {
2
- sessionTracker;
3
2
  lockManager;
4
3
  fuseEngine;
5
4
  sessionsTotalCompleted = 0;
6
5
  sessionsTotalFailed = 0;
7
6
  sessionsTotalStopped = 0;
8
- fusesFiredTotal = 0;
9
- clustersDeletedTotal = 0;
7
+ fusesExpiredTotal = 0;
10
8
  sessionDurations = []; // seconds
11
- constructor(sessionTracker, lockManager, fuseEngine) {
12
- this.sessionTracker = sessionTracker;
9
+ /** Last-known active session count, updated by session.list fan-out */
10
+ _activeSessionCount = 0;
11
+ constructor(lockManager, fuseEngine) {
13
12
  this.lockManager = lockManager;
14
13
  this.fuseEngine = fuseEngine;
15
14
  }
15
+ /** Update the active session gauge (called after session.list fan-out) */
16
+ setActiveSessionCount(count) {
17
+ this._activeSessionCount = count;
18
+ }
19
+ get activeSessionCount() {
20
+ return this._activeSessionCount;
21
+ }
16
22
  recordSessionCompleted(durationSeconds) {
17
23
  this.sessionsTotalCompleted++;
18
24
  if (durationSeconds != null)
@@ -28,9 +34,8 @@ export class MetricsRegistry {
28
34
  if (durationSeconds != null)
29
35
  this.sessionDurations.push(durationSeconds);
30
36
  }
31
- recordFuseFired() {
32
- this.fusesFiredTotal++;
33
- this.clustersDeletedTotal++;
37
+ recordFuseExpired() {
38
+ this.fusesExpiredTotal++;
34
39
  }
35
40
  generateMetrics() {
36
41
  const lines = [];
@@ -45,7 +50,7 @@ export class MetricsRegistry {
45
50
  lines.push(labels ? `${name}{${labels}} ${value}` : `${name} ${value}`);
46
51
  };
47
52
  // Gauges
48
- g("agentctl_sessions_active", "Number of active sessions", this.sessionTracker.activeCount());
53
+ g("agentctl_sessions_active", "Number of active sessions", this._activeSessionCount);
49
54
  const locks = this.lockManager.listAll();
50
55
  g("agentctl_locks_active", "Number of active locks", locks.filter((l) => l.type === "auto").length, 'type="auto"');
51
56
  g("agentctl_locks_active", "Number of active locks", locks.filter((l) => l.type === "manual").length, 'type="manual"');
@@ -54,8 +59,7 @@ export class MetricsRegistry {
54
59
  c("agentctl_sessions_total", "Total sessions by status", this.sessionsTotalCompleted, 'status="completed"');
55
60
  c("agentctl_sessions_total", "Total sessions by status", this.sessionsTotalFailed, 'status="failed"');
56
61
  c("agentctl_sessions_total", "Total sessions by status", this.sessionsTotalStopped, 'status="stopped"');
57
- c("agentctl_fuses_fired_total", "Total fuses fired", this.fusesFiredTotal);
58
- c("agentctl_kind_clusters_deleted_total", "Total Kind clusters deleted", this.clustersDeletedTotal);
62
+ c("agentctl_fuses_expired_total", "Total fuses expired", this.fusesExpiredTotal);
59
63
  // Histogram (session duration)
60
64
  lines.push("# HELP agentctl_session_duration_seconds Session duration histogram");
61
65
  lines.push("# TYPE agentctl_session_duration_seconds histogram");