@orgloop/agentctl 1.0.1 → 1.2.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 +145 -3
- package/dist/adapters/claude-code.js +10 -10
- package/dist/adapters/codex.d.ts +72 -0
- package/dist/adapters/codex.js +692 -0
- package/dist/adapters/openclaw.d.ts +60 -9
- package/dist/adapters/openclaw.js +195 -38
- package/dist/adapters/opencode.d.ts +143 -0
- package/dist/adapters/opencode.js +672 -0
- package/dist/adapters/pi-rust.d.ts +89 -0
- package/dist/adapters/pi-rust.js +743 -0
- package/dist/adapters/pi.d.ts +96 -0
- package/dist/adapters/pi.js +855 -0
- package/dist/cli.js +277 -59
- package/dist/core/types.d.ts +1 -0
- package/dist/daemon/server.js +34 -4
- package/dist/daemon/session-tracker.d.ts +20 -0
- package/dist/daemon/session-tracker.js +150 -4
- package/dist/daemon/state.d.ts +1 -0
- package/dist/hooks.d.ts +2 -0
- package/dist/hooks.js +4 -0
- package/dist/launch-orchestrator.d.ts +60 -0
- package/dist/launch-orchestrator.js +198 -0
- package/dist/matrix-parser.d.ts +40 -0
- package/dist/matrix-parser.js +69 -0
- package/dist/utils/partial-read.d.ts +20 -0
- package/dist/utils/partial-read.js +66 -0
- package/dist/worktree.d.ts +22 -0
- package/dist/worktree.js +68 -0
- package/package.json +3 -2
|
@@ -1,22 +1,40 @@
|
|
|
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;
|
|
9
|
+
isProcessAlive;
|
|
6
10
|
constructor(state, opts) {
|
|
7
11
|
this.state = state;
|
|
8
12
|
this.adapters = opts.adapters;
|
|
9
13
|
this.pollIntervalMs = opts.pollIntervalMs ?? 5000;
|
|
14
|
+
this.isProcessAlive = opts.isProcessAlive ?? defaultIsProcessAlive;
|
|
10
15
|
}
|
|
11
16
|
startPolling() {
|
|
12
17
|
if (this.pollHandle)
|
|
13
18
|
return;
|
|
19
|
+
// Prune old stopped sessions on startup
|
|
20
|
+
this.pruneOldSessions();
|
|
14
21
|
// Initial poll
|
|
15
|
-
this.
|
|
22
|
+
this.guardedPoll();
|
|
16
23
|
this.pollHandle = setInterval(() => {
|
|
17
|
-
this.
|
|
24
|
+
this.guardedPoll();
|
|
18
25
|
}, this.pollIntervalMs);
|
|
19
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
|
+
}
|
|
20
38
|
stopPolling() {
|
|
21
39
|
if (this.pollHandle) {
|
|
22
40
|
clearInterval(this.pollHandle);
|
|
@@ -24,23 +42,31 @@ export class SessionTracker {
|
|
|
24
42
|
}
|
|
25
43
|
}
|
|
26
44
|
async poll() {
|
|
45
|
+
// Collect PIDs from all adapter-returned sessions (the source of truth)
|
|
46
|
+
const adapterPidToId = new Map();
|
|
27
47
|
for (const [adapterName, adapter] of Object.entries(this.adapters)) {
|
|
28
48
|
try {
|
|
29
49
|
const sessions = await adapter.list({ all: true });
|
|
30
50
|
for (const session of sessions) {
|
|
51
|
+
if (session.pid) {
|
|
52
|
+
adapterPidToId.set(session.pid, session.id);
|
|
53
|
+
}
|
|
31
54
|
const existing = this.state.getSession(session.id);
|
|
32
55
|
const record = sessionToRecord(session, adapterName);
|
|
33
56
|
if (!existing) {
|
|
34
57
|
this.state.setSession(session.id, record);
|
|
35
58
|
}
|
|
36
|
-
else if (existing.status !== record.status
|
|
37
|
-
|
|
59
|
+
else if (existing.status !== record.status ||
|
|
60
|
+
(!existing.model && record.model)) {
|
|
61
|
+
// Status changed or model resolved — update
|
|
38
62
|
this.state.setSession(session.id, {
|
|
39
63
|
...existing,
|
|
40
64
|
status: record.status,
|
|
41
65
|
stoppedAt: record.stoppedAt,
|
|
66
|
+
model: record.model || existing.model,
|
|
42
67
|
tokens: record.tokens,
|
|
43
68
|
cost: record.cost,
|
|
69
|
+
prompt: record.prompt || existing.prompt,
|
|
44
70
|
});
|
|
45
71
|
}
|
|
46
72
|
}
|
|
@@ -49,10 +75,82 @@ export class SessionTracker {
|
|
|
49
75
|
// Adapter unavailable — skip
|
|
50
76
|
}
|
|
51
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
|
+
/**
|
|
117
|
+
* Remove stopped sessions from state that have been stopped for more than 7 days.
|
|
118
|
+
* This reduces overhead from accumulating hundreds of historical sessions.
|
|
119
|
+
*/
|
|
120
|
+
pruneOldSessions() {
|
|
121
|
+
const sessions = this.state.getSessions();
|
|
122
|
+
const now = Date.now();
|
|
123
|
+
let pruned = 0;
|
|
124
|
+
for (const [id, record] of Object.entries(sessions)) {
|
|
125
|
+
if (record.status !== "stopped" &&
|
|
126
|
+
record.status !== "completed" &&
|
|
127
|
+
record.status !== "failed") {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const stoppedAt = record.stoppedAt
|
|
131
|
+
? new Date(record.stoppedAt).getTime()
|
|
132
|
+
: new Date(record.startedAt).getTime();
|
|
133
|
+
if (now - stoppedAt > STOPPED_SESSION_PRUNE_AGE_MS) {
|
|
134
|
+
this.state.removeSession(id);
|
|
135
|
+
pruned++;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (pruned > 0) {
|
|
139
|
+
console.error(`Pruned ${pruned} sessions stopped >7 days ago from state`);
|
|
140
|
+
}
|
|
52
141
|
}
|
|
53
142
|
/** Track a newly launched session */
|
|
54
143
|
track(session, adapterName) {
|
|
55
144
|
const record = sessionToRecord(session, adapterName);
|
|
145
|
+
// Pending→UUID reconciliation: if this is a real session (not pending),
|
|
146
|
+
// remove any pending-PID placeholder with the same PID
|
|
147
|
+
if (!session.id.startsWith("pending-") && session.pid) {
|
|
148
|
+
for (const [id, existing] of Object.entries(this.state.getSessions())) {
|
|
149
|
+
if (id.startsWith("pending-") && existing.pid === session.pid) {
|
|
150
|
+
this.state.removeSession(id);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
56
154
|
this.state.setSession(session.id, record);
|
|
57
155
|
return record;
|
|
58
156
|
}
|
|
@@ -72,13 +170,28 @@ export class SessionTracker {
|
|
|
72
170
|
/** List all tracked sessions */
|
|
73
171
|
listSessions(opts) {
|
|
74
172
|
const sessions = Object.values(this.state.getSessions());
|
|
173
|
+
// Liveness check: mark sessions with dead PIDs as stopped
|
|
174
|
+
for (const s of sessions) {
|
|
175
|
+
if ((s.status === "running" || s.status === "idle") && s.pid) {
|
|
176
|
+
if (!this.isProcessAlive(s.pid)) {
|
|
177
|
+
s.status = "stopped";
|
|
178
|
+
s.stoppedAt = new Date().toISOString();
|
|
179
|
+
this.state.setSession(s.id, s);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
75
183
|
let filtered = sessions;
|
|
184
|
+
if (opts?.adapter) {
|
|
185
|
+
filtered = filtered.filter((s) => s.adapter === opts.adapter);
|
|
186
|
+
}
|
|
76
187
|
if (opts?.status) {
|
|
77
188
|
filtered = filtered.filter((s) => s.status === opts.status);
|
|
78
189
|
}
|
|
79
190
|
else if (!opts?.all) {
|
|
80
191
|
filtered = filtered.filter((s) => s.status === "running" || s.status === "idle");
|
|
81
192
|
}
|
|
193
|
+
// Dedup: if a pending-* entry shares a PID with a resolved entry, show only the resolved one
|
|
194
|
+
filtered = deduplicatePendingSessions(filtered);
|
|
82
195
|
return filtered.sort((a, b) => {
|
|
83
196
|
// Running first, then by recency
|
|
84
197
|
if (a.status === "running" && b.status !== "running")
|
|
@@ -91,6 +204,10 @@ export class SessionTracker {
|
|
|
91
204
|
activeCount() {
|
|
92
205
|
return Object.values(this.state.getSessions()).filter((s) => s.status === "running" || s.status === "idle").length;
|
|
93
206
|
}
|
|
207
|
+
/** Remove a session from state entirely (used for ghost cleanup) */
|
|
208
|
+
removeSession(sessionId) {
|
|
209
|
+
this.state.removeSession(sessionId);
|
|
210
|
+
}
|
|
94
211
|
/** Called when a session stops — returns the cwd for fuse/lock processing */
|
|
95
212
|
onSessionExit(sessionId) {
|
|
96
213
|
const session = this.state.getSession(sessionId);
|
|
@@ -102,6 +219,34 @@ export class SessionTracker {
|
|
|
102
219
|
return session;
|
|
103
220
|
}
|
|
104
221
|
}
|
|
222
|
+
/** Check if a process is alive via kill(pid, 0) signal check */
|
|
223
|
+
function defaultIsProcessAlive(pid) {
|
|
224
|
+
try {
|
|
225
|
+
process.kill(pid, 0);
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Remove pending-* entries that share a PID with a resolved (non-pending) session.
|
|
234
|
+
* This is a safety net for list output — the poll() reaper handles cleanup in state.
|
|
235
|
+
*/
|
|
236
|
+
function deduplicatePendingSessions(sessions) {
|
|
237
|
+
const realPids = new Set();
|
|
238
|
+
for (const s of sessions) {
|
|
239
|
+
if (!s.id.startsWith("pending-") && s.pid) {
|
|
240
|
+
realPids.add(s.pid);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return sessions.filter((s) => {
|
|
244
|
+
if (s.id.startsWith("pending-") && s.pid && realPids.has(s.pid)) {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
return true;
|
|
248
|
+
});
|
|
249
|
+
}
|
|
105
250
|
function sessionToRecord(session, adapterName) {
|
|
106
251
|
return {
|
|
107
252
|
id: session.id,
|
|
@@ -116,6 +261,7 @@ function sessionToRecord(session, adapterName) {
|
|
|
116
261
|
tokens: session.tokens,
|
|
117
262
|
cost: session.cost,
|
|
118
263
|
pid: session.pid,
|
|
264
|
+
group: session.group,
|
|
119
265
|
meta: session.meta,
|
|
120
266
|
};
|
|
121
267
|
}
|
package/dist/daemon/state.d.ts
CHANGED
package/dist/hooks.d.ts
CHANGED
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[];
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { runHook } from "./hooks.js";
|
|
4
|
+
import { createWorktree } from "./worktree.js";
|
|
5
|
+
// --- Group ID generation ---
|
|
6
|
+
/** Generate a short group ID like "g-a1b2c3" */
|
|
7
|
+
export function generateGroupId() {
|
|
8
|
+
const hex = crypto.randomBytes(3).toString("hex");
|
|
9
|
+
return `g-${hex}`;
|
|
10
|
+
}
|
|
11
|
+
// --- Slot disambiguation ---
|
|
12
|
+
/**
|
|
13
|
+
* Generate a short suffix for a slot, used in worktree/branch naming.
|
|
14
|
+
* When an adapter appears multiple times, disambiguate using the model short name.
|
|
15
|
+
*/
|
|
16
|
+
export function slotSuffix(slot, allSlots) {
|
|
17
|
+
const sameAdapter = allSlots.filter((s) => s.adapter === slot.adapter);
|
|
18
|
+
// Short adapter name: claude-code → cc, codex → codex, etc.
|
|
19
|
+
const adapterShort = shortenAdapter(slot.adapter);
|
|
20
|
+
if (sameAdapter.length <= 1) {
|
|
21
|
+
return adapterShort;
|
|
22
|
+
}
|
|
23
|
+
// Disambiguate with model short name
|
|
24
|
+
const modelShort = slot.model ? shortenModel(slot.model) : "default";
|
|
25
|
+
return `${adapterShort}-${modelShort}`;
|
|
26
|
+
}
|
|
27
|
+
/** Shorten adapter names for path-friendly suffixes */
|
|
28
|
+
function shortenAdapter(adapter) {
|
|
29
|
+
const map = {
|
|
30
|
+
"claude-code": "cc",
|
|
31
|
+
"pi-rust": "pi-rs",
|
|
32
|
+
};
|
|
33
|
+
return map[adapter] || adapter;
|
|
34
|
+
}
|
|
35
|
+
/** Extract a short model name from a full model identifier */
|
|
36
|
+
function shortenModel(model) {
|
|
37
|
+
// claude-opus-4-6 → opus, claude-sonnet-4-5 → sonnet
|
|
38
|
+
const opusMatch = model.match(/opus/i);
|
|
39
|
+
if (opusMatch)
|
|
40
|
+
return "opus";
|
|
41
|
+
const sonnetMatch = model.match(/sonnet/i);
|
|
42
|
+
if (sonnetMatch)
|
|
43
|
+
return "sonnet";
|
|
44
|
+
const haikuMatch = model.match(/haiku/i);
|
|
45
|
+
if (haikuMatch)
|
|
46
|
+
return "haiku";
|
|
47
|
+
// gpt-5.2-codex → gpt5-codex
|
|
48
|
+
const gptMatch = model.match(/gpt[- ]?(\d+)/i);
|
|
49
|
+
if (gptMatch) {
|
|
50
|
+
const rest = model.replace(/gpt[- ]?\d+\.?\d*/i, "").replace(/^[- .]+/, "");
|
|
51
|
+
return rest ? `gpt${gptMatch[1]}-${rest}` : `gpt${gptMatch[1]}`;
|
|
52
|
+
}
|
|
53
|
+
// Fallback: take last segment, sanitize
|
|
54
|
+
const parts = model.split(/[/:-]/);
|
|
55
|
+
return sanitizePath(parts[parts.length - 1] || model);
|
|
56
|
+
}
|
|
57
|
+
/** Sanitize a string for use in file paths and branch names */
|
|
58
|
+
function sanitizePath(s) {
|
|
59
|
+
return s.replace(/[^a-zA-Z0-9-]/g, "").toLowerCase() || "default";
|
|
60
|
+
}
|
|
61
|
+
// --- Worktree + branch naming ---
|
|
62
|
+
/** Build worktree path: <repo>-<groupId>-<suffix> */
|
|
63
|
+
export function worktreePath(repo, groupId, suffix) {
|
|
64
|
+
const repoResolved = path.resolve(repo);
|
|
65
|
+
return `${repoResolved}-${groupId}-${suffix}`;
|
|
66
|
+
}
|
|
67
|
+
/** Build branch name: try/<groupId>/<suffix> */
|
|
68
|
+
export function branchName(groupId, suffix) {
|
|
69
|
+
return `try/${groupId}/${suffix}`;
|
|
70
|
+
}
|
|
71
|
+
// --- Orchestrator ---
|
|
72
|
+
/**
|
|
73
|
+
* Orchestrate a parallel multi-adapter launch.
|
|
74
|
+
*
|
|
75
|
+
* 1. Generate group ID
|
|
76
|
+
* 2. For each slot: create worktree, run on_worktree_create hook, launch adapter
|
|
77
|
+
* 3. Return all results (successes and failures)
|
|
78
|
+
*/
|
|
79
|
+
export async function orchestrateLaunch(opts) {
|
|
80
|
+
const { slots, prompt, spec, cwd, hooks, adapters } = opts;
|
|
81
|
+
const groupId = generateGroupId();
|
|
82
|
+
opts.onGroupCreated?.(groupId);
|
|
83
|
+
const repo = path.resolve(cwd);
|
|
84
|
+
// Phase 1: Create all worktrees (sequential to avoid git lock contention)
|
|
85
|
+
const worktrees = [];
|
|
86
|
+
for (const slot of slots) {
|
|
87
|
+
const suffix = slotSuffix(slot, slots);
|
|
88
|
+
const branch = branchName(groupId, suffix);
|
|
89
|
+
const worktree = await createWorktree({
|
|
90
|
+
repo,
|
|
91
|
+
branch,
|
|
92
|
+
});
|
|
93
|
+
// The createWorktree function names the path based on repo+branch slug.
|
|
94
|
+
// We need to override for our naming convention.
|
|
95
|
+
// Actually — createWorktree uses `<repo>-<branch-slug>` which for
|
|
96
|
+
// branch "try/g-a1b2c3/cc" becomes "<repo>-try-g-a1b2c3-cc".
|
|
97
|
+
// That's acceptable. Let's use the path it returns.
|
|
98
|
+
worktrees.push({ slot, suffix, branch, worktree });
|
|
99
|
+
// Run on_worktree_create hook (onCreate) if provided
|
|
100
|
+
if (hooks?.onCreate) {
|
|
101
|
+
await runHook(hooks, "onCreate", {
|
|
102
|
+
sessionId: "", // not yet launched
|
|
103
|
+
cwd: worktree.path,
|
|
104
|
+
adapter: slot.adapter,
|
|
105
|
+
branch,
|
|
106
|
+
group: groupId,
|
|
107
|
+
model: slot.model,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Phase 2: Launch all adapters in parallel
|
|
112
|
+
const launchPromises = worktrees.map(async ({ slot, branch, worktree }) => {
|
|
113
|
+
const adapter = adapters[slot.adapter];
|
|
114
|
+
if (!adapter) {
|
|
115
|
+
return {
|
|
116
|
+
slot,
|
|
117
|
+
sessionId: "",
|
|
118
|
+
cwd: worktree.path,
|
|
119
|
+
branch,
|
|
120
|
+
error: `Unknown adapter: ${slot.adapter}`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const launchOpts = {
|
|
125
|
+
adapter: slot.adapter,
|
|
126
|
+
prompt,
|
|
127
|
+
spec,
|
|
128
|
+
cwd: worktree.path,
|
|
129
|
+
model: slot.model,
|
|
130
|
+
worktree: { repo: worktree.repo, branch },
|
|
131
|
+
hooks,
|
|
132
|
+
};
|
|
133
|
+
const session = await adapter.launch(launchOpts);
|
|
134
|
+
// Tag the session with the group
|
|
135
|
+
session.group = groupId;
|
|
136
|
+
const result = {
|
|
137
|
+
slot,
|
|
138
|
+
sessionId: session.id,
|
|
139
|
+
pid: session.pid,
|
|
140
|
+
cwd: worktree.path,
|
|
141
|
+
branch,
|
|
142
|
+
};
|
|
143
|
+
opts.onSessionLaunched?.(result);
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
return {
|
|
148
|
+
slot,
|
|
149
|
+
sessionId: "",
|
|
150
|
+
cwd: worktree.path,
|
|
151
|
+
branch,
|
|
152
|
+
error: err.message,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
const results = await Promise.all(launchPromises);
|
|
157
|
+
return { groupId, results };
|
|
158
|
+
}
|
|
159
|
+
// --- CLI flag parsing ---
|
|
160
|
+
/**
|
|
161
|
+
* Parse positional adapter slots from raw argv.
|
|
162
|
+
*
|
|
163
|
+
* Multiple --adapter flags, each optionally followed by --model:
|
|
164
|
+
* --adapter claude-code --model opus --adapter codex
|
|
165
|
+
*
|
|
166
|
+
* Returns AdapterSlot[] representing each launch slot.
|
|
167
|
+
*/
|
|
168
|
+
export function parseAdapterSlots(rawArgs) {
|
|
169
|
+
const slots = [];
|
|
170
|
+
let current = null;
|
|
171
|
+
for (let i = 0; i < rawArgs.length; i++) {
|
|
172
|
+
const arg = rawArgs[i];
|
|
173
|
+
if (arg === "--adapter" || arg === "-A") {
|
|
174
|
+
// Flush previous slot
|
|
175
|
+
if (current)
|
|
176
|
+
slots.push(current);
|
|
177
|
+
const value = rawArgs[++i];
|
|
178
|
+
if (!value || value.startsWith("-")) {
|
|
179
|
+
throw new Error(`--adapter requires a value`);
|
|
180
|
+
}
|
|
181
|
+
current = { adapter: value };
|
|
182
|
+
}
|
|
183
|
+
else if (arg === "--model" || arg === "-M") {
|
|
184
|
+
if (!current) {
|
|
185
|
+
throw new Error(`--model must follow an --adapter flag`);
|
|
186
|
+
}
|
|
187
|
+
const value = rawArgs[++i];
|
|
188
|
+
if (!value || value.startsWith("-")) {
|
|
189
|
+
throw new Error(`--model requires a value`);
|
|
190
|
+
}
|
|
191
|
+
current.model = value;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// Flush last slot
|
|
195
|
+
if (current)
|
|
196
|
+
slots.push(current);
|
|
197
|
+
return slots;
|
|
198
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { AdapterSlot } from "./launch-orchestrator.js";
|
|
2
|
+
/** A single entry in the matrix array */
|
|
3
|
+
export interface MatrixEntry {
|
|
4
|
+
adapter: string;
|
|
5
|
+
model?: string | string[];
|
|
6
|
+
}
|
|
7
|
+
/** Top-level matrix file schema */
|
|
8
|
+
export interface MatrixFile {
|
|
9
|
+
prompt: string;
|
|
10
|
+
cwd?: string;
|
|
11
|
+
spec?: string;
|
|
12
|
+
hooks?: {
|
|
13
|
+
on_create?: string;
|
|
14
|
+
on_complete?: string;
|
|
15
|
+
};
|
|
16
|
+
matrix: MatrixEntry[];
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Parse a YAML matrix file and expand into AdapterSlot[].
|
|
20
|
+
*
|
|
21
|
+
* Cross-product expansion: when a matrix entry has an array value for `model`,
|
|
22
|
+
* it expands into one slot per model value.
|
|
23
|
+
*
|
|
24
|
+
* Example:
|
|
25
|
+
* matrix:
|
|
26
|
+
* - adapter: claude-code
|
|
27
|
+
* model: [opus, sonnet]
|
|
28
|
+
* - adapter: codex
|
|
29
|
+
*
|
|
30
|
+
* Expands to 3 slots:
|
|
31
|
+
* [{ adapter: "claude-code", model: "opus" },
|
|
32
|
+
* { adapter: "claude-code", model: "sonnet" },
|
|
33
|
+
* { adapter: "codex" }]
|
|
34
|
+
*/
|
|
35
|
+
export declare function parseMatrixFile(filePath: string): Promise<MatrixFile>;
|
|
36
|
+
/**
|
|
37
|
+
* Expand a MatrixFile into AdapterSlot[].
|
|
38
|
+
* Handles cross-product expansion for array-valued fields.
|
|
39
|
+
*/
|
|
40
|
+
export declare function expandMatrix(matrix: MatrixFile): AdapterSlot[];
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import YAML from "yaml";
|
|
3
|
+
// --- Parsing ---
|
|
4
|
+
/**
|
|
5
|
+
* Parse a YAML matrix file and expand into AdapterSlot[].
|
|
6
|
+
*
|
|
7
|
+
* Cross-product expansion: when a matrix entry has an array value for `model`,
|
|
8
|
+
* it expands into one slot per model value.
|
|
9
|
+
*
|
|
10
|
+
* Example:
|
|
11
|
+
* matrix:
|
|
12
|
+
* - adapter: claude-code
|
|
13
|
+
* model: [opus, sonnet]
|
|
14
|
+
* - adapter: codex
|
|
15
|
+
*
|
|
16
|
+
* Expands to 3 slots:
|
|
17
|
+
* [{ adapter: "claude-code", model: "opus" },
|
|
18
|
+
* { adapter: "claude-code", model: "sonnet" },
|
|
19
|
+
* { adapter: "codex" }]
|
|
20
|
+
*/
|
|
21
|
+
export async function parseMatrixFile(filePath) {
|
|
22
|
+
const raw = await fs.readFile(filePath, "utf-8");
|
|
23
|
+
const parsed = YAML.parse(raw);
|
|
24
|
+
if (!parsed || typeof parsed !== "object") {
|
|
25
|
+
throw new Error(`Invalid matrix file: ${filePath}`);
|
|
26
|
+
}
|
|
27
|
+
if (!parsed.prompt || typeof parsed.prompt !== "string") {
|
|
28
|
+
throw new Error("Matrix file must have a 'prompt' field (string)");
|
|
29
|
+
}
|
|
30
|
+
if (!Array.isArray(parsed.matrix) || parsed.matrix.length === 0) {
|
|
31
|
+
throw new Error("Matrix file must have a non-empty 'matrix' array");
|
|
32
|
+
}
|
|
33
|
+
// Validate entries
|
|
34
|
+
for (const entry of parsed.matrix) {
|
|
35
|
+
if (!entry.adapter || typeof entry.adapter !== "string") {
|
|
36
|
+
throw new Error("Each matrix entry must have an 'adapter' field (string)");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return parsed;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Expand a MatrixFile into AdapterSlot[].
|
|
43
|
+
* Handles cross-product expansion for array-valued fields.
|
|
44
|
+
*/
|
|
45
|
+
export function expandMatrix(matrix) {
|
|
46
|
+
const slots = [];
|
|
47
|
+
for (const entry of matrix.matrix) {
|
|
48
|
+
const models = normalizeToArray(entry.model);
|
|
49
|
+
if (models.length === 0) {
|
|
50
|
+
// No model specified — single slot
|
|
51
|
+
slots.push({ adapter: entry.adapter });
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
// One slot per model
|
|
55
|
+
for (const model of models) {
|
|
56
|
+
slots.push({ adapter: entry.adapter, model });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return slots;
|
|
61
|
+
}
|
|
62
|
+
/** Normalize a value to an array (handles string | string[] | undefined) */
|
|
63
|
+
function normalizeToArray(value) {
|
|
64
|
+
if (value === undefined || value === null)
|
|
65
|
+
return [];
|
|
66
|
+
if (Array.isArray(value))
|
|
67
|
+
return value;
|
|
68
|
+
return [value];
|
|
69
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read the first N lines of a file by reading only `maxBytes` from the start.
|
|
3
|
+
* Avoids allocating the entire file into memory for large JSONL files.
|
|
4
|
+
*
|
|
5
|
+
* @param filePath - Path to the file
|
|
6
|
+
* @param maxLines - Maximum number of lines to return
|
|
7
|
+
* @param maxBytes - Maximum bytes to read from the start (default 8192)
|
|
8
|
+
* @returns Array of complete lines (up to maxLines)
|
|
9
|
+
*/
|
|
10
|
+
export declare function readHead(filePath: string, maxLines: number, maxBytes?: number): Promise<string[]>;
|
|
11
|
+
/**
|
|
12
|
+
* Read the last N lines of a file by reading only `maxBytes` from the end.
|
|
13
|
+
* Avoids allocating the entire file into memory for large JSONL files.
|
|
14
|
+
*
|
|
15
|
+
* @param filePath - Path to the file
|
|
16
|
+
* @param maxLines - Maximum number of lines to return
|
|
17
|
+
* @param maxBytes - Maximum bytes to read from the end (default 65536)
|
|
18
|
+
* @returns Array of complete lines (up to maxLines, in order)
|
|
19
|
+
*/
|
|
20
|
+
export declare function readTail(filePath: string, maxLines: number, maxBytes?: number): Promise<string[]>;
|