@cmgmyr/hive 1.7.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude-plugin/skills/profile/SKILL.md +12 -4
- package/claude-plugin/skills/profile/references/examples/README.md +26 -0
- package/claude-plugin/skills/profile/references/examples/skeleton.md +127 -0
- package/claude-plugin/skills/profile/references/examples/two-projects.md +105 -0
- package/claude-plugin/skills/profile/references/interview.md +25 -3
- package/claude-plugin/skills/profile/references/recipes/index.md +1 -0
- package/claude-plugin/skills/profile/references/recipes/review-decision.md +2 -0
- package/claude-plugin/skills/profile/references/recipes/shared-test-resource.md +2 -0
- package/claude-plugin/skills/profile/references/recipes/verification.md +2 -2
- package/claude-plugin/skills/profile/references/recipes/worker-context-checkpoint.md +2 -0
- package/dist/build-info.json +1 -1
- package/dist/claudeWake.js +44 -0
- package/dist/cli.js +16 -7
- package/dist/dashboard.js +65 -14
- package/dist/db.js +7 -0
- package/dist/hook.js +22 -0
- package/dist/hooks.js +14 -0
- package/dist/projectYml.js +14 -0
- package/dist/queenDashboard.js +1 -1
- package/dist/scheduler.js +117 -16
- package/dist/tools/wakes.js +6 -3
- package/package.json +1 -1
- package/profiles/orchestration/posture.md +17 -26
- package/profiles/orchestration/runbook.md +103 -47
- package/profiles/orchestration/worker.md +28 -18
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
|
-
|
|
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
|
-
|
|
1060
|
-
|
|
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
|
-
|
|
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 +
|
|
1073
|
-
:
|
|
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(
|
|
1078
|
-
|
|
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
|
-
|
|
1116
|
+
live = toggle.checked;
|
|
1117
|
+
s.live = live;
|
|
1083
1118
|
save(s);
|
|
1084
|
-
paintStamp(
|
|
1085
|
-
if (
|
|
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: {
|
package/dist/projectYml.js
CHANGED
|
@@ -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) {
|
package/dist/queenDashboard.js
CHANGED
|
@@ -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
|
|
459
|
-
|
|
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
|
|
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
|
|
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 = ?)
|
|
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
|
-
|
|
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,
|
|
1909
|
-
|
|
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 +
|
|
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.
|
package/dist/tools/wakes.js
CHANGED
|
@@ -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,27 +1,18 @@
|
|
|
1
|
-
You are the lead of a hive crew. You hold the plan, the workers do the work.
|
|
1
|
+
You are the lead of a hive crew. You hold the plan, the workers do the work, and you accept or reject what they hand back.
|
|
2
2
|
|
|
3
|
-
-
|
|
4
|
-
|
|
5
|
-
-
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
- Do not poll workers. With more than one running, set
|
|
20
|
-
wake_when_idle(scope="project") once and go quiet: it keeps watching and
|
|
21
|
-
reports each worker as it finishes. Waiting is free; a status loop is not.
|
|
22
|
-
- Read real output before believing a worker. agent_output and the actual
|
|
23
|
-
diff, not the worker's summary of them.
|
|
24
|
-
- Anything outward-facing (pushes, published PRs, posted reviews, anything
|
|
25
|
-
that leaves the machine) waits for explicit human approval.
|
|
26
|
-
- When the human asks how work runs here, read the project runbook first:
|
|
27
|
-
`hive runbook`. It is the standing process for this project.
|
|
3
|
+
- Name the lane before acting: change, research, review or smoke test. Each ends differently, and a worker told the wrong ending builds the wrong thing.
|
|
4
|
+
- Ambiguity is a question, not a guess. One good question up front beats a worker rebuilding the wrong thing for an hour.
|
|
5
|
+
- Dispatch by assigning todo ids. Nobody takes work off the shared queue by itself, or two workers end up on one task.
|
|
6
|
+
- Give a worker a brief it can follow cold: goal, decisions already made, files it owns, what is out of scope, base, checks, and the observable result that means done. A vague brief makes the worker invent scope.
|
|
7
|
+
- Keep every lane isolated: its own worktree for file edits, a lease for any shared runtime such as a dev database or a port. Two lanes in one checkout overwrite each other.
|
|
8
|
+
- Plans go in pads, work goes in todos, decisions go in comments. Sessions die; the store survives.
|
|
9
|
+
- Carry a short label with any todo or pad id you show anyone: "todo 318 (give todos a slug)", not "todo 318". A cold-booting session gets no other context.
|
|
10
|
+
- Do not poll workers. With more than one running, set wake_when_idle(scope="project") once and go quiet; a status loop costs more than waiting.
|
|
11
|
+
- Read the real diff and run the change yourself before you accept it. A worker's summary is a claim, not evidence.
|
|
12
|
+
- Only you complete a todo, and only after you have accepted it. Code-done is not lane-done.
|
|
13
|
+
- Outward-facing actions (pushes, PRs, posted comments, merges, tracker updates) need authority the human has already given or gives now. Approval in one place does not carry to another.
|
|
14
|
+
- Run yourself on the strongest model tier you have, the model in your lead command, and spawn workers on a cheaper one with agent_spawn's `model` argument. Running every worker on the strongest tier spends it on work a cheaper one finishes.
|
|
15
|
+
<!--if:worker_model-->
|
|
16
|
+
- Default worker model: {{worker_model}}.
|
|
17
|
+
<!--end-->
|
|
18
|
+
- When the human asks how work runs here, run `hive runbook`. It is the standing process.
|