@cmgmyr/hive 1.7.0 → 1.9.0

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/dist/dashboard.js CHANGED
@@ -799,7 +799,7 @@ const STYLE = `
799
799
  .muted { font-size: 0.8125rem; }
800
800
  .cap-note { display: flex; align-items: center; gap: 0.4rem; color: var(--fg-subtle);
801
801
  font-size: 0.78125rem; margin: 0 0 0.6rem; }
802
- pre.board { white-space: pre; overflow: auto; max-height: 60vh; background: var(--panel-sunken);
802
+ pre.board { white-space: pre-wrap; overflow-wrap: anywhere; overflow: auto; max-height: 60vh; background: var(--panel-sunken);
803
803
  border: 1px solid var(--border); border-radius: var(--r-ctl); padding: 0.85rem;
804
804
  font-family: var(--font-mono); font-size: 0.8125rem; line-height: 1.5; margin: 0;
805
805
  overscroll-behavior: contain; }
@@ -999,6 +999,7 @@ const SCRIPT = `
999
999
  s.filterCaret = filterInput.selectionStart;
1000
1000
  save(s);
1001
1001
  applyFilter(filterInput.value);
1002
+ paintStamp();
1002
1003
  deferReload();
1003
1004
  });
1004
1005
  }
@@ -1039,7 +1040,21 @@ const SCRIPT = `
1039
1040
  window.addEventListener("resize", function () { measureHeader(); paintNav(); });
1040
1041
  }
1041
1042
 
1042
- if (typeof state.scrollY === "number") window.scrollTo(0, state.scrollY);
1043
+ function absTop(el) { return el.getBoundingClientRect().top + window.scrollY; }
1044
+ var anchorEl = state.anchor && typeof state.anchor.id === "string"
1045
+ ? document.getElementById(state.anchor.id) : null;
1046
+ if (anchorEl && typeof state.anchor.offset === "number") {
1047
+ window.scrollTo(0, absTop(anchorEl) + state.anchor.offset);
1048
+ } else if (typeof state.scrollY === "number") {
1049
+ window.scrollTo(0, state.scrollY);
1050
+ }
1051
+ var itemSel = "details.pad-item, details.todo-item";
1052
+ var padItems = document.querySelectorAll("details.pad-item");
1053
+ for (var pi = 0; pi < padItems.length; pi++) {
1054
+ var saved = state.padScroll && state.padScroll[padItems[pi].id];
1055
+ var padPre = padItems[pi].querySelector("pre.board");
1056
+ if (padItems[pi].open && padPre && typeof saved === "number") padPre.scrollTop = saved;
1057
+ }
1043
1058
 
1044
1059
  // Live toggle. Replaces <meta http-equiv="refresh">, which the browser
1045
1060
  // schedules at PARSE TIME - removing the tag afterward does not cancel it,
@@ -1056,39 +1071,75 @@ const SCRIPT = `
1056
1071
  function clearReload() {
1057
1072
  if (reloadTimer !== null) { clearTimeout(reloadTimer); reloadTimer = null; }
1058
1073
  }
1059
- // Typing restarts the 10s rather than suspending it, so "refreshes every
1060
- // 10s" stays true - it is measured from the last keystroke, not from load.
1074
+ function anyItemOpen() {
1075
+ var items = document.querySelectorAll(itemSel);
1076
+ for (var i = 0; i < items.length; i++) {
1077
+ var it = items[i];
1078
+ if (it.open && !it.closest("details.section:not([open])") && !it.closest("[hidden]")) return true;
1079
+ }
1080
+ return false;
1081
+ }
1082
+ // Typing and scrolling restart the 10s rather than suspending it, so
1083
+ // "refreshes every 10s" stays true; an open pad or todo suspends it.
1061
1084
  function deferReload() {
1062
1085
  if (!live) return;
1063
1086
  clearReload();
1064
- armReload();
1087
+ if (!anyItemOpen()) armReload();
1065
1088
  }
1066
1089
  var toggle = document.getElementById("live-toggle");
1067
1090
  var stamp = document.getElementById("generated-stamp");
1068
1091
  var stampTime = stamp ? stamp.getAttribute("data-time") : "";
