herdr-plugin-amq 0.1.7 → 0.1.9

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/src/fleet.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import os from "node:os";
4
- import { execFileSync, execSync } from "node:child_process";
4
+ import { execFileSync } from "node:child_process";
5
5
  import { scanAgentBriefs } from "./briefs.mjs";
6
6
  import { ensureAgentWorktree } from "./worktrees.mjs";
7
7
  import { registerAgent, formatAgentTitle } from "./store.mjs";
@@ -173,145 +173,316 @@ export function buildFleetEnvPath() {
173
173
  process.env.PATH || "",
174
174
  ];
175
175
 
176
- return Array.from(new Set(paths.filter(Boolean))).join(":");
176
+ return Array.from(new Set(paths.filter(Boolean))).join(path.delimiter);
177
177
  }
178
178
 
179
- /**
180
- * Launch or recover the agent fleet inside Herdr
181
- */
182
- export async function launchFleet(amqRoot, repoRoot, options = {}) {
183
- const kind = options.kind || "agy";
184
- const dryRun = Boolean(options.dryRun);
185
- const timeoutMs = options.timeout || 25000;
186
- const customArgs = options.args || (kind === "agy" ? ["--dangerously-skip-permissions"] : []);
187
- const filterList = options.agents
188
- ? (Array.isArray(options.agents) ? options.agents : options.agents.split(",")).map((s) => s.trim().toLowerCase())
189
- : null;
179
+ function filterPersonas(fleet, filter) {
180
+ if (!filter) return fleet;
181
+ const selected = new Set(
182
+ (Array.isArray(filter) ? filter : filter.split(","))
183
+ .map((value) => value.trim().toLowerCase())
184
+ .filter(Boolean),
185
+ );
186
+ return fleet.filter((agent) => selected.has(agent.handle.toLowerCase()));
187
+ }
190
188
 
191
- // 1. Prepopulate maildirs and worktrees
192
- const fleet = prepopulateFleet(amqRoot, repoRoot);
193
- const targetFleet = filterList ? fleet.filter((f) => filterList.includes(f.handle)) : fleet;
189
+ function pathsMatch(left, right) {
190
+ if (!left || !right) return false;
191
+ try {
192
+ return fs.realpathSync(left) === fs.realpathSync(right);
193
+ } catch {
194
+ return path.resolve(left) === path.resolve(right);
195
+ }
196
+ }
194
197
 
195
- const result = {
196
- total: targetFleet.length,
197
- prepopulated: targetFleet.map((t) => t.handle),
198
- alreadyRunning: [],
199
- wouldLaunch: [],
200
- launched: [],
201
- failed: [],
202
- dryRun,
203
- };
198
+ function matchingFleetPanes(agent, liveAgents) {
199
+ return liveAgents.filter((live) => (
200
+ live.name === agent.handle && pathsMatch(live.cwd, agent.worktree)
201
+ ));
202
+ }
204
203
 
205
- // 2. Check Herdr connectivity and live agents
206
- let activeHandles = new Set();
207
- try {
208
- const liveAgents = await getHerdrAgents();
209
- activeHandles = new Set(liveAgents.map((a) => a.name).filter(Boolean));
210
- } catch {}
204
+ function runHerdr(args, execHerdr) {
205
+ if (execHerdr) return execHerdr(args);
206
+ return execFileSync("herdr", args, { encoding: "utf8" });
207
+ }
211
208
 
212
- for (const agent of targetFleet) {
213
- if (activeHandles.has(agent.handle)) {
214
- result.alreadyRunning.push(agent.handle);
215
- } else {
216
- result.wouldLaunch.push(agent.handle);
217
- }
218
- }
209
+ function waitFor(ms) {
210
+ return new Promise((resolve) => setTimeout(resolve, ms));
211
+ }
219
212
 
220
- if (dryRun) {
221
- return result;
213
+ export function defaultLaunchArgs(kind, handle) {
214
+ if (kind === "agy") return ["--dangerously-skip-permissions"];
215
+ if (kind === "opencode") return ["--agent", handle, "--auto"];
216
+ return [];
217
+ }
218
+
219
+ export function resolveExecutable(name, envPath = buildFleetEnvPath()) {
220
+ for (const dir of envPath.split(path.delimiter)) {
221
+ if (!dir) continue;
222
+ const candidate = path.join(dir, name);
223
+ try {
224
+ fs.accessSync(candidate, fs.constants.X_OK);
225
+ if (fs.statSync(candidate).isFile()) return fs.realpathSync(candidate);
226
+ } catch {}
222
227
  }
228
+ return null;
229
+ }
223
230
 
224
- // Determine Herdr workspace
225
- let workspaceId = process.env.HERDR_WORKSPACE_ID || null;
226
- if (!workspaceId) {
231
+ function validModel(model) {
232
+ return typeof model === "string" && /^[^\s/]+\/[^\s/]+/.test(model) ? model : null;
233
+ }
234
+
235
+ export function readOpencodeModel(worktree) {
236
+ for (const relativePath of ["opencode.json", path.join(".opencode", "opencode.json")]) {
237
+ const configPath = path.join(worktree, relativePath);
227
238
  try {
228
- const wsRaw = execFileSync("herdr", ["workspace", "list"], { encoding: "utf8" });
229
- const wsJson = JSON.parse(wsRaw);
230
- const workspaces = wsJson?.result?.workspaces || [];
231
- const matched = workspaces.find((w) => w.cwd === repoRoot || w.label === path.basename(repoRoot));
232
- workspaceId = matched ? matched.workspace_id : (workspaces[0]?.workspace_id || null);
239
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
240
+ const model = validModel(config.model);
241
+ if (model) return model;
233
242
  } catch {}
234
243
  }
244
+ return null;
245
+ }
235
246
 
236
- const safePath = buildFleetEnvPath();
247
+ function shellQuote(value) {
248
+ return `'${String(value).replaceAll("'", "'\"'\"'")}'`;
249
+ }
250
+
251
+ export function createOpencodeLauncher(handle, model, executable) {
252
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "herdr-amq-opencode-"));
253
+ const binDir = path.join(root, handle.replace(/[^a-zA-Z0-9._-]/g, "_"));
254
+ fs.mkdirSync(binDir, { recursive: true });
255
+ const agentConfig = { mode: "all" };
256
+ const selectedModel = validModel(model);
257
+ if (selectedModel) agentConfig.model = selectedModel;
258
+ const config = JSON.stringify({ agent: { [handle]: agentConfig } });
259
+ const launcher = path.join(binDir, "opencode");
260
+ fs.writeFileSync(
261
+ launcher,
262
+ `#!/bin/sh\nexport OPENCODE_CONFIG_CONTENT=${shellQuote(config)}\nexec ${shellQuote(executable)} "$@"\n`,
263
+ { mode: 0o700 },
264
+ );
265
+ return { root, binDir, launcher, config };
266
+ }
267
+
268
+ export async function stopFleet(amqRoot, repoRoot, options = {}) {
269
+ const personas = discoverFleetPersonas(repoRoot);
270
+ const fleet = [...personas.values()].map((agent) => ({
271
+ ...agent,
272
+ worktree: path.join(repoRoot, ".worktrees", agent.handle),
273
+ }));
274
+ const targetFleet = filterPersonas(fleet, options.agents);
275
+ const kind = options.kind || null;
276
+ const dryRun = Boolean(options.dryRun);
277
+ const getLiveAgents = options.getLiveAgents || getHerdrAgents;
278
+ const execHerdr = options.execHerdr || null;
279
+ const liveAgents = await getLiveAgents();
280
+ const result = {
281
+ total: targetFleet.length,
282
+ stopped: [],
283
+ wouldStop: [],
284
+ skipped: [],
285
+ failed: [],
286
+ dryRun,
287
+ };
237
288
 
238
- // 4. Launch each non-active agent into a tab
239
289
  for (const agent of targetFleet) {
240
- const handle = agent.handle;
241
- if (activeHandles.has(handle)) {
242
- result.alreadyRunning.push(handle);
290
+ const panes = matchingFleetPanes(agent, liveAgents);
291
+ const selected = kind ? panes.filter((pane) => pane.agent === kind) : panes;
292
+ if (selected.length === 0) {
293
+ if (panes.length > 0) {
294
+ result.skipped.push({ handle: agent.handle, reason: `kind is ${panes.map((pane) => pane.agent).join(", ")}` });
295
+ }
243
296
  continue;
244
297
  }
245
-
246
- try {
247
- // Create tab in Herdr targeting worktree
248
- const tabArgs = [
249
- "tab",
250
- "create",
251
- "--cwd",
252
- agent.worktree,
253
- "--label",
254
- handle,
255
- "--env",
256
- `PATH=${safePath}`,
257
- "--no-focus",
258
- ];
259
- if (workspaceId) {
260
- tabArgs.push("--workspace", workspaceId);
298
+ if (dryRun) {
299
+ result.wouldStop.push(...selected.map((pane) => ({ handle: agent.handle, paneId: pane.pane_id, kind: pane.agent })));
300
+ continue;
301
+ }
302
+ for (const pane of selected) {
303
+ try {
304
+ runHerdr(["pane", "close", pane.pane_id], execHerdr);
305
+ result.stopped.push({ handle: agent.handle, paneId: pane.pane_id, kind: pane.agent });
306
+ } catch (error) {
307
+ result.failed.push({ handle: agent.handle, paneId: pane.pane_id, error: error.message });
261
308
  }
309
+ }
310
+ }
262
311
 
263
- const tabOut = execFileSync("herdr", tabArgs, { encoding: "utf8" });
264
- const tabJson = JSON.parse(tabOut);
265
- const paneId = tabJson?.result?.root_pane?.pane_id;
312
+ return result;
313
+ }
266
314
 
267
- if (!paneId) {
268
- throw new Error(`Failed to acquire pane_id from herdr tab create: ${tabOut}`);
269
- }
315
+ /**
316
+ * Launch or recover the agent fleet inside Herdr
317
+ */
318
+ export async function launchFleet(amqRoot, repoRoot, options = {}) {
319
+ const kind = options.kind || "agy";
320
+ const dryRun = Boolean(options.dryRun);
321
+ const timeoutMs = options.timeout || 25000;
322
+ const replace = options.replace !== false;
323
+ const prepopulate = options.prepopulate || prepopulateFleet;
324
+ const getLiveAgents = options.getLiveAgents || getHerdrAgents;
325
+ const execHerdr = options.execHerdr || null;
326
+ const sleep = options.sleep || waitFor;
327
+ const safePath = options.envPath || buildFleetEnvPath();
328
+ const configuredArgs = options.args == null
329
+ ? defaultLaunchArgs(kind, "")
330
+ : Array.isArray(options.args) ? options.args : [String(options.args)];
331
+ const fleet = prepopulate(amqRoot, repoRoot);
332
+ const targetFleet = filterPersonas(fleet, options.agents);
333
+ const result = {
334
+ total: targetFleet.length,
335
+ prepopulated: targetFleet.map((agent) => agent.handle),
336
+ alreadyRunning: [],
337
+ replaced: [],
338
+ wouldReplace: [],
339
+ wouldLaunch: [],
340
+ blocked: [],
341
+ launched: [],
342
+ failed: [],
343
+ dryRun,
344
+ };
345
+ const liveAgents = await getLiveAgents();
346
+ const launcherRoots = new Set();
347
+ let workspaceId = process.env.HERDR_WORKSPACE_ID || null;
348
+ let workspaceLookupAttempted = Boolean(workspaceId);
270
349
 
271
- // Start the agent in the new pane with retry if the shell is still booting
272
- const startArgs = [
273
- "agent",
274
- "start",
275
- handle,
276
- "--kind",
277
- kind,
278
- "--pane",
279
- paneId,
280
- "--timeout",
281
- String(timeoutMs),
282
- ];
283
-
284
- if (customArgs.length > 0) {
285
- startArgs.push("--", ...customArgs);
350
+ try {
351
+ for (const agent of targetFleet) {
352
+ const handle = agent.handle;
353
+ const panes = matchingFleetPanes(agent, liveAgents);
354
+ const matchingKind = panes.filter((pane) => pane.agent === kind);
355
+
356
+ if (matchingKind.length > 0) {
357
+ result.alreadyRunning.push(handle);
358
+ for (const duplicate of matchingKind.slice(1)) {
359
+ try {
360
+ runHerdr(["pane", "close", duplicate.pane_id], execHerdr);
361
+ } catch (error) {
362
+ result.failed.push({ handle, error: error.message });
363
+ }
364
+ }
365
+ continue;
286
366
  }
287
367
 
288
- let started = false;
289
- let lastErr = null;
290
- for (let attempt = 0; attempt < 5; attempt++) {
368
+ if (panes.length > 0) {
369
+ if (dryRun) {
370
+ result.wouldReplace.push(handle);
371
+ continue;
372
+ }
373
+ if (!replace) {
374
+ result.blocked.push({ handle, reason: `already running as ${panes.map((pane) => pane.agent).join(", ")}` });
375
+ continue;
376
+ }
291
377
  try {
292
- if (attempt === 0) {
293
- await new Promise((r) => setTimeout(r, 800));
294
- } else {
295
- await new Promise((r) => setTimeout(r, 1200));
296
- }
297
- execFileSync("herdr", startArgs, { encoding: "utf8" });
298
- started = true;
299
- result.launched.push({ handle, paneId, kind });
300
- break;
301
- } catch (err) {
302
- lastErr = err;
303
- if (err.message && err.message.includes("agent_pane_busy")) {
304
- continue;
378
+ for (const pane of panes) {
379
+ runHerdr(["pane", "close", pane.pane_id], execHerdr);
305
380
  }
306
- break;
381
+ await sleep(300);
382
+ result.replaced.push({
383
+ handle,
384
+ fromKinds: panes.map((pane) => pane.agent),
385
+ paneIds: panes.map((pane) => pane.pane_id),
386
+ });
387
+ } catch (error) {
388
+ result.failed.push({ handle, error: error.message });
389
+ continue;
307
390
  }
308
391
  }
309
392
 
310
- if (!started) {
311
- result.failed.push({ handle, error: lastErr?.message || "Failed to start agent" });
393
+ if (dryRun) {
394
+ result.wouldLaunch.push(handle);
395
+ continue;
312
396
  }
313
- } catch (err) {
314
- result.failed.push({ handle, error: err.message });
397
+
398
+ if (!workspaceLookupAttempted) {
399
+ workspaceLookupAttempted = true;
400
+ try {
401
+ const workspaceOutput = runHerdr(["workspace", "list"], execHerdr);
402
+ const workspaceJson = JSON.parse(workspaceOutput);
403
+ const workspaces = workspaceJson?.result?.workspaces || [];
404
+ const matched = workspaces.find((workspace) => workspace.cwd === repoRoot || workspace.label === path.basename(repoRoot));
405
+ workspaceId = matched ? matched.workspace_id : (workspaces[0]?.workspace_id || null);
406
+ } catch {}
407
+ }
408
+
409
+ let paneId;
410
+ try {
411
+ let launchPath = safePath;
412
+ if (kind === "opencode") {
413
+ const executable = options.opencodeExecutable || resolveExecutable("opencode", safePath);
414
+ if (!executable) throw new Error("OpenCode executable not found in fleet PATH");
415
+ const createLauncher = options.createOpencodeLauncher || createOpencodeLauncher;
416
+ const launcher = createLauncher(handle, readOpencodeModel(agent.worktree), executable);
417
+ launcherRoots.add(launcher.root);
418
+ launchPath = `${launcher.binDir}${path.delimiter}${safePath}`;
419
+ }
420
+
421
+ const tabArgs = [
422
+ "tab",
423
+ "create",
424
+ "--cwd",
425
+ agent.worktree,
426
+ "--label",
427
+ handle,
428
+ "--env",
429
+ `PATH=${launchPath}`,
430
+ "--no-focus",
431
+ ];
432
+ if (workspaceId) tabArgs.push("--workspace", workspaceId);
433
+ const tabOutput = runHerdr(tabArgs, execHerdr);
434
+ const tabJson = JSON.parse(tabOutput);
435
+ paneId = tabJson?.result?.root_pane?.pane_id;
436
+ if (!paneId) throw new Error(`Failed to acquire pane_id from herdr tab create: ${tabOutput}`);
437
+
438
+ const startArgs = [
439
+ "agent",
440
+ "start",
441
+ handle,
442
+ "--kind",
443
+ kind,
444
+ "--pane",
445
+ paneId,
446
+ "--timeout",
447
+ String(timeoutMs),
448
+ ];
449
+ const launchArgs = kind === "opencode" && options.args == null
450
+ ? defaultLaunchArgs(kind, handle)
451
+ : configuredArgs;
452
+ if (launchArgs.length > 0) startArgs.push("--", ...launchArgs);
453
+
454
+ let started = false;
455
+ let lastError = null;
456
+ for (let attempt = 0; attempt < 5; attempt++) {
457
+ try {
458
+ await sleep(attempt === 0 ? 800 : 1200);
459
+ runHerdr(startArgs, execHerdr);
460
+ started = true;
461
+ result.launched.push({ handle, paneId, kind });
462
+ break;
463
+ } catch (error) {
464
+ lastError = error;
465
+ if (error.message && error.message.includes("agent_pane_busy")) continue;
466
+ break;
467
+ }
468
+ }
469
+ if (!started) {
470
+ throw lastError || new Error("Failed to start agent");
471
+ }
472
+ } catch (error) {
473
+ result.failed.push({ handle, error: error.message || String(error) });
474
+ if (paneId) {
475
+ try {
476
+ runHerdr(["pane", "close", paneId], execHerdr);
477
+ } catch {}
478
+ }
479
+ }
480
+ }
481
+ } finally {
482
+ for (const root of launcherRoots) {
483
+ try {
484
+ fs.rmSync(root, { recursive: true, force: true });
485
+ } catch {}
315
486
  }
316
487
  }
317
488
 
package/src/herdr.mjs CHANGED
@@ -11,9 +11,9 @@
11
11
  */
12
12
 
13
13
  import net from "node:net";
14
- import { execSync } from "node:child_process";
15
14
  import os from "node:os";
16
15
  import path from "node:path";
16
+ import { getOpenCodeSessionModels, resolveRuntimeModel } from "./runtime-models.mjs";
17
17
 
18
18
  // ─── Socket Path Resolution ──────────────────────────────────────────────────
19
19
 
@@ -108,24 +108,78 @@ export async function getHerdrAgents() {
108
108
  */
109
109
  export async function getHerdrStatusMap() {
110
110
  const agents = await getHerdrAgents();
111
+ const observedAt = new Date().toISOString();
112
+ const sessionModels = getOpenCodeSessionModels(agents);
111
113
  const map = new Map();
112
- for (const a of agents) {
113
- const handle = a.name; // e.g. "ballistics", "testkit"
114
- if (handle) {
115
- map.set(handle, {
116
- herdrStatus: a.agent_status || "unknown",
117
- herdrPaneId: a.pane_id,
118
- herdrWorkspaceId: a.workspace_id,
119
- herdrTabId: a.tab_id,
120
- herdrTitle: a.terminal_title_stripped || a.terminal_title,
121
- interactiveReady: a.interactive_ready || false,
122
- agentType: a.agent,
123
- });
124
- }
114
+ for (const agent of agents) {
115
+ const activity = mapHerdrAgentActivity(agent, observedAt, resolveRuntimeModel(agent, sessionModels));
116
+ if (activity) map.set(activity.herdrHandle, activity);
125
117
  }
126
118
  return map;
127
119
  }
128
120
 
121
+ export function normalizeHerdrStatus(value) {
122
+ const status = String(value || "").trim().toLowerCase();
123
+ if (status === "online" || status === "active") return "idle";
124
+ if (["idle", "working", "blocked", "done", "error", "unknown"].includes(status)) return status;
125
+ return "unknown";
126
+ }
127
+
128
+ function herdrText(value) {
129
+ if (typeof value === "string") return value.trim();
130
+ if (!value || typeof value !== "object") return "";
131
+ for (const key of ["text", "label", "title", "value"]) {
132
+ if (typeof value[key] === "string" && value[key].trim()) return value[key].trim();
133
+ }
134
+ return "";
135
+ }
136
+
137
+ function herdrStateLabels(value) {
138
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
139
+ const result = {};
140
+ for (const [status, label] of Object.entries(value)) {
141
+ const text = herdrText(label);
142
+ if (text) result[status] = text;
143
+ }
144
+ return result;
145
+ }
146
+
147
+ function herdrTokens(value) {
148
+ const values = Array.isArray(value) ? value : value ? [value] : [];
149
+ return values.map(herdrText).filter(Boolean);
150
+ }
151
+
152
+ export function mapHerdrAgentActivity(agent, observedAt = new Date().toISOString(), runtime = null) {
153
+ const handle = herdrText(agent?.name);
154
+ if (!handle) return null;
155
+ const status = normalizeHerdrStatus(agent?.agent_status);
156
+ const terminalTitle = herdrText(agent?.terminal_title_stripped) || herdrText(agent?.terminal_title);
157
+ const metadataTitle = herdrText(agent?.title);
158
+ const model = runtime || resolveRuntimeModel(agent);
159
+ return {
160
+ herdrHandle: handle,
161
+ herdrStatus: status,
162
+ herdrPaneId: herdrText(agent?.pane_id),
163
+ herdrWorkspaceId: herdrText(agent?.workspace_id),
164
+ herdrTabId: herdrText(agent?.tab_id),
165
+ herdrTerminalId: herdrText(agent?.terminal_id),
166
+ herdrTitle: terminalTitle || metadataTitle,
167
+ herdrTerminalTitle: terminalTitle,
168
+ herdrMetadataTitle: metadataTitle,
169
+ herdrStateLabels: herdrStateLabels(agent?.state_labels),
170
+ herdrTokens: herdrTokens(agent?.tokens),
171
+ herdrStateChangeSeq: Number.isFinite(agent?.state_change_seq) ? agent.state_change_seq : null,
172
+ interactiveReady: Boolean(agent?.interactive_ready),
173
+ herdrFocused: Boolean(agent?.focused),
174
+ herdrLaunchPending: Boolean(agent?.launch_pending),
175
+ agentType: herdrText(agent?.agent),
176
+ herdrSessionId: model.sessionId || null,
177
+ herdrModel: model.model || null,
178
+ herdrModelSource: model.source || null,
179
+ herdrObservedAt: observedAt,
180
+ };
181
+ }
182
+
129
183
  // ─── Long-lived event subscription ──────────────────────────────────────────
130
184
 
131
185
  /**
package/src/index.mjs CHANGED
@@ -7,3 +7,4 @@ export * from "./migration.mjs";
7
7
  export * from "./briefs.mjs";
8
8
  export * from "./worktrees.mjs";
9
9
  export * from "./fleet.mjs";
10
+ export * from "./templates.mjs";