@cmgmyr/hive 1.11.5 → 1.11.7

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.
Files changed (51) hide show
  1. package/dist/agentKinds.js +6 -0
  2. package/dist/agentRecords.js +98 -0
  3. package/dist/backup.js +1 -1
  4. package/dist/build-info.json +1 -1
  5. package/dist/claudeWake.js +52 -4
  6. package/dist/cli.js +69 -43
  7. package/dist/context.js +2 -2
  8. package/dist/crew.js +5 -4
  9. package/dist/dashboard.js +10 -12
  10. package/dist/db.js +1 -1
  11. package/dist/errors.js +3 -0
  12. package/dist/etime.js +37 -0
  13. package/dist/firstPrompt.js +6 -0
  14. package/dist/globalConfig.js +1 -1
  15. package/dist/heldLabel.js +1 -1
  16. package/dist/kickoff.js +2 -2
  17. package/dist/leadHandoff.js +12 -11
  18. package/dist/leadHandoffCli.js +22 -1
  19. package/dist/leadHandoffState.js +1 -2
  20. package/dist/leadLaunch.js +1 -1
  21. package/dist/leadMessage.js +13 -0
  22. package/dist/leadMessageDelivery.js +14 -74
  23. package/dist/leadState.js +3 -2
  24. package/dist/leadWatch.js +1 -1
  25. package/dist/leaseStore.js +54 -0
  26. package/dist/pads.js +70 -0
  27. package/dist/portfolio.js +7 -3
  28. package/dist/projectRemove.js +1 -1
  29. package/dist/projectYml.js +1 -1
  30. package/dist/ptys.js +2 -9
  31. package/dist/queenAudit.js +2 -18
  32. package/dist/queenAuditStore.js +20 -0
  33. package/dist/queenDashboard.js +2 -1
  34. package/dist/questions.js +1 -1
  35. package/dist/result.js +2 -3
  36. package/dist/scheduler.js +42 -166
  37. package/dist/spawn.js +2 -6
  38. package/dist/staleServers.js +4 -28
  39. package/dist/stateProvenance.js +1 -3
  40. package/dist/storeClock.js +4 -0
  41. package/dist/teardown.js +2 -1
  42. package/dist/todoScope.js +1 -0
  43. package/dist/todos.js +90 -0
  44. package/dist/tools/agents.js +4 -96
  45. package/dist/tools/leases.js +5 -25
  46. package/dist/tools/pads.js +3 -82
  47. package/dist/tools/todos.js +6 -89
  48. package/dist/tools/wakes.js +12 -88
  49. package/dist/wakeSql.js +135 -0
  50. package/dist/wakeTarget.js +76 -0
  51. package/package.json +1 -1
