@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.
@@ -1,218 +1,53 @@
1
- /** Max age for stopped sessions in state before pruning (7 days) */
2
- const STOPPED_SESSION_PRUNE_AGE_MS = 7 * 24 * 60 * 60 * 1000;
1
+ /**
2
+ * Grace period for recently-launched sessions.
3
+ * If a session was launched less than this many ms ago and the adapter
4
+ * doesn't return it yet, don't mark it stopped — the adapter may not
5
+ * have discovered it yet.
6
+ */
7
+ const LAUNCH_GRACE_PERIOD_MS = 30_000;
8
+ /**
9
+ * Simplified session tracker for the stateless daemon core (ADR 004).
10
+ *
11
+ * Adapters own session truth. The daemon only tracks:
12
+ * - Launch metadata (prompt, group, spec, cwd) for sessions launched via agentctl
13
+ * - Locks and fuses (handled by LockManager / FuseEngine)
14
+ *
15
+ * The old polling loop, pruning, and state-based session registry are removed.
16
+ * session.list now fans out adapter.discover() at call time.
17
+ */
3
18
  export class SessionTracker {
4
19
  state;
5
20
  adapters;
6
- pollIntervalMs;
7
- pollHandle = null;
8
- polling = false;
9
21
  isProcessAlive;
22
+ cleanupHandle = null;
10
23
  constructor(state, opts) {
11
24
  this.state = state;
12
25
  this.adapters = opts.adapters;
13
- this.pollIntervalMs = opts.pollIntervalMs ?? 5000;
14
26
  this.isProcessAlive = opts.isProcessAlive ?? defaultIsProcessAlive;
15
27
  }
16
- startPolling() {
17
- if (this.pollHandle)
18
- return;
19
- // Prune old stopped sessions on startup
20
- this.pruneOldSessions();
21
- // Initial poll
22
- this.guardedPoll();
23
- this.pollHandle = setInterval(() => {
24
- this.guardedPoll();
25
- }, this.pollIntervalMs);
26
- }
27
- /** Run poll() with a guard to skip if the previous cycle is still running */
28
- guardedPoll() {
29
- if (this.polling)
30
- return;
31
- this.polling = true;
32
- this.poll()
33
- .catch((err) => console.error("Poll error:", err))
34
- .finally(() => {
35
- this.polling = false;
36
- });
37
- }
38
- stopPolling() {
39
- if (this.pollHandle) {
40
- clearInterval(this.pollHandle);
41
- this.pollHandle = null;
42
- }
43
- }
44
- async poll() {
45
- // Collect PIDs from all adapter-returned sessions (the source of truth)
46
- const adapterPidToId = new Map();
47
- for (const [adapterName, adapter] of Object.entries(this.adapters)) {
48
- try {
49
- const sessions = await adapter.list({ all: true });
50
- for (const session of sessions) {
51
- if (session.pid) {
52
- adapterPidToId.set(session.pid, session.id);
53
- }
54
- const existing = this.state.getSession(session.id);
55
- const record = sessionToRecord(session, adapterName);
56
- if (!existing) {
57
- this.state.setSession(session.id, record);
58
- }
59
- else if (existing.status !== record.status ||
60
- (!existing.model && record.model)) {
61
- // Status changed or model resolved — update
62
- this.state.setSession(session.id, {
63
- ...existing,
64
- status: record.status,
65
- stoppedAt: record.stoppedAt,
66
- model: record.model || existing.model,
67
- tokens: record.tokens,
68
- cost: record.cost,
69
- prompt: record.prompt || existing.prompt,
70
- });
71
- }
72
- }
73
- }
74
- catch {
75
- // Adapter unavailable — skip
76
- }
77
- }
78
- // Reap stale entries from daemon state
79
- this.reapStaleEntries(adapterPidToId);
80
- }
81
- /**
82
- * Clean up ghost sessions in the daemon state:
83
- * - pending-* entries whose PID matches a resolved session → remove pending
84
- * - Any "running"/"idle" session in state whose PID is dead → mark stopped
85
- */
86
- reapStaleEntries(adapterPidToId) {
87
- const sessions = this.state.getSessions();
88
- for (const [id, record] of Object.entries(sessions)) {
89
- // Bug 2: If this is a pending-* entry and a real session has the same PID,
90
- // the pending entry is stale — remove it
91
- if (id.startsWith("pending-") && record.pid) {
92
- const resolvedId = adapterPidToId.get(record.pid);
93
- if (resolvedId && resolvedId !== id) {
94
- this.state.removeSession(id);
95
- continue;
96
- }
97
- }
98
- // Bug 1: If session is "running"/"idle" but PID is dead, mark stopped
99
- if ((record.status === "running" || record.status === "idle") &&
100
- record.pid) {
101
- // Only reap if the adapter didn't return this session as running
102
- // (adapter is the source of truth for sessions it knows about)
103
- const adapterId = adapterPidToId.get(record.pid);
104
- if (adapterId === id)
105
- continue; // Adapter confirmed this PID is active
106
- if (!this.isProcessAlive(record.pid)) {
107
- this.state.setSession(id, {
108
- ...record,
109
- status: "stopped",
110
- stoppedAt: new Date().toISOString(),
111
- });
112
- }
113
- }
114
- }
115
- }
116
28
  /**
117
- * Validate all sessions on daemon startup (#40).
118
- * Any session marked as "running" or "idle" whose PID is dead gets
119
- * immediately marked as "stopped". This prevents unbounded growth of
120
- * ghost sessions across daemon restarts.
29
+ * Start periodic PID liveness check for daemon-launched sessions.
30
+ * This is a lightweight check (no adapter fan-out) that runs every 30s
31
+ * to detect dead sessions and return their IDs for lock cleanup.
121
32
  */
122
- validateAllSessions() {
123
- const sessions = this.state.getSessions();
124
- let cleaned = 0;
125
- for (const [id, record] of Object.entries(sessions)) {
126
- if (record.status !== "running" && record.status !== "idle")
127
- continue;
128
- if (record.pid) {
129
- if (!this.isProcessAlive(record.pid)) {
130
- this.state.setSession(id, {
131
- ...record,
132
- status: "stopped",
133
- stoppedAt: new Date().toISOString(),
134
- });
135
- cleaned++;
136
- }
137
- }
138
- else {
139
- // No PID recorded — can't verify, mark as stopped
140
- this.state.setSession(id, {
141
- ...record,
142
- status: "stopped",
143
- stoppedAt: new Date().toISOString(),
144
- });
145
- cleaned++;
33
+ startLaunchCleanup(onDead) {
34
+ if (this.cleanupHandle)
35
+ return;
36
+ this.cleanupHandle = setInterval(() => {
37
+ const dead = this.cleanupDeadLaunches();
38
+ if (onDead) {
39
+ for (const id of dead)
40
+ onDead(id);
146
41
  }
147
- }
148
- if (cleaned > 0) {
149
- console.error(`Validated sessions on startup: marked ${cleaned} dead sessions as stopped`);
150
- }
42
+ }, 30_000);
151
43
  }
152
- /**
153
- * Aggressively prune all clearly-dead sessions (#40).
154
- * Returns the number of sessions pruned.
155
- * Called via `agentctl prune` command.
156
- */
157
- pruneDeadSessions() {
158
- const sessions = this.state.getSessions();
159
- let pruned = 0;
160
- for (const [id, record] of Object.entries(sessions)) {
161
- // Remove stopped/completed/failed sessions older than 24h
162
- if (record.status === "stopped" ||
163
- record.status === "completed" ||
164
- record.status === "failed") {
165
- const stoppedAt = record.stoppedAt
166
- ? new Date(record.stoppedAt).getTime()
167
- : new Date(record.startedAt).getTime();
168
- const age = Date.now() - stoppedAt;
169
- if (age > 24 * 60 * 60 * 1000) {
170
- this.state.removeSession(id);
171
- pruned++;
172
- }
173
- continue;
174
- }
175
- // Remove running/idle sessions whose PID is dead
176
- if (record.status === "running" || record.status === "idle") {
177
- if (record.pid && !this.isProcessAlive(record.pid)) {
178
- this.state.removeSession(id);
179
- pruned++;
180
- }
181
- else if (!record.pid) {
182
- this.state.removeSession(id);
183
- pruned++;
184
- }
185
- }
44
+ stopLaunchCleanup() {
45
+ if (this.cleanupHandle) {
46
+ clearInterval(this.cleanupHandle);
47
+ this.cleanupHandle = null;
186
48
  }
187
- return pruned;
188
49
  }
189
- /**
190
- * Remove stopped sessions from state that have been stopped for more than 7 days.
191
- * This reduces overhead from accumulating hundreds of historical sessions.
192
- */
193
- pruneOldSessions() {
194
- const sessions = this.state.getSessions();
195
- const now = Date.now();
196
- let pruned = 0;
197
- for (const [id, record] of Object.entries(sessions)) {
198
- if (record.status !== "stopped" &&
199
- record.status !== "completed" &&
200
- record.status !== "failed") {
201
- continue;
202
- }
203
- const stoppedAt = record.stoppedAt
204
- ? new Date(record.stoppedAt).getTime()
205
- : new Date(record.startedAt).getTime();
206
- if (now - stoppedAt > STOPPED_SESSION_PRUNE_AGE_MS) {
207
- this.state.removeSession(id);
208
- pruned++;
209
- }
210
- }
211
- if (pruned > 0) {
212
- console.error(`Pruned ${pruned} sessions stopped >7 days ago from state`);
213
- }
214
- }
215
- /** Track a newly launched session */
50
+ /** Track a newly launched session (stores launch metadata in state) */
216
51
  track(session, adapterName) {
217
52
  const record = sessionToRecord(session, adapterName);
218
53
  // Pending→UUID reconciliation: if this is a real session (not pending),
@@ -227,7 +62,7 @@ export class SessionTracker {
227
62
  this.state.setSession(session.id, record);
228
63
  return record;
229
64
  }
230
- /** Get session record by id (exact or prefix) */
65
+ /** Get session launch metadata by id (exact or prefix match) */
231
66
  getSession(id) {
232
67
  // Exact match
233
68
  const exact = this.state.getSession(id);
@@ -240,48 +75,11 @@ export class SessionTracker {
240
75
  return matches[0][1];
241
76
  return undefined;
242
77
  }
243
- /** List all tracked sessions */
244
- listSessions(opts) {
245
- const sessions = Object.values(this.state.getSessions());
246
- // Liveness check: mark sessions with dead PIDs as stopped
247
- for (const s of sessions) {
248
- if ((s.status === "running" || s.status === "idle") && s.pid) {
249
- if (!this.isProcessAlive(s.pid)) {
250
- s.status = "stopped";
251
- s.stoppedAt = new Date().toISOString();
252
- this.state.setSession(s.id, s);
253
- }
254
- }
255
- }
256
- let filtered = sessions;
257
- if (opts?.adapter) {
258
- filtered = filtered.filter((s) => s.adapter === opts.adapter);
259
- }
260
- if (opts?.status) {
261
- filtered = filtered.filter((s) => s.status === opts.status);
262
- }
263
- else if (!opts?.all) {
264
- filtered = filtered.filter((s) => s.status === "running" || s.status === "idle");
265
- }
266
- // Dedup: if a pending-* entry shares a PID with a resolved entry, show only the resolved one
267
- filtered = deduplicatePendingSessions(filtered);
268
- return filtered.sort((a, b) => {
269
- // Running first, then by recency
270
- if (a.status === "running" && b.status !== "running")
271
- return -1;
272
- if (b.status === "running" && a.status !== "running")
273
- return 1;
274
- return new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime();
275
- });
276
- }
277
- activeCount() {
278
- return Object.values(this.state.getSessions()).filter((s) => s.status === "running" || s.status === "idle").length;
279
- }
280
- /** Remove a session from state entirely (used for ghost cleanup) */
78
+ /** Remove a session from launch metadata */
281
79
  removeSession(sessionId) {
282
80
  this.state.removeSession(sessionId);
283
81
  }
284
- /** Called when a session stops — returns the cwd for fuse/lock processing */
82
+ /** Called when a session stops — marks it in launch metadata, returns the record */
285
83
  onSessionExit(sessionId) {
286
84
  const session = this.state.getSession(sessionId);
287
85
  if (session) {
@@ -291,6 +89,91 @@ export class SessionTracker {
291
89
  }
292
90
  return session;
293
91
  }
92
+ /**
93
+ * Merge adapter-discovered sessions with daemon launch metadata.
94
+ *
95
+ * 1. Enrich discovered sessions with launch metadata (prompt, group, spec, etc.)
96
+ * 2. Reconcile: mark daemon-launched sessions as stopped if their adapter
97
+ * succeeded but didn't return them (and they're past the grace period).
98
+ * 3. Include recently-launched sessions that adapters haven't discovered yet.
99
+ *
100
+ * Returns the merged session list and IDs of sessions that were marked stopped
101
+ * (for lock cleanup by the caller).
102
+ */
103
+ reconcileAndEnrich(discovered, succeededAdapters) {
104
+ // Build lookups for discovered sessions
105
+ const discoveredIds = new Set(discovered.map((d) => d.id));
106
+ const discoveredPids = new Map();
107
+ for (const d of discovered) {
108
+ if (d.pid)
109
+ discoveredPids.set(d.pid, d.id);
110
+ }
111
+ // 1. Convert discovered sessions to records, enriching with launch metadata
112
+ const sessions = discovered.map((disc) => enrichDiscovered(disc, this.state.getSession(disc.id)));
113
+ // 2. Reconcile daemon-launched sessions that disappeared from adapter results
114
+ const stoppedLaunchIds = [];
115
+ const now = Date.now();
116
+ for (const [id, record] of Object.entries(this.state.getSessions())) {
117
+ if (record.status !== "running" &&
118
+ record.status !== "idle" &&
119
+ record.status !== "pending")
120
+ continue;
121
+ // If adapter for this session didn't succeed, include as-is from launch metadata
122
+ // (we can't verify status, so trust the last-known state)
123
+ if (!succeededAdapters.has(record.adapter)) {
124
+ sessions.push(record);
125
+ continue;
126
+ }
127
+ // Skip if adapter returned this session (it's still active)
128
+ if (discoveredIds.has(id))
129
+ continue;
130
+ // Check if this session's PID was resolved to a different ID (pending→UUID)
131
+ if (record.pid && discoveredPids.has(record.pid)) {
132
+ // PID was resolved to a real session — remove stale launch entry
133
+ this.state.removeSession(id);
134
+ stoppedLaunchIds.push(id);
135
+ continue;
136
+ }
137
+ // Grace period: don't mark recently-launched sessions as stopped
138
+ const launchAge = now - new Date(record.startedAt).getTime();
139
+ if (launchAge < LAUNCH_GRACE_PERIOD_MS) {
140
+ // Still within grace period — include as-is in results
141
+ sessions.push(record);
142
+ continue;
143
+ }
144
+ // Session disappeared from adapter results — mark stopped
145
+ this.state.setSession(id, {
146
+ ...record,
147
+ status: "stopped",
148
+ stoppedAt: new Date().toISOString(),
149
+ });
150
+ stoppedLaunchIds.push(id);
151
+ }
152
+ return { sessions, stoppedLaunchIds };
153
+ }
154
+ /**
155
+ * Check PID liveness for daemon-launched sessions.
156
+ * Returns IDs of sessions whose PIDs have died.
157
+ * This is a lightweight check (no adapter fan-out) for lock cleanup.
158
+ */
159
+ cleanupDeadLaunches() {
160
+ const dead = [];
161
+ for (const [id, record] of Object.entries(this.state.getSessions())) {
162
+ if (record.status !== "running" &&
163
+ record.status !== "idle" &&
164
+ record.status !== "pending")
165
+ continue;
166
+ if (record.pid && !this.isProcessAlive(record.pid)) {
167
+ this.state.setSession(id, {
168
+ ...record,
169
+ status: "stopped",
170
+ stoppedAt: new Date().toISOString(),
171
+ });
172
+ dead.push(id);
173
+ }
174
+ }
175
+ return dead;
176
+ }
294
177
  }
295
178
  /** Check if a process is alive via kill(pid, 0) signal check */
296
179
  function defaultIsProcessAlive(pid) {
@@ -303,22 +186,25 @@ function defaultIsProcessAlive(pid) {
303
186
  }
304
187
  }
305
188
  /**
306
- * Remove pending-* entries that share a PID with a resolved (non-pending) session.
307
- * This is a safety net for list output — the poll() reaper handles cleanup in state.
189
+ * Convert a discovered session to a SessionRecord, enriching with launch metadata.
308
190
  */
309
- function deduplicatePendingSessions(sessions) {
310
- const realPids = new Set();
311
- for (const s of sessions) {
312
- if (!s.id.startsWith("pending-") && s.pid) {
313
- realPids.add(s.pid);
314
- }
315
- }
316
- return sessions.filter((s) => {
317
- if (s.id.startsWith("pending-") && s.pid && realPids.has(s.pid)) {
318
- return false;
319
- }
320
- return true;
321
- });
191
+ function enrichDiscovered(disc, launchMeta) {
192
+ return {
193
+ id: disc.id,
194
+ adapter: disc.adapter,
195
+ status: disc.status,
196
+ startedAt: disc.startedAt?.toISOString() ?? new Date().toISOString(),
197
+ stoppedAt: disc.stoppedAt?.toISOString(),
198
+ cwd: disc.cwd ?? launchMeta?.cwd,
199
+ model: disc.model ?? launchMeta?.model,
200
+ prompt: disc.prompt ?? launchMeta?.prompt,
201
+ tokens: disc.tokens,
202
+ cost: disc.cost,
203
+ pid: disc.pid,
204
+ spec: launchMeta?.spec,
205
+ group: launchMeta?.group,
206
+ meta: disc.nativeMetadata ?? launchMeta?.meta ?? {},
207
+ };
322
208
  }
323
209
  function sessionToRecord(session, adapterName) {
324
210
  return {
@@ -28,10 +28,20 @@ export interface Lock {
28
28
  }
29
29
  export interface FuseTimer {
30
30
  directory: string;
31
- clusterName: string;
32
- branch: string;
31
+ ttlMs: number;
33
32
  expiresAt: string;
34
33
  sessionId: string;
34
+ /** On-expire action: shell command, webhook URL, or event name */
35
+ onExpire?: FuseAction;
36
+ label?: string;
37
+ }
38
+ export interface FuseAction {
39
+ /** Shell script to run when fuse expires. CWD is the fuse directory. */
40
+ script?: string;
41
+ /** Webhook URL to POST to when fuse expires */
42
+ webhook?: string;
43
+ /** Event name to emit when fuse expires */
44
+ event?: string;
35
45
  }
36
46
  export interface PersistedState {
37
47
  sessions: Record<string, SessionRecord>;
package/dist/hooks.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { LifecycleHooks } from "./core/types.js";
2
- export type HookPhase = "onCreate" | "onComplete" | "preMerge" | "postMerge";
2
+ export type HookPhase = "onCreate" | "onComplete";
3
3
  export interface HookContext {
4
4
  sessionId: string;
5
5
  cwd: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orgloop/agentctl",
3
- "version": "1.2.1",
3
+ "version": "1.4.0",
4
4
  "description": "Universal agent supervision interface — monitor and control AI coding agents from a single CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/dist/merge.d.ts DELETED
@@ -1,24 +0,0 @@
1
- export interface MergeOpts {
2
- /** Working directory of the session */
3
- cwd: string;
4
- /** Commit message (auto-generated if omitted) */
5
- message?: string;
6
- /** Whether to remove worktree after push */
7
- removeWorktree?: boolean;
8
- /** The main repo path (needed for worktree removal) */
9
- repoPath?: string;
10
- }
11
- export interface MergeResult {
12
- committed: boolean;
13
- pushed: boolean;
14
- prUrl?: string;
15
- worktreeRemoved: boolean;
16
- }
17
- /**
18
- * Merge + cleanup workflow:
19
- * 1. Commit uncommitted changes
20
- * 2. Push to remote
21
- * 3. Open PR via `gh`
22
- * 4. Optionally remove worktree
23
- */
24
- export declare function mergeSession(opts: MergeOpts): Promise<MergeResult>;
package/dist/merge.js DELETED
@@ -1,65 +0,0 @@
1
- import { exec, execFile } from "node:child_process";
2
- import { promisify } from "node:util";
3
- const execAsync = promisify(exec);
4
- const execFileAsync = promisify(execFile);
5
- /**
6
- * Merge + cleanup workflow:
7
- * 1. Commit uncommitted changes
8
- * 2. Push to remote
9
- * 3. Open PR via `gh`
10
- * 4. Optionally remove worktree
11
- */
12
- export async function mergeSession(opts) {
13
- const { cwd } = opts;
14
- const result = {
15
- committed: false,
16
- pushed: false,
17
- worktreeRemoved: false,
18
- };
19
- // 1. Check for uncommitted changes
20
- const { stdout: status } = await execFileAsync("git", ["status", "--porcelain"], { cwd });
21
- if (status.trim()) {
22
- // Stage all changes and commit
23
- await execFileAsync("git", ["add", "-A"], { cwd });
24
- const message = opts.message || "chore: commit agent session work (via agentctl merge)";
25
- await execFileAsync("git", ["commit", "-m", message], { cwd });
26
- result.committed = true;
27
- }
28
- // 2. Get current branch name
29
- const { stdout: branchRaw } = await execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd });
30
- const branch = branchRaw.trim();
31
- // 3. Push to remote
32
- try {
33
- await execFileAsync("git", ["push", "-u", "origin", branch], { cwd });
34
- result.pushed = true;
35
- }
36
- catch (err) {
37
- console.error("Push failed:", err.message);
38
- return result;
39
- }
40
- // 4. Open PR via gh (best effort)
41
- try {
42
- const { stdout: prOut } = await execAsync(`gh pr create --fill --head ${branch} 2>&1 || gh pr view --json url -q .url 2>&1`, { cwd });
43
- // Extract URL from output
44
- const urlMatch = prOut.match(/https:\/\/github\.com\/[^\s]+/);
45
- if (urlMatch) {
46
- result.prUrl = urlMatch[0];
47
- }
48
- }
49
- catch {
50
- // gh not available or PR already exists
51
- }
52
- // 5. Optionally remove worktree
53
- if (opts.removeWorktree && opts.repoPath) {
54
- try {
55
- await execFileAsync("git", ["worktree", "remove", "--force", cwd], {
56
- cwd: opts.repoPath,
57
- });
58
- result.worktreeRemoved = true;
59
- }
60
- catch (err) {
61
- console.error("Worktree removal failed:", err.message);
62
- }
63
- }
64
- return result;
65
- }