@cmgmyr/hive 1.8.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,379 @@
1
+ import { join } from "node:path";
2
+ import { dataDir, db } from "./db.js";
3
+ import { CROSS_SESSION_CLOSE, postClaudeWake, senderAddress } from "./claudeWake.js";
4
+ import { harnessFor, paneClassifierFor } from "./harnesses.js";
5
+ import { LEAD_MESSAGE_THRESHOLD, renderLeadMessage, senderTag, shortenedSendNote, storeLeadMessage, } from "./leadMessage.js";
6
+ import { readLeadTurnState } from "./leadState.js";
7
+ import { loadProjectYml } from "./projectYml.js";
8
+ import { currentOwnership, PaneOwnershipLost, paneIdentity, requireStillOwned } from "./spawn.js";
9
+ import { holdsHumanInput, paneInCopyMode, sendText, sleep, TmuxTimeoutError, } from "./tmux.js";
10
+ export const SOCKET_MESSAGE_FOOTER = "Automated hive worker message. Do not reply to this socket sender.";
11
+ export const MESSAGE_STATUS = {
12
+ socketPending: "socket-pending",
13
+ fallbackPending: "fallback-pending",
14
+ fallbackClaimed: "fallback-claimed",
15
+ complete: "complete",
16
+ failed: "failed",
17
+ };
18
+ export const MESSAGE_METHOD = {
19
+ socket: "socket",
20
+ pty: "pty",
21
+ ptyAfterSocketTimeout: "pty-after-socket-timeout",
22
+ };
23
+ const PANE_LEASE_TTL_SECONDS = 6;
24
+ const PANE_LEASE_SETTLE_MS = 500;
25
+ const CONFIRM_GRACE_SECONDS = 60;
26
+ const ENVELOPE_OPEN = '<cross-session-message from="';
27
+ const ENVELOPE_SENDER_CLOSE = '" from-name="hive">';
28
+ const EV_PROMPT = "(CASE WHEN json_valid(ev.payload) THEN COALESCE(json_extract(ev.payload, '$.prompt'), '') ELSE ev.payload END)";
29
+ const EV_MARKER = "(agent_messages.sender_tag || '[message #' || agent_messages.id || ',')";
30
+ const EV_HEADER_END = `instr(${EV_PROMPT}, char(10))`;
31
+ // The exact "[hive:worker NAME] [message #N," at prompt offset 0 (typed) or opening hive's own envelope body (socket).
32
+ const messageEvidenceSql = (select) => `
33
+ SELECT ${select} FROM agent_state_log ev
34
+ WHERE ev.actor_id = (SELECT a.actor_id FROM agents a WHERE a.id = agent_messages.to_agent_id)
35
+ AND ev.event = 'prompt'
36
+ AND ev.created_at >= COALESCE(agent_messages.socket_attempt_at, agent_messages.created_at)
37
+ AND (substr(${EV_PROMPT}, 1, length(${EV_MARKER})) = ${EV_MARKER}
38
+ OR (${EV_HEADER_END} > ${ENVELOPE_OPEN.length + ENVELOPE_SENDER_CLOSE.length}
39
+ AND substr(${EV_PROMPT}, 1, ${ENVELOPE_OPEN.length}) = '${ENVELOPE_OPEN}'
40
+ AND substr(${EV_PROMPT}, ${EV_HEADER_END} - ${ENVELOPE_SENDER_CLOSE.length}, ${ENVELOPE_SENDER_CLOSE.length}) = '${ENVELOPE_SENDER_CLOSE}'
41
+ AND instr(substr(${EV_PROMPT}, ${ENVELOPE_OPEN.length + 1}, ${EV_HEADER_END} - ${ENVELOPE_OPEN.length + ENVELOPE_SENDER_CLOSE.length + 1}), '"') = 0
42
+ AND substr(${EV_PROMPT}, ${EV_HEADER_END} + 1, length(${EV_MARKER})) = ${EV_MARKER}
43
+ AND instr(${EV_PROMPT}, char(10) || '${CROSS_SESSION_CLOSE}') > ${EV_HEADER_END}))`;
44
+ const RETAINED = "agent_messages.created_at >= datetime('now', '-7 days')";
45
+ function quietMessagingOn(projectId) {
46
+ const project = db.prepare("SELECT path FROM projects WHERE id = ?").get(projectId);
47
+ return project !== undefined && loadProjectYml(project.path).config?.quiet_messaging === true;
48
+ }
49
+ function eligible(options) {
50
+ const { projectId, fromActor, target, submit } = options;
51
+ if (!submit || target.kind !== "lead" || target.status !== "running" || target.actor_id === fromActor)
52
+ return false;
53
+ if (harnessFor(target.command).name !== "claude")
54
+ return false;
55
+ const sender = db
56
+ .prepare("SELECT 1 AS hit FROM agents WHERE project_id = ? AND actor_id = ? AND status = 'running' AND kind = 'agent'")
57
+ .get(projectId, fromActor);
58
+ return sender !== undefined && quietMessagingOn(projectId);
59
+ }
60
+ function registeredSocket(target) {
61
+ return (target.claude_messaging_socket !== "" &&
62
+ target.claude_messaging_pane_pid !== "" &&
63
+ target.claude_messaging_pane_pid === target.pane_pid);
64
+ }
65
+ function leadSession(agentId, panePid) {
66
+ const turn = readLeadTurnState(agentId);
67
+ return turn !== null && turn.pane_pid === panePid ? turn.session_id : "";
68
+ }
69
+ function readRow(id) {
70
+ return db.prepare("SELECT * FROM agent_messages WHERE id = ?").get(id);
71
+ }
72
+ function setStatus(id, from, to, fields = {}) {
73
+ const names = Object.keys(fields);
74
+ const sets = ["delivery_status = ?", ...names.map((n) => `${n} = ?`)].join(", ");
75
+ try {
76
+ return (db
77
+ .prepare(`UPDATE agent_messages SET ${sets} WHERE id = ? AND delivery_status = ?`)
78
+ .run(to, ...names.map((n) => fields[n]), id, from).changes === 1);
79
+ }
80
+ catch {
81
+ return false;
82
+ }
83
+ }
84
+ const NOW = "strftime('%Y-%m-%d %H:%M:%f', 'now')";
85
+ function stamp(id, column) {
86
+ try {
87
+ db.prepare(`UPDATE agent_messages SET ${column} = ${NOW} WHERE id = ?`).run(id);
88
+ }
89
+ catch {
90
+ }
91
+ }
92
+ function socketText(id, text, tag) {
93
+ return `${renderLeadMessage(id, text, tag)}\n\n${SOCKET_MESSAGE_FOOTER}`;
94
+ }
95
+ function acquirePaneLease(row, key) {
96
+ try {
97
+ db.prepare("INSERT OR IGNORE INTO actors (id, name, kind) VALUES (?, ?, 'agent')").run(row.from_actor, row.from_name);
98
+ db.prepare("DELETE FROM leases WHERE project_id = ? AND lock_key = ? AND expires_at < datetime('now')").run(row.project_id, key);
99
+ const held = db
100
+ .prepare(`INSERT INTO leases (project_id, lock_key, owner, expires_at)
101
+ VALUES (?, ?, ?, datetime('now', printf('+%d seconds', ?)))
102
+ ON CONFLICT(project_id, lock_key) DO NOTHING
103
+ RETURNING expires_at`)
104
+ .get(row.project_id, key, row.from_actor, PANE_LEASE_TTL_SECONDS);
105
+ return held?.expires_at ?? null;
106
+ }
107
+ catch {
108
+ return null;
109
+ }
110
+ }
111
+ function releasePaneLease(row, key, expiresAt) {
112
+ try {
113
+ db.prepare("DELETE FROM leases WHERE project_id = ? AND lock_key = ? AND expires_at = ?").run(row.project_id, key, expiresAt);
114
+ }
115
+ catch {
116
+ }
117
+ }
118
+ function hold(row, reason) {
119
+ try {
120
+ db.prepare("UPDATE agent_messages SET delivery_note = ? WHERE id = ? AND delivery_status = ?").run(reason, row.id, MESSAGE_STATUS.fallbackPending);
121
+ }
122
+ catch {
123
+ }
124
+ return { kind: "held", reason };
125
+ }
126
+ function failPending(row, reason) {
127
+ setStatus(row.id, MESSAGE_STATUS.fallbackPending, MESSAGE_STATUS.failed, { delivery_note: reason });
128
+ return { kind: "failed", reason };
129
+ }
130
+ function pasteFailure(err, pasted, buffered) {
131
+ const why = err instanceof Error ? err.message : String(err);
132
+ if (pasted) {
133
+ return err instanceof PaneOwnershipLost
134
+ ? `pasted, then the Enter was withheld because pane ownership was lost: ${why} The text may be on screen unsubmitted.`
135
+ : `pasted, then the Enter failed: ${why} The text is on screen unsubmitted.`;
136
+ }
137
+ if (buffered && err instanceof TmuxTimeoutError)
138
+ return `the paste timed out, so the text MAY be on screen unsubmitted: ${why}`;
139
+ return `nothing was pasted: ${why}`;
140
+ }
141
+ // One guarded pane attempt. The claim is the CAS that makes it at most one per message.
142
+ async function paneFallback(row, snapshot) {
143
+ let identity;
144
+ try {
145
+ identity = paneIdentity(JSON.parse(row.target_identity ?? ""));
146
+ }
147
+ catch {
148
+ return failPending(row, "the accepted target identity is unreadable, so no pane can be certified as the lead's");
149
+ }
150
+ const target = db.prepare("SELECT kind, command FROM agents WHERE id = ?").get(row.to_agent_id);
151
+ const ownership = currentOwnership(row.to_agent_id, identity, snapshot);
152
+ if (ownership === "unknown")
153
+ return hold(row, "the lead's pane ownership reads unknown");
154
+ if (ownership !== "live" || target === undefined) {
155
+ return failPending(row, `the lead pane this message was accepted for is ${ownership}; nothing is typed into a replacement`);
156
+ }
157
+ const key = `wake-pane:${identity.tmux_socket}:${identity.tmux_target}`;
158
+ const expiresAt = acquirePaneLease(row, key);
159
+ if (expiresAt === null)
160
+ return hold(row, "another delivery holds the lead's pane");
161
+ let wrote = false;
162
+ try {
163
+ const pane = identity.tmux_target;
164
+ if (paneInCopyMode(pane) === true)
165
+ return hold(row, "the lead's pane is in copy mode");
166
+ const classifier = paneClassifierFor(target.command);
167
+ if (!classifier)
168
+ return hold(row, "hive cannot classify the lead's screen");
169
+ const choice = classifier.choiceCheck(pane).awaitingChoice;
170
+ if (choice !== false)
171
+ return hold(row, choice ? "the lead's pane is waiting on a choice" : "the lead's pane could not be read");
172
+ if (holdsHumanInput(classifier.inputBoxState(pane)))
173
+ return hold(row, "the lead's input box holds unsubmitted text");
174
+ const claimed = (() => {
175
+ try {
176
+ return (db
177
+ .prepare(`UPDATE agent_messages SET delivery_status = ?, fallback_claimed_at = ${NOW}
178
+ WHERE id = ? AND delivery_status = ? AND NOT EXISTS (${messageEvidenceSql("1")})`)
179
+ .run(MESSAGE_STATUS.fallbackClaimed, row.id, MESSAGE_STATUS.fallbackPending).changes === 1);
180
+ }
181
+ catch {
182
+ return false;
183
+ }
184
+ })();
185
+ if (!claimed)
186
+ return { kind: "settled" };
187
+ let pasted = false;
188
+ let buffered = false;
189
+ wrote = true;
190
+ try {
191
+ await sendText(pane, renderLeadMessage(row.id, row.text, row.sender_tag ?? "", row.socket_attempt_at !== null), true, () => {
192
+ pasted = true;
193
+ stamp(row.id, "typed_at");
194
+ }, () => {
195
+ buffered = true;
196
+ }, () => requireStillOwned(row.to_agent_id, identity, `message #${row.id} fallback`));
197
+ }
198
+ catch (err) {
199
+ const reason = pasteFailure(err, pasted, buffered);
200
+ setStatus(row.id, MESSAGE_STATUS.fallbackClaimed, MESSAGE_STATUS.failed, { delivery_note: reason });
201
+ return { kind: "failed", reason };
202
+ }
203
+ setStatus(row.id, MESSAGE_STATUS.fallbackClaimed, MESSAGE_STATUS.complete);
204
+ return { kind: "typed" };
205
+ }
206
+ finally {
207
+ if (wrote)
208
+ await sleep(PANE_LEASE_SETTLE_MS);
209
+ releasePaneLease(row, key, expiresAt);
210
+ }
211
+ }
212
+ function earlierFallbackPending(row) {
213
+ return (db
214
+ .prepare(`SELECT 1 AS hit FROM agent_messages
215
+ WHERE from_actor = ? AND to_agent_id = ? AND id < ? AND delivery_status = ? AND ${RETAINED}`)
216
+ .get(row.from_actor, row.to_agent_id, row.id, MESSAGE_STATUS.fallbackPending) !== undefined);
217
+ }
218
+ const DO_NOT_RESEND = "do not resend";
219
+ // null means this send is not eligible, and agent_send's ordinary pane path runs unchanged.
220
+ export async function sendQuietLeadMessage(options) {
221
+ if (!eligible(options))
222
+ return null;
223
+ const { projectId, fromActor, target, text } = options;
224
+ const identity = paneIdentity(target);
225
+ const tag = senderTag(projectId, fromActor);
226
+ const session = leadSession(target.id, target.pane_pid);
227
+ const { id } = storeLeadMessage(projectId, fromActor, target.id, text);
228
+ const long = text.length > LEAD_MESSAGE_THRESHOLD;
229
+ const base = { agent_id: target.id, name: target.name, message_id: id, ...(long ? { shortened: true } : {}) };
230
+ const shortNote = (channel, redelivered = false) => long ? ` ${shortenedSendNote(id, renderLeadMessage(id, text, tag, redelivered).length, channel)}` : "";
231
+ const body = socketText(id, text, tag);
232
+ const skip = !registeredSocket(target)
233
+ ? "no messaging socket registered for this lead pane"
234
+ : body.includes(CROSS_SESSION_CLOSE)
235
+ ? "the text contains the envelope's closing tag"
236
+ : null;
237
+ // One statement makes the row visible to the scheduler, already in its route's status.
238
+ db.prepare(`UPDATE agent_messages SET delivery_status = ?, delivery_method = ?, socket_attempt_at = ${skip === null ? NOW : "NULL"},
239
+ delivery_note = ?, target_identity = ?, sender_tag = ? WHERE id = ?`).run(skip === null ? MESSAGE_STATUS.socketPending : MESSAGE_STATUS.fallbackPending, skip === null ? MESSAGE_METHOD.socket : MESSAGE_METHOD.pty, skip, JSON.stringify(identity), tag, id);
240
+ if (skip === null) {
241
+ const posted = await postClaudeWake({
242
+ socketPath: target.claude_messaging_socket,
243
+ senderAddress: senderAddress(join(dataDir, "wake-sender.sock")),
244
+ text: body,
245
+ beforeWrite: () => {
246
+ requireStillOwned(target.id, identity, `message #${id} socket delivery`);
247
+ const now = db
248
+ .prepare(`SELECT a.status, a.kind, a.pane_pid, a.claude_messaging_socket AS socket, a.claude_messaging_pane_pid AS pid,
249
+ m.delivery_status
250
+ FROM agents a, agent_messages m WHERE a.id = ? AND m.id = ?`)
251
+ .get(target.id, id);
252
+ return (now !== undefined &&
253
+ now.status === "running" &&
254
+ now.kind === "lead" &&
255
+ now.socket === target.claude_messaging_socket &&
256
+ now.pid === identity.pane_pid &&
257
+ now.pane_pid === identity.pane_pid &&
258
+ now.delivery_status === MESSAGE_STATUS.socketPending &&
259
+ leadSession(target.id, identity.pane_pid) === session);
260
+ },
261
+ });
262
+ if (posted) {
263
+ return {
264
+ ...base,
265
+ sent: true,
266
+ pending: true,
267
+ delivery_method: MESSAGE_METHOD.socket,
268
+ confirmed: false,
269
+ note: `Posted locally to the lead's messaging socket as message #${id}, awaiting prompt confirmation; ${DO_NOT_RESEND}. ` +
270
+ "sent means handed to the transport, not read: if no prompt confirms it, hive types it into the lead's pane once." +
271
+ shortNote("socket"),
272
+ };
273
+ }
274
+ setStatus(id, MESSAGE_STATUS.socketPending, MESSAGE_STATUS.fallbackPending, {
275
+ delivery_method: MESSAGE_METHOD.ptyAfterSocketTimeout,
276
+ delivery_note: "the socket post failed or timed out",
277
+ });
278
+ }
279
+ const row = readRow(id);
280
+ const method = (row.delivery_method ?? MESSAGE_METHOD.pty);
281
+ const outcome = earlierFallbackPending(row)
282
+ ? hold(row, "an earlier message from this sender to this lead is still awaiting its pane fallback, and this one follows it")
283
+ : await paneFallback(row);
284
+ if (outcome.kind === "typed") {
285
+ const note = shortNote("pane", row.socket_attempt_at !== null).trim();
286
+ return { ...base, sent: true, pending: false, delivery_method: method, ...(note ? { note } : {}) };
287
+ }
288
+ if (outcome.kind === "held") {
289
+ return {
290
+ ...base,
291
+ sent: false,
292
+ pending: true,
293
+ delivery_method: method,
294
+ note: `Not typed yet: ${outcome.reason}. Message #${id} is accepted and stored, and hive retries it from the ` +
295
+ `scheduler; do NOT resubmit it. agent_message_get(${id}) shows its delivery state.` +
296
+ shortNote("socket"),
297
+ };
298
+ }
299
+ if (outcome.kind === "settled") {
300
+ const now = readRow(id);
301
+ return {
302
+ ...base,
303
+ sent: true,
304
+ pending: now?.delivery_status !== MESSAGE_STATUS.complete,
305
+ delivery_method: method,
306
+ confirmed: now?.confirmed_at != null,
307
+ note: `Message #${id} was settled by another delivery path; ${DO_NOT_RESEND}. agent_message_get(${id}) shows its state.`,
308
+ };
309
+ }
310
+ throw new Error(`[agent_send:quiet-fallback-failed] message #${id} to ${target.name}: ${outcome.reason} hive will not type it again. ` +
311
+ `Do NOT resend blindly: read the lead's pane first, and agent_message_get(${id}) shows the recorded state.`);
312
+ }
313
+ function graceExpired(row, actorId, panePid) {
314
+ const firstStop = db
315
+ .prepare("SELECT MIN(created_at) AS t FROM agent_state_log WHERE actor_id = ? AND event = 'stop' AND created_at >= ?")
316
+ .get(actorId, row.socket_attempt_at).t;
317
+ let from = firstStop;
318
+ if (from === null) {
319
+ const turn = readLeadTurnState(row.to_agent_id);
320
+ if (turn !== null && turn.state === "working" && turn.pane_pid === panePid)
321
+ return false;
322
+ from = row.socket_attempt_at;
323
+ }
324
+ return db.prepare("SELECT (julianday('now') - julianday(?)) * 86400 >= ? AS due").get(from, CONFIRM_GRACE_SECONDS).due === 1;
325
+ }
326
+ function confirmFromPrompts() {
327
+ db.prepare(`UPDATE agent_messages SET confirmed_at = (${messageEvidenceSql("MIN(ev.created_at)")}), delivery_status = ?
328
+ WHERE delivery_status IS NOT NULL AND delivery_status != ? AND confirmed_at IS NULL AND ${RETAINED}
329
+ AND EXISTS (${messageEvidenceSql("1")})`).run(MESSAGE_STATUS.complete, MESSAGE_STATUS.complete);
330
+ }
331
+ function expireGrace() {
332
+ const pending = db
333
+ .prepare(`SELECT agent_messages.* FROM agent_messages WHERE delivery_status = ? AND ${RETAINED} ORDER BY id`)
334
+ .all(MESSAGE_STATUS.socketPending);
335
+ for (const row of pending) {
336
+ try {
337
+ const target = db.prepare("SELECT actor_id FROM agents WHERE id = ?").get(row.to_agent_id);
338
+ const panePid = JSON.parse(row.target_identity ?? "{}").pane_pid ?? "";
339
+ if (target === undefined || !graceExpired(row, target.actor_id, panePid))
340
+ continue;
341
+ db.prepare(`UPDATE agent_messages SET delivery_status = ?, delivery_method = ?, delivery_note = ?
342
+ WHERE id = ? AND delivery_status = ? AND NOT EXISTS (${messageEvidenceSql("1")})`).run(MESSAGE_STATUS.fallbackPending, MESSAGE_METHOD.ptyAfterSocketTimeout, "no prompt confirmed the socket post within its grace", row.id, MESSAGE_STATUS.socketPending);
343
+ }
344
+ catch {
345
+ }
346
+ }
347
+ }
348
+ // Runs inside tick's try; each row's failure is contained so one bad delivery cannot strand the rest.
349
+ export async function retryQuietLeadMessages(snapshot) {
350
+ try {
351
+ confirmFromPrompts();
352
+ }
353
+ catch {
354
+ }
355
+ expireGrace();
356
+ let due;
357
+ try {
358
+ due = db
359
+ .prepare(`SELECT agent_messages.* FROM agent_messages WHERE delivery_status = ? AND ${RETAINED} ORDER BY id`)
360
+ .all(MESSAGE_STATUS.fallbackPending);
361
+ }
362
+ catch {
363
+ return;
364
+ }
365
+ const blocked = new Set();
366
+ for (const row of due) {
367
+ const pair = `${row.from_actor}\u0000${row.to_agent_id}`;
368
+ if (blocked.has(pair))
369
+ continue;
370
+ try {
371
+ const outcome = await paneFallback(row, snapshot);
372
+ if (outcome.kind === "held")
373
+ blocked.add(pair);
374
+ }
375
+ catch {
376
+ blocked.add(pair);
377
+ }
378
+ }
379
+ }
@@ -18,6 +18,7 @@ export const BUILT_IN_PROJECT_YML = Object.freeze({
18
18
  context_checkpoint_percent: null,
19
19
  lead_turn_budget: null,
20
20
  dashboard: false,
21
+ quiet_messaging: false,
21
22
  vars: Object.freeze({}),
22
23
  processes: Object.freeze({}),
23
24
  });
