@cmgmyr/hive 1.11.6 → 1.11.8

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.
@@ -1,52 +1,22 @@
1
- import { join } from "node:path";
2
1
  import { dataDir, db } from "./db.js";
3
- import { CROSS_SESSION_CLOSE, postClaudeWake, senderAddress } from "./claudeWake.js";
2
+ import { CROSS_SESSION_CLOSE, promptMarkerEvidenceSql, postClaudeWake, quietMessagingOn, socketGraceDue, socketSenderAddress, SOCKET_MESSAGE_FOOTER, } from "./claudeWake.js";
4
3
  import { harnessFor, paneClassifierFor } from "./harnesses.js";
5
- import { LEAD_MESSAGE_THRESHOLD, renderLeadMessage, senderTag, shortenedSendNote, storeLeadMessage, } from "./leadMessage.js";
4
+ import { LEAD_MESSAGE_THRESHOLD, MESSAGE_METHOD, MESSAGE_RETENTION, MESSAGE_STATUS, renderLeadMessage, senderTag, shortenedSendNote, storeLeadMessage, } from "./leadMessage.js";
6
5
  import { readHandoffGate } from "./leadHandoffState.js";
7
6
  import { HANDED_OFF_STATES_SQL, readLeadTurnState } from "./leadState.js";
8
- import { loadProjectYml } from "./projectYml.js";
9
7
  import { currentOwnership, PaneOwnershipLost, paneIdentity, requireStillOwned } from "./spawn.js";
8
+ import { PANE_LEASE_SETTLE_MS, releasePaneLease, tryAcquirePaneLease } from "./leaseStore.js";
10
9
  import { holdsHumanInput, paneInCopyMode, sendText, sleep, TmuxTimeoutError, } from "./tmux.js";
11
- export const SOCKET_MESSAGE_FOOTER = "Automated hive worker message. Do not reply to this socket sender.";
12
- export const MESSAGE_STATUS = {
13
- socketPending: "socket-pending",
14
- fallbackPending: "fallback-pending",
15
- fallbackClaimed: "fallback-claimed",
16
- complete: "complete",
17
- failed: "failed",
18
- };
19
- export const MESSAGE_METHOD = {
20
- socket: "socket",
21
- pty: "pty",
22
- ptyAfterSocketTimeout: "pty-after-socket-timeout",
23
- };
24
- const PANE_LEASE_TTL_SECONDS = 6;
25
- const PANE_LEASE_SETTLE_MS = 500;
26
- const CONFIRM_GRACE_SECONDS = 60;
27
- const ENVELOPE_OPEN = '<cross-session-message from="';
28
- const ENVELOPE_SENDER_CLOSE = '" from-name="hive">';
29
- const EV_PROMPT = "(CASE WHEN json_valid(ev.payload) THEN COALESCE(json_extract(ev.payload, '$.prompt'), '') ELSE ev.payload END)";
30
10
  const EV_MARKER = "(agent_messages.sender_tag || '[message #' || agent_messages.id || ',')";
31
- const EV_HEADER_END = `instr(${EV_PROMPT}, char(10))`;
11
+ const messageMarkerAt = (promptSql, startSql) => `substr(${promptSql}, ${startSql}, length(${EV_MARKER})) = ${EV_MARKER}`;
32
12
  // The exact "[hive:worker NAME] [message #N," at prompt offset 0 (typed) or opening hive's own envelope body (socket).
