@cmgmyr/hive 1.8.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@ Run a crew of Claude Code and Codex workers from one lead session: visible tmux
5
5
 
6
6
  - Visible workers: each one is a real tmux pane you can read and type into.
7
7
  - Exact state: workers report it through their own CLI's hooks, so nothing polls.
8
- - One local SQLite store: no daemon, and nothing leaves your machine.
8
+ - One local SQLite store: no daemon. The MCP server, hooks, and scheduler make no outbound requests; the CLI contacts npm only to check for updates, and upgrades can download ([details](docs/security.md)).
9
9
 
10
10
  ![hive: a lead plans, spawns workers, and is woken when they finish](https://raw.githubusercontent.com/cmgmyr/hive/main/docs/assets/demo.svg)
11
11
 
@@ -32,7 +32,7 @@ Run `cd ~/Code/your-project && hive`. It is shorthand for `hive lead`, and it op
32
32
 
33
33
  ## How it works
34
34
 
35
- Each Claude Code session runs its own `hive` MCP server over stdio, and every instance reads and writes one SQLite database (WAL mode) at `~/.hive/hive.db`, so every session sees the same state. There is no daemon and nothing leaves your machine. State is scoped to a project (a directory), resolved from the working directory; a lead spawns workers into tmux panes locked to that project.
35
+ Each Claude Code session runs its own `hive` MCP server over stdio, and every instance reads and writes one SQLite database (WAL mode) at `~/.hive/hive.db`, so every session sees the same state. There is no daemon. The MCP server, hooks, and scheduler make no outbound requests; the CLI contacts npm only to check for updates, and upgrades can download ([details](docs/security.md)). State is scoped to a project (a directory), resolved from the working directory; a lead spawns workers into tmux panes locked to that project.
36
36
 
37
37
  ## Why not subagents?
38
38
 
@@ -1 +1 @@
1
- {"version":"1.8.0","sha":"9d4e070","dirty":false,"build_id":"1871c7f0fe55c487adf13e0faae11b7efc34043070171e0de08356b62218bbad"}
1
+ {"version":"1.9.1","sha":"2056b89","dirty":false,"build_id":"6b9bbe7ee1cda1fc2700518664c980aac9692d27caead18970553a3f6b3cbae9"}
@@ -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 and worker messages 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
@@ -3372,7 +3376,7 @@ function cmdPad(argv) {
3372
3376
  process.stdout.write(withTrailingNewline(pad.content));
3373
3377
  }
3374
3378
  process.stdout.on("error", (error) => {
3375
- if (error.code === "EPIPE")
3379
+ if (error.code === "EPIPE" || error.code === "ENOTCONN")
3376
3380
  process.exit(0);
3377
3381
  throw error;
3378
3382
  });
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,26 @@ 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;
418
+ `,
419
+ `ALTER TABLE wakes ADD COLUMN delivered_by TEXT;`,
420
+ `
421
+ ALTER TABLE agent_messages ADD COLUMN delivery_status TEXT;
422
+ ALTER TABLE agent_messages ADD COLUMN delivery_method TEXT;
423
+ ALTER TABLE agent_messages ADD COLUMN socket_attempt_at TEXT;
424
+ ALTER TABLE agent_messages ADD COLUMN confirmed_at TEXT;
425
+ ALTER TABLE agent_messages ADD COLUMN fallback_claimed_at TEXT;
426
+ ALTER TABLE agent_messages ADD COLUMN typed_at TEXT;
427
+ ALTER TABLE agent_messages ADD COLUMN delivery_note TEXT;
428
+ ALTER TABLE agent_messages ADD COLUMN target_identity TEXT;
429
+ ALTER TABLE agent_messages ADD COLUMN sender_tag TEXT;
430
+ CREATE INDEX idx_agent_messages_delivery ON agent_messages(delivery_status, id) WHERE delivery_status IS NOT NULL;
411
431
  `,
412
432
  ];
413
433
  export function storeSchemaAhead(database) {
package/dist/harnesses.js CHANGED
@@ -1,4 +1,4 @@
1
- import { claudeTrailerView, codexInputBoxState, codexTrailerView, codexPaneChoiceCheck, codexPaneHasInputBox, inputBoxState, paneChoiceCheck, paneHasInputBox, } from "./tmux.js";
1
+ import { claudeTrailerView, codexInputBoxState, codexTrailerView, codexPaneChoiceCheck, codexPaneHasInputBox, inputBoxSnapshot, inputBoxState, paneChoiceCheck, paneHasInputBox, } from "./tmux.js";
2
2
  export function commandHead(command) {
3
3
  return command.trim().split(/\s+/)[0] ?? "";
4
4
  }
@@ -52,7 +52,7 @@ const claudeHarness = {
52
52
  supportsResume: true,
53
53
  supportsRename: true,
54
54
  classifiesPaneScreen: true,
55
- paneClassifier: { choiceCheck: paneChoiceCheck, inputBoxState, hasInputBox: paneHasInputBox, trailerView: claudeTrailerView },
55
+ paneClassifier: { choiceCheck: paneChoiceCheck, inputBoxState, inputBoxSnapshot, hasInputBox: paneHasInputBox, trailerView: claudeTrailerView },
56
56
  hasScopes: true,
57
57
  needsHome: false,
58
58
  // "--" so a message that starts with a dash is never read as a flag.
package/dist/help.js CHANGED
@@ -148,9 +148,13 @@ runbook (fork it first), not a pad write.`,
148
148
  Text is prefixed with the sender tag.
149
149
  Text over 300 chars sent to a LEAD by anyone but that lead is stored and
150
150
  delivered as a one-line pointer, so the human's own pane stays readable;
151
- the receipt says shortened and names the message id.
152
- agent_message_get(message_id) — the full text behind a pointer line. Kept
153
- 7 days; a lookup for a pruned id says it expired, not that it is missing.
151
+ the receipt says shortened and names the message id. With quiet_messaging
152
+ on, a worker's submitted text to a Claude lead is stored with an id and
153
+ posted to the lead's messaging socket instead; the receipt says pending
154
+ until the lead's prompt confirms it, and hive types it into the pane once
155
+ if nothing does. Do not resend a pending send.
156
+ agent_message_get(message_id) — the full text behind a pointer line, plus
157
+ the delivery state of a quiet message. Kept 7 days; a lookup for a pruned id says it expired, not that it is missing.
154
158
  agent_output(name|agent_id, lines?) — read the rendered terminal.
155
159
  agent_status(name|agent_id, include_brief?) — liveness, current command,
156
160
  short tail, and the path to the brief this worker was given
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: {
@@ -14,6 +14,17 @@ export const MESSAGE_RETENTION = "-7 days";
14
14
  // direction. Far above any observed rate, and lower than agent_state_log's 20,000 because a row here
15
15
  // holds unbounded message text rather than a hook payload.
16
16
  export const MESSAGE_MAX_ROWS = 5_000;
17
+ export const DELIVERY_FIELDS = [
18
+ "delivery_status",
19
+ "delivery_method",
20
+ "socket_attempt_at",
21
+ "confirmed_at",
22
+ "fallback_claimed_at",
23
+ "typed_at",
24
+ "delivery_note",
25
+ "target_identity",
26
+ "sender_tag",
27
+ ];
17
28
  function senderName(projectId, actorId) {
18
29
  const row = db
19
30
  .prepare(`SELECT name FROM agents WHERE project_id = ? AND actor_id = ?
@@ -44,7 +55,7 @@ export function storeLeadMessage(projectId, fromActor, toAgentId, text) {
44
55
  }
45
56
  export function readLeadMessage(projectId, messageId) {
46
57
  return db
47
- .prepare(`SELECT id, from_actor, from_name, to_agent_id, text, created_at
58
+ .prepare(`SELECT id, from_actor, from_name, to_agent_id, text, created_at, ${DELIVERY_FIELDS.join(", ")}
48
59
  FROM agent_messages WHERE id = ? AND project_id = ?`)
49
60
  .get(messageId, projectId);
50
61
  }
@@ -75,17 +86,27 @@ export function missMessage(messageId, kind) {
75
86
  // also covers format characters. The head comes from outside, and a raw control byte in it reaches tmux
76
87
  // as a keystroke rather than as text, submitting this pointer early and splitting it. The sender's name
77
88
  // is flattened by senderTag when it builds the tag ahead of this marker - the tag is who, this is what.
78
- export function leadPointerMarker(id, text) {
79
- return `[message #${id}, ${text.length} chars]`;
89
+ export function leadPointerMarker(id, text, redelivered = false) {
90
+ return `[message #${id}, ${text.length} chars${redelivered ? ", re-delivered" : ""}]`;
80
91
  }
81
- export function renderLeadPointer(id, text, tag) {
92
+ const redeliveredLead = (id, redelivered) => redelivered ? ` If you already handled message #${id}, ignore this.` : "";
93
+ export function renderLeadPointer(id, text, tag, redelivered = false) {
82
94
  const flat = flatten(text);
83
95
  const head = flat.length > HEAD_BUDGET ? cutToUnitBudget(flat, HEAD_BUDGET) + ELLIPSIS : flat;
84
- return `${tag}${leadPointerMarker(id, text)} ${head} agent_message_get(${id}) for the full text.`;
96
+ return `${tag}${leadPointerMarker(id, text, redelivered)}${redeliveredLead(id, redelivered)} ${head} agent_message_get(${id}) for the full text.`;
85
97
  }
86
- export function shortenedSendNote(id, deliveredChars) {
87
- return (`Over ${LEAD_MESSAGE_THRESHOLD} characters to a LEAD, so that pane got a ${deliveredChars}-character ` +
88
- `pointer instead of this text: a lead's pane is a human's own window and hive keeps it quiet. Nothing ` +
98
+ // A short message carries its id too, so a quiet send has a marker its confirmation can match.
99
+ export function renderLeadMessage(id, text, tag, redelivered = false) {
100
+ return text.length > LEAD_MESSAGE_THRESHOLD
101
+ ? renderLeadPointer(id, text, tag, redelivered)
102
+ : `${tag}${leadPointerMarker(id, text, redelivered)}${redeliveredLead(id, redelivered)} ${text}`;
103
+ }
104
+ export function shortenedSendNote(id, deliveredChars, channel = "pane") {
105
+ return (`Over ${LEAD_MESSAGE_THRESHOLD} characters to a LEAD, so ` +
106
+ (channel === "pane"
107
+ ? `that pane got a ${deliveredChars}-character pointer instead of this text`
108
+ : `hive delivers a ${deliveredChars}-character pointer instead of this text, over the lead's messaging socket or, failing that, typed once into its pane`) +
109
+ `: a lead's pane is a human's own window and hive keeps it quiet. Nothing ` +
89
110
  `was lost - the full text is stored as message ${id} and the pointer names agent_message_get(${id}) - ` +
90
111
  `but the lead reads the rest only if it chooses to. Put whatever must be ACTED on in the first ` +
91
112
  `${HEAD_BUDGET} characters, or on the todo, where it is durable. Worker-bound sends are never shortened.`);