1069
- function paintStamp(live) {
1092
+ var tail = " local. Read-only: nothing here writes back to the store.";
1093
+ function paintStamp() {
1070
1094
  if (!stamp) return;
1071
- stamp.textContent = live
1072
- ? "generated " + stampTime + " local, refreshes every 10s. Read-only: nothing here writes back to the store."
1073
- : "paused, generated " + stampTime + " local. Read-only: nothing here writes back to the store.";
1095
+ stamp.textContent = !live
1096
+ ? "paused, generated " + stampTime + tail
1097
+ : anyItemOpen()
1098
+ ? "paused while a pad or todo is open, generated " + stampTime + tail
1099
+ : "generated " + stampTime + " local, refreshes every 10s. Read-only: nothing here writes back to the store.";
1074
1100
  }
1075
1101
  var live = typeof state.live === "boolean" ? state.live : true;
1076
1102
  if (toggle) toggle.checked = live;
1077
- paintStamp(live);
1078
- if (live) armReload(); else clearReload();
1103
+ paintStamp();
1104
+ deferReload();
1105
+ var items = document.querySelectorAll(itemSel);
1106
+ for (var ii = 0; ii < items.length; ii++) {
1107
+ items[ii].addEventListener("toggle", function () { paintStamp(); deferReload(); });
1108
+ }
1109
+ var sectionEls = document.querySelectorAll("details.section");
1110
+ for (var si = 0; si < sectionEls.length; si++) {
1111
+ sectionEls[si].addEventListener("toggle", function () { paintStamp(); deferReload(); });
1112
+ }
1079
1113
  if (toggle) {
1080
1114
  toggle.addEventListener("change", function () {
1081
1115
  var s = load();
1082
- s.live = toggle.checked;
1116
+ live = toggle.checked;
1117
+ s.live = live;
1083
1118
  save(s);
1084
- paintStamp(toggle.checked);
1085
- if (toggle.checked) armReload(); else clearReload();
1119
+ paintStamp();
1120
+ if (live) deferReload(); else clearReload();
1086
1121
  });
1087
1122
  }
1123
+ window.addEventListener("scroll", function () {
1124
+ if (reloadTimer !== null) deferReload();
1125
+ }, { passive: true });
1088
1126
 
1089
1127
  window.addEventListener("beforeunload", function () {
1090
1128
  var s = load();
1091
1129
  s.scrollY = window.scrollY;
1130
+ s.anchor = null;
1131
+ var secs = document.querySelectorAll("details.section");
1132
+ for (var k = 0; k < secs.length; k++) {
1133
+ if (secs[k].getBoundingClientRect().top <= headerOffset) {
1134
+ s.anchor = { id: secs[k].id, offset: window.scrollY - absTop(secs[k]) };
1135
+ }
1136
+ }
1137
+ s.padScroll = {};
1138
+ var openPads = document.querySelectorAll("details.pad-item");
1139
+ for (var m = 0; m < openPads.length; m++) {
1140
+ var pre = openPads[m].querySelector("pre.board");
1141
+ if (openPads[m].open && pre && pre.scrollTop > 0) s.padScroll[openPads[m].id] = pre.scrollTop;
1142
+ }
1092
1143
  if (filterInput) {
1093
1144
  s.filterFocused = document.activeElement === filterInput;
1094
1145
  s.filterCaret = filterInput.selectionStart;
package/dist/db.js CHANGED
@@ -408,6 +408,13 @@ CREATE TABLE queen_audit (
408
408
  CHECK(home_project_id != target_project_id)
409
409
  );
410
410
  CREATE INDEX idx_queen_audit_target ON queen_audit(target_project_id, id);
411
+ `,
412
+ `
413
+ ALTER TABLE agents ADD COLUMN claude_messaging_socket TEXT NOT NULL DEFAULT '';
414
+ ALTER TABLE agents ADD COLUMN claude_messaging_pane_pid TEXT NOT NULL DEFAULT '';
415
+ ALTER TABLE wakes ADD COLUMN delivery_method TEXT;
416
+ ALTER TABLE wakes ADD COLUMN socket_attempt_at TEXT;
417
+ ALTER TABLE wakes ADD COLUMN socket_delivery_note TEXT;
411
418
  `,
412
419
  ];
413
420
  export function storeSchemaAhead(database) {
package/dist/hook.js CHANGED
@@ -185,6 +185,21 @@ async function stopProcessesForEndedLead(actorId, payload) {
185
185
  const { stopAllProcesses, STOP_REASONS } = await import("./processes.js");
186
186
  stopAllProcesses(row.project_id, STOP_REASONS.leadSessionEnded);
187
187
  }
188
+ // Bound to the pid of the pane this hook runs in, not to agents.pane_pid: on a fresh launch this can
189
+ // run before cmdLead records the new pane. The scheduler uses it only when the two pids agree.
190
+ async function registerLeadMessagingSocket(actorId) {
191
+ const raw = process.env.CLAUDE_CODE_MESSAGING_SOCKET ?? "";
192
+ const pane = process.env.TMUX_PANE ?? "";
193
+ let socket = isAbsolute(raw) && !raw.includes("\0") ? raw : "";
194
+ let pid = "";
195
+ if (socket !== "" && pane !== "") {
196
+ const { panePidForRecord } = await import("./tmux.js");
197
+ pid = panePidForRecord(pane);
198
+ }
199
+ if (pid === "")
200
+ socket = "";
201
+ db.prepare("UPDATE agents SET claude_messaging_socket = ?, claude_messaging_pane_pid = ? WHERE actor_id = ? AND kind = 'lead' AND status = 'running'").run(socket, pid, actorId);
202
+ }
188
203
  function reconcileSessionId(actorId, payload) {
189
204
  const sessionId = typeof payload.session_id === "string" ? payload.session_id : "";
190
205
  if (!sessionId)
@@ -306,6 +321,13 @@ try {
306
321
  catch {
307
322
  }
308
323
  }
324
+ if (event === "session_start" && process.env.HIVE_LEAD === "1") {
325
+ try {
326
+ await registerLeadMessagingSocket(actorId);
327
+ }
328
+ catch {
329
+ }
330
+ }
309
331
  if (event === "session_end") {
310
332
  try {
311
333
  await stopProcessesForEndedLead(actorId, readPayload());
package/dist/hooks.js CHANGED
@@ -22,6 +22,20 @@ export function ensureHooksFile() {
22
22
  writeFileSync(path, JSON.stringify(settings, null, 2) + "\n");
23
23
  return path;
24
24
  }
25
+ // Off returns the store-wide file unchanged. On writes a per-project file, because hooks.json is shared
26
+ // by every project's lead and an accept there would reach leads that never opted in.
27
+ export function ensureLeadHooksFile(projectId, quietMessaging) {
28
+ if (!quietMessaging)
29
+ return ensureHooksFile();
30
+ const base = stateHookSettings();
31
+ const settings = {
32
+ hooks: { ...base.hooks, SessionStart: [hookEntry("session_start")] },
33
+ crossSessionInbound: "accept",
34
+ };
35
+ const path = join(dataDir, `lead-${projectId}-hooks.json`);
36
+ writeFileSync(path, JSON.stringify(settings, null, 2) + "\n");
37
+ return path;
38
+ }
25
39
  function stateHookSettings() {
26
40
  return {
27
41
  hooks: {
@@ -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); }
package/dist/scheduler.js CHANGED
@@ -17,6 +17,8 @@ import { awaitingFirstPrompt, awaitingFirstPromptSql } from "./firstPrompt.js";
17
17
  import { evaluateLeadWatch, leadSubscription } from "./leadWatch.js";
18
18
  import { describeLiveTasks, describeOneTask, liveBackgroundTasks, STALL_BOUND_SECONDS, } from "./backgroundTasks.js";
19
19
  import { MESSAGE_MAX_ROWS, MESSAGE_RETENTION } from "./leadMessage.js";
20
+ import { CROSS_SESSION_CLOSE, postClaudeWake, senderAddress, SOCKET_WAKE_FOOTER } from "./claudeWake.js";
21
+ import { readLeadTurnState } from "./leadState.js";
20
22
  import { harnessFor, hasTranscriptSignal, paneClassifierFor, screenClassifiable, transcriptDirFor } from "./harnesses.js";
21
23
  import { transcriptDir, readContextFill } from "./transcript.js";
22
24
  import { ageSecondsSince, describeLastLogEvent, humanizeAge, lastLogEvent, reportsAgentStateLog, } from "./stateProvenance.js";
@@ -32,7 +34,9 @@ const DELIVER_ROW_COLUMNS = `COALESCE(agents.tmux_socket, '') AS deliver_socket,
32
34
  COALESCE(agents.pane_pid, '') AS deliver_pane_pid,
33
35
  COALESCE(agents.command, '') AS deliver_command,
34
36
  agents.id AS deliver_row_id, agents.status AS deliver_row_status,
35
- COALESCE(agents.tmux_target, '') AS deliver_row_target`;
37
+ COALESCE(agents.tmux_target, '') AS deliver_row_target,
38
+ COALESCE(agents.claude_messaging_socket, '') AS deliver_messaging_socket,
39
+ COALESCE(agents.claude_messaging_pane_pid, '') AS deliver_messaging_pane_pid`;
36
40
  // A row's pid certifies only the pane that row records, never a different deliver_pane.
37
41
  const deliveryIdentity = (row) => ({
38
42
  tmux_target: row.deliver_pane,
@@ -455,18 +459,15 @@ function maybeGenerateDashboards() {
455
459
  }
456
460
  }
457
461
  export function checkConfirmations() {
458
- const confirmationQuery = `
459
- SELECT MIN(created_at) FROM agent_state_log
460
- WHERE actor_id = wakes.deliver_actor AND event = 'prompt' AND created_at >= wakes.typed_at
461
- AND (payload LIKE '%[hive wake #' || wakes.id || ']%'
462
- OR payload LIKE '%[hive wake #' || wakes.id || ',%')`;
462
+ const sentAt = "COALESCE(wakes.socket_attempt_at, wakes.typed_at)";
463
+ const confirmationQuery = promptEvidenceSql(sentAt);
463
464
  try {
464
465
  const pending = stmt(`SELECT 1 AS hit FROM wakes
465
- WHERE typed_at IS NOT NULL AND confirmed_at IS NULL AND typed_at >= datetime('now', ?) LIMIT 1`).get(LOG_RETENTION);
466
+ WHERE ${sentAt} IS NOT NULL AND confirmed_at IS NULL AND ${sentAt} >= datetime('now', ?) LIMIT 1`).get(LOG_RETENTION);
466
467
  if (!pending)
467
468
  return;
468
469
  stmt(`UPDATE wakes SET confirmed_at = (${confirmationQuery})
469
- WHERE typed_at IS NOT NULL AND confirmed_at IS NULL AND typed_at >= datetime('now', ?)
470
+ WHERE ${sentAt} IS NOT NULL AND confirmed_at IS NULL AND ${sentAt} >= datetime('now', ?)
470
471
  AND EXISTS (${confirmationQuery.replace("MIN(created_at)", "1")})`).run(LOG_RETENTION);
471
472
  }
472
473
  catch {
@@ -511,6 +512,7 @@ export async function tick(snapshot) {
511
512
  else
512
513
  await maybeFireIdle(timer, snapshot, now, choices);
513
514
  }
515
+ await retryUnconfirmedSocketWakes(snapshot, choices);
514
516
  }
515
517
  catch {
516
518
  }
@@ -570,7 +572,8 @@ function holdTimer(timer, reason) {
570
572
  bestEffortRun(`UPDATE wakes SET held_at = datetime('now'), held_reason = ?,
571
573
  first_held_at = COALESCE(CASE WHEN held_at IS NULL THEN NULL ELSE first_held_at END, datetime('now'))
572
574
  WHERE id = ? AND cancelled_at IS NULL
573
- AND (fired_at IS NULL OR (repeat_every_ms IS NOT NULL AND due_at = ?))`, reason, timer.id, timer.due_at);
575
+ AND (fired_at IS NULL OR (repeat_every_ms IS NOT NULL AND due_at = ?)
576
+ OR (${socketAwaitingWhere()}))`, reason, timer.id, timer.due_at);
574
577
  }
575
578
  const HELD_REASON_MODAL_CHOICE = "pane is awaiting a modal choice (folder-trust or /model picker)";
576
579
  export const HELD_REASON_LEAD_PANE_DEAD = "the lead's pane is not live right now (likely mid-restart); lead-owned wakes are exempt from " +
@@ -931,7 +934,7 @@ const STANDING_EXPIRED_NOTE = "this standing watch has expired and nothing is wa
931
934
  const unreported = (condition, episode) => `NOT EXISTS (
932
935
  SELECT 1 FROM wake_idle_notices n LEFT JOIN wakes nt ON nt.id = n.notice_wake_id
933
936
  WHERE n.wake_id = ? AND n.agent_id = a.id AND n.condition = '${condition}' AND n.episode = ${episode}
934
- AND NOT (nt.fired_at IS NOT NULL AND nt.typed_at IS NULL AND nt.cancelled_at IS NULL
937
+ AND NOT (nt.fired_at IS NOT NULL AND COALESCE(nt.typed_at, nt.socket_attempt_at) IS NULL AND nt.cancelled_at IS NULL
935
938
  AND nt.fired_at < datetime('now', '${NOTICE_RETRY_AFTER}')))`;
936
939
  const CREW_COLUMNS = `a.id, a.name, a.actor_id, a.tmux_target, a.tmux_socket, a.pane_pid, a.agent_state,
937
940
  a.state_changed_at, a.status, a.command, a.kind, a.resumed_at, a.closed_at,
@@ -1174,7 +1177,7 @@ function rearmSpentEpisode(timerId, agentId, condition, episode) {
1174
1177
  WHERE wake_id = ? AND agent_id = ? AND condition = ? AND episode = ?
1175
1178
  AND notice_wake_id IS NOT NULL
1176
1179
  AND EXISTS (SELECT 1 FROM wakes t WHERE t.id = wake_idle_notices.notice_wake_id
1177
- AND t.fired_at IS NOT NULL AND t.typed_at IS NULL AND t.cancelled_at IS NULL
1180
+ AND t.fired_at IS NOT NULL AND COALESCE(t.typed_at, t.socket_attempt_at) IS NULL AND t.cancelled_at IS NULL
1178
1181
  AND t.fired_at < datetime('now', '${NOTICE_RETRY_AFTER}'))`).run(timerId, agentId, condition, episode);
1179
1182
  }
1180
1183
  function claimEpisode(timerId, agentId, condition, episode) {
@@ -1892,10 +1895,69 @@ function recordLostAfterClaim(timer, pasted, why) {
1892
1895
  cancelled_at = CASE WHEN repeat_every_ms IS NULL THEN datetime('now') ELSE cancelled_at END
1893
1896
  WHERE id = ?`, HELD_REASON_OWNERSHIP_LOST_AFTER_CLAIM + why + (pasted ? " The body was pasted; no Enter was sent." : ""), pasted ? 1 : 0, timer.id);
1894
1897
  }
1895
- async function deliver(timer, note, choices, typedSeen, firstHeldAt, owner) {
1898
+ export const DELIVERY_METHOD = { socket: "socket", pty: "pty", ptyAfterSocketTimeout: "pty-after-socket-timeout" };
1899
+ export const socketAwaitingWhere = (prefix = "") => `${prefix}delivery_method = '${DELIVERY_METHOD.socket}' AND ${prefix}confirmed_at IS NULL AND ${prefix}typed_at IS NULL AND ${prefix}cancelled_at IS NULL`;
1900
+ const SOCKET_CONFIRM_GRACE_SECONDS = 60;
1901
+ const withReDelivered = (note) => (note ? `${note}, re-delivered` : "re-delivered");
1902
+ const promptEvidenceSql = (lowerBound) => `
1903
+ SELECT MIN(created_at) FROM agent_state_log
1904
+ WHERE actor_id = wakes.deliver_actor AND event = 'prompt' AND created_at >= ${lowerBound}
1905
+ AND (payload LIKE '%[hive wake #' || wakes.id || ']%'
1906
+ OR payload LIKE '%[hive wake #' || wakes.id || ',%')`;
1907
+ function quietMessagingOn(projectId) {
1908
+ const project = stmt("SELECT path FROM projects WHERE id = ?").get(projectId);
1909
+ return project !== undefined && loadProjectYml(project.path).config?.quiet_messaging === true;
1910
+ }
1911
+ // Only a one-shot wake to a live, row-owned Claude lead whose socket was registered by this pane epoch.
1912
+ function socketRoute(timer, owner) {
1913
+ return (owner !== null &&
1914
+ timer.repeat_every_ms == null &&
1915
+ isLeadActorId(timer.deliver_actor) &&
1916
+ harnessFor(timer.deliver_command).name === "claude" &&
1917
+ timer.deliver_messaging_socket !== "" &&
1918
+ timer.deliver_messaging_pane_pid !== "" &&
1919
+ timer.deliver_messaging_pane_pid === timer.deliver_pane_pid &&
1920
+ timer.deliver_row_target === timer.deliver_pane &&
1921
+ quietMessagingOn(timer.project_id));
1922
+ }
1923
+ function socketStillRegistered(timer, owner) {
1924
+ requireStillOwned(owner.rowId, owner.identity, `wake #${timer.id} socket delivery`);
1925
+ const row = stmt(`SELECT claude_messaging_socket AS socket, claude_messaging_pane_pid AS pid, pane_pid FROM agents
1926
+ WHERE id = ? AND status = 'running' AND kind = 'lead'`).get(owner.rowId);
1927
+ const wake = stmt("SELECT 1 AS live FROM wakes WHERE id = ? AND cancelled_at IS NULL").get(timer.id);
1928
+ return (wake !== undefined &&
1929
+ row !== undefined &&
1930
+ row.socket === timer.deliver_messaging_socket &&
1931
+ row.pid === timer.deliver_messaging_pane_pid &&
1932
+ row.pid === row.pane_pid);
1933
+ }
1934
+ async function deliver(timer, note, choices, typedSeen, firstHeldAt, owner, fallback = false) {
1896
1935
  const tail = watchedTail(timer);
1897
- const prefix = `[hive wake #${timer.id}${note ? `, ${note}` : ""}] `;
1898
1936
  const body = (isLeadActorId(timer.deliver_actor) ? shortRenderForLeadDelivery(timer) : null) ?? timer.body;
1937
+ const staleness = noticeStalenessNote(timer);
1938
+ if (!fallback && socketRoute(timer, owner)) {
1939
+ const text = `[hive wake #${timer.id}${note ? `, ${note}` : ""}] ${body + staleness + tail}\n\n${SOCKET_WAKE_FOOTER}`;
1940
+ if (!text.includes(CROSS_SESSION_CLOSE)) {
1941
+ bestEffortRun(`UPDATE wakes SET socket_attempt_at = strftime('%Y-%m-%d %H:%M:%f', 'now'), socket_delivery_note = ?,
1942
+ delivery_method = ?, first_held_at = ?, held_at = NULL, held_reason = NULL, confirmed_at = NULL
1943
+ WHERE id = ?`, note, DELIVERY_METHOD.socket, firstHeldAt, timer.id);
1944
+ const posted = await postClaudeWake({
1945
+ socketPath: timer.deliver_messaging_socket,
1946
+ senderAddress: senderAddress(join(dataDir, "wake-sender.sock")),
1947
+ text,
1948
+ beforeWrite: () => socketStillRegistered(timer, owner),
1949
+ });
1950
+ if (posted) {
1951
+ forgetPaneAnswers(timer.deliver_pane, choices);
1952
+ return;
1953
+ }
1954
+ note = withReDelivered(note);
1955
+ fallback = true;
1956
+ }
1957
+ }
1958
+ const prefix = `[hive wake #${timer.id}${note ? `, ${note}` : ""}] ` +
1959
+ (fallback ? `If you already handled wake #${timer.id}, ignore this.\n` : "");
1960
+ const method = fallback ? DELIVERY_METHOD.ptyAfterSocketTimeout : DELIVERY_METHOD.pty;
1899
1961
  let typedBusy;
1900
1962
  try {
1901
1963
  const lastEvent = lastLogEvent(timer.deliver_actor);
@@ -1905,19 +1967,23 @@ async function deliver(timer, note, choices, typedSeen, firstHeldAt, owner) {
1905
1967
  typedBusy = null;
1906
1968
  }
1907
1969
  const recordTyped = () => bestEffortRun(`UPDATE wakes SET typed_at = strftime('%Y-%m-%d %H:%M:%f', 'now'), typed_busy = ?,
1908
- typed_seen = ?, first_held_at = ?, held_at = NULL, held_reason = NULL, confirmed_at = NULL
1909
- WHERE id = ?`, typedBusy, typedSeen, firstHeldAt, timer.id);
1970
+ typed_seen = ?, first_held_at = ?, held_at = NULL, held_reason = NULL, delivery_method = ?,
1971
+ confirmed_at = CASE WHEN socket_attempt_at IS NULL THEN NULL ELSE confirmed_at END
1972
+ WHERE id = ?`, typedBusy, typedSeen, firstHeldAt, method, timer.id);
1910
1973
  const box = cacheEntry(timer.deliver_pane, choices).box;
1911
1974
  const strandedTextWouldHold = box !== undefined && box !== null && box.state !== "unknown";
1912
1975
  let pasted = false;
1913
1976
  try {
1914
- await sendText(timer.deliver_pane, prefix + body + noticeStalenessNote(timer) + tail, true, () => {
1977
+ await sendText(timer.deliver_pane, prefix + body + staleness + tail, true, () => {
1915
1978
  pasted = true;
1916
1979
  if (strandedTextWouldHold)
1917
1980
  recordTyped();
1918
1981
  }, undefined, owner ? () => requireStillOwned(owner.rowId, owner.identity, `wake #${timer.id} delivery`) : undefined);
1919
1982
  }
1920
1983
  catch (err) {
1984
+ if (fallback && !pasted) {
1985
+ bestEffortRun("UPDATE wakes SET delivery_method = ? WHERE id = ? AND typed_at IS NULL", DELIVERY_METHOD.socket, timer.id);
1986
+ }
1921
1987
  if (!(err instanceof PaneOwnershipLost))
1922
1988
  throw err;
1923
1989
  recordLostAfterClaim(timer, pasted, err.message);
@@ -1929,6 +1995,41 @@ async function deliver(timer, note, choices, typedSeen, firstHeldAt, owner) {
1929
1995
  if (!strandedTextWouldHold)
1930
1996
  recordTyped();
1931
1997
  }
1998
+ // Measured from the lead's first turn end after the post, because a busy lead reads the post only then.
1999
+ function socketFallbackDue(timer) {
2000
+ const firstStop = stmt("SELECT MIN(created_at) AS t FROM agent_state_log WHERE actor_id = ? AND event = 'stop' AND created_at >= ?").get(timer.deliver_actor, timer.socket_attempt_at).t;
2001
+ let from = firstStop;
2002
+ if (from === null) {
2003
+ const turn = timer.deliver_row_id === null ? null : readLeadTurnState(timer.deliver_row_id);
2004
+ if (turn !== null && turn.state === "working" && turn.pane_pid === timer.deliver_pane_pid)
2005
+ return false;
2006
+ from = timer.socket_attempt_at;
2007
+ }
2008
+ return stmt("SELECT (julianday('now') - julianday(?)) * 86400 >= ? AS due").get(from, SOCKET_CONFIRM_GRACE_SECONDS).due === 1;
2009
+ }
2010
+ async function fallbackClaimed(timer, snapshot, choices) {
2011
+ const decision = deliverable(timer, snapshot, choices);
2012
+ if (!decision.ok)
2013
+ return false;
2014
+ const claimed = stmt(`UPDATE wakes SET delivery_method = ?
2015
+ WHERE id = ? AND ${socketAwaitingWhere()}
2016
+ AND NOT EXISTS (${promptEvidenceSql("wakes.socket_attempt_at").replace("MIN(created_at)", "1")})`).run(DELIVERY_METHOD.ptyAfterSocketTimeout, timer.id).changes === 1;
2017
+ if (!claimed)
2018
+ return false;
2019
+ await deliver(timer, withReDelivered(timer.socket_delivery_note), choices, decision.typedSeen, decision.firstHeldAt, decision.owner, true);
2020
+ return true;
2021
+ }
2022
+ async function retryUnconfirmedSocketWakes(snapshot, choices) {
2023
+ const awaiting = stmt(`SELECT wakes.*, ${DELIVER_ROW_COLUMNS}
2024
+ FROM wakes ${DELIVER_SOCKET_JOIN}
2025
+ WHERE ${socketAwaitingWhere("wakes.")}
2026
+ AND wakes.socket_attempt_at >= datetime('now', ?)`).all(LOG_RETENTION);
2027
+ for (const timer of awaiting) {
2028
+ if (!socketFallbackDue(timer))
2029
+ continue;
2030
+ await withPaneClaim(timer, () => fallbackClaimed(timer, snapshot, choices));
2031
+ }
2032
+ }
1932
2033
  export const DEFAULT_SCHEDULER_INTERVAL_MS = 3000;
1933
2034
  const MIN_SCHEDULER_INTERVAL_MS = 100;
1934
2035
  // Node clamps a setInterval delay above this to 1ms.
@@ -102,7 +102,7 @@ function deliveryState(t, hasChannel) {
102
102
  held_at: t.held_at,
103
103
  held_reason: t.held_reason,
104
104
  confirmed_at: t.confirmed_at,
105
- confirmation: t.typed_at == null
105
+ confirmation: t.typed_at == null && t.socket_attempt_at == null
106
106
  ? null
107
107
  : t.confirmed_at != null
108
108
  ? "confirmed"
@@ -111,6 +111,8 @@ function deliveryState(t, hasChannel) {
111
111
  : t.typed_busy === 1
112
112
  ? "unconfirmed_busy"
113
113
  : "unconfirmed",
114
+ delivery_method: t.delivery_method,
115
+ socket_attempt_at: t.socket_attempt_at,
114
116
  };
115
117
  }
116
118
  export const truncateBody = (body) => body.length > 120 ? `${cutToUnitBudget(body, 120)}…` : body;
@@ -484,7 +486,8 @@ export function registerWakes(server) {
484
486
  server.registerTool("wake_get", {
485
487
  description: "Read one wake-up by id, in this project, with its UNTRUNCATED body. wake_list truncates " +
486
488
  "body at 120 chars; use this to see exactly what a wake will say, or to confirm what " +
487
- "wake_update just changed.",
489
+ "wake_update just changed. Its delivery state includes delivery_method (socket, pty or " +
490
+ "pty-after-socket-timeout) and socket_attempt_at, set when a wake was posted to a Claude lead's socket.",
488
491
  annotations: {
489
492
  readOnlyHint: false,
490
493
  destructiveHint: false,
@@ -623,7 +626,7 @@ export function registerWakes(server) {
623
626
  server.registerTool("wake_list", {
624
627
  description: "List pending wake-ups in this project, plus recently_delivered: the last " +
625
628
  `${RECENTLY_FIRED_LIMIT} one-shot wakes that have already fired, with their delivery ` +
626
- "state (typed_at, held_at/held_reason, confirmation). A one-shot wake leaves the " +
629
+ "state (typed_at, held_at/held_reason, confirmation, delivery_method, socket_attempt_at). A one-shot wake leaves the " +
627
630
  "pending list the moment it fires; recently_delivered is where to check whether it " +
628
631
  "was actually typed and, if its target has a confirmation channel, acknowledged.",
629
632
  annotations: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmgmyr/hive",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Run a crew of Claude Code and Codex workers from one lead session: visible tmux panes, exact state from hooks, one local SQLite store.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,27 +1,18 @@
1
- You are the lead of a hive crew. You hold the plan, the workers do the work.
1
+ You are the lead of a hive crew. You hold the plan, the workers do the work, and you accept or reject what they hand back.
2
2
 
3
- - Lead, do not IC. Before writing code yourself, ask whether this belongs to a
4
- worker. Small edits and integration are yours; a lane of real work is not.
5
- - Name the lane before acting. Ticket work, ad-hoc change, review, research,
6
- and smoke test have different rules. Say which one you are in, out loud.
7
- - Ambiguity is a question, not a guess. One good question up front beats a
8
- worker rebuilding the wrong thing for an hour.
9
- - Plans go in pads, work goes in todos, decisions go in comments. Sessions
10
- die; the store survives. Anything you would have to re-explain tomorrow
11
- belongs in the store today.
12
- - Carry a short label with any todo or pad id you show anyone: the todo's
13
- slug, the pad's name - "todo 318 (give todos a slug)", not "todo 318".
14
- This holds in wake bodies and board entries too, not only in chat, since a
15
- cold-booting session gets no other context to fill the gap. Give a todo
16
- one when you create it: todo_create takes `slug` directly.
17
- - Give a worker a self-contained brief: the objective, its pad and todo ids,
18
- the files it owns, and how it will know it is done.
19
- - Do not poll workers. With more than one running, set
20
- wake_when_idle(scope="project") once and go quiet: it keeps watching and
21
- reports each worker as it finishes. Waiting is free; a status loop is not.
22
- - Read real output before believing a worker. agent_output and the actual
23
- diff, not the worker's summary of them.
24
- - Anything outward-facing (pushes, published PRs, posted reviews, anything
25
- that leaves the machine) waits for explicit human approval.
26
- - When the human asks how work runs here, read the project runbook first:
27
- `hive runbook`. It is the standing process for this project.
3
+ - Name the lane before acting: change, research, review or smoke test. Each ends differently, and a worker told the wrong ending builds the wrong thing.
4
+ - Ambiguity is a question, not a guess. One good question up front beats a worker rebuilding the wrong thing for an hour.
5
+ - Dispatch by assigning todo ids. Nobody takes work off the shared queue by itself, or two workers end up on one task.
6
+ - Give a worker a brief it can follow cold: goal, decisions already made, files it owns, what is out of scope, base, checks, and the observable result that means done. A vague brief makes the worker invent scope.
7
+ - Keep every lane isolated: its own worktree for file edits, a lease for any shared runtime such as a dev database or a port. Two lanes in one checkout overwrite each other.
8
+ - Plans go in pads, work goes in todos, decisions go in comments. Sessions die; the store survives.
9
+ - Carry a short label with any todo or pad id you show anyone: "todo 318 (give todos a slug)", not "todo 318". A cold-booting session gets no other context.
10
+ - Do not poll workers. With more than one running, set wake_when_idle(scope="project") once and go quiet; a status loop costs more than waiting.
11
+ - Read the real diff and run the change yourself before you accept it. A worker's summary is a claim, not evidence.
12
+ - Only you complete a todo, and only after you have accepted it. Code-done is not lane-done.
13
+ - Outward-facing actions (pushes, PRs, posted comments, merges, tracker updates) need authority the human has already given or gives now. Approval in one place does not carry to another.
14
+ - Run yourself on the strongest model tier you have, the model in your lead command, and spawn workers on a cheaper one with agent_spawn's `model` argument. Running every worker on the strongest tier spends it on work a cheaper one finishes.
15
+ <!--if:worker_model-->
16
+ - Default worker model: {{worker_model}}.
17
+ <!--end-->
18
+ - When the human asks how work runs here, run `hive runbook`. It is the standing process.