33
13
  const messageEvidenceSql = (select) => `
34
14
  SELECT ${select} FROM agent_state_log ev
35
15
  WHERE ev.actor_id = (SELECT a.actor_id FROM agents a WHERE a.id = agent_messages.to_agent_id)
36
16
  AND ev.event = 'prompt'
37
17
  AND ev.created_at >= COALESCE(agent_messages.socket_attempt_at, agent_messages.created_at)
38
- AND (substr(${EV_PROMPT}, 1, length(${EV_MARKER})) = ${EV_MARKER}
39
- OR (${EV_HEADER_END} > ${ENVELOPE_OPEN.length + ENVELOPE_SENDER_CLOSE.length}
40
- AND substr(${EV_PROMPT}, 1, ${ENVELOPE_OPEN.length}) = '${ENVELOPE_OPEN}'
41
- AND substr(${EV_PROMPT}, ${EV_HEADER_END} - ${ENVELOPE_SENDER_CLOSE.length}, ${ENVELOPE_SENDER_CLOSE.length}) = '${ENVELOPE_SENDER_CLOSE}'
42
- AND instr(substr(${EV_PROMPT}, ${ENVELOPE_OPEN.length + 1}, ${EV_HEADER_END} - ${ENVELOPE_OPEN.length + ENVELOPE_SENDER_CLOSE.length + 1}), '"') = 0
43
- AND substr(${EV_PROMPT}, ${EV_HEADER_END} + 1, length(${EV_MARKER})) = ${EV_MARKER}
44
- AND instr(${EV_PROMPT}, char(10) || '${CROSS_SESSION_CLOSE}') > ${EV_HEADER_END}))`;
45
- const RETAINED = "agent_messages.created_at >= datetime('now', '-7 days')";
46
- function quietMessagingOn(projectId) {
47
- const project = db.prepare("SELECT path FROM projects WHERE id = ?").get(projectId);
48
- return project !== undefined && loadProjectYml(project.path).config?.quiet_messaging === true;
49
- }
18
+ AND ${promptMarkerEvidenceSql(messageMarkerAt)}`;
19
+ const RETAINED = `agent_messages.created_at >= datetime('now', '${MESSAGE_RETENTION}')`;
50
20
  const handoffHolds = (agentId) => readHandoffGate(agentId)?.holdAutomation === true;
51
21
  const HANDOFF_HOLD_NOTE = "the lead is handing off to a fresh session; held until the successor completes its first turn";
52
22
  function eligible(options) {
@@ -97,29 +67,6 @@ function stamp(id, column) {
97
67
  function socketText(id, text, tag) {
98
68
  return `${renderLeadMessage(id, text, tag)}\n\n${SOCKET_MESSAGE_FOOTER}`;
99
69
  }
100
- function acquirePaneLease(row, key) {
101
- try {
102
- db.prepare("INSERT OR IGNORE INTO actors (id, name, kind) VALUES (?, ?, 'agent')").run(row.from_actor, row.from_name);
103
- db.prepare("DELETE FROM leases WHERE project_id = ? AND lock_key = ? AND expires_at < datetime('now')").run(row.project_id, key);
104
- const held = db
105
- .prepare(`INSERT INTO leases (project_id, lock_key, owner, expires_at)
106
- VALUES (?, ?, ?, datetime('now', printf('+%d seconds', ?)))
107
- ON CONFLICT(project_id, lock_key) DO NOTHING
108
- RETURNING expires_at`)
109
- .get(row.project_id, key, row.from_actor, PANE_LEASE_TTL_SECONDS);
110
- return held?.expires_at ?? null;
111
- }
112
- catch {
113
- return null;
114
- }
115
- }
116
- function releasePaneLease(row, key, expiresAt) {
117
- try {
118
- db.prepare("DELETE FROM leases WHERE project_id = ? AND lock_key = ? AND expires_at = ?").run(row.project_id, key, expiresAt);
119
- }
120
- catch {
121
- }
122
- }
123
70
  function hold(row, reason) {
124
71
  try {
125
72
  db.prepare("UPDATE agent_messages SET delivery_note = ? WHERE id = ? AND delivery_status = ?").run(reason, row.id, MESSAGE_STATUS.fallbackPending);
@@ -189,8 +136,11 @@ async function paneFallback(original, snapshot) {
189
136
  if (ownership !== "live" || target === undefined) {
190
137
  return failPending(row, `the lead pane this message was accepted for is ${ownership}; nothing is typed into a replacement`);
191
138
  }
192
- const key = `wake-pane:${identity.tmux_socket}:${identity.tmux_target}`;
193
- const expiresAt = acquirePaneLease(row, key);
139
+ const expiresAt = tryAcquirePaneLease(row.project_id, identity.tmux_socket, identity.tmux_target, {
140
+ id: row.from_actor,
141
+ name: row.from_name,
142
+ kind: "agent",
143
+ });
194
144
  if (expiresAt === null)
195
145
  return hold(row, "another delivery holds the lead's pane");
196
146
  let wrote = false;
@@ -241,7 +191,7 @@ async function paneFallback(original, snapshot) {
241
191
  finally {
242
192
  if (wrote)
243
193
  await sleep(PANE_LEASE_SETTLE_MS);
244
- releasePaneLease(row, key, expiresAt);
194
+ releasePaneLease(row.project_id, identity.tmux_socket, identity.tmux_target, expiresAt);
245
195
  }
246
196
  }
247
197
  function earlierFallbackPending(row) {
@@ -277,7 +227,7 @@ export async function sendQuietLeadMessage(options) {
277
227
  if (skip === null) {
278
228
  const posted = await postClaudeWake({
279
229
  socketPath: target.claude_messaging_socket,
280
- senderAddress: senderAddress(join(dataDir, "wake-sender.sock")),
230
+ senderAddress: socketSenderAddress(dataDir),
281
231
  text: body,
282
232
  beforeWrite: () => {
283
233
  requireStillOwned(target.id, identity, `message #${id} socket delivery`);
