@orgloop/agentctl 1.0.0 → 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.
package/README.md CHANGED
@@ -28,7 +28,7 @@ Over time, agentctl can extend to handle more concerns of headless coding — au
28
28
  ## Installation
29
29
 
30
30
  ```bash
31
- npm install -g agentctl
31
+ npm install -g @orgloop/agentctl
32
32
  ```
33
33
 
34
34
  Requires Node.js >= 20.
@@ -90,6 +90,11 @@ agentctl stop <id> [options]
90
90
  agentctl resume <id> <message> [options]
91
91
  --adapter <name> Adapter to use
92
92
 
93
+ # <message> is a continuation prompt sent to the agent.
94
+ # The agent receives it as new user input and resumes work.
95
+ # Example: resume a stopped session with a follow-up instruction
96
+ agentctl resume abc123 "fix the failing tests and re-run the suite"
97
+
93
98
  agentctl events [options]
94
99
  --json Output as NDJSON (default)
95
100
  ```
@@ -109,15 +114,68 @@ agentctl locks [options]
109
114
  --json Output as JSON
110
115
  ```
111
116
 
117
+ ### Lifecycle Hooks
118
+
119
+ Hooks are shell commands that run at specific points in a session's lifecycle. Pass them as flags to `launch` or `merge`:
120
+
121
+ ```bash
122
+ agentctl launch -p "implement feature X" \
123
+ --on-create "echo 'Session $AGENTCTL_SESSION_ID started'" \
124
+ --on-complete "npm test"
125
+
126
+ agentctl merge <id> \
127
+ --pre-merge "npm run lint && npm test" \
128
+ --post-merge "curl -X POST https://slack.example.com/webhook -d '{\"text\": \"PR merged\"}'"
129
+ ```
130
+
131
+ Available hooks:
132
+
133
+ | Hook | Trigger | Typical use |
134
+ |------|---------|-------------|
135
+ | `--on-create <script>` | After a session is created | Notify, set up environment |
136
+ | `--on-complete <script>` | After a session completes | Run tests, send alerts |
137
+ | `--pre-merge <script>` | Before `agentctl merge` commits | Lint, test, validate |
138
+ | `--post-merge <script>` | After `agentctl merge` pushes/opens PR | Notify, trigger CI |
139
+
140
+ Hook scripts receive context via environment variables:
141
+
142
+ | Variable | Description |
143
+ |----------|-------------|
144
+ | `AGENTCTL_SESSION_ID` | Session UUID |
145
+ | `AGENTCTL_CWD` | Working directory of the session |
146
+ | `AGENTCTL_ADAPTER` | Adapter name (e.g. `claude-code`) |
147
+ | `AGENTCTL_BRANCH` | Git branch (when using `--worktree`) |
148
+ | `AGENTCTL_EXIT_CODE` | Process exit code (in `--on-complete`) |
149
+
150
+ Hooks run with a 60-second timeout. If a hook fails, its stderr is printed but execution continues.
151
+
112
152
  ### Fuse Timers
113
153
 
