@orgloop/agentctl 1.1.0 → 1.2.1

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,38 +1,57 @@
1
+ import { execFile } from "node:child_process";
1
2
  import { EventEmitter } from "node:events";
2
3
  import fs from "node:fs/promises";
3
4
  import http from "node:http";
4
5
  import net from "node:net";
5
6
  import os from "node:os";
6
7
  import path from "node:path";
8
+ import { promisify } from "node:util";
7
9
  import { ClaudeCodeAdapter } from "../adapters/claude-code.js";
10
+ import { CodexAdapter } from "../adapters/codex.js";
8
11
  import { OpenClawAdapter } from "../adapters/openclaw.js";
12
+ import { OpenCodeAdapter } from "../adapters/opencode.js";
13
+ import { PiAdapter } from "../adapters/pi.js";
14
+ import { PiRustAdapter } from "../adapters/pi-rust.js";
9
15
  import { migrateLocks } from "../migration/migrate-locks.js";
16
+ import { saveEnvironment } from "../utils/daemon-env.js";
17
+ import { clearBinaryCache } from "../utils/resolve-binary.js";
10
18
  import { FuseEngine } from "./fuse-engine.js";
11
19
  import { LockManager } from "./lock-manager.js";
12
20
  import { MetricsRegistry } from "./metrics.js";
13
21
  import { SessionTracker } from "./session-tracker.js";
14
22
  import { StateManager } from "./state.js";
23
+ const execFileAsync = promisify(execFile);
15
24
  const startTime = Date.now();
