@orgloop/agentctl 1.0.1 → 1.1.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.
@@ -148,7 +148,7 @@ export class ClaudeCodeAdapter {
148
148
  if (pid) {
149
149
  resolvedSessionId = await this.pollForSessionId(logPath, pid, 5000);
150
150
  }
151
- const sessionId = resolvedSessionId || crypto.randomUUID();
151
+ const sessionId = resolvedSessionId || (pid ? `pending-${pid}` : crypto.randomUUID());
152
152
  // Persist session metadata so status checks work after wrapper exits
153
153
  if (pid) {
154
154
  await this.writeSessionMeta({
@@ -3,16 +3,25 @@ import type { SessionRecord, StateManager } from "./state.js";
3
3
  export interface SessionTrackerOpts {
4
4
  adapters: Record<string, AgentAdapter>;
5
5
  pollIntervalMs?: number;
6
+ /** Override PID liveness check for testing (default: process.kill(pid, 0)) */
7
+ isProcessAlive?: (pid: number) => boolean;
6
8
  }
7
9
  export declare class SessionTracker {
8
10
  private state;
9
11
  private adapters;
10
12
  private pollIntervalMs;
11
13
  private pollHandle;
14
+ private readonly isProcessAlive;
12
15
  constructor(state: StateManager, opts: SessionTrackerOpts);
13
16
  startPolling(): void;
14
17
  stopPolling(): void;
15
18
  private poll;
19
+ /**
20
+ * Clean up ghost sessions in the daemon state:
21
+ * - pending-* entries whose PID matches a resolved session → remove pending
22
+ * - Any "running"/"idle" session in state whose PID is dead → mark stopped
23
+ */
24
+ private reapStaleEntries;
16
25
  /** Track a newly launched session */
17
26
  track(session: AgentSession, adapterName: string): SessionRecord;
18
27
  /** Get session record by id (exact or prefix) */
@@ -3,10 +3,12 @@ export class SessionTracker {
3
3
  adapters;
4
4
  pollIntervalMs;
5
5
  pollHandle = null;
6
+ isProcessAlive;
6
7
  constructor(state, opts) {
7
8
  this.state = state;
8
9
  this.adapters = opts.adapters;
9
10
  this.pollIntervalMs = opts.pollIntervalMs ?? 5000;
11
+ this.isProcessAlive = opts.isProcessAlive ?? defaultIsProcessAlive;
10
12
  }
11
13
  startPolling() {
12
14
  if (this.pollHandle)
@@ -24,10 +26,15 @@ export class SessionTracker {
24
26
  }
25
27
  }
26
28
  async poll() {
29
+ // Collect PIDs from all adapter-returned sessions (the source of truth)
30
+ const adapterPidToId = new Map();
27
31
  for (const [adapterName, adapter] of Object.entries(this.adapters)) {
28
32
  try {
29
33
  const sessions = await adapter.list({ all: true });
30
34
  for (const session of sessions) {
35
+ if (session.pid) {
36
+ adapterPidToId.set(session.pid, session.id);
37
+ }
31
38
  const existing = this.state.getSession(session.id);
32
39
  const record = sessionToRecord(session, adapterName);
33
40
  if (!existing) {
@@ -49,6 +56,43 @@ export class SessionTracker {
49
56
  // Adapter unavailable — skip
50
57
  }
51
58
  }
59
+ // Reap stale entries from daemon state
60
+ this.reapStaleEntries(adapterPidToId);
61
+ }
62
+ /**
63
+ * Clean up ghost sessions in the daemon state:
64
+ * - pending-* entries whose PID matches a resolved session → remove pending
65
+ * - Any "running"/"idle" session in state whose PID is dead → mark stopped
66
+ */
67
+ reapStaleEntries(adapterPidToId) {
68
+ const sessions = this.state.getSessions();
69
+ for (const [id, record] of Object.entries(sessions)) {
70
+ // Bug 2: If this is a pending-* entry and a real session has the same PID,
71
+ // the pending entry is stale — remove it
72
+ if (id.startsWith("pending-") && record.pid) {
73
+ const resolvedId = adapterPidToId.get(record.pid);
74
+ if (resolvedId && resolvedId !== id) {
75
+ this.state.removeSession(id);
76
+ continue;
77
+ }
78
+ }
79
+ // Bug 1: If session is "running"/"idle" but PID is dead, mark stopped
80
+ if ((record.status === "running" || record.status === "idle") &&
81
+ record.pid) {
82
+ // Only reap if the adapter didn't return this session as running
83
+ // (adapter is the source of truth for sessions it knows about)
84
+ const adapterId = adapterPidToId.get(record.pid);
85
+ if (adapterId === id)
86
+ continue; // Adapter confirmed this PID is active
87
+ if (!this.isProcessAlive(record.pid)) {
88
+ this.state.setSession(id, {
89
+ ...record,
90
+ status: "stopped",
91
+ stoppedAt: new Date().toISOString(),
92
+ });
93
+ }
94
+ }
95
+ }
52
96
  }
53
97
  /** Track a newly launched session */
54
98
  track(session, adapterName) {
@@ -79,6 +123,8 @@ export class SessionTracker {
79
123
  else if (!opts?.all) {
80
124
  filtered = filtered.filter((s) => s.status === "running" || s.status === "idle");
81
125
  }
126
+ // Dedup: if a pending-* entry shares a PID with a resolved entry, show only the resolved one
127
+ filtered = deduplicatePendingSessions(filtered);
82
128
  return filtered.sort((a, b) => {
83
129
  // Running first, then by recency
84
130
  if (a.status === "running" && b.status !== "running")
@@ -102,6 +148,34 @@ export class SessionTracker {
102
148
  return session;
103
149
  }
104
150
  }
151
+ /** Check if a process is alive via kill(pid, 0) signal check */
152
+ function defaultIsProcessAlive(pid) {
153
+ try {
154
+ process.kill(pid, 0);
155
+ return true;
156
+ }
157
+ catch {
158
+ return false;
159
+ }
160
+ }
161
+ /**
162
+ * Remove pending-* entries that share a PID with a resolved (non-pending) session.
163
+ * This is a safety net for list output — the poll() reaper handles cleanup in state.
164
+ */
165
+ function deduplicatePendingSessions(sessions) {
166
+ const realPids = new Set();
167
+ for (const s of sessions) {
168
+ if (!s.id.startsWith("pending-") && s.pid) {
169
+ realPids.add(s.pid);
170
+ }
171
+ }
172
+ return sessions.filter((s) => {
173
+ if (s.id.startsWith("pending-") && s.pid && realPids.has(s.pid)) {
174
+ return false;
175
+ }
176
+ return true;
177
+ });
178
+ }
105
179
  function sessionToRecord(session, adapterName) {
106
180
  return {
107
181
  id: session.id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orgloop/agentctl",
3
- "version": "1.0.1",
3
+ "version": "1.1.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": {