@@ -198,6 +199,19 @@ function applyLayer(root, path, source, config, sources, warnings) {
198
199
  else
199
200
  warn(`dashboard must be true or false; ignoring "${String(value)}".`);
200
201
  }
202
+ if (has("quiet_messaging")) {
203
+ const value = root.quiet_messaging;
204
+ if (value == null) {
205
+ config.quiet_messaging = false;
206
+ sources.quiet_messaging = source;
207
+ }
208
+ else if (typeof value === "boolean") {
209
+ config.quiet_messaging = value;
210
+ sources.quiet_messaging = source;
211
+ }
212
+ else
213
+ warn(`quiet_messaging must be true or false; ignoring "${String(value)}".`);
214
+ }
201
215
  if (has("agents")) {
202
216
  const value = root.agents;
203
217
  if (value == null) {
@@ -444,7 +444,7 @@ a.proj:hover { color: var(--accent); text-decoration: underline; text-underline-
444
444
  .c-brief li { counter-increment: pick; font-size: 0.875rem; min-width: 0; overflow-wrap: anywhere; }
445
445
  .c-brief li::before { content: counter(pick); font-family: var(--font-mono); font-weight: 650; color: var(--accent); margin-right: 0.4rem; }
446
446
  .c-brief .why { display: block; color: var(--fg-muted); font-size: 0.8125rem; margin-top: 0.15rem; }
447
- .brief-summary, .brief-empty { margin: 0; font-size: 0.875rem; color: var(--fg-muted); grid-column: 2; }
447
+ .brief-summary, .brief-empty { margin: 0; font-size: 0.875rem; color: var(--fg-muted); grid-column: 2; min-width: 0; overflow-wrap: anywhere; }
448
448
  .brief-summary { color: var(--fg); }
449
449
  @media (max-width: 52rem) {
450
450
  .c-brief { grid-template-columns: minmax(0, 1fr); }