herdr-plugin-amq 0.1.8 → 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.
@@ -17,7 +17,8 @@ metadata:
17
17
  herdr-amq mail drain --me <handle> --include-body
18
18
  herdr-amq task drain --me <handle>
19
19
  ```
20
- 2. **Reply to the Sender**: Always reply to the sender on the same thread/ref chain. Never drop thread context:
20
+ Registration welcomes are status messages. Drain them, but do not reply unless the welcome explicitly requests action.
21
+ 2. **Reply Only When Action Is Requested**: Reply on the same thread/ref chain when a message asks a question, requests action, or reports a blocker. Do not reply to terminal ACKs or informational status messages, which prevents ACK loops:
21
22
  ```bash
22
23
  herdr-amq mail reply --me <handle> --id <msg_id> --body "..."
23
24
  ```
@@ -71,7 +72,7 @@ Unifies external tool agent briefs (`.opencode/agents`, `.agents`, `.pi/agents`,
71
72
  | Command | Usage | Description |
72
73
  |---|---|---|
73
74
  | `fleet status` | `herdr-amq fleet status` | Discover personas across external tools and show live Herdr pane states |
74
- | `fleet prepopulate` | `herdr-amq fleet prepopulate` | Ensure Maildirs, Git worktrees, and workspace trust exist for all personas |
75
+ | `fleet prepopulate` | `herdr-amq fleet prepopulate` | Ensure Maildirs, Git worktrees, and workspace trust exist; welcome each newly registered agent once |
75
76
  | `fleet up` | `herdr-amq fleet up [--kind agy\|opencode\|pi]` | Launch missing fleet agents into isolated Herdr tabs with auto-trust & clean PATH |
76
77
  | `bootstrap` | `herdr-amq bootstrap [--kind agy]` | Instant cold-start: prepopulate + launch fleet + start bridge daemon + doorbell pass |
77
78
  | `migrate` | `herdr-amq migrate [--dry-run]` | Migrate historical attachments into CAS blobs or pinned Git commits |
@@ -81,6 +82,6 @@ Unifies external tool agent briefs (`.opencode/agents`, `.agents`, `.pi/agents`,
81
82
  | Command | Usage | Description |
82
83
  |---|---|---|
83
84
  | `status` | `herdr-amq status` | Show bridge daemon status, unread counts per handle, and paths |
84
- | `doorbell` | `herdr-amq doorbell` | Run a single doorbell pass alerting idle/blocked agents |
85
+ | `doorbell` | `herdr-amq doorbell` | Run a single doorbell pass; operators may customize `.agent-mail/templates/doorbell.md` with scalar substitutions |
85
86
  | `dashboard` | `herdr-amq dashboard [--port 8505]` | Launch web AGmail UI & REST API |
86
87
  | `--skill` | `herdr-amq --skill [--install [dir]]` | Print or install this agentic skill definition |
package/src/actions.mjs CHANGED
@@ -116,7 +116,7 @@ export function handleDoorbell() {
116
116
 
117
117
  const force = process.argv.includes("--force") || process.argv.includes("-f");
118
118
  console.log(`🔔 Checking AMQ inboxes at ${amqRoot}${force ? " (force=true)" : ""}...`);
119
- const res = runDoorbellPass({ amqRoot, force });
119
+ const res = runDoorbellPass({ amqRoot, force, allowPrompt: true, persistState: true });
120
120
 
121
121
  if (!res.ok) {
122
122
  console.error(`❌ Doorbell check failed: ${res.error}`);
@@ -147,7 +147,7 @@ export function handleAgentStatusChanged() {
147
147
  if (status === "idle" || status === "done") {
148
148
  const amqRoot = findAmqRoot();
149
149
  if (amqRoot && handle) {
150
- runDoorbellPass({ amqRoot, targetHandle: handle });
150
+ runDoorbellPass({ amqRoot, targetHandle: handle, allowPrompt: true, persistState: true });
151
151
  }
152
152
  }
153
153
  }
package/src/bridge.mjs CHANGED
@@ -11,6 +11,10 @@ import {
11
11
  execCmd,
12
12
  } from "./config.mjs";
13
13
  import { listBacklogTasks, getAgentTaskStats } from "./board.mjs";
14
+ import { loadLocalTemplate, renderTemplate } from "./templates.mjs";
15
+ import { isSafeMailIdentifier, listMaildirMessageFiles, readMaildirMessageFile, writeBoundedFileAtomic } from "./protocol.mjs";
16
+
17
+ const MAX_DOORBELL_PROMPT_BYTES = 64 * 1024;
14
18
 
15
19
  function getPidFile() {
16
20
  return path.join(getStateDir(), "bridge.pid");
@@ -122,12 +126,13 @@ function healAgentName(handle, dryRun = false) {
122
126
  }
123
127
 
124
128
  function promptAgent(handle, text, dryRun = false) {
129
+ const boundedText = boundDoorbellPrompt(text);
125
130
  if (dryRun || process.env.HERDR_DISABLE_PROMPT === "1" || process.env.NODE_ENV === "test") {
126
- console.log(`[bridge] DRY: would prompt ${handle}: ${text.slice(0, 60)}...`);
131
+ console.log(`[bridge] DRY: would prompt ${handle}: ${boundedText.slice(0, 60)}...`);
127
132
  return true;
128
133
  }
129
134
  try {
130
- runHerdr(["agent", "prompt", handle, text]);
135
+ runHerdr(["agent", "prompt", handle, boundedText]);
131
136
  return true;
132
137
  } catch (err) {
133
138
  console.warn(`[bridge] Warning: prompt ${handle} failed: ${err.message}`);
@@ -151,15 +156,50 @@ function recordAlert(handle, count, from, dryRun = false) {
151
156
  } catch {}
152
157
  }
153
158
 
159
+ function safeStateText(value, maxLength = 512) {
160
+ if (typeof value !== "string") return "";
161
+ return value.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim().slice(0, maxLength);
162
+ }
163
+
164
+ function sanitizeDeliveredState(value) {
165
+ const delivered = {};
166
+ const deliveredTasks = {};
167
+ if (!value || typeof value !== "object") return { delivered, deliveredTasks, recoveryRequired: true };
168
+
169
+ for (const [id, entry] of Object.entries(value.delivered || {})) {
170
+ if (!isSafeMailIdentifier(id) || !entry || typeof entry !== "object") continue;
171
+ if (!isSafeMailIdentifier(entry.to, 128) || !isSafeMailIdentifier(entry.from, 128)) continue;
172
+ if (typeof entry.at !== "string") continue;
173
+ delivered[id] = {
174
+ at: entry.at,
175
+ to: entry.to,
176
+ from: entry.from,
177
+ attempts: Number.isFinite(entry.attempts) ? Math.max(1, Math.trunc(entry.attempts)) : 1,
178
+ };
179
+ }
180
+
181
+ for (const [id, entry] of Object.entries(value.deliveredTasks || {})) {
182
+ if (!isSafeMailIdentifier(id) || !entry || typeof entry !== "object") continue;
183
+ if (!isSafeMailIdentifier(entry.to, 128) || typeof entry.at !== "string") continue;
184
+ deliveredTasks[id] = {
185
+ at: entry.at,
186
+ to: entry.to,
187
+ title: safeStateText(entry.title),
188
+ attempts: Number.isFinite(entry.attempts) ? Math.max(1, Math.trunc(entry.attempts)) : 1,
189
+ };
190
+ }
191
+
192
+ return { delivered, deliveredTasks, recoveryRequired: false };
193
+ }
194
+
154
195
  function loadDeliveredState() {
155
196
  const stateFile = getStateFile();
156
197
  try {
157
- const s = JSON.parse(fs.readFileSync(stateFile, "utf8"));
158
- s.delivered = s.delivered || {};
159
- s.deliveredTasks = s.deliveredTasks || {};
160
- return s;
198
+ const content = readMaildirMessageFile(stateFile, 2 * 1024 * 1024);
199
+ if (content === null) return { delivered: {}, deliveredTasks: {}, recoveryRequired: true };
200
+ return sanitizeDeliveredState(JSON.parse(content));
161
201
  } catch {
162
- return { delivered: {}, deliveredTasks: {} };
202
+ return { delivered: {}, deliveredTasks: {}, recoveryRequired: true };
163
203
  }
164
204
  }
165
205
 
@@ -179,7 +219,7 @@ function saveDeliveredState(state) {
179
219
  delete state.deliveredTasks[id];
180
220
  }
181
221
  }
182
- fs.writeFileSync(stateFile, JSON.stringify(state, null, 1), "utf8");
222
+ writeBoundedFileAtomic(stateFile, JSON.stringify(state, null, 1), 2 * 1024 * 1024);
183
223
  }
184
224
 
185
225
  export function listInbox(amqRoot, handle) {
@@ -202,11 +242,12 @@ export function listInbox(amqRoot, handle) {
202
242
  try {
203
243
  const newDir = path.join(amqRoot, "agents", handle, "inbox", "new");
204
244
  if (!fs.existsSync(newDir)) return [];
205
- const files = fs.readdirSync(newDir).filter((f) => !f.startsWith("."));
245
+ const files = listMaildirMessageFiles(newDir).filter((f) => !f.startsWith("."));
206
246
  const msgs = [];
207
247
  for (const f of files) {
208
248
  const fullPath = path.join(newDir, f);
209
- const content = fs.readFileSync(fullPath, "utf8");
249
+ const content = readMaildirMessageFile(fullPath);
250
+ if (content === null) continue;
210
251
  const jsonMatch = content.match(/^---json\r?\n([\s\S]*?)\r?\n---/);
211
252
  if (jsonMatch) {
212
253
  try {
@@ -251,47 +292,98 @@ export function listInbox(amqRoot, handle) {
251
292
  }
252
293
  }
253
294
 
254
- export const buildDoorbellPrompt = (handle, msgs = [], taskStatsOrBacklog = null) => {
255
- const senders = [...new Set(msgs.map((m) => m.from))].join(", ");
256
- const mCount = msgs.length;
295
+ function boundDoorbellPrompt(text, maxBytes = MAX_DOORBELL_PROMPT_BYTES) {
296
+ const value = typeof text === "string" ? text : String(text ?? "");
297
+ const bytes = Buffer.from(value, "utf8");
298
+ return bytes.length <= maxBytes
299
+ ? value
300
+ : bytes.subarray(0, Math.max(0, maxBytes)).toString("utf8");
301
+ }
257
302
 
258
- let stats;
259
- if (Array.isArray(taskStatsOrBacklog)) {
260
- stats = { backlog: taskStatsOrBacklog.length, blocked: 0, doing: 0, done: 0 };
261
- } else if (taskStatsOrBacklog && typeof taskStatsOrBacklog === "object") {
262
- stats = taskStatsOrBacklog;
263
- } else {
264
- stats = { backlog: 0, blocked: 0, doing: 0, done: 0 };
265
- }
303
+ function safeCount(value) {
304
+ const count = Number(value);
305
+ return Number.isFinite(count) ? Math.max(0, Math.trunc(count)) : 0;
306
+ }
266
307
 
267
- const bCount = stats.backlog || 0;
308
+ function safeSender(value) {
309
+ const sender = typeof value === "string" ? value.trim() : "";
310
+ return /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(sender) ? sender : "unknown";
311
+ }
268
312
 
269
- // Build task numbers breakdown string: e.g. " (1 blocked, 2 in progress, 3 done)"
270
- const taskDetails = [];
271
- if (stats.blocked > 0) taskDetails.push(`${stats.blocked} blocked`);
272
- if (stats.doing > 0) taskDetails.push(`${stats.doing} in progress`);
273
- if (stats.done > 0) taskDetails.push(`${stats.done} done`);
274
- const detailsStr = taskDetails.length > 0 ? ` (${taskDetails.join(", ")})` : "";
275
-
276
- if (mCount > 0 && bCount > 0) {
277
- return (
278
- `AMQ & Task doorbell: ${mCount} new message(s) from ${senders}. You have ${bCount} task(s) in backlog${detailsStr}. ` +
279
- `Run: herdr-amq mail drain --me ${handle} --include-body && herdr-amq task drain --me ${handle}. ` +
280
- `Claim next task via: herdr-amq task next --me ${handle}, then reply on-thread with herdr-amq mail reply --id <msg_id>.`
281
- );
313
+ function normalizeDoorbellStats(taskStatsOrBacklog) {
314
+ const raw = Array.isArray(taskStatsOrBacklog)
315
+ ? { backlog: taskStatsOrBacklog.length, blocked: 0, doing: 0, done: 0 }
316
+ : taskStatsOrBacklog && typeof taskStatsOrBacklog === "object"
317
+ ? taskStatsOrBacklog
318
+ : { backlog: 0, blocked: 0, doing: 0, done: 0 };
319
+ const stats = {
320
+ backlog: safeCount(raw.backlog),
321
+ blocked: safeCount(raw.blocked),
322
+ doing: safeCount(raw.doing),
323
+ done: safeCount(raw.done),
324
+ };
325
+ stats.total = safeCount(raw.total ?? Object.values(stats).reduce((sum, value) => sum + value, 0));
326
+ return stats;
327
+ }
328
+
329
+ function buildDoorbellContext(handle, msgs, stats) {
330
+ return {
331
+ agent: { handle },
332
+ mail: {
333
+ count: safeCount(msgs.length),
334
+ senders: [...new Set(msgs.map((message) => safeSender(message.from)))].slice(0, 16).join(", "),
335
+ },
336
+ board: stats,
337
+ };
338
+ }
339
+
340
+ function buildRequiredDoorbellActions(handle, context) {
341
+ const actions = [];
342
+ if (context.mail.count > 0) {
343
+ actions.push(`Run: herdr-amq mail drain --me ${handle} --include-body.`);
282
344
  }
345
+ if (context.board.backlog > 0) {
346
+ actions.push(`Run: herdr-amq task drain --me ${handle}; claim with herdr-amq task next --me ${handle}.`);
347
+ }
348
+ if (context.mail.count > 0) {
349
+ actions.push("Reply only when a message explicitly requests action or asks a question.");
350
+ }
351
+ return actions.join(" ");
352
+ }
283
353
 
284
- if (bCount > 0) {
285
- return (
286
- `Task doorbell: You have ${bCount} task(s) in backlog${detailsStr}. ` +
287
- `Run: herdr-amq task drain --me ${handle} and claim via: herdr-amq task next --me ${handle}.`
288
- );
354
+ function buildDefaultDoorbellPrompt(handle, msgs, stats) {
355
+ const context = buildDoorbellContext(handle, msgs, stats);
356
+ const taskDetails = [];
357
+ if (context.board.blocked > 0) taskDetails.push(`${context.board.blocked} blocked`);
358
+ if (context.board.doing > 0) taskDetails.push(`${context.board.doing} in progress`);
359
+ if (context.board.done > 0) taskDetails.push(`${context.board.done} done`);
360
+ const details = taskDetails.length ? ` (${taskDetails.join(", ")})` : "";
361
+ let summary;
362
+ if (context.mail.count > 0 && context.board.backlog > 0) {
363
+ summary = `AMQ & Task doorbell: ${context.mail.count} new message(s) from ${context.mail.senders}. You have ${context.board.backlog} task(s) in backlog${details}.`;
364
+ } else if (context.board.backlog > 0) {
365
+ summary = `Task doorbell: You have ${context.board.backlog} task(s) in backlog${details}.`;
366
+ } else {
367
+ summary = `AMQ doorbell: ${context.mail.count} new message(s) in your inbox from ${context.mail.senders}${details}.`;
289
368
  }
369
+ return boundDoorbellPrompt(`${summary} ${buildRequiredDoorbellActions(handle, context)}`.trim());
370
+ }
290
371
 
291
- return (
292
- `AMQ doorbell: ${mCount} new message(s) in your inbox from ${senders}${detailsStr}. ` +
293
- `Run: herdr-amq mail drain --me ${handle} --include-body, then reply on-thread with herdr-amq mail reply --id <msg_id>. After replying, resume your work.`
294
- );
372
+ export const buildDoorbellPrompt = (handle, msgs = [], taskStatsOrBacklog = null, templateSource = null) => {
373
+ const stats = normalizeDoorbellStats(taskStatsOrBacklog);
374
+ const context = buildDoorbellContext(handle, msgs, stats);
375
+ const fallback = buildDefaultDoorbellPrompt(handle, msgs, stats);
376
+ if (!templateSource) return fallback;
377
+
378
+ try {
379
+ const custom = renderTemplate(templateSource, context).trim();
380
+ if (!custom) return fallback;
381
+ const requiredActions = `Required actions: ${buildRequiredDoorbellActions(handle, context)}`;
382
+ const bodyLimit = Math.max(0, MAX_DOORBELL_PROMPT_BYTES - Buffer.byteLength(requiredActions, "utf8") - 2);
383
+ return `${boundDoorbellPrompt(custom, bodyLimit)}\n\n${requiredActions}`;
384
+ } catch {
385
+ return fallback;
386
+ }
295
387
  };
296
388
 
297
389
  export const DEFAULT_DOORBELL_COOLDOWN_MS = 45000; // 45s cooldown window
@@ -317,6 +409,12 @@ export function runDoorbellPass({
317
409
  targetHandle = null,
318
410
  dryRun = false,
319
411
  force = false,
412
+ allowPrompt = false,
413
+ persistState = false,
414
+ state: injectedState = null,
415
+ getStatus = getAgentStatus,
416
+ healName = healAgentName,
417
+ prompt = promptAgent,
320
418
  cooldownMs = parseInt(process.env.HERDR_DOORBELL_COOLDOWN_MS || String(DEFAULT_DOORBELL_COOLDOWN_MS), 10),
321
419
  } = {}) {
322
420
  if (!amqRoot) {
@@ -332,7 +430,8 @@ export function runDoorbellPass({
332
430
  }
333
431
 
334
432
  const repoRoot = getRepoRootFromAmq(amqRoot);
335
- const state = loadDeliveredState();
433
+ const doorbellTemplate = loadLocalTemplate(amqRoot, "doorbell");
434
+ const state = injectedState || loadDeliveredState();
336
435
  state.delivered = state.delivered || {};
337
436
  state.deliveredTasks = state.deliveredTasks || {};
338
437
 
@@ -341,6 +440,10 @@ export function runDoorbellPass({
341
440
  const results = [];
342
441
 
343
442
  for (const handle of agentList) {
443
+ if (typeof handle !== "string" || !/^[a-z0-9_-]{1,128}$/.test(handle)) {
444
+ results.push({ handle: String(handle), status: "invalid", count: 0, tasksCount: 0, action: "invalid_handle" });
445
+ continue;
446
+ }
344
447
  const rawMsgs = listInbox(amqRoot, handle);
345
448
  // Ignore self-messages
346
449
  const msgs = rawMsgs.filter((m) => m.from !== handle);
@@ -353,9 +456,12 @@ export function runDoorbellPass({
353
456
 
354
457
  if (!undeliveredMsgs.length && !undeliveredTasks.length) continue;
355
458
 
356
- let status = getAgentStatus(handle);
459
+ let status = getStatus(handle);
357
460
  if (status === "missing") {
358
- const healed = healAgentName(handle, dryRun);
461
+ const healed = healName(
462
+ handle,
463
+ dryRun || !allowPrompt || process.env.HERDR_DISABLE_PROMPT === "1"
464
+ );
359
465
  if (healed) status = getAgentStatus(handle);
360
466
  }
361
467
 
@@ -363,8 +469,8 @@ export function runDoorbellPass({
363
469
  taskStats.backlog = undeliveredTasks.length > 0 ? undeliveredTasks.length : backlogTasks.length;
364
470
 
365
471
  if (status === "idle" || status === "done") {
366
- const text = buildDoorbellPrompt(handle, undeliveredMsgs, taskStats);
367
- const ok = promptAgent(handle, text, dryRun);
472
+ const text = buildDoorbellPrompt(handle, undeliveredMsgs, taskStats, doorbellTemplate?.source || null);
473
+ const ok = prompt(handle, text, dryRun || !allowPrompt);
368
474
  if (ok) {
369
475
  doorbelledCount += undeliveredMsgs.length;
370
476
  doorbelledTasksCount += undeliveredTasks.length;
@@ -393,7 +499,7 @@ export function runDoorbellPass({
393
499
  status,
394
500
  count: undeliveredMsgs.length,
395
501
  tasksCount: undeliveredTasks.length,
396
- action: "prompted",
502
+ action: allowPrompt && !dryRun ? "prompted" : "simulated",
397
503
  });
398
504
  }
399
505
  } else if (status === "working") {
@@ -406,7 +512,7 @@ export function runDoorbellPass({
406
512
  });
407
513
  } else if (status === "blocked") {
408
514
  if (undeliveredMsgs.length) {
409
- recordAlert(handle, undeliveredMsgs.length, undeliveredMsgs[0]?.from, dryRun);
515
+ recordAlert(handle, undeliveredMsgs.length, undeliveredMsgs[0]?.from, dryRun || !allowPrompt);
410
516
  }
411
517
  results.push({
412
518
  handle,
@@ -426,7 +532,7 @@ export function runDoorbellPass({
426
532
  }
427
533
  }
428
534
 
429
- if (!dryRun && (doorbelledCount > 0 || doorbelledTasksCount > 0)) {
535
+ if (allowPrompt && persistState && !dryRun && (doorbelledCount > 0 || doorbelledTasksCount > 0)) {
430
536
  saveDeliveredState(state);
431
537
  }
432
538
 
@@ -468,7 +574,7 @@ export function startDaemonLoop({ interval = 3000, dryRun = false } = {}) {
468
574
  const tick = () => {
469
575
  try {
470
576
  const handles = getAgentHandles(amqRoot);
471
- const res = runDoorbellPass({ amqRoot, handles, dryRun });
577
+ const res = runDoorbellPass({ amqRoot, handles, dryRun, allowPrompt: true, persistState: true });
472
578
  if (res.doorbelled > 0) {
473
579
  console.log(`[bridge] Doorbelled ${res.doorbelled} message(s)`);
474
580
  }
package/src/fleet.mjs CHANGED
@@ -345,18 +345,9 @@ export async function launchFleet(amqRoot, repoRoot, options = {}) {
345
345
  const liveAgents = await getLiveAgents();
346
346
  const launcherRoots = new Set();
347
347
  let workspaceId = process.env.HERDR_WORKSPACE_ID || null;
348
+ let workspaceLookupAttempted = Boolean(workspaceId);
348
349
 
349
350
  try {
350
- if (!dryRun && !workspaceId) {
351
- try {
352
- const workspaceOutput = runHerdr(["workspace", "list"], execHerdr);
353
- const workspaceJson = JSON.parse(workspaceOutput);
354
- const workspaces = workspaceJson?.result?.workspaces || [];
355
- const matched = workspaces.find((workspace) => workspace.cwd === repoRoot || workspace.label === path.basename(repoRoot));
356
- workspaceId = matched ? matched.workspace_id : (workspaces[0]?.workspace_id || null);
357
- } catch {}
358
- }
359
-
360
351
  for (const agent of targetFleet) {
361
352
  const handle = agent.handle;
362
353
  const panes = matchingFleetPanes(agent, liveAgents);
@@ -404,6 +395,17 @@ export async function launchFleet(amqRoot, repoRoot, options = {}) {
404
395
  continue;
405
396
  }
406
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
+
407
409
  let paneId;
408
410
  try {
409
411
  let launchPath = safePath;
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";