package/dist/kickoff.js CHANGED
@@ -17,9 +17,9 @@ export function truncate(text, limit) {
17
17
  async function digest(projectPath, profile, warnings) {
18
18
  const { migrate, db } = await import("./db.js");
19
19
  const { findProjectForCwd } = await import("./context.js");
20
- const { ACTIVE_TIMER_WHERE } = await import("./scheduler.js");
21
- const { getActivePadByName } = await import("./tools/pads.js");
22
20
  const { ACTIVE_TODO_SQL, OPEN_BLOCKERS_SQL } = await import("./todoScope.js");
21
+ const { ACTIVE_TIMER_WHERE } = await import("./wakeSql.js");
22
+ const { getActivePadByName } = await import("./pads.js");
23
23
  migrate();
24
24
  const project = findProjectForCwd();
25
25
  if (!project || project.path !== projectPath)
@@ -1,7 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { db } from "./db.js";
3
+ import { MESSAGE_RETENTION, MESSAGE_STATUS } from "./leadMessage.js";
4
+ import { HELD_REASON_HANDOFF, LOG_RETENTION, handoffSocketInFlightWhere, pendingOneShotWhere, } from "./wakeSql.js";
3
5
  import { harnessFor, paneClassifierFor } from "./harnesses.js";
4
- import { casHandoff, HELD_REASON_HANDOFF, handoffHoldsAutomation, nowSql, olderThan, PASS_TIMEOUT_SECONDS, PRE_RESPAWN_STATES, passPolicy, quotedPolicy, readActiveHandoff, readHandoff, STOP_HOLD_CAP_SECONDS, SUCCESSOR_NOT_READY, } from "./leadHandoffState.js";
6
+ import { casHandoff, handoffHoldsAutomation, nowSql, olderThan, PASS_TIMEOUT_SECONDS, PRE_RESPAWN_STATES, passPolicy, quotedPolicy, readActiveHandoff, readHandoff, STOP_HOLD_CAP_SECONDS, SUCCESSOR_NOT_READY, } from "./leadHandoffState.js";
5
7
  import { holdsHumanInput, paneInCopyMode, rowOwnership, tmuxSocketPath } from "./tmux.js";
6
8
  import { readLeadSafetySnapshot } from "./leadState.js";
7
9
  import { readTurnCount } from "./turnCount.js";
@@ -94,7 +96,7 @@ const failTransaction = db.transaction((id, failure, from, where) => {
94
96
  if (!casHandoff(id, from, { state: "failed", failure, owner_token: null, owner_pid: null }, where))
95
97
  return false;
96
98
  db.prepare(`UPDATE wakes SET cancelled_at = datetime('now'), held_reason = ?
97
- WHERE id = (SELECT request_wake_id FROM lead_handoffs WHERE id = ?) AND fired_at IS NULL AND cancelled_at IS NULL`).run(`lead handoff #${id} failed: ${failure}`, id);
99
+ WHERE id = (SELECT request_wake_id FROM lead_handoffs WHERE id = ?) AND ${pendingOneShotWhere()}`).run(`lead handoff #${id} failed: ${failure}`, id);
98
100
  return true;
99
101
  });
100
102
  export function failHandoff(id, failure, from = PRE_RESPAWN_STATES, where = {}) {
@@ -158,13 +160,12 @@ export function requestBody(row, turns) {
158
160
  // Automated deliveries to the lead other than the handoff's own request wakes.
159
161
  export function automationBlocker(lead, seconds) {
160
162
  const inFlight = db
161
- .prepare(`SELECT 1 AS hit FROM agent_messages WHERE to_agent_id = ? AND delivery_status IN ('socket-pending', 'fallback-claimed')
162
- AND created_at >= datetime('now', '-7 days')
163
+ .prepare(`SELECT 1 AS hit FROM agent_messages WHERE to_agent_id = ? AND delivery_status IN (?, ?)
164
+ AND created_at >= datetime('now', ?)
163
165
  UNION ALL
164
- SELECT 1 FROM wakes WHERE deliver_actor = ? AND body NOT LIKE ? AND fired_at IS NOT NULL AND cancelled_at IS NULL
165
- AND delivery_method LIKE 'socket%' AND confirmed_at IS NULL AND socket_attempt_at >= datetime('now', '-7 days')
166
+ SELECT 1 FROM wakes WHERE deliver_actor = ? AND body NOT LIKE ? AND ${handoffSocketInFlightWhere()}
166
167
  LIMIT 1`)
167
- .get(lead.id, lead.actor_id, `${REQUEST_BODY_PREFIX}%`);
168
+ .get(lead.id, MESSAGE_STATUS.socketPending, MESSAGE_STATUS.fallbackClaimed, MESSAGE_RETENTION, lead.actor_id, `${REQUEST_BODY_PREFIX}%`, LOG_RETENTION);
168
169
  if (inFlight !== undefined)
169
170
  return "a delivery to the lead is still in flight";
170
171
  if (seconds === null)
@@ -204,7 +205,7 @@ const requestTransaction = db.transaction((row, lead, turns) => {
204
205
  const waiting = current.request_wake_id === null
205
206
  ? undefined
206
207
  : db
207
- .prepare("SELECT id FROM wakes WHERE id = ? AND due_at IS NULL AND fired_at IS NULL AND cancelled_at IS NULL")
208
+ .prepare(`SELECT id FROM wakes WHERE id = ? AND due_at IS NULL AND ${pendingOneShotWhere()}`)
208
209
  .get(current.request_wake_id);
209
210
  let wakeId;
210
211
  if (waiting !== undefined) {
@@ -435,12 +436,12 @@ export function handoffInjection(leadActor) {
435
436
  .prepare("SELECT name, agent_state FROM agents WHERE project_id = ? AND kind = 'agent' AND status = 'running' ORDER BY id")
436
437
  .all(lead.project_id);
437
438
  const pending = db
438
- .prepare("SELECT COUNT(*) AS n FROM wakes WHERE deliver_actor = ? AND fired_at IS NULL AND cancelled_at IS NULL")
439
+ .prepare(`SELECT COUNT(*) AS n FROM wakes WHERE deliver_actor = ? AND ${pendingOneShotWhere()}`)
439
440
  .get(lead.actor_id).n;
440
441
  const heldWakes = db.prepare("SELECT COUNT(*) AS n FROM wakes WHERE deliver_actor = ? AND held_reason = ? AND cancelled_at IS NULL").get(lead.actor_id, HELD_REASON_HANDOFF).n;
441
442
  const heldMessages = db
442
- .prepare("SELECT COUNT(*) AS n FROM agent_messages WHERE to_agent_id = ? AND delivery_status = 'fallback-pending' AND created_at >= datetime('now', '-7 days')")
443
- .get(lead.id).n;
443
+ .prepare("SELECT COUNT(*) AS n FROM agent_messages WHERE to_agent_id = ? AND delivery_status = ? AND created_at >= datetime('now', ?)")
444
+ .get(lead.id, MESSAGE_STATUS.fallbackPending, MESSAGE_RETENTION).n;
444
445
  const lines = [`[hive] LEAD HANDOFF: pad "${HANDOFF_PAD_NAME}" #${pad.id} revision ${pad.revision}, written ${pad.updated_at} UTC.`];
445
446
  {
446
447
  lines.push(`Handed off by session ${row.predecessor_session_id} at turn ${row.predecessor_turns ?? "?"} (${row.reason}, pass ${row.pass})` +
@@ -21,13 +21,28 @@ function flags(argv, names) {
21
21
  const out = {};
22
22
  for (let i = 0; i < argv.length; i++) {
23
23
  const name = argv[i].replace(/^--/, "");
24
- if (!argv[i].startsWith("--") || !names.includes(name) || argv[i + 1] === undefined)
24
+ if (!argv[i].startsWith("--") || !names.includes(name) || argv[i + 1] === undefined) {
25
25
  return `unexpected argument "${argv[i]}"`;
26
+ }
26
27
  out[name] = argv[++i];
27
28
  }
28
29
  const absent = names.filter((n) => out[n] === undefined);
29
30
  return absent.length > 0 ? `missing --${absent.join(", --")}` : out;
30
31
  }
32
+ function rejectUnknownFlag(argv, command, names, flagsText) {
33
+ const known = new Set(names.map((name) => `--${name}`));
34
+ for (let i = 0; i < argv.length; i++) {
35
+ if (known.has(argv[i])) {
36
+ i++;
37
+ continue;
38
+ }
39
+ if (argv[i].startsWith("-")) {
40
+ console.error(`hive ${command}: unknown argument "${argv[i]}". Flags are ${flagsText}.`);
41
+ return true;
42
+ }
43
+ }
44
+ return false;
45
+ }
31
46
  const intFlag = (value) => (/^\d+$/.test(value) ? Number(value) : null);
32
47
  function runningLeadById(id) {
33
48
  return db
@@ -36,6 +51,8 @@ function runningLeadById(id) {
36
51
  .get(id);
37
52
  }
38
53
  export async function cmdLeadHandoff(argv) {
54
+ if (rejectUnknownFlag(argv, "lead-handoff", ["request", "pad", "revision"], "--request <id>, --pad <id>, --revision <n>"))
55
+ return 1;
39
56
  const refuse = (why) => {
40
57
  console.error(`hive lead-handoff: ${why}. This session stays as it is.`);
41
58
  return 1;
@@ -152,6 +169,8 @@ const sleepMs = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
152
169
  // `hive lead-handoff-grace`: the one detached owner of an attempt. It never outlives a changed
153
170
  // attempt or token, and after it claims the respawn it never repeats the kill.
154
171
  export async function runHandoffGrace(argv) {
172
+ if (rejectUnknownFlag(argv, "lead-handoff-grace", ["request", "attempt", "token"], "--request <id>, --attempt <n>, --token <token>"))
173
+ return 1;
155
174
  try {
156
175
  return await graceLoop(argv);
157
176
  }
@@ -245,6 +264,8 @@ async function graceLoop(argv) {
245
264
  // `hive lead-handoff-start`: runs in the respawned pane before Claude. It publishes the successor's
246
265
  // pid on the same lead row by CAS against the predecessor, and only then lets the shell exec Claude.
247
266
  export async function runHandoffStart(argv) {
267
+ if (rejectUnknownFlag(argv, "lead-handoff-start", ["request", "attempt", "token"], "--request <id>, --attempt <n>, --token <token>"))
268
+ return 1;
248
269
  const parsed = flags(argv, ["request", "attempt", "token"]);
249
270
  const refuse = (why, id) => {
250
271
  if (id !== undefined) {
@@ -1,4 +1,5 @@
1
1
  import { db } from "./db.js";
2
+ export { HELD_REASON_HANDOFF } from "./wakeSql.js";
2
3
  export const PRE_RESPAWN_STATES = ["pending", "wind_down", "requested", "grace", "postponed"];
3
4
  export const PASS_TIMEOUT_SECONDS = 10 * 60;
4
5
  export const STOP_HOLD_CAP_SECONDS = 15 * 60;
@@ -53,8 +54,6 @@ export function readHandoffGate(leadAgentId) {
53
54
  ? null
54
55
  : { requestId: row.id, requestWakeId: row.request_wake_id, holdAutomation: handoffHoldsAutomation(row) };
55
56
  }
56
- export const HELD_REASON_HANDOFF = "the lead is handing off to a fresh session; held through the wind-down and the successor's boot, and delivered to " +
57
- "the lead row once the successor completes its first turn, or when the handoff fails or releases its hold";
58
57
  // A wake addressed to a lead row is held while that lead's handoff holds automation, except the
59
58
  // current request wake itself, which still passes every human-input and ownership guard.
60
59
  export function handoffHoldsWake(wakeId, deliverRowId) {
@@ -4,7 +4,7 @@ import { carriesNameFlag } from "./harnesses.js";
4
4
  import { join } from "node:path";
5
5
  import { checkoutRoot, renderProfileFile, resolveProfileFile } from "./profiles.js";
6
6
  import { activeProfile, configHash, mergedProjectVars } from "./projectYml.js";
7
- import { LEAD_NAME } from "./spawn.js";
7
+ import { LEAD_NAME } from "./agentKinds.js";
8
8
  import { shellQuote } from "./tmux.js";
9
9
  // What `hive lead` and the handoff bootstrap both need to launch a claude lead: trust, posture,
10
10
  // hooks and identity env. Pane adoption and leftover cleanup stay in cmdLead.
@@ -1,4 +1,5 @@
1
1
  import { db } from "./db.js";
2
+ import { DELIVERY_METHOD } from "./claudeWake.js";
2
3
  import { cutToUnitBudget, flatten } from "./slug.js";
3
4
  // 300 sits in the empty band between the two measured populations (todo 475): the largest lead-bound
4
5
  // message anyone has argued must arrive whole is 143 bytes, and the smallest real worker report is 440.
@@ -14,6 +15,18 @@ export const MESSAGE_RETENTION = "-7 days";
14
15
  // direction. Far above any observed rate, and lower than agent_state_log's 20,000 because a row here
15
16
  // holds unbounded message text rather than a hook payload.
16
17
  export const MESSAGE_MAX_ROWS = 5_000;
18
+ export const MESSAGE_STATUS = {
19
+ socketPending: "socket-pending",
20
+ fallbackPending: "fallback-pending",
21
+ fallbackClaimed: "fallback-claimed",
22
+ complete: "complete",
23
+ failed: "failed",
24
+ };
25
+ export const MESSAGE_METHOD = {
26
+ socket: DELIVERY_METHOD.socket,
27
+ pty: DELIVERY_METHOD.pty,
28
+ ptyAfterSocketTimeout: DELIVERY_METHOD.ptyAfterSocketTimeout,
29
+ };
17
30
  export const DELIVERY_FIELDS = [
18
31
  "delivery_status",
19
32
  "delivery_method",
@@ -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";
package/dist/leadWatch.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { db } from "./db.js";
2
2
  import { paneClassifierFor } from "./harnesses.js";
3
3
  import { readLeadTurnState } from "./leadState.js";
4
- import { LEAD_KIND } from "./spawn.js";
4
+ import { LEAD_KIND } from "./agentKinds.js";
5
5
  import { foreignSocket, holdsHumanInput, observationFailed, rowOwnership } from "./tmux.js";
6
6
  export const LEAD_TARGET_GONE = "LEAD_TARGET_GONE";
7
7
  export const LEAD_TARGET_REISSUED = "LEAD_TARGET_REISSUED";
@@ -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/pads.js ADDED
@@ -0,0 +1,70 @@
1
+ import { db } from "./db.js";
2
+ import { currentActor } from "./context.js";
3
+ export function splitsSurrogatePair(content, index) {
4
+ if (index <= 0 || index >= content.length)
5
+ return false;
6
+ const before = content.charCodeAt(index - 1);
7
+ const after = content.charCodeAt(index);
8
+ return before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff;
9
+ }
10
+ function selectPad(projectId, padId, columns) {
11
+ const row = db.prepare(`SELECT ${columns} FROM pads WHERE project_id = ? AND id = ?`).get(projectId, padId);
12
+ if (!row)
13
+ throw new Error(`No pad with id ${padId} in project ${projectId}. Call pad_list.`);
14
+ return row;
15
+ }
16
+ export function getPad(projectId, padId) {
17
+ return selectPad(projectId, padId, "*");
18
+ }
19
+ export function getPadMeta(projectId, padId) {
20
+ return selectPad(projectId, padId, "id, name, revision, archived");
21
+ }
22
+ function revisionMismatchError(padId, expected, current) {
23
+ if (current == null) {
24
+ return new Error(`Pad ${padId} no longer exists; it may have just been deleted by another write. Call pad_list to confirm.`);
25
+ }
26
+ return new Error(`Revision mismatch for pad ${padId}: expected ${expected}, current ${current}. Re-read with pad_read and retry.`);
27
+ }
28
+ function currentRevisionOrDeleted(padId) {
29
+ const row = db.prepare("SELECT revision FROM pads WHERE id = ?").get(padId);
30
+ return row?.revision ?? null;
31
+ }
32
+ export function assertRowChanged(padId, predicateRevision, changed) {
33
+ if (!changed)
34
+ throw revisionMismatchError(padId, predicateRevision, currentRevisionOrDeleted(padId));
35
+ }
36
+ export function checkRevision(pad, expected, required) {
37
+ if (expected == null) {
38
+ if (required)
39
+ throw new Error(`expected_revision is required to overwrite pad ${pad.id}. Current revision: ${pad.revision}. Read it first with pad_read.`);
40
+ return;
41
+ }
42
+ if (expected !== pad.revision)
43
+ throw revisionMismatchError(pad.id, expected, pad.revision);
44
+ }
45
+ export function bumpPad(padId, predicateRevision, set, ...params) {
46
+ const where = predicateRevision != null ? "id = ? AND revision = ?" : "id = ?";
47
+ const whereParams = predicateRevision != null ? [padId, predicateRevision] : [padId];
48
+ const row = db.prepare(`UPDATE pads SET ${set}, revision = revision + 1,
49
+ updated_by = ?, updated_at = datetime('now') WHERE ${where} RETURNING revision`).get(...params, currentActor(), ...whereParams);
50
+ assertRowChanged(padId, predicateRevision, row != null);
51
+ return row.revision;
52
+ }
53
+ export const APPEND_WITH_SEPARATOR_SET = "content = content || (CASE WHEN content = '' OR substr(content, -1) = char(10) THEN '' ELSE char(10) END) || ?";
54
+ export function getActivePadByName(projectId, name) {
55
+ return db.prepare("SELECT * FROM pads WHERE project_id = ? AND name = ? AND archived = 0").get(projectId, name);
56
+ }
57
+ export function listActivePads(projectId) {
58
+ return db.prepare(`SELECT name, revision, length(content) AS content_length, updated_by, updated_at
59
+ FROM pads WHERE project_id = ? AND archived = 0 ORDER BY name`).all(projectId);
60
+ }
61
+ export function overwritePadContent(projectId, padId, content, expectedRevision) {
62
+ const pad = getPadMeta(projectId, padId);
63
+ checkRevision(pad, expectedRevision, true);
64
+ return bumpPad(padId, pad.revision, "content = ?", content);
65
+ }
66
+ export function createPad(projectId, name, content, tags) {
67
+ const row = db.prepare(`INSERT OR IGNORE INTO pads (project_id, name, content, tags, updated_by)
68
+ VALUES (?, ?, ?, ?, ?) RETURNING id`).get(projectId, name, content, JSON.stringify(tags), currentActor());
69
+ return row?.id ?? null;
70
+ }
package/dist/portfolio.js CHANGED
@@ -4,7 +4,10 @@ import { existsSync } from "node:fs";
4
4
  import { parseTags } from "./result.js";
5
5
  import { ACTIVE_TODO_SQL, OPEN_BLOCKERS_SQL } from "./todoScope.js";
6
6
  import { pendingQuestionCount } from "./questions.js";
7
+ import { ACTIVE_TIMER_WHERE as ACTIVE_WAKE_WHERE } from "./wakeSql.js";
8
+ export { ACTIVE_WAKE_WHERE };
7
9
  import { leadSessionEnded, readLeadTurnState } from "./leadState.js";
10
+ import { finishedIdle } from "./firstPrompt.js";
8
11
  import { liveTargets, ownershipLiveness, rowOwnership } from "./tmux.js";
9
12
  export const PORTFOLIO_REASONS = [
10
13
  "needs_human",
@@ -26,7 +29,6 @@ const NEEDS_HUMAN_ITEM_CAP = 5;
26
29
  const WAKE_OVERDUE_GRACE = "-5 minutes";
27
30
  const STALE_IN_PROGRESS = "-48 hours";
28
31
  const WAKE_UPCOMING = "+24 hours";
29
- export const ACTIVE_WAKE_WHERE = "cancelled_at IS NULL AND (fired_at IS NULL OR repeat_every_ms IS NOT NULL)";
30
32
  export function leadText(p) {
31
33
  return p.lead.state === "alive" && p.lead.turn !== "unknown"
32
34
  ? `alive, turn ${p.lead.turn === "working" ? "working" : "ended"}`
@@ -84,7 +86,7 @@ function projectRow(project, now, snapshot) {
84
86
  }
85
87
  tagged.sort((a, b) => (a.updated_at < b.updated_at ? -1 : a.updated_at > b.updated_at ? 1 : a.id - b.id));
86
88
  const agents = db
87
- .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")
88
90
  .all(project.id);
89
91
  const liveness = (a) => ownershipLiveness(rowOwnership(a, snapshot));
90
92
  const workers = { working: 0, idle: 0, needs_input: 0, other: 0, unreachable: 0, unconfirmed: 0 };
@@ -115,8 +117,10 @@ function projectRow(project, now, snapshot) {
115
117
  workers.working++;
116
118
  liveWorking++;
117
119
  }
118
- else if (a.agent_state === "idle")
120
+ else if (finishedIdle(a))
119
121
  workers.idle++;
122
+ else if (a.agent_state === "idle")
123
+ workers.other++;
120
124
  else if (a.agent_state === "waiting") {
121
125
  workers.needs_input++;
122
126
  liveWaiting++;
@@ -1,6 +1,6 @@
1
1
  import { backupNow } from "./backup.js";
2
2
  import { dataDir, db } from "./db.js";
3
- import { LEAD_KIND } from "./spawn.js";
3
+ import { LEAD_KIND } from "./agentKinds.js";
4
4
  import { foreignSocket, liveTargets, observationFailed, rowOwnership } from "./tmux.js";
5
5
  export const PROJECT_OWNER_TABLES = [
6
6
  "pads",
@@ -4,7 +4,7 @@ import { resolve, sep } from "node:path";
4
4
  import { parse } from "yaml";
5
5
  import { harnessNames } from "./harnesses.js";
6
6
  import { isValidProfileName } from "./profiles.js";
7
- import { errorMessage } from "./result.js";
7
+ import { errorMessage } from "./errors.js";
8
8
  import { isWindowLayout, WINDOW_LAYOUTS } from "./tmux.js";
9
9
  import { globalConfigPath, isEmptyYamlDocument, readGlobalConfig, resolveAttachSetting, } from "./globalConfig.js";
10
10
  export const BUILT_IN_PROJECT_YML = Object.freeze({
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")) {
@@ -1,11 +1,8 @@
1
1
  import { captureQueenWriteIdentity, effectiveProjectId, isQueenLead } from "./context.js";
2
+ import { insertQueenAuditRow, QUEEN_AUDIT_DEFAULT_LIMIT, QUEEN_AUDIT_MAX_LIMIT, QUEEN_AUDIT_SUMMARY_MAX, } from "./queenAuditStore.js";
2
3
  import { db } from "./db.js";
3
4
  import { cutToUnitBudget, flatten } from "./slug.js";
4
- export const QUEEN_AUDIT_SUMMARY_MAX = 160;
5
- export const QUEEN_AUDIT_DEFAULT_LIMIT = 20;
6
- export const QUEEN_AUDIT_MAX_LIMIT = 100;
7
- export const QUEEN_AUDIT_RETENTION = "-30 days";
8
- export const QUEEN_AUDIT_MAX_ROWS = 20_000;
5
+ export { QUEEN_AUDIT_DEFAULT_LIMIT, QUEEN_AUDIT_MAX_LIMIT, QUEEN_AUDIT_MAX_ROWS, QUEEN_AUDIT_RETENTION, QUEEN_AUDIT_SUMMARY_MAX, recordQueenRefusal, } from "./queenAuditStore.js";
9
6
  function resource(type, value) {
10
7
  return typeof value === "number" && Number.isInteger(value) && value > 0 ? { type, id: value } : null;
11
8
  }
@@ -13,11 +10,6 @@ function removalSummary(r) {
13
10
  const counts = Object.entries((r.counts ?? {})).map(([t, n]) => `${t} ${n}`);
14
11
  return `removed project "${r.name}"${counts.length > 0 ? ` with ${counts.join(", ")}` : ""}`;
15
12
  }
16
- function insertQueenAuditRow(operation, targetProjectId, identity, resourceType, resourceId, summary, outcome = "confirmed", problem = "") {
17
- db.prepare(`INSERT INTO queen_audit
18
- (actor_id, home_project_id, target_project_id, operation, resource_type, resource_id, summary, outcome, problem)
19
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(identity.actor_id, identity.home_project_id, targetProjectId, operation, resourceType, resourceId, summary, outcome, problem);
20
- }
21
13
  export const QUEEN_AUDIT_OPERATIONS = {
22
14
  todo_create: {
23
15
  mode: "database",
@@ -85,14 +77,6 @@ function insertAudit(operation, targetProjectId, args, receipt, identity) {
85
77
  const summary = text.length <= QUEEN_AUDIT_SUMMARY_MAX ? text : cutToUnitBudget(text, QUEEN_AUDIT_SUMMARY_MAX - 1) + "…";
86
78
  insertQueenAuditRow(operation, targetProjectId, identity, touched.type, touched.id, summary);
87
79
  }
88
- export function recordQueenRefusal(operation, targetProjectId, problem, identity) {
89
- const text = flatten(problem);
90
- const summaryText = `refused: ${text}`;
91
- const summary = summaryText.length <= QUEEN_AUDIT_SUMMARY_MAX
92
- ? summaryText
93
- : cutToUnitBudget(summaryText, QUEEN_AUDIT_SUMMARY_MAX - 1) + "…";
94
- insertQueenAuditRow(operation, targetProjectId, identity, "project", targetProjectId, summary, "refused", text);
95
- }
96
80
  export function commitQueenStateWrite(operation, targetProjectId, args, mutate) {
97
81
  const identity = captureQueenWriteIdentity(targetProjectId);
98
82
  if (!identity)
@@ -0,0 +1,20 @@
1
+ import { db } from "./db.js";
2
+ import { cutToUnitBudget, flatten } from "./slug.js";
3
+ export const QUEEN_AUDIT_SUMMARY_MAX = 160;
4
+ export const QUEEN_AUDIT_DEFAULT_LIMIT = 20;
5
+ export const QUEEN_AUDIT_MAX_LIMIT = 100;
6
+ export const QUEEN_AUDIT_RETENTION = "-30 days";
7
+ export const QUEEN_AUDIT_MAX_ROWS = 20_000;
8
+ export function insertQueenAuditRow(operation, targetProjectId, identity, resourceType, resourceId, summary, outcome = "confirmed", problem = "") {
9
+ db.prepare(`INSERT INTO queen_audit
10
+ (actor_id, home_project_id, target_project_id, operation, resource_type, resource_id, summary, outcome, problem)
11
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(identity.actor_id, identity.home_project_id, targetProjectId, operation, resourceType, resourceId, summary, outcome, problem);
12
+ }
13
+ export function recordQueenRefusal(operation, targetProjectId, problem, identity) {
14
+ const text = flatten(problem);
15
+ const summaryText = `refused: ${text}`;
16
+ const summary = summaryText.length <= QUEEN_AUDIT_SUMMARY_MAX
17
+ ? summaryText
18
+ : cutToUnitBudget(summaryText, QUEEN_AUDIT_SUMMARY_MAX - 1) + "…";
19
+ insertQueenAuditRow(operation, targetProjectId, identity, "project", targetProjectId, summary, "refused", text);
20
+ }