16
25
  export async function startDaemon(opts = {}) {
17
26
  const configDir = opts.configDir || path.join(os.homedir(), ".agentctl");
18
27
  await fs.mkdir(configDir, { recursive: true });
19
- // 1. Check for existing daemon
20
28
  const pidFilePath = path.join(configDir, "agentctl.pid");
21
- const existingPid = await readPidFile(pidFilePath);
22
- if (existingPid && isProcessAlive(existingPid)) {
23
- throw new Error(`Daemon already running (PID ${existingPid})`);
24
- }
25
- // 2. Clean stale socket
26
29
  const sockPath = path.join(configDir, "agentctl.sock");
30
+ // 1. Kill stale daemon/supervisor processes before anything else (#39)
31
+ await killStaleDaemons(configDir);
32
+ // 2. Verify no daemon is actually running by trying to connect to socket
33
+ const socketAlive = await isSocketAlive(sockPath);
34
+ if (socketAlive) {
35
+ throw new Error("Daemon already running (socket responsive)");
36
+ }
37
+ // 3. Clean stale socket file
27
38
  await fs.rm(sockPath, { force: true });
28
- // 3. Run migration (idempotent)
39
+ // 4. Save shell environment for subprocess spawning (#42)
40
+ await saveEnvironment(configDir);
41
+ // 5. Clear binary cache on restart (#41 — pick up moved/updated binaries)
42
+ clearBinaryCache();
43
+ // 6. Run migration (idempotent)
29
44
  await migrateLocks(configDir).catch((err) => console.error("Migration warning:", err.message));
30
- // 4. Load persisted state
45
+ // 7. Load persisted state
31
46
  const state = await StateManager.load(configDir);
32
- // 5. Initialize subsystems
47
+ // 8. Initialize subsystems
33
48
  const adapters = opts.adapters || {
34
49
  "claude-code": new ClaudeCodeAdapter(),
50
+ codex: new CodexAdapter(),
35
51
  openclaw: new OpenClawAdapter(),
52
+ opencode: new OpenCodeAdapter(),
53
+ pi: new PiAdapter(),
54
+ "pi-rust": new PiRustAdapter(),
36
55
  };
37
56
  const lockManager = new LockManager(state);
38
57
  const emitter = new EventEmitter();
@@ -46,11 +65,13 @@ export async function startDaemon(opts = {}) {
46
65
  emitter.on("fuse.fired", () => {
47
66
  metrics.recordFuseFired();
48
67
  });
49
- // 6. Resume fuse timers
68
+ // 9. Validate all sessions on startup — mark dead ones as stopped (#40)
69
+ sessionTracker.validateAllSessions();
70
+ // 10. Resume fuse timers
50
71
  fuseEngine.resumeTimers();
51
- // 7. Start session polling
72
+ // 11. Start session polling
52
73
  sessionTracker.startPolling();
53
- // 8. Create request handler
74
+ // 12. Create request handler
54
75
  const handleRequest = createRequestHandler({
55
76
  sessionTracker,
56
77
  lockManager,
@@ -61,7 +82,7 @@ export async function startDaemon(opts = {}) {
61
82
  configDir,
62
83
  sockPath,
63
84
  });
64
- // 9. Start Unix socket server
85
+ // 13. Start Unix socket server
65
86
  const socketServer = net.createServer((conn) => {
66
87
  let buffer = "";
67
88
  conn.on("data", (chunk) => {
@@ -97,7 +118,9 @@ export async function startDaemon(opts = {}) {
97
118
  socketServer.listen(sockPath, () => resolve());
98
119
  socketServer.on("error", reject);
99
120
  });
100
- // 10. Start HTTP metrics server
121
+ // 14. Write PID file (after socket is listening — acts as "lock acquired")
122
+ await fs.writeFile(pidFilePath, String(process.pid));
123
+ // 15. Start HTTP metrics server
101
124
  const metricsPort = opts.metricsPort ?? 9200;
102
125
  const httpServer = http.createServer((req, res) => {
103
126
  if (req.url === "/metrics" && req.method === "GET") {
@@ -115,8 +138,6 @@ export async function startDaemon(opts = {}) {
115
138
  httpServer.listen(metricsPort, "127.0.0.1", () => resolve());
116
139
  httpServer.on("error", reject);
117
140
  });
118
- // 11. Write PID file
119
- await fs.writeFile(pidFilePath, String(process.pid));
120
141
  // Shutdown function
121
142
  const shutdown = async () => {
122
143
  sessionTracker.stopPolling();
@@ -128,7 +149,7 @@ export async function startDaemon(opts = {}) {
128
149
  await fs.rm(sockPath, { force: true });
129
150
  await fs.rm(pidFilePath, { force: true });
130
151
  };
131
- // 12. Signal handlers
152
+ // 16. Signal handlers
132
153
  for (const sig of ["SIGTERM", "SIGINT"]) {
133
154
  process.on(sig, async () => {
134
155
  console.log(`Received ${sig}, shutting down...`);
@@ -141,15 +162,100 @@ export async function startDaemon(opts = {}) {
141
162
  console.log(` Metrics: http://localhost:${metricsPort}/metrics`);
142
163
  return { socketServer, httpServer, shutdown };
143
164
  }
165
+ // --- Stale daemon cleanup (#39) ---
166
+ /**
167
+ * Find and kill ALL stale agentctl daemon/supervisor processes.
168
+ * This ensures singleton enforcement even after unclean shutdowns.
169
+ */
170
+ async function killStaleDaemons(configDir) {
171
+ // 1. Kill processes recorded in PID files
172
+ for (const pidFile of ["agentctl.pid", "supervisor.pid"]) {
173
+ const p = path.join(configDir, pidFile);
174
+ const pid = await readPidFile(p);
175
+ if (pid && pid !== process.pid && isProcessAlive(pid)) {
176
+ try {
177
+ process.kill(pid, "SIGTERM");
178
+ // Wait briefly for clean shutdown
179
+ await sleep(500);
180
+ if (isProcessAlive(pid)) {
181
+ process.kill(pid, "SIGKILL");
182
+ }
183
+ }
184
+ catch {
185
+ // Already gone
186
+ }
187
+ }
188
+ // Clean up stale PID file
189
+ await fs.rm(p, { force: true }).catch(() => { });
190
+ }
191
+ // 2. Scan ps for any remaining agentctl daemon processes
192
+ try {
193
+ const { stdout } = await execFileAsync("ps", ["aux"]);
194
+ for (const line of stdout.split("\n")) {
195
+ if (!line.includes("agentctl") || !line.includes("daemon"))
196
+ continue;
197
+ if (line.includes("grep"))
198
+ continue;
199
+ const fields = line.trim().split(/\s+/);
200
+ if (fields.length < 2)
201
+ continue;
202
+ const pid = Number.parseInt(fields[1], 10);
203
+ if (Number.isNaN(pid) || pid === process.pid)
204
+ continue;
205
+ // Also skip our parent process (supervisor)
206
+ if (pid === process.ppid)
207
+ continue;
208
+ try {
209
+ process.kill(pid, "SIGTERM");
210
+ await sleep(200);
211
+ if (isProcessAlive(pid)) {
212
+ process.kill(pid, "SIGKILL");
213
+ }
214
+ }
215
+ catch {
216
+ // Already gone
217
+ }
218
+ }
219
+ }
220
+ catch {
221
+ // ps failed — best effort
222
+ }
223
+ }
224
+ /**
225
+ * Check if a Unix socket is actually responsive (not just a stale file).
226
+ */
227
+ async function isSocketAlive(sockPath) {
228
+ return new Promise((resolve) => {
229
+ const socket = net.createConnection(sockPath);
230
+ const timeout = setTimeout(() => {
231
+ socket.destroy();
232
+ resolve(false);
233
+ }, 1000);
234
+ socket.on("connect", () => {
235
+ clearTimeout(timeout);
236
+ socket.destroy();
237
+ resolve(true);
238
+ });
239
+ socket.on("error", () => {
240
+ clearTimeout(timeout);
241
+ resolve(false);
242
+ });
243
+ });
244
+ }
144
245
  function createRequestHandler(ctx) {
145
246
  return async (req) => {
146
247
  const params = (req.params || {});
147
248
  switch (req.method) {
148
- case "session.list":
149
- return ctx.sessionTracker.listSessions({
249
+ case "session.list": {
250
+ let sessions = ctx.sessionTracker.listSessions({
150
251
  status: params.status,
151
252
  all: params.all,
152
253
  });
254
+ if (params.group) {
255
+ sessions = sessions.filter((s) => s.group === params.group);
256
+ }
257
+ return sessions;
258
+ }
153
259
  case "session.status": {
154
260
  const session = ctx.sessionTracker.getSession(params.id);
155
261
  if (!session)
@@ -157,11 +263,15 @@ function createRequestHandler(ctx) {
157
263
  return session;
158
264
  }
159
265
  case "session.peek": {
160
- const adapterName = params.adapter || "claude-code";
266
+ // Auto-detect adapter from tracked session, fall back to param or claude-code
267
+ const tracked = ctx.sessionTracker.getSession(params.id);
268
+ const adapterName = params.adapter || tracked?.adapter || "claude-code";
161
269
  const adapter = ctx.adapters[adapterName];
162
270
  if (!adapter)
163
271
  throw new Error(`Unknown adapter: ${adapterName}`);
164
- return adapter.peek(params.id, {
272
+ // Use the full session ID if we resolved it from the tracker
273
+ const peekId = tracked?.id || params.id;
274
+ return adapter.peek(peekId, {
165
275
  lines: params.lines,
166
276
  });
167
277
  }
@@ -193,6 +303,10 @@ function createRequestHandler(ctx) {
193
303
  env: params.env,
194
304
  adapterOpts: params.adapterOpts,
195
305
  });
306
+ // Propagate group tag if provided
307
+ if (params.group) {
308
+ session.group = params.group;
309
+ }
196
310
  const record = ctx.sessionTracker.track(session, adapterName);
197
311
  // Auto-lock
198
312
  if (cwd) {
@@ -204,6 +318,15 @@ function createRequestHandler(ctx) {
204
318
  const session = ctx.sessionTracker.getSession(params.id);
205
319
  if (!session)
206
320
  throw new Error(`Session not found: ${params.id}`);
321
+ // Ghost pending entry with dead PID: remove from state with --force
322
+ if (session.id.startsWith("pending-") &&
323
+ params.force &&
324
+ session.pid &&
325
+ !isProcessAlive(session.pid)) {
326
+ ctx.lockManager.autoUnlock(session.id);
327
+ ctx.sessionTracker.removeSession(session.id);
328
+ return null;
329
+ }
207
330
  const adapter = ctx.adapters[session.adapter];
208
331
  if (!adapter)
209
332
  throw new Error(`Unknown adapter: ${session.adapter}`);
@@ -230,6 +353,11 @@ function createRequestHandler(ctx) {
230
353
  await adapter.resume(session.id, params.message);
231
354
  return null;
232
355
  }
356
+ // --- Prune command (#40) ---
357
+ case "session.prune": {
358
+ const pruned = ctx.sessionTracker.pruneDeadSessions();
359
+ return { pruned };
360
+ }
233
361
  case "lock.list":
234
362
  return ctx.lockManager.listAll();
235
363
  case "lock.acquire":
@@ -281,3 +409,6 @@ function isProcessAlive(pid) {
281
409
  return false;
282
410
  }
283
411
  }
412
+ function sleep(ms) {
413
+ return new Promise((resolve) => setTimeout(resolve, ms));
414
+ }
@@ -11,9 +11,12 @@ export declare class SessionTracker {
11
11
  private adapters;
12
12
  private pollIntervalMs;
13
13
  private pollHandle;
14
+ private polling;
14
15
  private readonly isProcessAlive;
15
16
  constructor(state: StateManager, opts: SessionTrackerOpts);
16
17
  startPolling(): void;
18
+ /** Run poll() with a guard to skip if the previous cycle is still running */
19
+ private guardedPoll;
17
20
  stopPolling(): void;
18
21
  private poll;
19
22
  /**
@@ -22,6 +25,24 @@ export declare class SessionTracker {
22
25
  * - Any "running"/"idle" session in state whose PID is dead → mark stopped
23
26
  */
24
27
  private reapStaleEntries;
28
+ /**
29
+ * Validate all sessions on daemon startup (#40).
30
+ * Any session marked as "running" or "idle" whose PID is dead gets
31
+ * immediately marked as "stopped". This prevents unbounded growth of
32
+ * ghost sessions across daemon restarts.
33
+ */
34
+ validateAllSessions(): void;
35
+ /**
36
+ * Aggressively prune all clearly-dead sessions (#40).
37
+ * Returns the number of sessions pruned.
38
+ * Called via `agentctl prune` command.
39
+ */
40
+ pruneDeadSessions(): number;
41
+ /**
42
+ * Remove stopped sessions from state that have been stopped for more than 7 days.
43
+ * This reduces overhead from accumulating hundreds of historical sessions.
44
+ */
45
+ private pruneOldSessions;
25
46
  /** Track a newly launched session */
26
47
  track(session: AgentSession, adapterName: string): SessionRecord;
27
48
  /** Get session record by id (exact or prefix) */
@@ -30,8 +51,11 @@ export declare class SessionTracker {
30
51
  listSessions(opts?: {
31
52
  status?: string;
32
53
  all?: boolean;
54
+ adapter?: string;
33
55
  }): SessionRecord[];
34
56
  activeCount(): number;
57
+ /** Remove a session from state entirely (used for ghost cleanup) */
58
+ removeSession(sessionId: string): void;
35
59
  /** Called when a session stops — returns the cwd for fuse/lock processing */
36
60
  onSessionExit(sessionId: string): SessionRecord | undefined;
37
61
  }
@@ -1,8 +1,11 @@
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
3
  export class SessionTracker {
2
4
  state;
3
5
  adapters;
4
6
  pollIntervalMs;
5
7
  pollHandle = null;
8
+ polling = false;
6
9
  isProcessAlive;
7
10
  constructor(state, opts) {
8
11
  this.state = state;
@@ -13,12 +16,25 @@ export class SessionTracker {
13
16
  startPolling() {
14
17
  if (this.pollHandle)
15
18
  return;
19
+ // Prune old stopped sessions on startup
20
+ this.pruneOldSessions();
16
21
  // Initial poll
17
- this.poll().catch((err) => console.error("Poll error:", err));
22
+ this.guardedPoll();
18
23
  this.pollHandle = setInterval(() => {
19
- this.poll().catch((err) => console.error("Poll error:", err));
24
+ this.guardedPoll();
20
25
  }, this.pollIntervalMs);
21
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
+ }
22
38
  stopPolling() {
23
39
  if (this.pollHandle) {
24
40
  clearInterval(this.pollHandle);
@@ -40,14 +56,17 @@ export class SessionTracker {
40
56
  if (!existing) {
41
57
  this.state.setSession(session.id, record);
42
58
  }
43
- else if (existing.status !== record.status) {
44
- // Status changed — update
59
+ else if (existing.status !== record.status ||
60
+ (!existing.model && record.model)) {
61
+ // Status changed or model resolved — update
45
62
  this.state.setSession(session.id, {
46
63
  ...existing,
47
64
  status: record.status,
48
65
  stoppedAt: record.stoppedAt,
66
+ model: record.model || existing.model,
49
67
  tokens: record.tokens,
50
68
  cost: record.cost,
69
+ prompt: record.prompt || existing.prompt,
51
70
  });
52
71
  }
53
72
  }
@@ -94,9 +113,117 @@ export class SessionTracker {
94
113
  }
95
114
  }
96
115
  }
116
+ /**
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.
121
+ */
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++;
146
+ }
147
+ }
148
+ if (cleaned > 0) {
149
+ console.error(`Validated sessions on startup: marked ${cleaned} dead sessions as stopped`);
150
+ }
151
+ }
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
+ }
186
+ }
187
+ return pruned;
188
+ }
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
+ }
97
215
  /** Track a newly launched session */
98
216
  track(session, adapterName) {
99
217
  const record = sessionToRecord(session, adapterName);
218
+ // Pending→UUID reconciliation: if this is a real session (not pending),
219
+ // remove any pending-PID placeholder with the same PID
220
+ if (!session.id.startsWith("pending-") && session.pid) {
221
+ for (const [id, existing] of Object.entries(this.state.getSessions())) {
222
+ if (id.startsWith("pending-") && existing.pid === session.pid) {
223
+ this.state.removeSession(id);
224
+ }
225
+ }
226
+ }
100
227
  this.state.setSession(session.id, record);
101
228
  return record;
102
229
  }
@@ -116,7 +243,20 @@ export class SessionTracker {
116
243
  /** List all tracked sessions */
117
244
  listSessions(opts) {
118
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
+ }
119
256
  let filtered = sessions;
257
+ if (opts?.adapter) {
258
+ filtered = filtered.filter((s) => s.adapter === opts.adapter);
259
+ }
120
260
  if (opts?.status) {
121
261
  filtered = filtered.filter((s) => s.status === opts.status);
122
262
  }
@@ -137,6 +277,10 @@ export class SessionTracker {
137
277
  activeCount() {
138
278
  return Object.values(this.state.getSessions()).filter((s) => s.status === "running" || s.status === "idle").length;
139
279
  }
280
+ /** Remove a session from state entirely (used for ghost cleanup) */
281
+ removeSession(sessionId) {
282
+ this.state.removeSession(sessionId);
283
+ }
140
284
  /** Called when a session stops — returns the cwd for fuse/lock processing */
141
285
  onSessionExit(sessionId) {
142
286
  const session = this.state.getSession(sessionId);
@@ -190,6 +334,7 @@ function sessionToRecord(session, adapterName) {
190
334
  tokens: session.tokens,
191
335
  cost: session.cost,
192
336
  pid: session.pid,
337
+ group: session.group,
193
338
  meta: session.meta,
194
339
  };
195
340
  }
@@ -15,6 +15,7 @@ export interface SessionRecord {
15
15
  cost?: number;
16
16
  pid?: number;
17
17
  exitCode?: number;
18
+ group?: string;
18
19
  meta: Record<string, unknown>;
19
20
  }
20
21
  export interface Lock {
package/dist/hooks.d.ts CHANGED
@@ -6,6 +6,8 @@ export interface HookContext {
6
6
  adapter: string;
7
7
  branch?: string;
8
8
  exitCode?: number;
9
+ group?: string;
10
+ model?: string;
9
11
  }
10
12
  /**
11
13
  * Run a lifecycle hook script if defined.
package/dist/hooks.js CHANGED
@@ -23,6 +23,10 @@ export async function runHook(hooks, phase, ctx) {
23
23
  env.AGENTCTL_BRANCH = ctx.branch;
24
24
  if (ctx.exitCode != null)
25
25
  env.AGENTCTL_EXIT_CODE = String(ctx.exitCode);
26
+ if (ctx.group)
27
+ env.AGENTCTL_GROUP = ctx.group;
28
+ if (ctx.model)
29
+ env.AGENTCTL_MODEL = ctx.model;
26
30
  try {
27
31
  const result = await execAsync(script, {
28
32
  cwd: ctx.cwd,
@@ -0,0 +1,60 @@
1
+ import type { AgentAdapter, LifecycleHooks } from "./core/types.js";
2
+ /** A single adapter+model slot parsed from CLI flags */
3
+ export interface AdapterSlot {
4
+ adapter: string;
5
+ model?: string;
6
+ }
7
+ /** Result of launching one slot within a group */
8
+ export interface SlotLaunchResult {
9
+ slot: AdapterSlot;
10
+ sessionId: string;
11
+ pid?: number;
12
+ cwd: string;
13
+ branch: string;
14
+ error?: string;
15
+ }
16
+ /** Result of the full orchestrated launch */
17
+ export interface OrchestratedLaunchResult {
18
+ groupId: string;
19
+ results: SlotLaunchResult[];
20
+ }
21
+ export interface OrchestrateOpts {
22
+ slots: AdapterSlot[];
23
+ prompt: string;
24
+ spec?: string;
25
+ cwd: string;
26
+ hooks?: LifecycleHooks;
27
+ adapters: Record<string, AgentAdapter>;
28
+ /** Optional: callback when daemon is available for lock/track */
29
+ onSessionLaunched?: (result: SlotLaunchResult) => void;
30
+ /** Optional: callback when group ID is generated (before launches) */
31
+ onGroupCreated?: (groupId: string) => void;
32
+ }
33
+ /** Generate a short group ID like "g-a1b2c3" */
34
+ export declare function generateGroupId(): string;
35
+ /**
36
+ * Generate a short suffix for a slot, used in worktree/branch naming.
37
+ * When an adapter appears multiple times, disambiguate using the model short name.
38
+ */
39
+ export declare function slotSuffix(slot: AdapterSlot, allSlots: AdapterSlot[]): string;
40
+ /** Build worktree path: <repo>-<groupId>-<suffix> */
41
+ export declare function worktreePath(repo: string, groupId: string, suffix: string): string;
42
+ /** Build branch name: try/<groupId>/<suffix> */
43
+ export declare function branchName(groupId: string, suffix: string): string;
44
+ /**
45
+ * Orchestrate a parallel multi-adapter launch.
46
+ *
47
+ * 1. Generate group ID
48
+ * 2. For each slot: create worktree, run on_worktree_create hook, launch adapter
49
+ * 3. Return all results (successes and failures)
50
+ */
51
+ export declare function orchestrateLaunch(opts: OrchestrateOpts): Promise<OrchestratedLaunchResult>;
52
+ /**
53
+ * Parse positional adapter slots from raw argv.
54
+ *
55
+ * Multiple --adapter flags, each optionally followed by --model:
56
+ * --adapter claude-code --model opus --adapter codex
57
+ *
58
+ * Returns AdapterSlot[] representing each launch slot.
59
+ */
60
+ export declare function parseAdapterSlots(rawArgs: string[]): AdapterSlot[];