@@ -348,17 +298,7 @@ export async function sendQuietLeadMessage(options) {
348
298
  `Do NOT resend blindly: read the lead's pane first, and agent_message_get(${id}) shows the recorded state.`);
349
299
  }
350
300
  function graceExpired(row, actorId, panePid) {
351
- const firstStop = db
352
- .prepare("SELECT MIN(created_at) AS t FROM agent_state_log WHERE actor_id = ? AND event = 'stop' AND created_at >= ?")
353
- .get(actorId, row.socket_attempt_at).t;
354
- let from = firstStop;
355
- if (from === null) {
356
- const turn = readLeadTurnState(row.to_agent_id);
357
- if (turn !== null && turn.state === "working" && turn.pane_pid === panePid)
358
- return false;
359
- from = row.socket_attempt_at;
360
- }
361
- return db.prepare("SELECT (julianday('now') - julianday(?)) * 86400 >= ? AS due").get(from, CONFIRM_GRACE_SECONDS).due === 1;
301
+ return socketGraceDue(actorId, row.socket_attempt_at, panePid, () => readLeadTurnState(row.to_agent_id));
362
302
  }
363
303
  function confirmFromPrompts() {
364
304
  db.prepare(`UPDATE agent_messages SET confirmed_at = (${messageEvidenceSql("MIN(ev.created_at)")}), delivery_status = ?
package/dist/leadState.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { liveBackgroundTasks } from "./backgroundTasks.js";
2
2
  import { db } from "./db.js";
3
+ import { hiveWorkerEnvelopeLikeSql, isHiveWorkerEnvelope } from "./claudeWake.js";
3
4
  import { FIRST_MESSAGE_SHA_ENV, firstMessageDigest } from "./firstMessage.js";
4
5
  import { postponeGrace, readActiveHandoff } from "./leadHandoffState.js";
5
6
  export function readLeadTurnState(agentId) {
@@ -16,14 +17,14 @@ export function leadSessionEnded(row, ownership) {
16
17
  export function humanPromptSql(col) {
17
18
  const p = `CASE WHEN json_valid(${col}) THEN COALESCE(json_extract(${col}, '$.prompt'), ${col}) ELSE ${col} END`;
18
19
  return (`${p} NOT LIKE '%[hive wake #%' AND ${p} NOT LIKE '[hive:%' AND ${p} NOT LIKE '<task-notification>%' ` +
19
- `AND ${p} NOT LIKE '<cross-session-message from="%" from-name="hive">' || char(10) || '[hive:worker %] [message #%,%' || char(10) || '</cross-session-message>%'`);
20
+ `AND NOT ${hiveWorkerEnvelopeLikeSql(p)}`);
20
21
  }
21
22
  // Includes SQLite LIKE's ASCII case-insensitivity.
22
23
  export function isHumanPrompt(prompt) {
23
24
  const p = prompt.toLowerCase();
24
25
  if (p.includes("[hive wake #") || p.startsWith("[hive:") || p.startsWith("<task-notification>"))
25
26
  return false;
26
- return !/^<cross-session-message from="[\s\S]*" from-name="hive">\n\[hive:worker [\s\S]*\] \[message #[\s\S]*,[\s\S]*\n<\/cross-session-message>/.test(p);
27
+ return !isHiveWorkerEnvelope(p);
27
28
  }
28
29
  // The agent_state_log state label on hive's own first message to a lead; conversationHoldsWake skips it.
29
30
  export const FIRST_MESSAGE_LOG_STATE = "first_message";
@@ -0,0 +1,54 @@
1
+ import { db } from "./db.js";
2
+ export function purgeExpiredLeases(projectId, key) {
3
+ if (key === undefined) {
4
+ db.prepare("DELETE FROM leases WHERE project_id = ? AND expires_at < datetime('now')").run(projectId);
5
+ return;
6
+ }
7
+ db.prepare("DELETE FROM leases WHERE project_id = ? AND lock_key = ? AND expires_at < datetime('now')").run(projectId, key);
8
+ }
9
+ export function insertLease(projectId, key, actor, ttlSeconds) {
10
+ return db
11
+ .prepare(`INSERT INTO leases (project_id, lock_key, owner, expires_at)
12
+ VALUES (?, ?, ?, datetime('now', printf('+%d seconds', ?)))
13
+ ON CONFLICT(project_id, lock_key) DO NOTHING
14
+ RETURNING expires_at`)
15
+ .get(projectId, key, actor, ttlSeconds);
16
+ }
17
+ export function readLease(projectId, key) {
18
+ return db.prepare("SELECT * FROM leases WHERE project_id = ? AND lock_key = ?").get(projectId, key);
19
+ }
20
+ export function extendOwnedLease(projectId, key, actor, ttlSeconds) {
21
+ const info = db
22
+ .prepare(`UPDATE leases SET expires_at = datetime('now', printf('+%d seconds', ?))
23
+ WHERE project_id = ? AND lock_key = ? AND owner = ?`)
24
+ .run(ttlSeconds, projectId, key, actor);
25
+ return info.changes > 0;
26
+ }
27
+ export function releaseOwnedLease(projectId, key, actor) {
28
+ return db
29
+ .prepare("DELETE FROM leases WHERE project_id = ? AND lock_key = ? AND owner = ?")
30
+ .run(projectId, key, actor).changes > 0;
31
+ }
32
+ export function paneLeaseKey(socket, pane) {
33
+ return `wake-pane:${socket}:${pane}`;
34
+ }
35
+ export const PANE_LEASE_TTL_SECONDS = 6;
36
+ export const PANE_LEASE_SETTLE_MS = 500;
37
+ export function tryAcquirePaneLease(projectId, socket, pane, actor) {
38
+ try {
39
+ db.prepare("INSERT OR IGNORE INTO actors (id, name, kind) VALUES (?, ?, ?)").run(actor.id, actor.name, actor.kind);
40
+ const key = paneLeaseKey(socket, pane);
41
+ purgeExpiredLeases(projectId, key);
42
+ return insertLease(projectId, key, actor.id, PANE_LEASE_TTL_SECONDS)?.expires_at ?? null;
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
48
+ export function releasePaneLease(projectId, socket, pane, expiresAt) {
49
+ try {
50
+ db.prepare("DELETE FROM leases WHERE project_id = ? AND lock_key = ? AND expires_at = ?").run(projectId, paneLeaseKey(socket, pane), expiresAt);
51
+ }
52
+ catch {
53
+ }
54
+ }
package/dist/portfolio.js CHANGED
@@ -7,6 +7,7 @@ import { pendingQuestionCount } from "./questions.js";
7
7
  import { ACTIVE_TIMER_WHERE as ACTIVE_WAKE_WHERE } from "./wakeSql.js";
8
8
  export { ACTIVE_WAKE_WHERE };
9
9
  import { leadSessionEnded, readLeadTurnState } from "./leadState.js";
10
+ import { finishedIdle } from "./firstPrompt.js";
10
11
  import { liveTargets, ownershipLiveness, rowOwnership } from "./tmux.js";
11
12
  export const PORTFOLIO_REASONS = [
12
13
  "needs_human",
@@ -85,7 +86,7 @@ function projectRow(project, now, snapshot) {
85
86
  }
86
87
  tagged.sort((a, b) => (a.updated_at < b.updated_at ? -1 : a.updated_at > b.updated_at ? 1 : a.id - b.id));
87
88
  const agents = db
88
- .prepare("SELECT id, kind, agent_state, tmux_target, tmux_socket, pane_pid FROM agents WHERE project_id = ? AND status = 'running' ORDER BY id")
89
+ .prepare("SELECT id, kind, agent_state, resumed_at, tmux_target, tmux_socket, pane_pid FROM agents WHERE project_id = ? AND status = 'running' ORDER BY id")
89
90
  .all(project.id);
90
91
  const liveness = (a) => ownershipLiveness(rowOwnership(a, snapshot));
91
92
  const workers = { working: 0, idle: 0, needs_input: 0, other: 0, unreachable: 0, unconfirmed: 0 };
@@ -116,8 +117,10 @@ function projectRow(project, now, snapshot) {
116
117
  workers.working++;
117
118
  liveWorking++;
118
119
  }
119
- else if (a.agent_state === "idle")
120
+ else if (finishedIdle(a))
120
121
  workers.idle++;
122
+ else if (a.agent_state === "idle")
123
+ workers.other++;
121
124
  else if (a.agent_state === "waiting") {
122
125
  workers.needs_input++;
123
126
  liveWaiting++;
package/dist/ptys.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { platform } from "node:os";
4
+ import { parseEtimeSeconds } from "./etime.js";
5
+ export { parseEtimeSeconds } from "./etime.js";
4
6
  const PS_LINE = /^\s*(\S+)\s+(\d+)\s+(.+?)\s*$/;
5
7
  export function parsePsSnapshot(output) {
6
8
  const rows = [];
@@ -100,15 +102,6 @@ export function ptyHeadroom() {
100
102
  }
101
103
  }
102
104
  const PS_AGE_LINE = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(.+?)\s*$/;
103
- export function parseEtimeSeconds(etime) {
104
- const m = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(etime) ?? /^(\d+)$/.exec(etime);
105
- if (!m)
106
- return null;
107
- if (m.length === 2)
108
- return Number(m[1]);
109
- const [, days, hours, minutes, seconds] = m;
110
- return (Number(days ?? 0) * 24 + Number(hours ?? 0)) * 3600 + Number(minutes) * 60 + Number(seconds);
111
- }
112
105
  export function parsePsSnapshotWithAge(output) {
113
106
  const rows = [];
114
107
  for (const line of output.split("\n")) {
@@ -3,6 +3,7 @@ import { join, sep } from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { db } from "./db.js";
5
5
  import { SECTION_STYLE, SPRITE, chevron, renderBoardPadsTodos, sectionScript } from "./dashboard.js";
6
+ import { parseStoreTimestamp } from "./storeClock.js";
6
7
  export const QUEEN_BRIEF_KEY = "queen:brief";
7
8
  const LANE_ORDER = ["waiting_on_you", "stuck", "moving", "quiet"];
8
9
  const LANE_RANK = { waiting_on_you: 0, stuck: 1, moving: 2, quiet: 3 };
@@ -103,7 +104,7 @@ const REASON_SEVERE = new Set([
103
104
  "wake_overdue_5m",
104
105
  ]);
105
106
  function parseUtc(ts) {
106
- return Date.parse(`${ts.replace(" ", "T")}Z`);
107
+ return parseStoreTimestamp(ts);
107
108
  }
108
109
  function age(from, asOf) {
109
110
  const seconds = Math.max(0, Math.floor((parseUtc(asOf) - parseUtc(from)) / 1000));