@orgloop/agentctl 1.0.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/LICENSE +21 -0
- package/README.md +228 -0
- package/dist/adapters/claude-code.d.ts +83 -0
- package/dist/adapters/claude-code.js +783 -0
- package/dist/adapters/openclaw.d.ts +88 -0
- package/dist/adapters/openclaw.js +297 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +808 -0
- package/dist/client/daemon-client.d.ts +6 -0
- package/dist/client/daemon-client.js +81 -0
- package/dist/compat-shim.d.ts +2 -0
- package/dist/compat-shim.js +15 -0
- package/dist/core/types.d.ts +68 -0
- package/dist/core/types.js +2 -0
- package/dist/daemon/fuse-engine.d.ts +30 -0
- package/dist/daemon/fuse-engine.js +118 -0
- package/dist/daemon/launchagent.d.ts +7 -0
- package/dist/daemon/launchagent.js +49 -0
- package/dist/daemon/lock-manager.d.ts +16 -0
- package/dist/daemon/lock-manager.js +71 -0
- package/dist/daemon/metrics.d.ts +20 -0
- package/dist/daemon/metrics.js +72 -0
- package/dist/daemon/server.d.ts +33 -0
- package/dist/daemon/server.js +283 -0
- package/dist/daemon/session-tracker.d.ts +28 -0
- package/dist/daemon/session-tracker.js +121 -0
- package/dist/daemon/state.d.ts +61 -0
- package/dist/daemon/state.js +126 -0
- package/dist/daemon/supervisor.d.ts +24 -0
- package/dist/daemon/supervisor.js +79 -0
- package/dist/hooks.d.ts +19 -0
- package/dist/hooks.js +39 -0
- package/dist/merge.d.ts +24 -0
- package/dist/merge.js +65 -0
- package/dist/migration/migrate-locks.d.ts +5 -0
- package/dist/migration/migrate-locks.js +41 -0
- package/dist/worktree.d.ts +24 -0
- package/dist/worktree.js +65 -0
- package/package.json +60 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
import { ClaudeCodeAdapter } from "./adapters/claude-code.js";
|
|
9
|
+
import { OpenClawAdapter } from "./adapters/openclaw.js";
|
|
10
|
+
import { DaemonClient } from "./client/daemon-client.js";
|
|
11
|
+
import { runHook } from "./hooks.js";
|
|
12
|
+
import { mergeSession } from "./merge.js";
|
|
13
|
+
import { createWorktree } from "./worktree.js";
|
|
14
|
+
const adapters = {
|
|
15
|
+
"claude-code": new ClaudeCodeAdapter(),
|
|
16
|
+
openclaw: new OpenClawAdapter(),
|
|
17
|
+
};
|
|
18
|
+
const client = new DaemonClient();
|
|
19
|
+
/**
|
|
20
|
+
* Ensure the daemon is running. Auto-starts it if not.
|
|
21
|
+
* Returns true if daemon is available after the call.
|
|
22
|
+
*/
|
|
23
|
+
async function ensureDaemon() {
|
|
24
|
+
if (await client.isRunning())
|
|
25
|
+
return true;
|
|
26
|
+
// Auto-start daemon in background
|
|
27
|
+
try {
|
|
28
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
29
|
+
const logDir = path.join(os.homedir(), ".agentctl");
|
|
30
|
+
await fs.mkdir(logDir, { recursive: true });
|
|
31
|
+
const child = spawn(process.execPath, [__filename, "daemon", "start", "--supervised"], {
|
|
32
|
+
detached: true,
|
|
33
|
+
stdio: [
|
|
34
|
+
"ignore",
|
|
35
|
+
(await fs.open(path.join(logDir, "daemon.stdout.log"), "a")).fd,
|
|
36
|
+
(await fs.open(path.join(logDir, "daemon.stderr.log"), "a")).fd,
|
|
37
|
+
],
|
|
38
|
+
});
|
|
39
|
+
child.unref();
|
|
40
|
+
// Wait briefly for daemon to be ready (up to 3s)
|
|
41
|
+
for (let i = 0; i < 30; i++) {
|
|
42
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
43
|
+
if (await client.isRunning())
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Failed to auto-start — fall through to direct mode
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
function getAdapter(name) {
|
|
53
|
+
if (!name) {
|
|
54
|
+
return adapters["claude-code"];
|
|
55
|
+
}
|
|
56
|
+
const adapter = adapters[name];
|
|
57
|
+
if (!adapter) {
|
|
58
|
+
console.error(`Unknown adapter: ${name}`);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
return adapter;
|
|
62
|
+
}
|
|
63
|
+
function getAllAdapters() {
|
|
64
|
+
return Object.values(adapters);
|
|
65
|
+
}
|
|
66
|
+
// --- Formatters ---
|
|
67
|
+
function formatSession(s) {
|
|
68
|
+
return {
|
|
69
|
+
ID: s.id.slice(0, 8),
|
|
70
|
+
Status: s.status,
|
|
71
|
+
Model: s.model || "-",
|
|
72
|
+
CWD: s.cwd ? shortenPath(s.cwd) : "-",
|
|
73
|
+
PID: s.pid?.toString() || "-",
|
|
74
|
+
Started: timeAgo(s.startedAt),
|
|
75
|
+
Prompt: (s.prompt || "-").slice(0, 60),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function formatRecord(s) {
|
|
79
|
+
return {
|
|
80
|
+
ID: s.id.slice(0, 8),
|
|
81
|
+
Status: s.status,
|
|
82
|
+
Model: s.model || "-",
|
|
83
|
+
CWD: s.cwd ? shortenPath(s.cwd) : "-",
|
|
84
|
+
PID: s.pid?.toString() || "-",
|
|
85
|
+
Started: timeAgo(new Date(s.startedAt)),
|
|
86
|
+
Prompt: (s.prompt || "-").slice(0, 60),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function shortenPath(p) {
|
|
90
|
+
const home = process.env.HOME || "";
|
|
91
|
+
if (p.startsWith(home))
|
|
92
|
+
return `~${p.slice(home.length)}`;
|
|
93
|
+
return p;
|
|
94
|
+
}
|
|
95
|
+
function timeAgo(date) {
|
|
96
|
+
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
|
97
|
+
if (seconds < 60)
|
|
98
|
+
return `${seconds}s ago`;
|
|
99
|
+
const minutes = Math.floor(seconds / 60);
|
|
100
|
+
if (minutes < 60)
|
|
101
|
+
return `${minutes}m ago`;
|
|
102
|
+
const hours = Math.floor(minutes / 60);
|
|
103
|
+
if (hours < 24)
|
|
104
|
+
return `${hours}h ago`;
|
|
105
|
+
const days = Math.floor(hours / 24);
|
|
106
|
+
return `${days}d ago`;
|
|
107
|
+
}
|
|
108
|
+
function formatDuration(ms) {
|
|
109
|
+
if (ms < 0)
|
|
110
|
+
return "expired";
|
|
111
|
+
const seconds = Math.floor(ms / 1000);
|
|
112
|
+
if (seconds < 60)
|
|
113
|
+
return `${seconds}s`;
|
|
114
|
+
const minutes = Math.floor(seconds / 60);
|
|
115
|
+
if (minutes < 60)
|
|
116
|
+
return `${minutes}m`;
|
|
117
|
+
const hours = Math.floor(minutes / 60);
|
|
118
|
+
return `${hours}h ${minutes % 60}m`;
|
|
119
|
+
}
|
|
120
|
+
function printTable(rows) {
|
|
121
|
+
if (rows.length === 0) {
|
|
122
|
+
console.log("No sessions found.");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const keys = Object.keys(rows[0]);
|
|
126
|
+
const widths = keys.map((k) => Math.max(k.length, ...rows.map((r) => (r[k] || "").length)));
|
|
127
|
+
const header = keys.map((k, i) => k.padEnd(widths[i])).join(" ");
|
|
128
|
+
console.log(header);
|
|
129
|
+
console.log(widths.map((w) => "-".repeat(w)).join(" "));
|
|
130
|
+
for (const row of rows) {
|
|
131
|
+
const line = keys
|
|
132
|
+
.map((k, i) => (row[k] || "").padEnd(widths[i]))
|
|
133
|
+
.join(" ");
|
|
134
|
+
console.log(line);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function printJson(data) {
|
|
138
|
+
console.log(JSON.stringify(data, null, 2));
|
|
139
|
+
}
|
|
140
|
+
function sessionToJson(s) {
|
|
141
|
+
return {
|
|
142
|
+
id: s.id,
|
|
143
|
+
adapter: s.adapter,
|
|
144
|
+
status: s.status,
|
|
145
|
+
startedAt: s.startedAt.toISOString(),
|
|
146
|
+
stoppedAt: s.stoppedAt?.toISOString(),
|
|
147
|
+
cwd: s.cwd,
|
|
148
|
+
model: s.model,
|
|
149
|
+
prompt: s.prompt,
|
|
150
|
+
tokens: s.tokens,
|
|
151
|
+
cost: s.cost,
|
|
152
|
+
pid: s.pid,
|
|
153
|
+
meta: s.meta,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
// --- CLI ---
|
|
157
|
+
const program = new Command();
|
|
158
|
+
program
|
|
159
|
+
.name("agentctl")
|
|
160
|
+
.description("Universal agent supervision interface")
|
|
161
|
+
.version("0.3.0");
|
|
162
|
+
// list
|
|
163
|
+
program
|
|
164
|
+
.command("list")
|
|
165
|
+
.description("List agent sessions")
|
|
166
|
+
.option("--adapter <name>", "Filter by adapter")
|
|
167
|
+
.option("--status <status>", "Filter by status (running|stopped|idle|error)")
|
|
168
|
+
.option("-a, --all", "Include stopped sessions (last 7 days)")
|
|
169
|
+
.option("--json", "Output as JSON")
|
|
170
|
+
.action(async (opts) => {
|
|
171
|
+
const daemonRunning = await ensureDaemon();
|
|
172
|
+
if (daemonRunning) {
|
|
173
|
+
const sessions = await client.call("session.list", {
|
|
174
|
+
status: opts.status,
|
|
175
|
+
all: opts.all,
|
|
176
|
+
});
|
|
177
|
+
if (opts.json) {
|
|
178
|
+
printJson(sessions);
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
printTable(sessions.map(formatRecord));
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
// Direct fallback
|
|
186
|
+
const listOpts = { status: opts.status, all: opts.all };
|
|
187
|
+
let sessions = [];
|
|
188
|
+
if (opts.adapter) {
|
|
189
|
+
const adapter = getAdapter(opts.adapter);
|
|
190
|
+
sessions = await adapter.list(listOpts);
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
for (const adapter of getAllAdapters()) {
|
|
194
|
+
const s = await adapter.list(listOpts);
|
|
195
|
+
sessions.push(...s);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (opts.json) {
|
|
199
|
+
printJson(sessions.map(sessionToJson));
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
printTable(sessions.map(formatSession));
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
// status
|
|
206
|
+
program
|
|
207
|
+
.command("status <id>")
|
|
208
|
+
.description("Show detailed session status")
|
|
209
|
+
.option("--adapter <name>", "Adapter to use")
|
|
210
|
+
.option("--json", "Output as JSON")
|
|
211
|
+
.action(async (id, opts) => {
|
|
212
|
+
const daemonRunning = await ensureDaemon();
|
|
213
|
+
if (daemonRunning) {
|
|
214
|
+
try {
|
|
215
|
+
const session = await client.call("session.status", {
|
|
216
|
+
id,
|
|
217
|
+
});
|
|
218
|
+
if (opts.json) {
|
|
219
|
+
printJson(session);
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
const fmt = formatRecord(session);
|
|
223
|
+
for (const [k, v] of Object.entries(fmt)) {
|
|
224
|
+
console.log(`${k.padEnd(10)} ${v}`);
|
|
225
|
+
}
|
|
226
|
+
if (session.tokens) {
|
|
227
|
+
console.log(`Tokens in: ${session.tokens.in}, out: ${session.tokens.out}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
console.error(err.message);
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// Direct fallback
|
|
238
|
+
const adapter = getAdapter(opts.adapter);
|
|
239
|
+
try {
|
|
240
|
+
const session = await adapter.status(id);
|
|
241
|
+
if (opts.json) {
|
|
242
|
+
printJson(sessionToJson(session));
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
const fmt = formatSession(session);
|
|
246
|
+
for (const [k, v] of Object.entries(fmt)) {
|
|
247
|
+
console.log(`${k.padEnd(10)} ${v}`);
|
|
248
|
+
}
|
|
249
|
+
if (session.tokens) {
|
|
250
|
+
console.log(`Tokens in: ${session.tokens.in}, out: ${session.tokens.out}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
console.error(err.message);
|
|
256
|
+
process.exit(1);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
// peek
|
|
260
|
+
program
|
|
261
|
+
.command("peek <id>")
|
|
262
|
+
.description("Peek at recent output from a session")
|
|
263
|
+
.option("-n, --lines <n>", "Number of recent messages", "20")
|
|
264
|
+
.option("--adapter <name>", "Adapter to use")
|
|
265
|
+
.action(async (id, opts) => {
|
|
266
|
+
const daemonRunning = await ensureDaemon();
|
|
267
|
+
if (daemonRunning) {
|
|
268
|
+
try {
|
|
269
|
+
const output = await client.call("session.peek", {
|
|
270
|
+
id,
|
|
271
|
+
lines: Number.parseInt(opts.lines, 10),
|
|
272
|
+
});
|
|
273
|
+
console.log(output);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
console.error(err.message);
|
|
278
|
+
process.exit(1);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const adapter = getAdapter(opts.adapter);
|
|
282
|
+
try {
|
|
283
|
+
const output = await adapter.peek(id, {
|
|
284
|
+
lines: Number.parseInt(opts.lines, 10),
|
|
285
|
+
});
|
|
286
|
+
console.log(output);
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
289
|
+
console.error(err.message);
|
|
290
|
+
process.exit(1);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
// stop
|
|
294
|
+
program
|
|
295
|
+
.command("stop <id>")
|
|
296
|
+
.description("Stop a running session")
|
|
297
|
+
.option("--force", "Force kill (SIGINT then SIGKILL)")
|
|
298
|
+
.option("--adapter <name>", "Adapter to use")
|
|
299
|
+
.action(async (id, opts) => {
|
|
300
|
+
const daemonRunning = await ensureDaemon();
|
|
301
|
+
if (daemonRunning) {
|
|
302
|
+
try {
|
|
303
|
+
await client.call("session.stop", {
|
|
304
|
+
id,
|
|
305
|
+
force: opts.force,
|
|
306
|
+
});
|
|
307
|
+
console.log(`Stopped session ${id.slice(0, 8)}`);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
console.error(err.message);
|
|
312
|
+
process.exit(1);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const adapter = getAdapter(opts.adapter);
|
|
316
|
+
try {
|
|
317
|
+
await adapter.stop(id, { force: opts.force });
|
|
318
|
+
console.log(`Stopped session ${id.slice(0, 8)}`);
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
console.error(err.message);
|
|
322
|
+
process.exit(1);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
// resume
|
|
326
|
+
program
|
|
327
|
+
.command("resume <id> <message>")
|
|
328
|
+
.description("Resume a session with a new message")
|
|
329
|
+
.option("--adapter <name>", "Adapter to use")
|
|
330
|
+
.action(async (id, message, opts) => {
|
|
331
|
+
const daemonRunning = await ensureDaemon();
|
|
332
|
+
if (daemonRunning) {
|
|
333
|
+
try {
|
|
334
|
+
await client.call("session.resume", { id, message });
|
|
335
|
+
console.log(`Resumed session ${id.slice(0, 8)}`);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
catch (err) {
|
|
339
|
+
console.error(err.message);
|
|
340
|
+
process.exit(1);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
const adapter = getAdapter(opts.adapter);
|
|
344
|
+
try {
|
|
345
|
+
await adapter.resume(id, message);
|
|
346
|
+
console.log(`Resumed session ${id.slice(0, 8)}`);
|
|
347
|
+
}
|
|
348
|
+
catch (err) {
|
|
349
|
+
console.error(err.message);
|
|
350
|
+
process.exit(1);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
// launch
|
|
354
|
+
program
|
|
355
|
+
.command("launch [adapter]")
|
|
356
|
+
.description("Launch a new agent session")
|
|
357
|
+
.requiredOption("-p, --prompt <text>", "Prompt to send")
|
|
358
|
+
.option("--spec <path>", "Spec file path")
|
|
359
|
+
.option("--cwd <dir>", "Working directory")
|
|
360
|
+
.option("--model <model>", "Model to use (e.g. sonnet, opus)")
|
|
361
|
+
.option("--force", "Override directory locks")
|
|
362
|
+
.option("--worktree <repo>", "Auto-create git worktree from this repo before launch")
|
|
363
|
+
.option("--branch <name>", "Branch name for --worktree")
|
|
364
|
+
.option("--on-create <script>", "Hook: run after session is created")
|
|
365
|
+
.option("--on-complete <script>", "Hook: run after session completes")
|
|
366
|
+
.option("--pre-merge <script>", "Hook: run before merge")
|
|
367
|
+
.option("--post-merge <script>", "Hook: run after merge")
|
|
368
|
+
.action(async (adapterName, opts) => {
|
|
369
|
+
let cwd = opts.cwd ? path.resolve(opts.cwd) : process.cwd();
|
|
370
|
+
const name = adapterName || "claude-code";
|
|
371
|
+
// FEAT-1: Worktree lifecycle
|
|
372
|
+
let worktreeInfo;
|
|
373
|
+
if (opts.worktree) {
|
|
374
|
+
if (!opts.branch) {
|
|
375
|
+
console.error("--branch is required when using --worktree");
|
|
376
|
+
process.exit(1);
|
|
377
|
+
}
|
|
378
|
+
try {
|
|
379
|
+
worktreeInfo = await createWorktree({
|
|
380
|
+
repo: opts.worktree,
|
|
381
|
+
branch: opts.branch,
|
|
382
|
+
});
|
|
383
|
+
cwd = worktreeInfo.path;
|
|
384
|
+
console.log(`Worktree created: ${worktreeInfo.path}`);
|
|
385
|
+
}
|
|
386
|
+
catch (err) {
|
|
387
|
+
console.error(`Failed to create worktree: ${err.message}`);
|
|
388
|
+
process.exit(1);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
// Collect hooks
|
|
392
|
+
const hooks = opts.onCreate || opts.onComplete || opts.preMerge || opts.postMerge
|
|
393
|
+
? {
|
|
394
|
+
onCreate: opts.onCreate,
|
|
395
|
+
onComplete: opts.onComplete,
|
|
396
|
+
preMerge: opts.preMerge,
|
|
397
|
+
postMerge: opts.postMerge,
|
|
398
|
+
}
|
|
399
|
+
: undefined;
|
|
400
|
+
const daemonRunning = await ensureDaemon();
|
|
401
|
+
if (daemonRunning) {
|
|
402
|
+
try {
|
|
403
|
+
const session = await client.call("session.launch", {
|
|
404
|
+
adapter: name,
|
|
405
|
+
prompt: opts.prompt,
|
|
406
|
+
cwd,
|
|
407
|
+
spec: opts.spec,
|
|
408
|
+
model: opts.model,
|
|
409
|
+
force: opts.force,
|
|
410
|
+
worktree: worktreeInfo
|
|
411
|
+
? { repo: worktreeInfo.repo, branch: worktreeInfo.branch }
|
|
412
|
+
: undefined,
|
|
413
|
+
hooks,
|
|
414
|
+
});
|
|
415
|
+
console.log(`Launched session ${session.id.slice(0, 8)} (PID: ${session.pid})`);
|
|
416
|
+
// Run onCreate hook
|
|
417
|
+
if (hooks?.onCreate) {
|
|
418
|
+
await runHook(hooks, "onCreate", {
|
|
419
|
+
sessionId: session.id,
|
|
420
|
+
cwd,
|
|
421
|
+
adapter: name,
|
|
422
|
+
branch: opts.branch,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
catch (err) {
|
|
428
|
+
console.error(err.message);
|
|
429
|
+
process.exit(1);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
// Direct fallback
|
|
433
|
+
if (!opts.force) {
|
|
434
|
+
console.error("Warning: Daemon not running, launching without lock protection");
|
|
435
|
+
}
|
|
436
|
+
const adapter = getAdapter(name);
|
|
437
|
+
try {
|
|
438
|
+
const session = await adapter.launch({
|
|
439
|
+
adapter: name,
|
|
440
|
+
prompt: opts.prompt,
|
|
441
|
+
spec: opts.spec,
|
|
442
|
+
cwd,
|
|
443
|
+
model: opts.model,
|
|
444
|
+
hooks,
|
|
445
|
+
});
|
|
446
|
+
console.log(`Launched session ${session.id.slice(0, 8)} (PID: ${session.pid})`);
|
|
447
|
+
// Run onCreate hook
|
|
448
|
+
if (hooks?.onCreate) {
|
|
449
|
+
await runHook(hooks, "onCreate", {
|
|
450
|
+
sessionId: session.id,
|
|
451
|
+
cwd,
|
|
452
|
+
adapter: name,
|
|
453
|
+
branch: opts.branch,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
catch (err) {
|
|
458
|
+
console.error(err.message);
|
|
459
|
+
process.exit(1);
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
// events
|
|
463
|
+
program
|
|
464
|
+
.command("events")
|
|
465
|
+
.description("Stream lifecycle events")
|
|
466
|
+
.option("--json", "Output as NDJSON (default)")
|
|
467
|
+
.action(async () => {
|
|
468
|
+
const adapter = getAdapter("claude-code");
|
|
469
|
+
for await (const event of adapter.events()) {
|
|
470
|
+
const out = {
|
|
471
|
+
type: event.type,
|
|
472
|
+
adapter: event.adapter,
|
|
473
|
+
sessionId: event.sessionId,
|
|
474
|
+
timestamp: event.timestamp.toISOString(),
|
|
475
|
+
session: sessionToJson(event.session),
|
|
476
|
+
meta: event.meta,
|
|
477
|
+
};
|
|
478
|
+
console.log(JSON.stringify(out));
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
// --- Merge command (FEAT-4) ---
|
|
482
|
+
program
|
|
483
|
+
.command("merge <id>")
|
|
484
|
+
.description("Commit, push, and open PR for a session's work")
|
|
485
|
+
.option("-m, --message <text>", "Commit message")
|
|
486
|
+
.option("--remove-worktree", "Remove worktree after merge")
|
|
487
|
+
.option("--repo <path>", "Main repo path (for worktree removal)")
|
|
488
|
+
.option("--pre-merge <script>", "Hook: run before merge")
|
|
489
|
+
.option("--post-merge <script>", "Hook: run after merge")
|
|
490
|
+
.action(async (id, opts) => {
|
|
491
|
+
// Find session
|
|
492
|
+
const daemonRunning = await ensureDaemon();
|
|
493
|
+
let sessionCwd;
|
|
494
|
+
let sessionAdapter;
|
|
495
|
+
if (daemonRunning) {
|
|
496
|
+
try {
|
|
497
|
+
const session = await client.call("session.status", {
|
|
498
|
+
id,
|
|
499
|
+
});
|
|
500
|
+
sessionCwd = session.cwd;
|
|
501
|
+
sessionAdapter = session.adapter;
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
// Fall through to adapter
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if (!sessionCwd) {
|
|
508
|
+
const adapter = getAdapter();
|
|
509
|
+
try {
|
|
510
|
+
const session = await adapter.status(id);
|
|
511
|
+
sessionCwd = session.cwd;
|
|
512
|
+
sessionAdapter = session.adapter;
|
|
513
|
+
}
|
|
514
|
+
catch (err) {
|
|
515
|
+
console.error(`Session not found: ${err.message}`);
|
|
516
|
+
process.exit(1);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if (!sessionCwd) {
|
|
520
|
+
console.error("Cannot determine session working directory");
|
|
521
|
+
process.exit(1);
|
|
522
|
+
}
|
|
523
|
+
const hooks = opts.preMerge || opts.postMerge
|
|
524
|
+
? { preMerge: opts.preMerge, postMerge: opts.postMerge }
|
|
525
|
+
: undefined;
|
|
526
|
+
// Pre-merge hook
|
|
527
|
+
if (hooks?.preMerge) {
|
|
528
|
+
await runHook(hooks, "preMerge", {
|
|
529
|
+
sessionId: id,
|
|
530
|
+
cwd: sessionCwd,
|
|
531
|
+
adapter: sessionAdapter || "claude-code",
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
const result = await mergeSession({
|
|
535
|
+
cwd: sessionCwd,
|
|
536
|
+
message: opts.message,
|
|
537
|
+
removeWorktree: opts.removeWorktree,
|
|
538
|
+
repoPath: opts.repo,
|
|
539
|
+
});
|
|
540
|
+
if (result.committed)
|
|
541
|
+
console.log("Changes committed");
|
|
542
|
+
if (result.pushed)
|
|
543
|
+
console.log("Pushed to remote");
|
|
544
|
+
if (result.prUrl)
|
|
545
|
+
console.log(`PR: ${result.prUrl}`);
|
|
546
|
+
if (result.worktreeRemoved)
|
|
547
|
+
console.log("Worktree removed");
|
|
548
|
+
if (!result.committed && !result.pushed) {
|
|
549
|
+
console.log("No changes to commit or push");
|
|
550
|
+
}
|
|
551
|
+
// Post-merge hook
|
|
552
|
+
if (hooks?.postMerge) {
|
|
553
|
+
await runHook(hooks, "postMerge", {
|
|
554
|
+
sessionId: id,
|
|
555
|
+
cwd: sessionCwd,
|
|
556
|
+
adapter: sessionAdapter || "claude-code",
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
// --- Lock commands ---
|
|
561
|
+
program
|
|
562
|
+
.command("lock <directory>")
|
|
563
|
+
.description("Manually lock a directory")
|
|
564
|
+
.option("--by <name>", "Who is locking", os.userInfo().username)
|
|
565
|
+
.option("--reason <reason>", "Why")
|
|
566
|
+
.action(async (directory, opts) => {
|
|
567
|
+
const absDir = path.resolve(directory);
|
|
568
|
+
try {
|
|
569
|
+
await client.call("lock.acquire", {
|
|
570
|
+
directory: absDir,
|
|
571
|
+
by: opts.by,
|
|
572
|
+
reason: opts.reason,
|
|
573
|
+
});
|
|
574
|
+
console.log(`Locked: ${absDir}`);
|
|
575
|
+
}
|
|
576
|
+
catch (err) {
|
|
577
|
+
console.error(err.message);
|
|
578
|
+
process.exit(1);
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
program
|
|
582
|
+
.command("unlock <directory>")
|
|
583
|
+
.description("Unlock a manually locked directory")
|
|
584
|
+
.action(async (directory) => {
|
|
585
|
+
const absDir = path.resolve(directory);
|
|
586
|
+
try {
|
|
587
|
+
await client.call("lock.release", { directory: absDir });
|
|
588
|
+
console.log(`Unlocked: ${absDir}`);
|
|
589
|
+
}
|
|
590
|
+
catch (err) {
|
|
591
|
+
console.error(err.message);
|
|
592
|
+
process.exit(1);
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
program
|
|
596
|
+
.command("locks")
|
|
597
|
+
.description("List all directory locks")
|
|
598
|
+
.option("--json", "Output as JSON")
|
|
599
|
+
.action(async (opts) => {
|
|
600
|
+
try {
|
|
601
|
+
const locks = await client.call("lock.list");
|
|
602
|
+
if (opts.json) {
|
|
603
|
+
printJson(locks);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (locks.length === 0) {
|
|
607
|
+
console.log("No active locks");
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
printTable(locks.map((l) => ({
|
|
611
|
+
Directory: shortenPath(l.directory),
|
|
612
|
+
Type: l.type,
|
|
613
|
+
"Locked By": l.lockedBy || l.sessionId?.slice(0, 8) || "-",
|
|
614
|
+
Reason: l.reason || "-",
|
|
615
|
+
Since: timeAgo(new Date(l.lockedAt)),
|
|
616
|
+
})));
|
|
617
|
+
}
|
|
618
|
+
catch (err) {
|
|
619
|
+
console.error(err.message);
|
|
620
|
+
process.exit(1);
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
// --- Fuses command ---
|
|
624
|
+
program
|
|
625
|
+
.command("fuses")
|
|
626
|
+
.description("List active Kind cluster fuse timers")
|
|
627
|
+
.option("--json", "Output as JSON")
|
|
628
|
+
.action(async (opts) => {
|
|
629
|
+
try {
|
|
630
|
+
const fuses = await client.call("fuse.list");
|
|
631
|
+
if (opts.json) {
|
|
632
|
+
printJson(fuses);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
if (fuses.length === 0) {
|
|
636
|
+
console.log("No active fuses");
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
printTable(fuses.map((f) => ({
|
|
640
|
+
Directory: shortenPath(f.directory),
|
|
641
|
+
Cluster: f.clusterName,
|
|
642
|
+
"Expires In": formatDuration(new Date(f.expiresAt).getTime() - Date.now()),
|
|
643
|
+
})));
|
|
644
|
+
}
|
|
645
|
+
catch (err) {
|
|
646
|
+
console.error(err.message);
|
|
647
|
+
process.exit(1);
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
// --- Daemon subcommand ---
|
|
651
|
+
const daemonCmd = new Command("daemon").description("Manage the agentctl daemon");
|
|
652
|
+
daemonCmd
|
|
653
|
+
.command("start")
|
|
654
|
+
.description("Start the daemon")
|
|
655
|
+
.option("--foreground", "Run in foreground (don't daemonize)")
|
|
656
|
+
.option("--supervised", "Run under supervisor (auto-restart on crash)")
|
|
657
|
+
.option("--metrics-port <port>", "Prometheus metrics port", "9200")
|
|
658
|
+
.action(async (opts) => {
|
|
659
|
+
if (opts.foreground) {
|
|
660
|
+
// Foreground mode — import and start directly
|
|
661
|
+
const { startDaemon } = await import("./daemon/server.js");
|
|
662
|
+
await startDaemon({
|
|
663
|
+
metricsPort: Number(opts.metricsPort),
|
|
664
|
+
});
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
if (opts.supervised) {
|
|
668
|
+
// Supervised mode — run supervisor loop in foreground (launched detached)
|
|
669
|
+
const { runSupervisor } = await import("./daemon/supervisor.js");
|
|
670
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
671
|
+
await runSupervisor({
|
|
672
|
+
nodePath: process.execPath,
|
|
673
|
+
cliPath: __filename,
|
|
674
|
+
metricsPort: Number(opts.metricsPort),
|
|
675
|
+
configDir: path.join(os.homedir(), ".agentctl"),
|
|
676
|
+
});
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
// Default: launch supervisor in background (detached)
|
|
680
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
681
|
+
const logDir = path.join(os.homedir(), ".agentctl");
|
|
682
|
+
await fs.mkdir(logDir, { recursive: true });
|
|
683
|
+
const child = spawn(process.execPath, [
|
|
684
|
+
__filename,
|
|
685
|
+
"daemon",
|
|
686
|
+
"start",
|
|
687
|
+
"--supervised",
|
|
688
|
+
"--metrics-port",
|
|
689
|
+
opts.metricsPort,
|
|
690
|
+
], {
|
|
691
|
+
detached: true,
|
|
692
|
+
stdio: [
|
|
693
|
+
"ignore",
|
|
694
|
+
(await fs.open(path.join(logDir, "daemon.stdout.log"), "a")).fd,
|
|
695
|
+
(await fs.open(path.join(logDir, "daemon.stderr.log"), "a")).fd,
|
|
696
|
+
],
|
|
697
|
+
});
|
|
698
|
+
child.unref();
|
|
699
|
+
console.log(`Daemon started with supervisor (PID ${child.pid})`);
|
|
700
|
+
});
|
|
701
|
+
daemonCmd
|
|
702
|
+
.command("stop")
|
|
703
|
+
.description("Stop the daemon")
|
|
704
|
+
.action(async () => {
|
|
705
|
+
// Stop supervisor first (prevents auto-restart)
|
|
706
|
+
const { getSupervisorPid } = await import("./daemon/supervisor.js");
|
|
707
|
+
const supPid = await getSupervisorPid();
|
|
708
|
+
if (supPid) {
|
|
709
|
+
try {
|
|
710
|
+
process.kill(supPid, "SIGTERM");
|
|
711
|
+
}
|
|
712
|
+
catch {
|
|
713
|
+
// Already gone
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
try {
|
|
717
|
+
await client.call("daemon.shutdown");
|
|
718
|
+
console.log("Daemon stopped");
|
|
719
|
+
}
|
|
720
|
+
catch (err) {
|
|
721
|
+
console.error(err.message);
|
|
722
|
+
process.exit(1);
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
daemonCmd
|
|
726
|
+
.command("status")
|
|
727
|
+
.description("Show daemon status")
|
|
728
|
+
.action(async () => {
|
|
729
|
+
try {
|
|
730
|
+
const status = await client.call("daemon.status");
|
|
731
|
+
console.log(`Daemon running (PID ${status.pid})`);
|
|
732
|
+
console.log(` Uptime: ${formatDuration(status.uptime)}`);
|
|
733
|
+
console.log(` Active sessions: ${status.sessions}`);
|
|
734
|
+
console.log(` Active locks: ${status.locks}`);
|
|
735
|
+
console.log(` Active fuses: ${status.fuses}`);
|
|
736
|
+
}
|
|
737
|
+
catch {
|
|
738
|
+
console.log("Daemon not running");
|
|
739
|
+
}
|
|
740
|
+
});
|
|
741
|
+
daemonCmd
|
|
742
|
+
.command("restart")
|
|
743
|
+
.description("Restart the daemon")
|
|
744
|
+
.action(async () => {
|
|
745
|
+
try {
|
|
746
|
+
await client.call("daemon.shutdown");
|
|
747
|
+
}
|
|
748
|
+
catch {
|
|
749
|
+
// Daemon wasn't running — that's fine
|
|
750
|
+
}
|
|
751
|
+
// Also kill supervisor if running
|
|
752
|
+
const { getSupervisorPid } = await import("./daemon/supervisor.js");
|
|
753
|
+
const supPid = await getSupervisorPid();
|
|
754
|
+
if (supPid) {
|
|
755
|
+
try {
|
|
756
|
+
process.kill(supPid, "SIGTERM");
|
|
757
|
+
}
|
|
758
|
+
catch {
|
|
759
|
+
// Already gone
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
// Wait for old processes to exit
|
|
763
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
764
|
+
// Start new daemon with supervisor
|
|
765
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
766
|
+
const logDir = path.join(os.homedir(), ".agentctl");
|
|
767
|
+
await fs.mkdir(logDir, { recursive: true });
|
|
768
|
+
const child = spawn(process.execPath, [__filename, "daemon", "start", "--supervised"], {
|
|
769
|
+
detached: true,
|
|
770
|
+
stdio: [
|
|
771
|
+
"ignore",
|
|
772
|
+
(await fs.open(path.join(logDir, "daemon.stdout.log"), "a")).fd,
|
|
773
|
+
(await fs.open(path.join(logDir, "daemon.stderr.log"), "a")).fd,
|
|
774
|
+
],
|
|
775
|
+
});
|
|
776
|
+
child.unref();
|
|
777
|
+
console.log(`Daemon restarted with supervisor (PID ${child.pid})`);
|
|
778
|
+
});
|
|
779
|
+
daemonCmd
|
|
780
|
+
.command("install")
|
|
781
|
+
.description("Install LaunchAgent (auto-start on login)")
|
|
782
|
+
.action(async () => {
|
|
783
|
+
const { generatePlist } = await import("./daemon/launchagent.js");
|
|
784
|
+
const plistPath = path.join(os.homedir(), "Library/LaunchAgents/com.agentctl.daemon.plist");
|
|
785
|
+
const plistContent = generatePlist();
|
|
786
|
+
await fs.mkdir(path.dirname(plistPath), { recursive: true });
|
|
787
|
+
await fs.writeFile(plistPath, plistContent);
|
|
788
|
+
const { execSync } = await import("node:child_process");
|
|
789
|
+
execSync(`launchctl load ${plistPath}`);
|
|
790
|
+
console.log("LaunchAgent installed. Daemon will start on login.");
|
|
791
|
+
});
|
|
792
|
+
daemonCmd
|
|
793
|
+
.command("uninstall")
|
|
794
|
+
.description("Remove LaunchAgent")
|
|
795
|
+
.action(async () => {
|
|
796
|
+
const plistPath = path.join(os.homedir(), "Library/LaunchAgents/com.agentctl.daemon.plist");
|
|
797
|
+
const { execSync } = await import("node:child_process");
|
|
798
|
+
try {
|
|
799
|
+
execSync(`launchctl unload ${plistPath}`);
|
|
800
|
+
}
|
|
801
|
+
catch {
|
|
802
|
+
// Already unloaded
|
|
803
|
+
}
|
|
804
|
+
await fs.rm(plistPath, { force: true });
|
|
805
|
+
console.log("LaunchAgent removed.");
|
|
806
|
+
});
|
|
807
|
+
program.addCommand(daemonCmd);
|
|
808
|
+
program.parse();
|