114
- For Kind cluster management automatically shuts down clusters when sessions end.
154
+ Fuse timers provide automatic cleanup of [Kind](https://kind.sigs.k8s.io/) Kubernetes clusters tied to coding sessions. When a session exits, agentctl starts a countdown timer. If no new session starts in the same worktree directory before the timer expires, the associated Kind cluster is deleted to free resources.
155
+
156
+ This is useful when running agents in worktree-per-branch workflows where each branch has its own Kind cluster (e.g. `kindo-charlie-<branch>`). Without fuse timers, forgotten clusters accumulate and waste resources.
157
+
158
+ **How it works:**
159
+
160
+ 1. Agent session exits in a worktree directory (e.g. `~/code/mono-my-feature`)
161
+ 2. agentctl derives the cluster name (`kindo-charlie-my-feature`) and starts a fuse timer
162
+ 3. If the timer expires (default: configured at daemon startup), the cluster is deleted via `kind delete cluster`
163
+ 4. If a new session starts in the same directory before expiry, the fuse is cancelled
115
164
 
116
165
  ```bash
166
+ # List active fuse timers
117
167
  agentctl fuses [options]
118
168
  --json Output as JSON
119
169
  ```
120
170
 
171
+ Example output:
172
+
173
+ ```
174
+ Directory Cluster Expires In
175
+ ~/code/mono-feat-x kindo-charlie-feat-x 12m
176
+ ~/code/mono-hotfix kindo-charlie-hotfix 45m
177
+ ```
178
+
121
179
  ### Daemon
122
180
 
123
181
  The daemon provides session tracking, directory locks, fuse timers, and Prometheus metrics. It auto-starts on first `agentctl` command.
@@ -135,7 +193,76 @@ agentctl daemon install # Install macOS LaunchAgent (auto-start on login)
135
193
  agentctl daemon uninstall # Remove LaunchAgent
136
194
  ```
137
195
 
138
- Metrics are exposed at `http://localhost:9200/metrics` in Prometheus text format.
196
+ Metrics are exposed at `http://localhost:9200/metrics` in Prometheus text format. See [Prometheus Metrics](#prometheus-metrics) for the full list.
197
+
198
+ ### Events
199
+
200
+ `agentctl events` streams session lifecycle events as NDJSON (newline-delimited JSON). Each line is a self-contained JSON object:
201
+
202
+ ```json
203
+ {"type":"session.started","adapter":"claude-code","sessionId":"abc123...","timestamp":"2025-06-15T10:30:00.000Z","session":{...}}
204
+ {"type":"session.stopped","adapter":"claude-code","sessionId":"abc123...","timestamp":"2025-06-15T11:00:00.000Z","session":{...}}
205
+ {"type":"session.idle","adapter":"claude-code","sessionId":"def456...","timestamp":"2025-06-15T11:05:00.000Z","session":{...}}
206
+ ```
207
+
208
+ Event types: `session.started`, `session.stopped`, `session.idle`, `session.error`.
209
+
210
+ The `session` field contains the full session object (id, adapter, status, cwd, model, prompt, tokens, etc.).
211
+
212
+ **Piping events into OrgLoop (or similar event routers):**
213
+
214
+ ```bash
215
+ # Pipe lifecycle events to OrgLoop's webhook endpoint
216
+ agentctl events | while IFS= read -r event; do
217
+ curl -s -X POST https://orgloop.example.com/hooks/agentctl \
218
+ -H "Content-Type: application/json" \
219
+ -d "$event"
220
+ done
221
+
222
+ # Or use a persistent pipe with jq filtering
223
+ agentctl events | jq -c 'select(.type == "session.stopped")' | while IFS= read -r event; do
224
+ curl -s -X POST https://orgloop.example.com/hooks/session-ended \
225
+ -H "Content-Type: application/json" \
226
+ -d "$event"
227
+ done
228
+ ```
229
+
230
+ This pattern works with any webhook-based system — OrgLoop, Zapier, n8n, or a custom event router. The NDJSON format is compatible with standard Unix tools (`jq`, `grep`, `awk`) for filtering and transformation.
231
+
232
+ ## Prometheus Metrics
233
+
234
+ The daemon exposes metrics at `http://localhost:9200/metrics` in Prometheus text format. Default port is 9200, configurable via `--metrics-port`.
235
+
236
+ ### Gauges
237
+
238
+ | Metric | Labels | Description |
239
+ |--------|--------|-------------|
240
+ | `agentctl_sessions_active` | — | Number of currently active sessions |
241
+ | `agentctl_locks_active` | `type="auto"\|"manual"` | Number of active directory locks by type |
242
+ | `agentctl_fuses_active` | — | Number of active fuse timers |
243
+
244
+ ### Counters
245
+
246
+ | Metric | Labels | Description |
247
+ |--------|--------|-------------|
248
+ | `agentctl_sessions_total` | `status="completed"\|"failed"\|"stopped"` | Total sessions by final status |
249
+ | `agentctl_fuses_fired_total` | — | Total fuse timers that fired (clusters deleted) |
250
+ | `agentctl_kind_clusters_deleted_total` | — | Total Kind clusters deleted by fuse timers |
251
+
252
+ ### Histogram
253
+
254
+ | Metric | Buckets (seconds) | Description |
255
+ |--------|-------------------|-------------|
256
+ | `agentctl_session_duration_seconds` | 60, 300, 600, 1800, 3600, 7200, +Inf | Session duration distribution |
257
+
258
+ Example scrape config for Prometheus:
259
+
260
+ ```yaml
261
+ scrape_configs:
262
+ - job_name: agentctl
263
+ static_configs:
264
+ - targets: ["localhost:9200"]
265
+ ```
139
266
 
140
267
  ## Architecture
141
268
 
@@ -155,6 +282,8 @@ Reads session data from `~/.claude/projects/` and cross-references with running
155
282
 
156
283
  Connects to the OpenClaw gateway via WebSocket RPC. Read-only — sessions are managed through the gateway.
157
284
 
285
+ Requires the `OPENCLAW_WEBHOOK_TOKEN` environment variable. The adapter warns clearly if the token is missing or authentication fails.
286
+
158
287
  ### Writing an Adapter
159
288
 
160
289
  Implement the `AgentAdapter` interface:
@@ -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({
@@ -17,6 +17,11 @@ export class OpenClawAdapter {
17
17
  this.rpcCall = opts?.rpcCall || this.defaultRpcCall.bind(this);
18
18
  }
19
19
  async list(opts) {
20
+ if (!this.authToken) {
21
+ console.warn("Warning: OPENCLAW_WEBHOOK_TOKEN is not set — OpenClaw adapter cannot authenticate. " +
22
+ "Set this environment variable to connect to the gateway.");
23
+ return [];
24
+ }
20
25
  let result;
21
26
  try {
22
27
  result = (await this.rpcCall("sessions.list", {
@@ -24,8 +29,16 @@ export class OpenClawAdapter {
24
29
  includeLastMessage: true,
25
30
  }));
26
31
  }
27
- catch {
28
- // Gateway unreachable return empty
32
+ catch (err) {
33
+ const msg = err?.message || "unknown error";
34
+ if (msg.includes("auth") || msg.includes("Auth")) {
35
+ console.warn(`Warning: OpenClaw gateway authentication failed: ${msg}. ` +
36
+ "Check that OPENCLAW_WEBHOOK_TOKEN is valid.");
37
+ }
38
+ else {
39
+ console.warn(`Warning: OpenClaw gateway unreachable (${msg}). ` +
40
+ `Is the gateway running at ${this.baseUrl}?`);
41
+ }
29
42
  return [];
30
43
  }
31
44
  let sessions = result.sessions.map((row) => this.mapRowToSession(row, result.defaults));
@@ -67,6 +80,9 @@ export class OpenClawAdapter {
67
80
  return assistantMessages.slice(-limit).join("\n---\n");
68
81
  }
69
82
  async status(sessionId) {
83
+ if (!this.authToken) {
84
+ throw new Error("OPENCLAW_WEBHOOK_TOKEN is not set — cannot connect to OpenClaw gateway");
85
+ }
70
86
  let result;
71
87
  try {
72
88
  result = (await this.rpcCall("sessions.list", {
@@ -184,14 +200,17 @@ export class OpenClawAdapter {
184
200
  * Resolve a sessionId (or prefix) to a gateway session key.
185
201
  */
186
202
  async resolveKey(sessionId) {
203
+ if (!this.authToken) {
204
+ throw new Error("OPENCLAW_WEBHOOK_TOKEN is not set — cannot connect to OpenClaw gateway");
205
+ }
187
206
  let result;
188
207
  try {
189
208
  result = (await this.rpcCall("sessions.list", {
190
209
  search: sessionId,
191
210
  }));
192
211
  }
193
- catch {
194
- return null;
212
+ catch (err) {
213
+ throw new Error(`Failed to resolve session ${sessionId}: ${err.message}`);
195
214
  }
196
215
  const row = result.sessions.find((s) => s.sessionId === sessionId ||
197
216
  s.key === sessionId ||
package/dist/cli.js CHANGED
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
3
  import fs from "node:fs/promises";
4
+ import { createRequire } from "node:module";
4
5
  import os from "node:os";
6
+ const require = createRequire(import.meta.url);
7
+ const { version: PKG_VERSION } = require("../package.json");
5
8
  import path from "node:path";
6
9
  import { fileURLToPath } from "node:url";
7
10
  import { Command } from "commander";
@@ -158,7 +161,7 @@ const program = new Command();
158
161
  program
159
162
  .name("agentctl")
160
163
  .description("Universal agent supervision interface")
161
- .version("0.3.0");
164
+ .version(PKG_VERSION);
162
165
  // list
163
166
  program
164
167
  .command("list")
@@ -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.0",
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": {