@cmgmyr/hive 1.8.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.
@@ -1 +1 @@
1
- {"version":"1.8.0","sha":"9d4e070","dirty":false,"build_id":"1871c7f0fe55c487adf13e0faae11b7efc34043070171e0de08356b62218bbad"}
1
+ {"version":"1.9.0","sha":"b0be61b","dirty":false,"build_id":"b0c86484d75006efe978e316cdbccb3c9b4e09604634cfff5f7a61c8b8d825b0"}
@@ -0,0 +1,44 @@
1
+ import { createConnection } from "node:net";
2
+ export const CROSS_SESSION_CLOSE = "</cross-session-message>";
3
+ export const SOCKET_WAKE_FOOTER = "Automated hive wake. Do not reply to this sender.";
4
+ const SOCKET_WRITE_BOUND_MS = 1000;
5
+ export function senderAddress(socketPath) {
6
+ const encoded = socketPath.replace(/[^A-Za-z0-9/._-]/gu, (c) => [...Buffer.from(c)].map((b) => `%${b.toString(16).toUpperCase().padStart(2, "0")}`).join(""));
7
+ return `uds:${encoded}`;
8
+ }
9
+ function claudeWakeFrame(senderAddress, text) {
10
+ const content = `<cross-session-message from="${senderAddress}" from-name="hive">\n${text}\n${CROSS_SESSION_CLOSE}`;
11
+ return JSON.stringify({ type: "user", from: senderAddress, message: { role: "user", content } }) + "\n";
12
+ }
13
+ // True only for a completed local write. The socket sends no ack, so true never means the lead read it.
14
+ export function postClaudeWake(options) {
15
+ return new Promise((resolve) => {
16
+ let settled = false;
17
+ const finish = (ok) => {
18
+ if (settled)
19
+ return;
20
+ settled = true;
21
+ clearTimeout(timer);
22
+ if (ok)
23
+ socket.end();
24
+ else
25
+ socket.destroy();
26
+ resolve(ok);
27
+ };
28
+ const socket = createConnection(options.socketPath);
29
+ const timer = setTimeout(() => finish(false), SOCKET_WRITE_BOUND_MS);
30
+ socket.on("error", () => finish(false));
31
+ socket.on("connect", () => {
32
+ let allowed = false;
33
+ try {
34
+ allowed = options.beforeWrite();
35
+ }
36
+ catch {
37
+ allowed = false;
38
+ }
39
+ if (!allowed)
40
+ return finish(false);
41
+ socket.write(claudeWakeFrame(options.senderAddress, options.text), (err) => finish(!err));
42
+ });
43
+ });
44
+ }
package/dist/cli.js CHANGED
@@ -20,16 +20,16 @@ import { isLowHeadroom, orphanLoginShellDetails, ptyHeadroom } from "./ptys.js";
20
20
  import { addProject, agentProjectPin, assertQueenCrossProjectWrite, captureQueenWriteIdentity, currentActor, effectiveProjectId, findProjectForCwd, findProjectForDir, getProjectByPath, gitPrimaryRoot, getProject, listProjects, QUEEN_PROFILE, queenHomeDir, registeredAncestor, takeRegistrationNotice, } from "./context.js";
21
21
  import { confirmQueenWrite, listQueenAudit } from "./queenAudit.js";
22
22
  import { formatRowCounts, projectRowCounts, removeProject } from "./projectRemove.js";
23
- import { ensureHooksFile } from "./hooks.js";
23
+ import { ensureHooksFile, ensureLeadHooksFile } from "./hooks.js";
24
24
  import { errorMessage, registrationNoticeText, withTrailingNewline } from "./result.js";
25
- import { ACTIVE_TIMER_WHERE, dashboardFileContained, describeStall, HELD_REASON_CONVERSATION, HELD_REASON_LEAD_PANE_DEAD, HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX, isLeadRowClosedHold, isUnclassifiablePaneHold, HELD_REASON_UNSUBMITTED_INPUT_PREFIX, isUnsubmittedInputHold, janitor, resolveDashboardFile, generateQueenDashboardNow, transcriptStaleness, wasHeldForPaneIdentity, } from "./scheduler.js";
25
+ import { ACTIVE_TIMER_WHERE, socketAwaitingWhere, dashboardFileContained, describeStall, HELD_REASON_CONVERSATION, HELD_REASON_LEAD_PANE_DEAD, HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX, isLeadRowClosedHold, isUnclassifiablePaneHold, HELD_REASON_UNSUBMITTED_INPUT_PREFIX, isUnsubmittedInputHold, janitor, resolveDashboardFile, generateQueenDashboardNow, transcriptStaleness, wasHeldForPaneIdentity, } from "./scheduler.js";
26
26
  import { readTurnCount } from "./turnCount.js";
27
27
  import { collectPortfolio, leadText } from "./portfolio.js";
28
28
  import { STALL_BOUND_SECONDS } from "./backgroundTasks.js";
29
29
  import { probeSessionInterpreter, reexecTarget, sessionStartVerdict, } from "./sessionProbe.js";
