@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/README.md +6 -1
- package/claude-plugin/README.md +1 -1
- package/dist/brief.js +1 -1
- package/dist/build-info.json +1 -1
- package/dist/cli.js +360 -55
- package/dist/context.js +191 -2
- package/dist/db.js +61 -18
- package/dist/firstMessage.js +5 -0
- package/dist/harnesses.js +6 -4
- package/dist/help.js +28 -10
- package/dist/hook.js +9 -0
- package/dist/index.js +11 -5
- package/dist/kickoff.js +41 -27
- package/dist/leadState.js +63 -0
- package/dist/leadWatch.js +73 -0
- package/dist/portfolio.js +210 -0
- package/dist/projectYml.js +19 -0
- package/dist/queenAudit.js +118 -0
- package/dist/queenDashboard.js +573 -0
- package/dist/scheduler.js +179 -15
- package/dist/strictInput.js +24 -7
- package/dist/tmux.js +65 -0
- package/dist/tools/agents.js +4 -0
- package/dist/tools/leases.js +2 -2
- package/dist/tools/meta.js +23 -3
- package/dist/tools/queenAudit.js +13 -0
- package/dist/tools/todos.js +18 -13
- package/dist/tools/wakes.js +197 -65
- package/package.json +4 -4
- package/profiles/orchestration/worker.md +1 -1
- package/profiles/queen/posture.md +31 -0
- package/profiles/queen/runbook.md +26 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1478
|
-
|
|
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
|
-
|
|
1501
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/strictInput.js
CHANGED
|
@@ -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) =>
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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()));
|
package/dist/tools/agents.js
CHANGED
|
@@ -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.");
|
package/dist/tools/leases.js
CHANGED
|
@@ -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
|
|
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 "
|
|
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
|
},
|
package/dist/tools/meta.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/tools/todos.js
CHANGED
|
@@ -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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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.",
|