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.
- package/README.md +35 -268
- package/docs/architecture.md +50 -0
- package/docs/cli-and-workflows.md +72 -0
- package/docs/images/agmail-agent-activity.webp +0 -0
- package/docs/images/agmail-mobile-new-task.webp +0 -0
- package/docs/images/agmail-task-drawer.webp +0 -0
- package/docs/installation.md +70 -0
- package/docs/security-and-testing.md +33 -0
- package/docs/ui-screenshots.md +21 -0
- package/package.json +11 -5
- package/skills/herdr-amq/SKILL.md +4 -3
- package/src/actions.mjs +2 -2
- package/src/bridge.mjs +159 -53
- package/src/fleet.mjs +12 -10
- package/src/herdr.mjs +68 -14
- package/src/index.mjs +1 -0
- package/src/protocol.mjs +315 -67
- package/src/runtime-models.mjs +118 -0
- package/src/server.mjs +81 -37
- package/src/store.mjs +219 -34
- package/src/templates.mjs +118 -0
- package/src/web/app.js +487 -105
- package/src/web/index.html +130 -11
- package/src/web/style.css +1022 -39
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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}: ${
|
|
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,
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
255
|
-
const
|
|
256
|
-
const
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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
|
-
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
|
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 =
|
|
459
|
+
let status = getStatus(handle);
|
|
357
460
|
if (status === "missing") {
|
|
358
|
-
const healed =
|
|
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 =
|
|
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
|
|
113
|
-
const
|
|
114
|
-
if (
|
|
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