30
30
  import { backupHealth, backupNow, backupsDir, formatBytes, listSnapshots, previewRestore, restoreSnapshot, totalSizeBytes, } from "./backup.js";
31
31
  import { describeTeardownWindow, readTeardowns, teardownLogPath, } from "./teardown.js";
32
- import { asNameClash, buildEnvFlags, closeAgentRow, isReservedAgentName, launchAgent, LEAD_KIND, LEAD_NAME, mintLeadActorId, upsertActor, withWindowClaim, } from "./spawn.js";
32
+ import { asNameClash, buildEnvFlags, closeAgentRow, isReservedAgentName, launchAgent, LEAD_KIND, LEAD_NAME, mintLeadActorId, relayoutAfterPaneLeft, upsertActor, withWindowClaim, } from "./spawn.js";
33
33
  import { adoptableWindow, applyLayout, claimInitialWindow, configureHiveWindow, controlModeFor, createWindow, DEFAULT_LAYOUT, describePaneChoice, ensureSession, findProjectWindow, foreignSocket, isViewSessionName, listOwnedWindows, liveTargets, observationFailed, observeRowOwnership, ORPHAN_MIN_AGE_MS, orphanScratchServers, orphansWorthWarningAbout, paneIsAloneInWindow, paneVisibility, panePidForRecord, PROCESSES_LAYOUT, processesPaneTitle, processesWindowName, projectWindows, RAW_ATTACH_TMUX_CONFIG, renderAttachCommand, resolveAttachTarget, resolveInTmuxTarget, rowLive, rowOwnership, SESSION_PREFIX, appendGlobalHook, globalHooks, replaceGlobalHooks, sessionName, setPaneTitle, shellQuote, shownPaneTitle, makeProcessesWindow, tmux, TMUX_DOC, tmuxSaysNothingThere, tmuxTimeoutOverride, tmuxSocketPath, untrustedTmuxServer, waitForPaneEstablished, windowLayout, } from "./tmux.js";
34
34
  import { describeVisibility, processCounts } from "./dashboard.js";
35
35
  import { runningCommandRow, runningCommandRows, snapshotProcesses, stopAllProcesses, stopLine, stopProcess, STOP_REASONS, } from "./processes.js";
@@ -400,7 +400,7 @@ async function cmdLead(argv) {
400
400
  for (const w of warnings)
401
401
  console.log(`! ${w}`);
402
402
  const session = sessionName();
403
- const hooksPath = ensureHooksFile();
403
+ const hooksPath = ensureLeadHooksFile(project.id, config?.quiet_messaging === true);
404
404
  let leadCommand = "claude";
405
405
  if (!config) {
406
406
  console.log("No hive.yml here; starting a plain claude lead. Add a hive.yml to define project commands and a custom lead (run hive --help for the format).");
@@ -546,7 +546,9 @@ async function cmdLead(argv) {
546
546
  createdPane = true;
547
547
  }
548
548
  else {
549
- leadPane = tmux("split-window", ...(detach ? ["-d"] : []), "-P", "-F", "#{pane_id}", "-t", foundWindow, "-c", project.path, ...envFlags, launchCommand);
549
+ const firstPane = tmux("list-panes", "-t", foundWindow, "-F", "#{pane_id}").split("\n")[0];
550
+ leadPane = tmux("split-window", "-b", ...(detach ? ["-d"] : []), "-P", "-F", "#{pane_id}", "-t", firstPane || foundWindow, "-c", project.path, ...envFlags, launchCommand);
551
+ relayoutAfterPaneLeft(foundWindow);
550
552
  leadWindow = foundWindow;
551
553
  createdPane = true;
552
554
  }
@@ -601,7 +603,7 @@ async function cmdLead(argv) {
601
603
  if (updated === 0)
602
604
  return false;
603
605
  db.prepare(`UPDATE wakes SET deliver_pane = ?, held_at = NULL, held_reason = NULL
604
- WHERE ${ACTIVE_TIMER_WHERE} AND deliver_actor = ?
606
+ WHERE ((${ACTIVE_TIMER_WHERE}) OR (${socketAwaitingWhere()})) AND deliver_actor = ?
605
607
  AND (? = 1 OR held_reason IS NULL OR held_reason NOT LIKE ?)`).run(leadPane, leadActorId, createdPane ? 1 : 0, `${HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX}%`);
606
608
  confirmQueenWrite("hive lead", project.id, { detach }, {
607
609
  agent_id: leadAgentId,
@@ -743,6 +745,8 @@ const HIVE_YML_TEMPLATE = `# hive project config. Read by \`hive lead\` from the
743
745
 
744
746
  # dashboard: true # write .hive/dashboard.html (default: false)
745
747
 
748
+ # quiet_messaging: true # wakes reach a claude lead as one row (default: false; restart the lead)
749
+
746
750
  # vars: # substituted into the profile runbook
747
751
  # repo: owner/name # {{repo}}
748
752
  # ticket_prefix: DEVX # sections needing it drop when it is unset
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.8.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": {