@cmgmyr/hive 1.3.0 → 1.4.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/scheduler.js CHANGED
@@ -1,21 +1,26 @@
1
+ import { QUEEN_AUDIT_RETENTION, QUEEN_AUDIT_MAX_ROWS, recentQueenAudit } from "./queenAudit.js";
1
2
  import { runningBuildChange, runningBuildNotice } from "./version.js";
2
- import { existsSync, mkdirSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { existsSync, lstatSync, mkdirSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { createHash } from "node:crypto";
3
5
  import { dirname, join, sep } from "node:path";
4
6
  import { dataDir, db, storeReplaced } from "./db.js";
5
7
  import { maybeBackupHourly } from "./backup.js";
6
8
  import { renderDashboardForWrite } from "./dashboard.js";
7
9
  import { snapshotProcesses } from "./processes.js";
8
10
  import { loadProjectYml } from "./projectYml.js";
9
- import { listProjects } from "./context.js";
11
+ import { getProjectByPath, listProjects, queenHomeDir } from "./context.js";
12
+ import { collectPortfolio } from "./portfolio.js";
13
+ import { QUEEN_GENERATED_MARKER, readQueenBrief, renderQueenDashboard } from "./queenDashboard.js";
10
14
  import { COMMAND_KIND, stoppingMarkerLive } from "./processes.js";
11
15
  import { closeAgentRow, isLeadActorId, LEAD_ACTOR_PREFIX, LEAD_KIND, reapCodexHomeForClosedAgent } from "./spawn.js";
12
16
  import { awaitingFirstPrompt, awaitingFirstPromptSql } from "./firstPrompt.js";
17
+ import { evaluateLeadWatch, leadSubscription } from "./leadWatch.js";
13
18
  import { describeLiveTasks, describeOneTask, liveBackgroundTasks, STALL_BOUND_SECONDS, } from "./backgroundTasks.js";
14
19
  import { MESSAGE_MAX_ROWS, MESSAGE_RETENTION } from "./leadMessage.js";
15
20
  import { harnessFor, hasTranscriptSignal, paneClassifierFor, screenClassifiable, transcriptDirFor } from "./harnesses.js";
16
21
  import { transcriptDir, readContextFill } from "./transcript.js";
17
22
  import { ageSecondsSince, describeLastLogEvent, humanizeAge, lastLogEvent, reportsAgentStateLog, } from "./stateProvenance.js";
18
- import { capturePane, foreignSocket, holdsHumanInput, liveTargets, maskChoiceMarker, paneInCopyMode, paneReissued, rowAlive, rowAliveProbe, rowLiveProbe, sanitizeTail, sendText, tailCaptureLines, tmuxSocketPath, } from "./tmux.js";
23
+ import { capturePane, foreignSocket, holdsHumanInput, liveTargets, maskChoiceMarker, paneInCopyMode, paneReissued, rowAlive, rowAliveProbe, rowLiveProbe, renderTrailerView, sanitizeTail, sendText, tailCaptureLines, sleep, tmuxSocketPath, } from "./tmux.js";
19
24
  import { appendTeardown, NOT_ATTRIBUTED, sqlNow, teardownWindow, TEARDOWN_TRIGGER, } from "./teardown.js";
20
25
  export const WATCH_SCOPE_PROJECT = "project";
21
26
  const isStandingWatch = (timer) => timer.watch_scope === WATCH_SCOPE_PROJECT;
@@ -245,6 +250,19 @@ function cancelTimer(timerId) {
245
250
  export const LOG_RETENTION = "-7 days";
246
251
  const LOG_MAX_ROWS = 20_000;
247
252
  function pruneStateLog() {
253
+ try {
254
+ const staleAudit = stmt("SELECT 1 AS hit FROM queen_audit WHERE created_at < datetime('now', ?) LIMIT 1").get(QUEEN_AUDIT_RETENTION);
255
+ if (staleAudit) {
256
+ stmt("DELETE FROM queen_audit WHERE created_at < datetime('now', ?)").run(QUEEN_AUDIT_RETENTION);
257
+ }
258
+ const hi = stmt("SELECT MAX(id) AS v FROM queen_audit").get().v;
259
+ const lo = stmt("SELECT MIN(id) AS v FROM queen_audit").get().v;
260
+ if (hi != null && lo != null && hi - lo >= QUEEN_AUDIT_MAX_ROWS) {
261
+ stmt("DELETE FROM queen_audit WHERE id <= ?").run(hi - QUEEN_AUDIT_MAX_ROWS);
262
+ }
263
+ }
264
+ catch {
265
+ }
248
266
  try {
249
267
  const stale = stmt("SELECT 1 AS hit FROM agent_state_log WHERE created_at < datetime('now', ?) LIMIT 1").get(LOG_RETENTION);
250
268
  if (stale) {
@@ -339,10 +357,75 @@ function maybeGenerateDashboard(project) {
339
357
  catch {
340
358
  }
341
359
  }
360
+ function queenHome() {
361
+ try {
362
+ const home = queenHomeDir();
363
+ if (lstatSync(home).isSymbolicLink())
364
+ return null;
365
+ const project = getProjectByPath(realpathSync(home));
366
+ return project ? { id: project.id, path: home } : null;
367
+ }
368
+ catch {
369
+ return null;
370
+ }
371
+ }
372
+ function renderQueenPage(queenId) {
373
+ const report = collectPortfolio();
374
+ const html = renderQueenDashboard(report, readQueenBrief(queenId, report.as_of), undefined, recentQueenAudit(10), queenId);
375
+ const stable = html.replace(new RegExp(`<p class="generated" ${QUEEN_GENERATED_MARKER}>[^<]*</p>`), "");
376
+ return { html, hash: createHash("sha256").update(stable).digest("hex") };
377
+ }
378
+ const QUEEN_PAGE_CLAIM_KEY = "hive:queen-page-claim";
379
+ const QUEEN_PAGE_MARK_KEY = "hive:queen-page-mark";
380
+ function claimQueenPageAttempt(queenId) {
381
+ bestEffortRun("INSERT OR IGNORE INTO kv (project_id, key, value, updated_at) VALUES (?, ?, '', '1970-01-01 00:00:00')", queenId, QUEEN_PAGE_CLAIM_KEY);
382
+ return (stmt(`UPDATE kv SET updated_at = datetime('now')
383
+ WHERE project_id = ? AND key = ?
384
+ AND updated_at <= datetime('now', '-${DASHBOARD_MIN_INTERVAL_SECONDS} seconds')`).run(queenId, QUEEN_PAGE_CLAIM_KEY).changes === 1);
385
+ }
386
+ function readQueenPageMark(queenId) {
387
+ const row = stmt("SELECT value FROM kv WHERE project_id = ? AND key = ?").get(queenId, QUEEN_PAGE_MARK_KEY);
388
+ return row?.value ?? null;
389
+ }
390
+ function publishQueenPage(queenId, target, page) {
391
+ writeDashboardAtomically(target, page.html);
392
+ bestEffortRun(`INSERT INTO kv (project_id, key, value) VALUES (?, ?, ?)
393
+ ON CONFLICT(project_id, key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`, queenId, QUEEN_PAGE_MARK_KEY, page.hash);
394
+ }
395
+ export function generateQueenDashboardNow() {
396
+ try {
397
+ const home = queenHome();
398
+ if (home === null)
399
+ return null;
400
+ const target = join(home.path, "dashboard.html");
401
+ publishQueenPage(home.id, target, renderQueenPage(home.id));
402
+ return target;
403
+ }
404
+ catch {
405
+ return null;
406
+ }
407
+ }
408
+ function maybeGenerateQueenDashboard(queen) {
409
+ try {
410
+ if (!claimQueenPageAttempt(queen.id))
411
+ return;
412
+ const target = join(queen.path, "dashboard.html");
413
+ const page = renderQueenPage(queen.id);
414
+ if (readQueenPageMark(queen.id) === page.hash && existsSync(target))
415
+ return;
416
+ publishQueenPage(queen.id, target, page);
417
+ }
418
+ catch {
419
+ }
420
+ }
342
421
  function maybeGenerateDashboards() {
343
422
  try {
423
+ const queen = queenHome();
344
424
  for (const project of listProjects()) {
345
- maybeGenerateDashboard(project);
425
+ if (queen !== null && project.id === queen.id)
426
+ maybeGenerateQueenDashboard(queen);
427
+ else
428
+ maybeGenerateDashboard(project);
346
429
  }
347
430
  }
348
431
  catch {
@@ -397,8 +480,13 @@ export async function tick(snapshot) {
397
480
  )`).all();
398
481
  const choices = new Map();
399
482
  for (const timer of candidates) {
400
- if (timer.kind === "delay")
483
+ if (timer.kind === "delay") {
401
484
  await fireDelay(timer, snapshot, choices);
485
+ continue;
486
+ }
487
+ const leadWatch = leadSubscription(timer.id);
488
+ if (leadWatch !== null)
489
+ await maybeFireLeadWatch(timer, leadWatch, snapshot, now, choices);
402
490
  else
403
491
  await maybeFireIdle(timer, snapshot, now, choices);
404
492
  }
@@ -1399,6 +1487,41 @@ function deliverable(timer, snapshot, choices) {
1399
1487
  firstHeldAt: timer.held_at != null ? timer.first_held_at : null,
1400
1488
  };
1401
1489
  }
1490
+ const PANE_CLAIM_TTL_SECONDS = 6;
1491
+ const PANE_CLAIM_SETTLE_MS = 500;
1492
+ const PANE_CLAIM_OWNER = "hive:scheduler";
1493
+ function acquirePaneClaim(projectId, key) {
1494
+ try {
1495
+ stmt("INSERT OR IGNORE INTO actors (id, name, kind) VALUES (?, 'hive scheduler', 'scheduler')").run(PANE_CLAIM_OWNER);
1496
+ stmt("DELETE FROM leases WHERE project_id = ? AND lock_key = ? AND expires_at < datetime('now')").run(projectId, key);
1497
+ const row = stmt(`INSERT INTO leases (project_id, lock_key, owner, expires_at)
1498
+ VALUES (?, ?, ?, datetime('now', printf('+%d seconds', ?)))
1499
+ ON CONFLICT(project_id, lock_key) DO NOTHING
1500
+ RETURNING expires_at`).get(projectId, key, PANE_CLAIM_OWNER, PANE_CLAIM_TTL_SECONDS);
1501
+ return row?.expires_at ?? null;
1502
+ }
1503
+ catch {
1504
+ return null;
1505
+ }
1506
+ }
1507
+ // Serializes check-then-type per pane ACROSS scheduler instances: the per-wake claim is atomic but two
1508
+ // instances claiming different wakes for one pane would otherwise both paste before either submits.
1509
+ // A loser skips this tick and its wake stays unclaimed.
1510
+ async function withPaneClaim(timer, fn) {
1511
+ const key = `wake-pane:${timer.deliver_socket}:${timer.deliver_pane}`;
1512
+ const held = timer.deliver_pane ? acquirePaneClaim(timer.project_id, key) : null;
1513
+ if (held === null)
1514
+ return;
1515
+ try {
1516
+ // Settle: a slow TUI still showing the pasted text would make the next instance hold instead of type.
1517
+ if (await fn())
1518
+ await sleep(PANE_CLAIM_SETTLE_MS);
1519
+ }
1520
+ finally {
1521
+ // Match this acquire's expires_at: after a TTL takeover the owner is shared, so owner alone would delete the taker's row.
1522
+ bestEffortRun("DELETE FROM leases WHERE project_id = ? AND lock_key = ? AND expires_at = ?", timer.project_id, key, held);
1523
+ }
1524
+ }
1402
1525
  async function fireDelay(timer, snapshot, choices) {
1403
1526
  const disposition = noticeDisposition(timer);
1404
1527
  if (disposition === "aged") {
@@ -1413,9 +1536,12 @@ async function fireDelay(timer, snapshot, choices) {
1413
1536
  bestEffortRun("UPDATE wakes SET cancelled_at = datetime('now') WHERE id = ? AND created_at IS ?", timer.id, timer.created_at);
1414
1537
  return;
1415
1538
  }
1539
+ await withPaneClaim(timer, () => fireDelayClaimed(timer, snapshot, choices));
1540
+ }
1541
+ async function fireDelayClaimed(timer, snapshot, choices) {
1416
1542
  const decision = deliverable(timer, snapshot, choices);
1417
1543
  if (!decision.ok)
1418
- return;
1544
+ return false;
1419
1545
  let claimed;
1420
1546
  if (timer.repeat_every_ms != null) {
1421
1547
  const seconds = Math.max(1, Math.round(timer.repeat_every_ms / 1000));
@@ -1429,8 +1555,10 @@ async function fireDelay(timer, snapshot, choices) {
1429
1555
  else {
1430
1556
  claimed = claimOneShot(timer);
1431
1557
  }
1432
- if (claimed)
1433
- await deliver(timer, "", choices, decision.typedSeen, decision.firstHeldAt);
1558
+ if (!claimed)
1559
+ return false;
1560
+ await deliver(timer, "", choices, decision.typedSeen, decision.firstHeldAt);
1561
+ return true;
1434
1562
  }
1435
1563
  function claimOneShot(timer) {
1436
1564
  return (stmt(`UPDATE wakes SET fired_at = datetime('now'), fire_count = fire_count + 1
@@ -1474,10 +1602,13 @@ async function maybeFireIdle(timer, snapshot, now, choices) {
1474
1602
  if (snapshot !== null && !timedOut)
1475
1603
  noteBlockedWatched(timer, snapshot, choices);
1476
1604
  if (timedOut) {
1477
- const decision = deliverable(timer, snapshot, choices);
1478
- if (decision.ok && claimOneShot(timer)) {
1605
+ await withPaneClaim(timer, async () => {
1606
+ const decision = deliverable(timer, snapshot, choices);
1607
+ if (!decision.ok || !claimOneShot(timer))
1608
+ return false;
1479
1609
  await deliver(timer, STANDING_EXPIRED_NOTE, choices, decision.typedSeen, decision.firstHeldAt);
1480
- }
1610
+ return true;
1611
+ });
1481
1612
  }
1482
1613
  return;
1483
1614
  }
@@ -1497,12 +1628,44 @@ async function maybeFireIdle(timer, snapshot, now, choices) {
1497
1628
  noteBlockedWatched(timer, snapshot, choices);
1498
1629
  }
1499
1630
  if (ready) {
1500
- const decision = deliverable(timer, snapshot, choices);
1501
- if (decision.ok && claimOneShot(timer)) {
1631
+ await withPaneClaim(timer, async () => {
1632
+ const decision = deliverable(timer, snapshot, choices);
1633
+ if (!decision.ok || !claimOneShot(timer))
1634
+ return false;
1502
1635
  await deliver(timer, timedOut && !idleMet ? "max wait reached" : "", choices, decision.typedSeen, decision.firstHeldAt);
1503
- }
1636
+ return true;
1637
+ });
1504
1638
  }
1505
1639
  }
1640
+ // A lead watch never enters the worker helpers below: it reads its own subscription row and the
1641
+ // watched lead's own turn record, and ends with a named reason rather than ever reading as idle.
1642
+ async function maybeFireLeadWatch(timer, sub, snapshot, now, choices) {
1643
+ const timedOut = timer.max_wait_at != null && timer.max_wait_at <= now;
1644
+ const readers = {
1645
+ awaitingChoice: (pane, command) => awaitingChoice(pane, command, choices),
1646
+ holdsInput: (pane, command) => inputBoxHoldsWake(pane, command, choices),
1647
+ };
1648
+ const result = evaluateLeadWatch(sub, timer.kind === "idle_all" ? "all" : "any", snapshot, readers);
1649
+ const note = result.kind === "idle"
1650
+ ? "watched lead's turn ended (not necessarily finished; read it before acting)"
1651
+ : result.kind === "invalid"
1652
+ ? `lead watch ended: ${result.reason}`
1653
+ : timedOut
1654
+ ? "max wait reached"
1655
+ : null;
1656
+ if (note === null)
1657
+ return;
1658
+ await withPaneClaim(timer, async () => {
1659
+ const decision = deliverable(timer, snapshot, choices);
1660
+ if (!decision.ok || !claimOneShot(timer))
1661
+ return false;
1662
+ if (result.kind === "invalid") {
1663
+ bestEffortRun("UPDATE lead_idle_subscriptions SET terminal_reason = ? WHERE wake_id = ?", result.reason, timer.id);
1664
+ }
1665
+ await deliver(timer, note, choices, decision.typedSeen, decision.firstHeldAt);
1666
+ return true;
1667
+ });
1668
+ }
1506
1669
  const TAIL_AGENTS = 3;
1507
1670
  function stateNowClause(agent) {
1508
1671
  if (!reportsAgentStateLog(agent))
@@ -1552,7 +1715,8 @@ function watchedTail(timer) {
1552
1715
  }
1553
1716
  let tail = "";
1554
1717
  try {
1555
- tail = maskChoiceMarker(sanitizeTail(capturePane(agent.tmux_target, tailCaptureLines())));
1718
+ const view = paneClassifierFor(agent.command)?.trailerView(agent.tmux_target) ?? null;
1719
+ tail = maskChoiceMarker(view !== null ? renderTrailerView(view) : sanitizeTail(capturePane(agent.tmux_target, tailCaptureLines())));
1556
1720
  }
1557
1721
  catch {
1558
1722
  }
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { noticeFor, releaseNotice } from "./result.js";
2
+ import { noticeFor, releaseNotice, run } from "./result.js";
3
3
  function isBuiltSchema(value) {
4
4
  return typeof value === "object" && value !== null && ("_zod" in value || "_def" in value);
5
5
  }
@@ -51,6 +51,20 @@ function withStructuredContent(name, cb) {
51
51
  };
52
52
  };
53
53
  }
54
+ function withCallGuard(name, cb, guard) {
55
+ return async (...args) => {
56
+ const input = (typeof args[0] === "object" && args[0] !== null ? args[0] : {});
57
+ try {
58
+ guard(name, input);
59
+ }
60
+ catch (e) {
61
+ return run(() => {
62
+ throw e;
63
+ });
64
+ }
65
+ return cb(...args);
66
+ };
67
+ }
54
68
  function withNotice(result) {
55
69
  const notice = noticeFor(result);
56
70
  if (notice && result.structuredContent)
@@ -62,12 +76,15 @@ function withNoticeField(outputSchema) {
62
76
  ? outputSchema
63
77
  : { ...outputSchema, hive_notice: z.string().optional() };
64
78
  }
65
- export function enforceStrictInput(server) {
79
+ export function enforceStrictInput(server, guard) {
66
80
  const register = server.registerTool.bind(server);
67
- server.registerTool = ((name, config, cb) => register(name, {
68
- ...config,
69
- inputSchema: strictSchemaFor(name, config.inputSchema),
70
- outputSchema: withNoticeField(config.outputSchema),
71
- }, (config.outputSchema ? withStructuredContent(name, cb) : cb)));
81
+ server.registerTool = ((name, config, cb) => {
82
+ const guarded = guard ? withCallGuard(name, cb, guard) : cb;
83
+ return register(name, {
84
+ ...config,
85
+ inputSchema: strictSchemaFor(name, config.inputSchema),
86
+ outputSchema: withNoticeField(config.outputSchema),
87
+ }, (config.outputSchema ? withStructuredContent(name, guarded) : guarded));
88
+ });
72
89
  return server;
73
90
  }
package/dist/tmux.js CHANGED
@@ -898,6 +898,52 @@ export function inputBoxState(target) {
898
898
  return null;
899
899
  }
900
900
  }
901
+ const NOISE_ROW = /^[\s\u2500-\u257F\u2580-\u259F\u2800-\u28FF]*$/;
902
+ const FOOTER_KEEP = /esc to interrupt|\d+ (?:shell|background|task|monitor|local agent|team)s?\b|remote dynamic workflow|cloud session|ultraplan needs your input/i;
903
+ const OSC_SEQUENCE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
904
+ const cleanRow = (row) => stripControlBytes(stripSgr(row.replace(OSC_SEQUENCE, ""))).trimEnd();
905
+ function trailerRows(rows, from, to, keep) {
906
+ const out = [];
907
+ for (let i = from; i < to; i++) {
908
+ const row = cleanRow(rows[i]);
909
+ if (NOISE_ROW.test(row))
910
+ continue;
911
+ if (keep !== null && !keep.test(row))
912
+ continue;
913
+ out.push(row.slice(0, TAIL_LINE_CHARS));
914
+ }
915
+ return out;
916
+ }
917
+ export function renderTrailerView(view) {
918
+ const { box } = view;
919
+ const boxLine = box.state === "empty"
920
+ ? "input box: empty"
921
+ : box.state === "ghost"
922
+ ? "input box: empty (a model suggestion is showing; nobody typed it)"
923
+ : box.state === "pending"
924
+ ? `input box: UNSUBMITTED TEXT, not sent: "${box.text}"`
925
+ : "input box: could not be classified";
926
+ return [...view.above.slice(-TAIL_LINES), boxLine, ...view.below].join("\n");
927
+ }
928
+ export function claudeTrailerView(target) {
929
+ try {
930
+ const rows = tmux("capture-pane", "-p", "-e", "-t", target, "-S", `-${tailCaptureLines()}`).split("\n");
931
+ const anchor = findInputBox(rows);
932
+ if (anchor === null)
933
+ return null;
934
+ const box = anchor.prompt === null ? { state: "unknown", text: "" } : classifyInputBox(rows, anchor.prompt, anchor.bottom);
935
+ if (box.state === "unknown")
936
+ return null;
937
+ return {
938
+ above: trailerRows(rows, 0, anchor.top, null),
939
+ box,
940
+ below: trailerRows(rows, anchor.bottom + 1, rows.length, FOOTER_KEEP),
941
+ };
942
+ }
943
+ catch {
944
+ return null;
945
+ }
946
+ }
901
947
  const CHOICE_DIALOG = /Esc to cancel|ctrl\+g to edit in/;
902
948
  const isAwaitingChoiceScreen = (tail, wide) => CHOICE_DIALOG.test(tail) && !inputBoxOnScreen(wide);
903
949
  export const paneHasInputBox = (target) => {
@@ -1056,6 +1102,25 @@ export function codexInputBoxState(target) {
1056
1102
  return null;
1057
1103
  }
1058
1104
  }
1105
+ export function codexTrailerView(target) {
1106
+ try {
1107
+ const rows = tmux("capture-pane", "-p", "-e", "-t", target, "-S", `-${tailCaptureLines()}`).split("\n");
1108
+ const anchor = findCodexPromptBox(rows);
1109
+ if (anchor === null)
1110
+ return null;
1111
+ const box = classifyCodexInputBox(rows, anchor.prompt, anchor.footer);
1112
+ if (box.state === "unknown")
1113
+ return null;
1114
+ return {
1115
+ above: trailerRows(rows, 0, anchor.prompt, null),
1116
+ box,
1117
+ below: trailerRows(rows, anchor.footer, rows.length, FOOTER_KEEP),
1118
+ };
1119
+ }
1120
+ catch {
1121
+ return null;
1122
+ }
1123
+ }
1059
1124
  export const codexPaneHasInputBox = (target) => {
1060
1125
  try {
1061
1126
  return codexInputBoxOnScreen(capturePane(target, tailCaptureLines()));
@@ -2,6 +2,8 @@ import { existsSync, rmSync, statSync, realpathSync } from "node:fs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { z } from "zod";
4
4
  import { db } from "../db.js";
5
+ import { confirmQueenWrite } from "../queenAudit.js";
6
+ import { captureQueenWriteIdentity } from "../context.js";
5
7
  import { agentBriefPath, mergedBriefVars, readAgentBrief, workerBrief, workerCommandString, writeAgentBrief, } from "../brief.js";
6
8
  import { codexHomeDir, codexInstructionsPhrase, codexLaunchArgs, ensureCodexHome, ensureCodexHooksFile, refreshCodexMcpEnv } from "../codexHome.js";
7
9
  import { currentActor, findProjectForDir, getProject, linkedWorktreePrimaryRoot, resolveProject } from "../context.js";
@@ -1067,6 +1069,7 @@ export function registerAgents(server) {
1067
1069
  const agent = findAgent(project.id, args);
1068
1070
  requireLive(agent);
1069
1071
  const target = agent.tmux_target;
1072
+ const auditIdentity = captureQueenWriteIdentity(project.id);
1070
1073
  let outgoing = args.text ?? "";
1071
1074
  const tag = senderTag(project.id, currentActor());
1072
1075
  let shortened = null;
@@ -1210,6 +1213,7 @@ export function registerAgents(server) {
1210
1213
  : `That target is a LEAD session, so agent_send's keys path is refused against it from a non-lead caller: you cannot finish this yourself. A human at that terminal, or another lead, has to press Enter there. Leave it and try again later.${shortened === null ? "" : " Pressing Enter there is the INTENDED finish, not a stray paste: that stranded line is hive's own pointer, and submitting it is exactly what this send was supposed to do."}`) +
1211
1214
  shortenedClause, { cause: err });
1212
1215
  }
1216
+ confirmQueenWrite("agent_send", project.id, args, { sent: true, agent_id: agent.id }, auditIdentity);
1213
1217
  }
1214
1218
  else {
1215
1219
  throw new Error("Pass text or keys.");
@@ -18,7 +18,7 @@ export function extendOwnedLease(projectId, key, actor, ttlSeconds) {
18
18
  }
19
19
  export function registerLeases(server) {
20
20
  server.registerTool("lease_acquire", {
21
- description: "Try to take a named lease on a shared work area (non-blocking). Re-taking your own lease extends it. Leases expire on their own.",
21
+ description: "Try to take a named lease on state another session could also change, such as a shared checkout, a dev database or a port (non-blocking). Re-taking your own lease extends it. Leases expire on their own.",
22
22
  annotations: {
23
23
  readOnlyHint: false,
24
24
  destructiveHint: false,
@@ -26,7 +26,7 @@ export function registerLeases(server) {
26
26
  openWorldHint: false,
27
27
  },
28
28
  inputSchema: {
29
- key: z.string().describe('Stable and specific, like "file:src/api/routes.ts".'),
29
+ key: z.string().describe('Stable and specific, like "db:dev".'),
30
30
  ttl_seconds: z.number().int().positive(),
31
31
  project_id: projectIdParam,
32
32
  },
@@ -24,6 +24,12 @@ function existsWhere(table, column, value) {
24
24
  function projectOwnsRows(projectId) {
25
25
  return PROJECT_OWNER_TABLES.some((table) => existsWhere(table, "project_id", projectId));
26
26
  }
27
+ function ownedRowCounts(projectId) {
28
+ return PROJECT_OWNER_TABLES.flatMap((table) => {
29
+ const n = db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE project_id = ?`).get(projectId).n;
30
+ return n > 0 ? [`${table}: ${n}`] : [];
31
+ });
32
+ }
27
33
  const pruneProjectIfEmpty = db.transaction((projectId) => {
28
34
  if (projectOwnsRows(projectId))
29
35
  return false;
@@ -143,22 +149,36 @@ export function registerMeta(server) {
143
149
  inputSchema: { project_id: idParam },
144
150
  }, ({ project_id }) => run(() => selectProjectById(project_id)));
145
151
  server.registerTool("project_prune", {
146
- description: "Delete every registered project that owns no rows anywhere in the store (pads, todos, kv, leases, agents, wakes, command_trust), verified individually before each delete. Never prunes the caller's own project. Refuses under HIVE_PROJECT_LOCK=1: this is a whole-store sweep, and a project-locked session may only touch its own project. Immediate, permanent: no dry-run mode.",
152
+ description: "Delete every registered project that owns no rows anywhere in the store (pads, todos, kv, leases, agents, wakes, command_trust), verified individually before each delete. Never prunes the caller's own project. With project_id, removes exactly that one project instead of sweeping, and only if it owns no rows; otherwise refuses and names what it owns. Refuses under HIVE_PROJECT_LOCK=1: this is a whole-store sweep, and a project-locked session may only touch its own project. Immediate, permanent: no dry-run mode.",
147
153
  annotations: {
148
154
  readOnlyHint: false,
149
155
  destructiveHint: true,
150
156
  idempotentHint: true,
151
157
  openWorldHint: false,
152
158
  },
153
- inputSchema: {},
159
+ inputSchema: { project_id: idParam.optional() },
154
160
  outputSchema: {
155
161
  deleted: z.array(z.object({ id: idParam, name: z.string() })),
156
162
  held_back: z.object({ project_id: idParam, reason: z.string() }),
157
163
  errors: z.array(z.object({ id: idParam, name: z.string(), error: z.string() })).optional(),
158
164
  },
159
- }, () => run(() => {
165
+ }, ({ project_id }) => run(() => {
160
166
  refuseIfLocked("project_prune");
161
167
  const homeId = resolveProject().id;
168
+ if (project_id !== undefined) {
169
+ if (project_id === homeId)
170
+ throw new Error(`project_prune: project ${project_id} is the caller's own project and is never pruned.`);
171
+ const target = listProjects().find((p) => p.id === project_id);
172
+ if (!target)
173
+ throw new Error(`project_prune: no project ${project_id}. List them with project_list.`);
174
+ if (!pruneProjectIfEmpty.immediate(project_id)) {
175
+ throw new Error(`project_prune: project ${project_id} ("${target.name}") is not empty, nothing deleted. It owns ${ownedRowCounts(project_id).join(", ")}.`);
176
+ }
177
+ return {
178
+ deleted: [{ id: target.id, name: target.name }],
179
+ held_back: { project_id: homeId, reason: "caller's own project" },
180
+ };
181
+ }
162
182
  const deleted = [];
163
183
  const errors = [];
164
184
  for (const project of listProjects()) {
@@ -0,0 +1,13 @@
1
+ import { listQueenAudit } from "../queenAudit.js";
2
+ import { run } from "../result.js";
3
+ import { idParam, limitParam } from "./params.js";
4
+ export function registerQueenAudit(server) {
5
+ server.registerTool("queen_audit_list", {
6
+ description: "List confirmed queen writes into other projects, newest first. The queen may read all targets; other callers see only their own project. Default 20, maximum 100 entries. History is retained for 30 days with a 20,000 id-range backstop. A crash between a completed terminal send and its audit insert can leave that send unrecorded.",
7
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
8
+ inputSchema: {
9
+ project_id: idParam.optional().describe("Filter by the target project id. Only the queen may name another project."),
10
+ limit: limitParam,
11
+ },
12
+ }, (args) => run(() => listQueenAudit(args)));
13
+ }
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { db } from "../db.js";
3
+ import { commitQueenStateWrite } from "../queenAudit.js";
3
4
  import { currentActor, effectiveProjectId, resolveProject } from "../context.js";
4
5
  import { matchesAnyTag, parseTags, run } from "../result.js";
5
6
  import { findUnsafeControlChar } from "../tmux.js";
@@ -220,14 +221,16 @@ export function registerTodos(server) {
220
221
  }, (args) => run(() => {
221
222
  const projectId = effectiveProjectId(args.project_id);
222
223
  currentActor();
223
- const info = db
224
- .prepare("INSERT INTO todos (project_id, title, body, priority, tags, slug) VALUES (?, ?, ?, ?, ?, ?)")
225
- .run(projectId, args.title, args.body ?? "", args.priority ?? "medium", JSON.stringify(args.tags ?? []), args.slug ?? "");
226
- const todoId = Number(info.lastInsertRowid);
227
- for (const blockerId of args.blocked_by ?? []) {
228
- addBlocker.immediate(projectId, todoId, blockerId);
229
- }
230
- return { project_id: projectId, todo_id: todoId };
224
+ return commitQueenStateWrite("todo_create", projectId, args, () => {
225
+ const info = db
226
+ .prepare("INSERT INTO todos (project_id, title, body, priority, tags, slug) VALUES (?, ?, ?, ?, ?, ?)")
227
+ .run(projectId, args.title, args.body ?? "", args.priority ?? "medium", JSON.stringify(args.tags ?? []), args.slug ?? "");
228
+ const todoId = Number(info.lastInsertRowid);
229
+ for (const blockerId of args.blocked_by ?? []) {
230
+ addBlocker.immediate(projectId, todoId, blockerId);
231
+ }
232
+ return { project_id: projectId, todo_id: todoId };
233
+ });
231
234
  }));
232
235
  server.registerTool("todo_list", {
233
236
  description: "List todo summaries. is_blocked=false finds dispatchable work. query matches title, body, and slug. Archived todos are excluded by default; include_archived=true retrieves them too.",
@@ -374,11 +377,13 @@ export function registerTodos(server) {
374
377
  }, (args) => run(() => {
375
378
  const projectId = effectiveProjectId(args.project_id);
376
379
  const todo = getTodo(projectId, args.todo_id);
377
- const info = db
378
- .prepare("INSERT INTO todo_comments (todo_id, author, body) VALUES (?, ?, ?)")
379
- .run(todo.id, currentActor(), args.body);
380
- touch(todo.id);
381
- return { project_id: projectId, todo_id: todo.id, comment_id: Number(info.lastInsertRowid) };
380
+ return commitQueenStateWrite("todo_comment", projectId, args, () => {
381
+ const info = db
382
+ .prepare("INSERT INTO todo_comments (todo_id, author, body) VALUES (?, ?, ?)")
383
+ .run(todo.id, currentActor(), args.body);
384
+ touch(todo.id);
385
+ return { project_id: projectId, todo_id: todo.id, comment_id: Number(info.lastInsertRowid) };
386
+ });
382
387
  }));
383
388
  server.registerTool("todo_block", {
384
389
  description: "Add a blocker: todo_id cannot start until blocker_id completes. Cycles are rejected.",