@cmgmyr/hive 0.1.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.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/claude-plugin/.claude-plugin/plugin.json +7 -0
  4. package/claude-plugin/README.md +47 -0
  5. package/claude-plugin/hooks/hooks.json +14 -0
  6. package/claude-plugin/kickoff.mjs +41 -0
  7. package/claude-plugin/skills/cleanup/SKILL.md +60 -0
  8. package/dist/abi.js +220 -0
  9. package/dist/abiProbe.js +9 -0
  10. package/dist/backgroundTasks.js +51 -0
  11. package/dist/backup.js +378 -0
  12. package/dist/brief.js +113 -0
  13. package/dist/build-info.json +1 -0
  14. package/dist/claudeDir.js +5 -0
  15. package/dist/cli.js +2901 -0
  16. package/dist/codexHome.js +297 -0
  17. package/dist/config.js +72 -0
  18. package/dist/context.js +256 -0
  19. package/dist/contextCheckpoint.js +55 -0
  20. package/dist/dashboard.js +1158 -0
  21. package/dist/dataDir.js +89 -0
  22. package/dist/db.js +421 -0
  23. package/dist/dispatcher.js +150 -0
  24. package/dist/firstPrompt.js +6 -0
  25. package/dist/harnesses.js +173 -0
  26. package/dist/help.js +353 -0
  27. package/dist/hook.js +197 -0
  28. package/dist/hooks.js +50 -0
  29. package/dist/index.js +34 -0
  30. package/dist/kickoff.js +191 -0
  31. package/dist/leadMessage.js +98 -0
  32. package/dist/mcpConfig.js +121 -0
  33. package/dist/processes.js +161 -0
  34. package/dist/profiles.js +278 -0
  35. package/dist/projectYml.js +254 -0
  36. package/dist/prompts.js +18 -0
  37. package/dist/ptys.js +146 -0
  38. package/dist/result.js +53 -0
  39. package/dist/scheduler.js +1622 -0
  40. package/dist/sessionProbe.js +131 -0
  41. package/dist/slug.js +29 -0
  42. package/dist/spawn.js +370 -0
  43. package/dist/stateProvenance.js +97 -0
  44. package/dist/statusline.js +82 -0
  45. package/dist/strictInput.js +52 -0
  46. package/dist/teardown.js +89 -0
  47. package/dist/tmux.js +1295 -0
  48. package/dist/tools/agents.js +1246 -0
  49. package/dist/tools/kv.js +89 -0
  50. package/dist/tools/leases.js +87 -0
  51. package/dist/tools/meta.js +183 -0
  52. package/dist/tools/pads.js +279 -0
  53. package/dist/tools/params.js +14 -0
  54. package/dist/tools/todos.js +382 -0
  55. package/dist/tools/wakes.js +466 -0
  56. package/dist/transcript.js +123 -0
  57. package/dist/version.js +59 -0
  58. package/package.json +62 -0
  59. package/profiles/orchestration/posture.md +27 -0
  60. package/profiles/orchestration/runbook.md +72 -0
  61. package/profiles/orchestration/worker.md +38 -0
  62. package/profiles/simple/posture.md +14 -0
@@ -0,0 +1,1622 @@
1
+ import { existsSync, mkdirSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, sep } from "node:path";
3
+ import { dataDir, db, storeReplaced } from "./db.js";
4
+ import { maybeBackupHourly } from "./backup.js";
5
+ import { renderDashboardForWrite } from "./dashboard.js";
6
+ import { snapshotProcesses } from "./processes.js";
7
+ import { loadProjectYml } from "./projectYml.js";
8
+ import { listProjects } from "./context.js";
9
+ import { COMMAND_KIND, stoppingMarkerLive } from "./processes.js";
10
+ import { closeAgentRow, isLeadActorId, LEAD_ACTOR_PREFIX, LEAD_KIND, reapCodexHomeForClosedAgent } from "./spawn.js";
11
+ import { awaitingFirstPrompt, awaitingFirstPromptSql } from "./firstPrompt.js";
12
+ import { describeLiveTasks, describeOneTask, liveBackgroundTasks, STALL_BOUND_SECONDS, } from "./backgroundTasks.js";
13
+ import { MESSAGE_MAX_ROWS, MESSAGE_RETENTION } from "./leadMessage.js";
14
+ import { harnessFor, hasTranscriptSignal, paneClassifierFor, screenClassifiable, transcriptDirFor } from "./harnesses.js";
15
+ import { transcriptDir, readContextFill } from "./transcript.js";
16
+ import { ageSecondsSince, describeLastLogEvent, humanizeAge, lastLogEvent, reportsAgentStateLog, } from "./stateProvenance.js";
17
+ import { capturePane, foreignSocket, holdsHumanInput, liveTargets, maskChoiceMarker, paneInCopyMode, paneReissued, rowAlive, rowAliveProbe, rowLiveProbe, sanitizeTail, sendText, tailCaptureLines, tmuxSocketPath, } from "./tmux.js";
18
+ import { appendTeardown, NOT_ATTRIBUTED, sqlNow, teardownWindow, TEARDOWN_TRIGGER, } from "./teardown.js";
19
+ export const WATCH_SCOPE_PROJECT = "project";
20
+ const isStandingWatch = (timer) => timer.watch_scope === WATCH_SCOPE_PROJECT;
21
+ export const DELIVER_SOCKET_JOIN = `LEFT JOIN agents ON agents.id = (
22
+ SELECT a.id FROM agents a WHERE a.actor_id = wakes.deliver_actor
23
+ ORDER BY (a.status = 'running') DESC, a.id DESC LIMIT 1
24
+ )`;
25
+ export const ACTIVE_TIMER_WHERE = "cancelled_at IS NULL AND (fired_at IS NULL OR repeat_every_ms IS NOT NULL)";
26
+ const SETTLE_WINDOW = "-15 seconds";
27
+ const prepared = new Map();
28
+ function stmt(sql) {
29
+ let s = prepared.get(sql);
30
+ if (!s) {
31
+ s = db.prepare(sql);
32
+ prepared.set(sql, s);
33
+ }
34
+ return s;
35
+ }
36
+ function bestEffortRun(sql, ...params) {
37
+ try {
38
+ stmt(sql).run(...params);
39
+ }
40
+ catch {
41
+ }
42
+ }
43
+ let ticking = false;
44
+ let schedulerInterval;
45
+ export function startScheduler(intervalMs = 3000) {
46
+ schedulerInterval = setInterval(() => {
47
+ void tick();
48
+ }, intervalMs).unref();
49
+ }
50
+ const CREW_ROW_COLUMNS = "id, project_id, name, actor_id, kind, cwd, tmux_target, tmux_socket, pane_pid, session_id, agent_state, " +
51
+ "state_changed_at, created_at";
52
+ // Set only from a tick that SAW panes, so it separates "hive watched this socket die" from "hive
53
+ // arrived after the fact" - the two bounds the record has to tell apart.
54
+ let socketLastSeenAlive = null;
55
+ let socketLastSeenAliveMs = 0;
56
+ // A socket path outlives the server on it, so a sighting older than a few ticks may belong to a
57
+ // DIFFERENT server and cannot bound this one's death. Falling back to "inferred" is always honest.
58
+ export const SIGHTING_MAX_AGE_MS = 15_000;
59
+ // The override is testing only (a 15s bound cannot expire inside a test) and is read at call time.
60
+ // CLAMPED, so it can only ever SHORTEN the bound: an unclamped knob lengthens it, and a lengthened
61
+ // bound manufactures an "observed" this process did not earn - the one claim `basis` exists to
62
+ // prevent. The clamp makes that true by construction rather than by promise.
63
+ export const sightingMaxAgeMs = () => {
64
+ const set = process.env.HIVE_TEARDOWN_SIGHTING_MAX_AGE_MS;
65
+ const raw = Number(set);
66
+ return set && Number.isFinite(raw) ? Math.min(raw, SIGHTING_MAX_AGE_MS) : SIGHTING_MAX_AGE_MS;
67
+ };
68
+ function lastEvidenceOfLife(row) {
69
+ const logged = stmt("SELECT MAX(created_at) AS at FROM agent_state_log WHERE actor_id = ?").get(row.actor_id).at;
70
+ return [row.created_at, row.state_changed_at, logged].filter((t) => t !== null).sort().at(-1);
71
+ }
72
+ const teardownMember = (row, swept) => ({
73
+ agent_id: row.id,
74
+ project_id: row.project_id,
75
+ name: row.name,
76
+ actor_id: row.actor_id,
77
+ kind: row.kind,
78
+ cwd: row.cwd,
79
+ tmux_target: row.tmux_target,
80
+ pane_pid: row.pane_pid,
81
+ session_id: row.session_id,
82
+ agent_state: row.agent_state ?? "",
83
+ last_evidence_at: lastEvidenceOfLife(row),
84
+ swept,
85
+ });
86
+ // Leads are READ for the roster and never swept: janitor's own query excludes them deliberately, and
87
+ // a crew death that took a lead's pane is exactly the casualty the roster would otherwise omit.
88
+ function goneLeadRows(snapshot) {
89
+ return stmt(`SELECT ${CREW_ROW_COLUMNS} FROM agents WHERE status = 'running' AND kind = ? AND tmux_target != ''`).all(LEAD_KIND).filter((row) => rowAliveProbe(row.tmux_socket, row.tmux_target, snapshot).live === false);
90
+ }
91
+ function recordTeardown(snapshot, swept) {
92
+ try {
93
+ const crew = [...swept.map((r) => teardownMember(r, true)), ...goneLeadRows(snapshot).map((r) => teardownMember(r, false))];
94
+ const detectedAt = sqlNow();
95
+ const newestEvidence = crew.map((m) => m.last_evidence_at).sort().at(-1);
96
+ const sightingIsFresh = socketLastSeenAlive !== null && Date.now() - socketLastSeenAliveMs < sightingMaxAgeMs();
97
+ const observed = sightingIsFresh && socketLastSeenAlive > newestEvidence;
98
+ const record = {
99
+ detected_at: detectedAt,
100
+ socket: tmuxSocketPath(process.env.TMUX, process.env.TMUX_TMPDIR),
101
+ trigger: TEARDOWN_TRIGGER,
102
+ attribution: NOT_ATTRIBUTED,
103
+ window: teardownWindow(observed ? socketLastSeenAlive : newestEvidence, detectedAt, observed ? "observed" : "inferred"),
104
+ crew,
105
+ };
106
+ appendTeardown(dataDir, record);
107
+ return record;
108
+ }
109
+ catch {
110
+ // The scheduler must never throw, and a breadcrumb that cannot be written must cost the
111
+ // diagnosis rather than the sweep that produced it.
112
+ return undefined;
113
+ }
114
+ }
115
+ // The backstop for CODEX_HOME reaping, not the primary path: agent_close already reaps
116
+ // synchronously (src/tools/agents.ts). This catches a row that became closed WITHOUT agent_close
117
+ // ever running the reap - the dead-pane sweep below closing a crashed worker's row, or a prior
118
+ // reap attempt that threw. No settle window needed and none is added: `codex_home` is written into
119
+ // the SAME INSERT that creates the row (src/spawn.ts's launchAgent), strictly before
120
+ // ensureCodexHome's mkdirSync ever runs, so a row naming a given codex_home always exists before
121
+ // that directory does - there is no window where this query's `status = 'closed'` could be racing
122
+ // a spawn that has made the directory but not yet written its row. `parked_at = ''` is named
123
+ // explicitly, not left to fall out of "closed" incidentally: a PARKED row is `status = 'closed'`
124
+ // too (src/spawn.ts's parkAgentRow), and reaping a parked worker's home is the one way this sweep
125
+ // could destroy a lane someone expects to resume.
126
+ function reapClosedCodexHomes() {
127
+ let reaped = 0;
128
+ try {
129
+ const rows = stmt("SELECT id, codex_home FROM agents WHERE codex_home != '' AND status = 'closed' AND parked_at = ''").all();
130
+ for (const row of rows) {
131
+ try {
132
+ reapCodexHomeForClosedAgent(row.id, row.codex_home);
133
+ reaped += 1;
134
+ }
135
+ catch {
136
+ }
137
+ }
138
+ }
139
+ catch {
140
+ }
141
+ return reaped;
142
+ }
143
+ // A deliberate stop is told apart by its STOPPING MARKER (stopProcess, src/processes.ts), never by
144
+ // the order two writes happened in: an interrupted stop is a swept row too. Parentless, like
145
+ // ageOutNotice's replacement and for the same reason - nothing can age out a notice about
146
+ // something that already happened.
147
+ function reportDeadProcess(agent, snapshot) {
148
+ try {
149
+ // A deliberate stop writes this marker before it touches the pane, so a stop that was
150
+ // interrupted between the two is not reported as a crash.
151
+ if (stoppingMarkerLive(agent.project_id, agent.id))
152
+ return;
153
+ const lead = stmt(`SELECT actor_id, tmux_target, tmux_socket FROM agents
154
+ WHERE project_id = ? AND kind = ? AND status = 'running' ORDER BY id DESC LIMIT 1`).get(agent.project_id, LEAD_KIND);
155
+ // Never mint a notice hive could not deliver: with no live lead pane there is nobody this could
156
+ // ever reach, and the row would sit in wakes forever.
157
+ if (!lead?.tmux_target || rowAlive(lead.tmux_socket, lead.tmux_target, snapshot) !== true)
158
+ return;
159
+ insertNotice({ project_id: agent.project_id, owner: lead.actor_id }, lead.actor_id, lead.tmux_target, deadProcessBody(agent.name), null);
160
+ }
161
+ catch {
162
+ }
163
+ }
164
+ const deadProcessBody = (name) => `[hive] process "${name}" exited on its own; its pane is gone. Restart it with: hive start "${name}"`;
165
+ export function janitor(snapshot = liveTargets()) {
166
+ const reapedCodexHomes = reapClosedCodexHomes();
167
+ if (snapshot === null)
168
+ return { closed_agents: 0, cancelled_timers: 0, probed: false, reaped_codex_homes: reapedCodexHomes };
169
+ if (snapshot.panes.size > 0) {
170
+ socketLastSeenAlive = sqlNow();
171
+ socketLastSeenAliveMs = Date.now();
172
+ }
173
+ let closedAgents = 0;
174
+ let cancelledTimers = 0;
175
+ const swept = [];
176
+ const agents = stmt(`SELECT ${CREW_ROW_COLUMNS} FROM agents WHERE status = 'running' AND kind != ? AND tmux_target != ''
177
+ AND created_at < datetime('now', ?)`).all(LEAD_KIND, SETTLE_WINDOW);
178
+ for (const agent of agents) {
179
+ const probe = rowAliveProbe(agent.tmux_socket, agent.tmux_target, snapshot);
180
+ if (probe.live === false || paneReissued(agent.pane_pid, probe)) {
181
+ // The conditional UPDATE is the claim: two instances probing the same dead socket both reach
182
+ // this branch, and only the one that actually closed the row may put it in a record.
183
+ if (closeAgentRow(agent.id)) {
184
+ closedAgents += 1;
185
+ swept.push(agent);
186
+ }
187
+ }
188
+ }
189
+ for (const agent of swept) {
190
+ if (agent.kind === COMMAND_KIND)
191
+ reportDeadProcess(agent, snapshot);
192
+ }
193
+ // A sweep is not evidence of a death when the thing that would report the death was never
194
+ // reachable: with no tmux binary on PATH, liveTargets() answers with an EMPTY snapshot, and
195
+ // recording that as a teardown manufactures an incident out of an unset PATH.
196
+ const sawARealServer = snapshot.serverAnswered !== false;
197
+ const teardown = sawARealServer && snapshot.panes.size === 0 && swept.length > 0 ? recordTeardown(snapshot, swept) : undefined;
198
+ const wakes = stmt(`SELECT wakes.id, wakes.due_at, wakes.held_reason, wakes.deliver_pane,
199
+ COALESCE(agents.tmux_socket, '') AS deliver_socket
200
+ FROM wakes ${DELIVER_SOCKET_JOIN}
201
+ WHERE ${ACTIVE_TIMER_WHERE} AND wakes.deliver_actor NOT LIKE ?
202
+ AND wakes.created_at < datetime('now', ?)`).all(`${LEAD_ACTOR_PREFIX}%`, SETTLE_WINDOW);
203
+ for (const timer of wakes) {
204
+ if (rowAlive(timer.deliver_socket, timer.deliver_pane, snapshot) === false) {
205
+ if (wasHeldForPaneReissue(timer.held_reason)) {
206
+ holdTimer(timer, HELD_REASON_PANE_REISSUED_THEN_DEAD);
207
+ }
208
+ else {
209
+ cancelTimer(timer.id);
210
+ cancelledTimers += 1;
211
+ }
212
+ }
213
+ }
214
+ return {
215
+ closed_agents: closedAgents,
216
+ cancelled_timers: cancelledTimers,
217
+ probed: true,
218
+ reaped_codex_homes: reapedCodexHomes,
219
+ ...(teardown ? { teardown } : {}),
220
+ };
221
+ }
222
+ function cancelTimer(timerId) {
223
+ stmt("UPDATE wakes SET cancelled_at = datetime('now') WHERE id = ?").run(timerId);
224
+ }
225
+ export const LOG_RETENTION = "-7 days";
226
+ const LOG_MAX_ROWS = 20_000;
227
+ function pruneStateLog() {
228
+ try {
229
+ const stale = stmt("SELECT 1 AS hit FROM agent_state_log WHERE created_at < datetime('now', ?) LIMIT 1").get(LOG_RETENTION);
230
+ if (stale) {
231
+ stmt("DELETE FROM agent_state_log WHERE created_at < datetime('now', ?)").run(LOG_RETENTION);
232
+ }
233
+ const hi = stmt("SELECT MAX(id) AS v FROM agent_state_log").get().v;
234
+ const lo = stmt("SELECT MIN(id) AS v FROM agent_state_log").get().v;
235
+ if (hi != null && lo != null && hi - lo >= LOG_MAX_ROWS) {
236
+ stmt("DELETE FROM agent_state_log WHERE id <= ?").run(hi - LOG_MAX_ROWS);
237
+ }
238
+ const staleNotices = stmt("SELECT 1 AS hit FROM wake_block_notices WHERE notified_at < datetime('now', ?) LIMIT 1").get(LOG_RETENTION);
239
+ if (staleNotices) {
240
+ stmt("DELETE FROM wake_block_notices WHERE notified_at < datetime('now', ?)").run(LOG_RETENTION);
241
+ }
242
+ const staleMessages = stmt("SELECT 1 AS hit FROM agent_messages WHERE created_at < datetime('now', ?) LIMIT 1").get(MESSAGE_RETENTION);
243
+ if (staleMessages) {
244
+ stmt("DELETE FROM agent_messages WHERE created_at < datetime('now', ?)").run(MESSAGE_RETENTION);
245
+ }
246
+ const messageHi = stmt("SELECT MAX(id) AS v FROM agent_messages").get().v;
247
+ const messageLo = stmt("SELECT MIN(id) AS v FROM agent_messages").get().v;
248
+ if (messageHi != null && messageLo != null && messageHi - messageLo >= MESSAGE_MAX_ROWS) {
249
+ stmt("DELETE FROM agent_messages WHERE id <= ?").run(messageHi - MESSAGE_MAX_ROWS);
250
+ }
251
+ const staleIdleNotices = stmt("SELECT 1 AS hit FROM wake_idle_notices WHERE notified_at < datetime('now', ?) LIMIT 1").get(LOG_RETENTION);
252
+ if (staleIdleNotices) {
253
+ stmt("DELETE FROM wake_idle_notices WHERE notified_at < datetime('now', ?)").run(LOG_RETENTION);
254
+ }
255
+ }
256
+ catch {
257
+ }
258
+ }
259
+ const DASHBOARD_MIN_INTERVAL_SECONDS = 5;
260
+ function claimDashboardAttempt(projectId) {
261
+ bestEffortRun("INSERT OR IGNORE INTO dashboard_meta (project_id) VALUES (?)", projectId);
262
+ return (stmt(`UPDATE dashboard_meta SET last_attempt_at = datetime('now')
263
+ WHERE project_id = ? AND (last_attempt_at IS NULL
264
+ OR last_attempt_at <= datetime('now', '-${DASHBOARD_MIN_INTERVAL_SECONDS} seconds'))`).run(projectId).changes === 1);
265
+ }
266
+ function writeDashboardAtomically(target, html) {
267
+ const temp = join(dirname(target), `.dashboard.html.tmp-${process.pid}`);
268
+ try {
269
+ writeFileSync(temp, html);
270
+ renameSync(temp, target);
271
+ }
272
+ catch (e) {
273
+ try {
274
+ unlinkSync(temp);
275
+ }
276
+ catch {
277
+ }
278
+ throw e;
279
+ }
280
+ }
281
+ function realpathContained(existingPath, projectPath) {
282
+ const resolved = realpathSync(existingPath);
283
+ const resolvedProjectPath = realpathSync(projectPath);
284
+ return resolved === resolvedProjectPath || resolved.startsWith(resolvedProjectPath + sep);
285
+ }
286
+ export function resolveDashboardFile(projectPath) {
287
+ const dashboardFile = join(projectPath, ".hive", "dashboard.html");
288
+ let ancestor = dirname(dashboardFile);
289
+ while (!existsSync(ancestor)) {
290
+ const parent = dirname(ancestor);
291
+ if (parent === ancestor)
292
+ break;
293
+ ancestor = parent;
294
+ }
295
+ if (!realpathContained(ancestor, projectPath))
296
+ return null;
297
+ return dashboardFile;
298
+ }
299
+ export function dashboardFileContained(dashboardFile, projectPath) {
300
+ return realpathContained(dashboardFile, projectPath);
301
+ }
302
+ function maybeGenerateDashboard(project) {
303
+ try {
304
+ if (!loadProjectYml(project.path).config?.dashboard)
305
+ return;
306
+ const dashboardFile = resolveDashboardFile(project.path);
307
+ if (dashboardFile === null)
308
+ return;
309
+ mkdirSync(dirname(dashboardFile), { recursive: true });
310
+ if (!claimDashboardAttempt(project.id))
311
+ return;
312
+ const { html, contentHash } = renderDashboardForWrite(project.id, snapshotProcesses(project.id));
313
+ const known = stmt("SELECT last_mark FROM dashboard_meta WHERE project_id = ?").get(project.id);
314
+ if (known.last_mark === contentHash && existsSync(dashboardFile))
315
+ return;
316
+ writeDashboardAtomically(dashboardFile, html);
317
+ bestEffortRun("UPDATE dashboard_meta SET last_mark = ? WHERE project_id = ?", contentHash, project.id);
318
+ }
319
+ catch {
320
+ }
321
+ }
322
+ function maybeGenerateDashboards() {
323
+ try {
324
+ for (const project of listProjects()) {
325
+ maybeGenerateDashboard(project);
326
+ }
327
+ }
328
+ catch {
329
+ }
330
+ }
331
+ export function checkConfirmations() {
332
+ const confirmationQuery = `
333
+ SELECT MIN(created_at) FROM agent_state_log
334
+ WHERE actor_id = wakes.deliver_actor AND event = 'prompt' AND created_at >= wakes.typed_at
335
+ AND (payload LIKE '%[hive wake #' || wakes.id || ']%'
336
+ OR payload LIKE '%[hive wake #' || wakes.id || ',%')`;
337
+ try {
338
+ const pending = stmt(`SELECT 1 AS hit FROM wakes
339
+ WHERE typed_at IS NOT NULL AND confirmed_at IS NULL AND typed_at >= datetime('now', ?) LIMIT 1`).get(LOG_RETENTION);
340
+ if (!pending)
341
+ return;
342
+ stmt(`UPDATE wakes SET confirmed_at = (${confirmationQuery})
343
+ WHERE typed_at IS NOT NULL AND confirmed_at IS NULL AND typed_at >= datetime('now', ?)
344
+ AND EXISTS (${confirmationQuery.replace("MIN(created_at)", "1")})`).run(LOG_RETENTION);
345
+ }
346
+ catch {
347
+ }
348
+ }
349
+ export async function tick(snapshot) {
350
+ if (ticking)
351
+ return;
352
+ ticking = true;
353
+ try {
354
+ if (storeReplaced()) {
355
+ clearInterval(schedulerInterval);
356
+ schedulerInterval = undefined;
357
+ return;
358
+ }
359
+ if (snapshot === undefined)
360
+ snapshot = liveTargets();
361
+ janitor(snapshot);
362
+ checkConfirmations();
363
+ pruneStateLog();
364
+ maybeBackupHourly(db, dataDir);
365
+ maybeGenerateDashboards();
366
+ const now = stmt("SELECT datetime('now') AS now").get().now;
367
+ const candidates = stmt(`SELECT wakes.*, COALESCE(agents.tmux_socket, '') AS deliver_socket,
368
+ COALESCE(agents.pane_pid, '') AS deliver_pane_pid,
369
+ COALESCE(agents.command, '') AS deliver_command
370
+ FROM wakes ${DELIVER_SOCKET_JOIN}
371
+ WHERE wakes.cancelled_at IS NULL AND (
372
+ (wakes.kind = 'delay' AND wakes.due_at <= datetime('now')
373
+ AND (wakes.fired_at IS NULL OR wakes.repeat_every_ms IS NOT NULL))
374
+ OR (wakes.kind != 'delay' AND wakes.fired_at IS NULL)
375
+ )`).all();
376
+ const choices = new Map();
377
+ for (const timer of candidates) {
378
+ if (timer.kind === "delay")
379
+ await fireDelay(timer, snapshot, choices);
380
+ else
381
+ await maybeFireIdle(timer, snapshot, now, choices);
382
+ }
383
+ }
384
+ catch {
385
+ }
386
+ finally {
387
+ ticking = false;
388
+ }
389
+ }
390
+ function cacheEntry(pane, cache) {
391
+ let entry = cache.get(pane);
392
+ if (!entry) {
393
+ entry = {};
394
+ cache.set(pane, entry);
395
+ }
396
+ return entry;
397
+ }
398
+ function awaitingChoice(pane, command, cache) {
399
+ const entry = cacheEntry(pane, cache);
400
+ if (entry.choice === undefined) {
401
+ entry.choice = paneClassifierFor(command)?.choiceCheck(pane).awaitingChoice ?? null;
402
+ }
403
+ return entry.choice;
404
+ }
405
+ function inputBoxHoldsWake(pane, command, cache) {
406
+ const entry = cacheEntry(pane, cache);
407
+ if (entry.box === undefined)
408
+ entry.box = paneClassifierFor(command)?.inputBoxState(pane) ?? null;
409
+ return holdsHumanInput(entry.box);
410
+ }
411
+ const NO_DIALOG_TTL_MS = 30_000;
412
+ const NO_DIALOG_MAX_ENTRIES = 512;
413
+ const noDialogUntil = new Map();
414
+ function recentlyHadNoDialog(socket, pane) {
415
+ const seen = noDialogUntil.get(pane);
416
+ if (seen === undefined || seen.socket !== socket)
417
+ return false;
418
+ if (seen.until > Date.now())
419
+ return true;
420
+ noDialogUntil.delete(pane);
421
+ return false;
422
+ }
423
+ function rememberNoDialog(socket, pane) {
424
+ const now = Date.now();
425
+ if (noDialogUntil.size >= NO_DIALOG_MAX_ENTRIES) {
426
+ for (const [key, seen] of noDialogUntil)
427
+ if (seen.until <= now)
428
+ noDialogUntil.delete(key);
429
+ if (noDialogUntil.size >= NO_DIALOG_MAX_ENTRIES)
430
+ noDialogUntil.clear();
431
+ }
432
+ noDialogUntil.set(pane, { socket, until: now + NO_DIALOG_TTL_MS });
433
+ }
434
+ function forgetPaneAnswers(pane, choices) {
435
+ choices.delete(pane);
436
+ noDialogUntil.delete(pane);
437
+ }
438
+ function holdTimer(timer, reason) {
439
+ bestEffortRun(`UPDATE wakes SET held_at = datetime('now'), held_reason = ?,
440
+ first_held_at = COALESCE(CASE WHEN held_at IS NULL THEN NULL ELSE first_held_at END, datetime('now'))
441
+ WHERE id = ? AND cancelled_at IS NULL
442
+ AND (fired_at IS NULL OR (repeat_every_ms IS NOT NULL AND due_at = ?))`, reason, timer.id, timer.due_at);
443
+ }
444
+ const HELD_REASON_MODAL_CHOICE = "pane is awaiting a modal choice (folder-trust or /model picker)";
445
+ 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 " +
446
+ "cancellation for this alone, so it is held rather than lost";
447
+ const HELD_REASON_PANE_REISSUED_PREFIX = "the pane id recorded for this wake now belongs to a different pane than the one it was set " +
448
+ "against (its pid no longer matches, most likely a tmux server restart reissuing the id); held " +
449
+ "rather than typed into the wrong pane - ";
450
+ const HELD_REASON_PANE_REISSUED_LEAD = `${HELD_REASON_PANE_REISSUED_PREFIX}run \`hive lead\` to re-point it at the live one`;
451
+ const HELD_REASON_PANE_REISSUED_WORKER = `${HELD_REASON_PANE_REISSUED_PREFIX}nothing re-points a worker's wake automatically, so cancel ` +
452
+ "it with wake_cancel and set a fresh one once the worker's pane is confirmed live, or leave it: " +
453
+ "it will keep holding rather than deliver wrongly";
454
+ const HELD_REASON_PANE_REISSUED_THEN_DEAD = `${HELD_REASON_PANE_REISSUED_PREFIX}the pane it was reissued to has since gone dead too; nothing ` +
455
+ "re-points a worker's wake automatically, so cancel it with wake_cancel (any running lead may do " +
456
+ "this even though the wake is not theirs) if it is no longer needed";
457
+ export function wasHeldForPaneReissue(heldReason) {
458
+ return heldReason != null && heldReason.startsWith(HELD_REASON_PANE_REISSUED_PREFIX);
459
+ }
460
+ export const HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX = "the pane's harness is not one hive can classify; ";
461
+ export const HELD_REASON_UNCLASSIFIABLE_PANE = `${HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX}neither the dialog guard nor the unsubmitted-text ` +
462
+ "guard can read this screen and delivery would type blind. wake_set and wake_when_idle refuse such a " +
463
+ "target outright now, so this wake predates that or its pane's command changed underneath it. Nothing " +
464
+ "hive does on its own clears this: it lifts only when that pane is replaced by one hive starts itself " +
465
+ "(`hive lead` after the pane exits). Cancel it with wake_cancel (any running lead may, even though the " +
466
+ "wake is not theirs) and drive that pane with agent_send(keys: [...])";
467
+ export function isUnclassifiablePaneHold(heldReason) {
468
+ return heldReason != null && heldReason.startsWith(HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX);
469
+ }
470
+ export const HELD_REASON_COPY_MODE = "the pane is in tmux copy mode; tmux clears its bracketed-paste flag there, so the body would arrive " +
471
+ "with no paste markers (losing everything before the last 1022-byte write) and the Enter would be " +
472
+ "eaten by the mode instead of submitting - both silently, with tmux reporting success. It delivers " +
473
+ "once whoever is reading that pane leaves copy mode";
474
+ export const HELD_REASON_UNSUBMITTED_INPUT_PREFIX = "the pane's input box has unsubmitted human text; ";
475
+ export const HELD_REASON_UNSUBMITTED_INPUT = `${HELD_REASON_UNSUBMITTED_INPUT_PREFIX}delivering now would paste the wake body onto it and submit ` +
476
+ "both as one message";
477
+ // Distinct from HELD_REASON_UNSUBMITTED_INPUT so the claim's debounce doesn't latch a transient
478
+ // pane-resolution failure shut forever - the next tick retries instead of giving up permanently.
479
+ const HELD_REASON_UNSUBMITTED_INPUT_OWNER_UNRESOLVED = `${HELD_REASON_UNSUBMITTED_INPUT_PREFIX}hive could not resolve a live pane for the wake's owner on ` +
480
+ "this tick - retrying, since that may be transient rather than permanent";
481
+ export function isUnsubmittedInputHold(heldReason) {
482
+ return heldReason != null && heldReason.startsWith(HELD_REASON_UNSUBMITTED_INPUT_PREFIX);
483
+ }
484
+ export const HELD_REASON_CONVERSATION = "a human talked to this lead more recently than the conversation-hold window; holding so a wake " +
485
+ "does not split an in-progress discussion - it delivers once the window passes or the hold's own " +
486
+ "ceiling is reached, whichever comes first";
487
+ // A human message keeps a lead-bound wake held for this long after it lands. Measured deferral rates
488
+ // for this and CONVERSATION_HOLD_MAX are in hive-internals/references/worker-state.md.
489
+ const CONVERSATION_HOLD_TTL_SECONDS = 5 * 60;
490
+ const CONVERSATION_HOLD_TTL = `-${CONVERSATION_HOLD_TTL_SECONDS} seconds`;
491
+ // Must stay well under NOTICE_MAX_AGE, past which noticeDisposition cancels a held notice rather than
492
+ // typing stale news (it says so in a replacement wake; it is not silent). Measured against due_at, not first_held_at: cli.ts's hive-lead
493
+ // re-point clears held_at (and so first_held_at) on every ordinary reattach, which would otherwise
494
+ // launder this ceiling indefinitely.
495
+ const CONVERSATION_HOLD_MAX = "-15 minutes";
496
+ function conversationHoldsWake(timer) {
497
+ if (timer.due_at !== null) {
498
+ const withinCeiling = stmt(`SELECT ? >= datetime('now', ?) AS within`).get(timer.due_at, CONVERSATION_HOLD_MAX);
499
+ if (!withinCeiling.within)
500
+ return false;
501
+ }
502
+ return (stmt(`SELECT 1 AS hit FROM agent_state_log
503
+ WHERE actor_id = ? AND event = 'prompt' AND CASE WHEN json_valid(payload) THEN COALESCE(json_extract(payload, '$.prompt'), payload) ELSE payload END NOT LIKE '%[hive wake #%' AND CASE WHEN json_valid(payload) THEN COALESCE(json_extract(payload, '$.prompt'), payload) ELSE payload END NOT LIKE '[hive:%'
504
+ AND created_at >= datetime('now', ?)
505
+ ORDER BY id DESC LIMIT 1`).get(timer.deliver_actor, CONVERSATION_HOLD_TTL) !== undefined);
506
+ }
507
+ function heldTarget(timer) {
508
+ const row = stmt(`SELECT id, name, kind, COALESCE(state_changed_at, '') AS blocked_since FROM agents WHERE actor_id = ?
509
+ ORDER BY (status = 'running') DESC, id DESC LIMIT 1`).get(timer.deliver_actor);
510
+ return {
511
+ name: row?.name ?? timer.deliver_actor,
512
+ isLead: row?.kind === LEAD_KIND,
513
+ agentId: row?.id ?? null,
514
+ blockedSince: row?.blocked_since ?? "",
515
+ };
516
+ }
517
+ const readPaneCall = (name) => `agent_output(name: ${JSON.stringify(name)})`;
518
+ const answerDialogCall = (name) => `agent_send(name: ${JSON.stringify(name)}, keys: ["1", "Enter"])`;
519
+ const howToClearIt = (name, isLead) => isLead
520
+ ? `That target is a LEAD session, so agent_send's keys path is refused against it from a worker: a human ` +
521
+ `at that terminal, or another lead, has to answer the dialog.`
522
+ : `Read its pane with ${readPaneCall(name)} FIRST, since this notice can arrive after the dialog ` +
523
+ `was already answered, and if it is still up answer it with ${answerDialogCall(name)} or whichever ` +
524
+ `keys that dialog wants - keys is the only supported way to answer one, because ` +
525
+ `agent_send's text path refuses a pane that is on a dialog.`;
526
+ function holdNoticeBody(timer, target) {
527
+ return (`"${target.name}" has a dialog up in its pane and is waiting for a human to answer it. hive is HOLDING ` +
528
+ `wake #${timer.id} for it rather than typing the wake body into the dialog. That wake is not lost: it ` +
529
+ `stays pending and delivers on its own once the dialog clears. ${howToClearIt(target.name, target.isLead)}`);
530
+ }
531
+ function unsubmittedInputNoticeBody(timer, target) {
532
+ return (`"${target.name}" has unsubmitted text sitting in its own pane's input box - something was typed there and ` +
533
+ `never submitted. hive is HOLDING wake #${timer.id} for it rather than pasting the wake body on top of that ` +
534
+ `text and submitting both together as one message. That wake is not lost: it stays pending and delivers on ` +
535
+ `its own once the box is empty. Clearing it needs a human at "${target.name}"'s own terminal to submit or ` +
536
+ `delete what's there - hive will not type into a pane holding unsubmitted human text, because that is ` +
537
+ `exactly the merge this hold exists to prevent.`);
538
+ }
539
+ function blockNoticeBody(timer, name) {
540
+ return (`"${name}" is stopped on a dialog in its pane, waiting for a human to answer it, so it cannot go idle. ` +
541
+ `wake #${timer.id} is waiting for exactly that, so it will not fire until the dialog is answered (or its ` +
542
+ `max wait runs out, if it has one). The wake is not lost and nothing has been typed into the dialog. ` +
543
+ `${howToClearIt(name, false)}`);
544
+ }
545
+ function standingBlockNoticeBody(timer, names, observedAt) {
546
+ const lines = [
547
+ `${names.length} crew member(s) in this project are stopped on a dialog in their pane, waiting for a ` +
548
+ "human to answer it, so they cannot finish:",
549
+ ];
550
+ for (const name of names) {
551
+ lines.push(` ${name}: read its pane with ${readPaneCall(name)}, then answer the dialog with ` +
552
+ `${answerDialogCall(name)} or whichever keys that dialog wants.`);
553
+ }
554
+ lines.push(`Read each pane FIRST: this notice can arrive after a dialog was already answered. keys is the only ` +
555
+ "supported way to answer one, because agent_send's text path refuses a pane that is on a dialog.");
556
+ lines.push(`Standing watch #${timer.id} is watching this project's crew and reports each finish as it happens, so ` +
557
+ "it will report nothing about the workers above until their dialogs are answered (or a worker goes " +
558
+ `away, which it reports as such). hive last confirmed the dialogs above at ${observedAt} UTC; ` +
559
+ "nothing has been typed into any dialog, and this notice does not say whether the watch itself is " +
560
+ "still running by the time you read it.");
561
+ return lines.join("\n");
562
+ }
563
+ function ownerPaneIfLive(timer, snapshot) {
564
+ const row = stmt(`SELECT tmux_target, tmux_socket FROM agents WHERE actor_id = ? AND status = 'running'
565
+ ORDER BY id DESC LIMIT 1`).get(timer.owner);
566
+ if (!row?.tmux_target || snapshot === null)
567
+ return null;
568
+ return rowAlive(row.tmux_socket, row.tmux_target, snapshot) === true ? row.tmux_target : null;
569
+ }
570
+ function blockNoticeTarget(timer, snapshot) {
571
+ const pane = ownerPaneIfLive(timer, snapshot);
572
+ if (pane !== null)
573
+ return { actor: timer.owner, pane };
574
+ return timer.deliver_pane ? { actor: timer.deliver_actor, pane: timer.deliver_pane } : null;
575
+ }
576
+ function ownerPaneToTell(timer, snapshot) {
577
+ if (timer.owner === timer.deliver_actor)
578
+ return null;
579
+ const pane = ownerPaneIfLive(timer, snapshot);
580
+ if (pane === null || pane === timer.deliver_pane)
581
+ return null;
582
+ return pane;
583
+ }
584
+ // The command recorded for whoever this notice is aimed at, resolved the way DELIVER_SOCKET_JOIN
585
+ // resolves it. '' means no row, which is "no fact recorded" and not "unclassifiable".
586
+ function commandForActor(actorId) {
587
+ const row = stmt(`SELECT command FROM agents WHERE actor_id = ?
588
+ ORDER BY (status = 'running') DESC, id DESC LIMIT 1`).get(actorId);
589
+ return row?.command ?? "";
590
+ }
591
+ // The first argument is only ever read for these two columns, so a notice with no timer behind it -
592
+ // the janitor's dead-process report - routes through here too rather than growing a second INSERT
593
+ // that a future column would silently miss.
594
+ function insertNotice(timer, deliverActor, pane, body, parentTimerId) {
595
+ return stmt(`INSERT INTO wakes (project_id, owner, body, kind, deliver_actor, deliver_pane, due_at, parent_wake_id)
596
+ VALUES (?, ?, ?, 'delay', ?, ?, datetime('now'), ?)
597
+ RETURNING id`).get(timer.project_id, timer.owner, body, deliverActor, pane, parentTimerId).id;
598
+ }
599
+ function pendingNoticeFor(parentTimerId, conditions) {
600
+ const placeholders = conditions.map(() => "?").join(", ");
601
+ return stmt(`SELECT t.id FROM wakes t
602
+ WHERE t.parent_wake_id = ? AND t.fired_at IS NULL AND t.cancelled_at IS NULL
603
+ AND EXISTS (
604
+ SELECT 1 FROM wake_idle_notices n WHERE n.notice_wake_id = t.id AND n.condition IN (${placeholders})
605
+ )
606
+ ORDER BY t.id DESC LIMIT 1`).get(parentTimerId, ...conditions);
607
+ }
608
+ function updateNoticeInPlace(noticeId, body) {
609
+ return (stmt(`UPDATE wakes SET body = ?, created_at = datetime('now')
610
+ WHERE id = ? AND fired_at IS NULL AND cancelled_at IS NULL`).run(body, noticeId).changes === 1);
611
+ }
612
+ function claimBlockNotice(timerId, agentId, blockedSince) {
613
+ return (stmt(`INSERT OR IGNORE INTO wake_block_notices (wake_id, agent_id, blocked_since)
614
+ VALUES (?, ?, ?)`).run(timerId, agentId, blockedSince).changes === 1);
615
+ }
616
+ const claimModalHoldWithNotice = db.transaction((timer, pane, body, target) => {
617
+ const claimed = stmt(`UPDATE wakes SET held_at = datetime('now'), held_reason = ?,
618
+ first_held_at = COALESCE(CASE WHEN held_at IS NULL THEN NULL ELSE first_held_at END, datetime('now'))
619
+ WHERE id = ? AND cancelled_at IS NULL
620
+ AND due_at IS ? AND body IS ? AND repeat_every_ms IS ?
621
+ AND (fired_at IS NULL OR repeat_every_ms IS NOT NULL)
622
+ AND held_reason IS NOT ?`).run(HELD_REASON_MODAL_CHOICE, timer.id, timer.due_at, timer.body, timer.repeat_every_ms, HELD_REASON_MODAL_CHOICE).changes === 1;
623
+ if (!claimed)
624
+ return false;
625
+ if (target.agentId !== null &&
626
+ target.blockedSince !== "" &&
627
+ !claimBlockNotice(timer.id, target.agentId, target.blockedSince)) {
628
+ return false;
629
+ }
630
+ insertNotice(timer, timer.owner, pane, body, timer.id);
631
+ return true;
632
+ });
633
+ function noteModalHold(timer, snapshot) {
634
+ if (timer.held_reason !== HELD_REASON_MODAL_CHOICE) {
635
+ try {
636
+ const pane = ownerPaneToTell(timer, snapshot);
637
+ if (pane !== null) {
638
+ const target = heldTarget(timer);
639
+ const body = holdNoticeBody(timer, target);
640
+ if (claimModalHoldWithNotice.immediate(timer, pane, body, target))
641
+ return;
642
+ }
643
+ }
644
+ catch {
645
+ }
646
+ }
647
+ holdTimer(timer, HELD_REASON_MODAL_CHOICE);
648
+ }
649
+ const claimUnsubmittedInputHoldWithNotice = db.transaction((timer, pane, body) => {
650
+ const claimed = stmt(`UPDATE wakes SET held_at = datetime('now'), held_reason = ?,
651
+ first_held_at = COALESCE(CASE WHEN held_at IS NULL THEN NULL ELSE first_held_at END, datetime('now'))
652
+ WHERE id = ? AND cancelled_at IS NULL
653
+ AND due_at IS ? AND body IS ? AND repeat_every_ms IS ?
654
+ AND (fired_at IS NULL OR repeat_every_ms IS NOT NULL)
655
+ AND held_reason IS NOT ?`).run(HELD_REASON_UNSUBMITTED_INPUT, timer.id, timer.due_at, timer.body, timer.repeat_every_ms, HELD_REASON_UNSUBMITTED_INPUT).changes === 1;
656
+ if (!claimed)
657
+ return false;
658
+ // Deliberate widening beyond todo 314's two call sites (todo 322): a notice about a cancelled
659
+ // wake has nothing left to say, and with noticeDisposition's finish-shaped exemption above this
660
+ // link is pure cascade - it can never be aged out for sitting undelivered.
661
+ insertNotice(timer, timer.owner, pane, body, timer.id);
662
+ return true;
663
+ });
664
+ function noteUnsubmittedInputHold(timer, snapshot) {
665
+ if (timer.held_reason === HELD_REASON_UNSUBMITTED_INPUT) {
666
+ holdTimer(timer, HELD_REASON_UNSUBMITTED_INPUT);
667
+ return;
668
+ }
669
+ if (timer.owner === timer.deliver_actor) {
670
+ holdTimer(timer, HELD_REASON_UNSUBMITTED_INPUT);
671
+ return;
672
+ }
673
+ try {
674
+ const pane = ownerPaneToTell(timer, snapshot);
675
+ if (pane !== null) {
676
+ const target = heldTarget(timer);
677
+ const body = unsubmittedInputNoticeBody(timer, target);
678
+ if (claimUnsubmittedInputHoldWithNotice.immediate(timer, pane, body))
679
+ return;
680
+ holdTimer(timer, HELD_REASON_UNSUBMITTED_INPUT);
681
+ return;
682
+ }
683
+ }
684
+ catch {
685
+ }
686
+ holdTimer(timer, HELD_REASON_UNSUBMITTED_INPUT_OWNER_UNRESOLVED);
687
+ }
688
+ const BLOCKED_EPISODE = `COALESCE(a.state_changed_at, '') AS episode`;
689
+ const oneShotBlockedRows = (timer) => {
690
+ const ids = JSON.parse(timer.watch);
691
+ if (ids.length === 0)
692
+ return [];
693
+ return stmt(`SELECT ${CREW_COLUMNS}, ${BLOCKED_EPISODE}
694
+ FROM agents a
695
+ WHERE a.id IN (${ids.map(() => "?").join(",")})
696
+ AND a.status = 'running' AND a.agent_state = 'waiting'`).all(...ids);
697
+ };
698
+ const standingBlockedRows = (timer, tellActor) => stmt(`SELECT ${CREW_COLUMNS}, ${BLOCKED_EPISODE}
699
+ FROM agents a
700
+ WHERE a.project_id = ? AND a.kind = 'agent' AND a.status = 'running' AND a.actor_id != ?
701
+ AND a.agent_state = 'waiting'
702
+ ORDER BY a.id`).all(timer.project_id, tellActor);
703
+ function blockedWatchedAgents(timer, snapshot, tellActor) {
704
+ const rows = isStandingWatch(timer) ? standingBlockedRows(timer, tellActor) : oneShotBlockedRows(timer);
705
+ return rows
706
+ .filter((r) => rowAlive(r.tmux_socket, r.tmux_target, snapshot) === true)
707
+ .map((r) => ({
708
+ id: r.id,
709
+ name: r.name,
710
+ pane: r.tmux_target,
711
+ socket: r.tmux_socket,
712
+ command: r.command,
713
+ blockedSince: r.episode,
714
+ }));
715
+ }
716
+ const stillPending = (timerId) => stmt(`SELECT 1 AS hit FROM wakes WHERE id = ? AND cancelled_at IS NULL AND fired_at IS NULL`).get(timerId) !== undefined;
717
+ const claimBlockNoticeWithNotice = db.transaction((timer, agentId, blockedSince, tell, body) => {
718
+ if (!stillPending(timer.id))
719
+ return false;
720
+ if (!claimBlockNotice(timer.id, agentId, blockedSince))
721
+ return false;
722
+ insertNotice(timer, tell.actor, tell.pane, body, timer.id);
723
+ return true;
724
+ });
725
+ const claimBlockBatch = db.transaction((timer, tell, blocked, observedAt) => {
726
+ if (!stillPending(timer.id))
727
+ return false;
728
+ const won = blocked.filter((a) => claimBlockNotice(timer.id, a.id, a.blockedSince));
729
+ if (won.length === 0)
730
+ return false;
731
+ insertNotice(timer, tell.actor, tell.pane, standingBlockNoticeBody(timer, won.map((a) => a.name), observedAt), null);
732
+ return true;
733
+ });
734
+ function noteBlockedWatched(timer, snapshot, choices) {
735
+ try {
736
+ const tell = blockNoticeTarget(timer, snapshot);
737
+ if (tell === null)
738
+ return;
739
+ const tellPane = tell.pane;
740
+ const batched = [];
741
+ for (const agent of blockedWatchedAgents(timer, snapshot, tell.actor)) {
742
+ if (agent.pane === tellPane)
743
+ continue;
744
+ if (alreadyToldAbout(timer.id, agent.id, agent.blockedSince))
745
+ continue;
746
+ if (recentlyHadNoDialog(agent.socket, agent.pane))
747
+ continue;
748
+ const choice = awaitingChoice(agent.pane, agent.command, choices);
749
+ if (choice !== true) {
750
+ if (choice === false)
751
+ rememberNoDialog(agent.socket, agent.pane);
752
+ continue;
753
+ }
754
+ if (isStandingWatch(timer)) {
755
+ batched.push(agent);
756
+ continue;
757
+ }
758
+ claimBlockNoticeWithNotice.immediate(timer, agent.id, agent.blockedSince, tell, blockNoticeBody(timer, agent.name));
759
+ }
760
+ if (batched.length > 0)
761
+ claimBlockBatch.immediate(timer, tell, batched, storeNow());
762
+ }
763
+ catch {
764
+ }
765
+ }
766
+ function alreadyToldAbout(timerId, agentId, blockedSince) {
767
+ return (stmt(`SELECT 1 AS hit FROM wake_block_notices
768
+ WHERE wake_id = ? AND agent_id = ? AND blocked_since = ?`).get(timerId, agentId, blockedSince) !== undefined);
769
+ }
770
+ const CONDITION_IDLE = "idle";
771
+ const CONDITION_GONE = "gone";
772
+ const NOTICE_RETRY_AFTER = "-60 seconds";
773
+ const NOTICE_MAX_AGE = "-1 hours";
774
+ const ROSTER_STILL_GOING = 8;
775
+ const FINISHED_SHOWN_CAP = ROSTER_STILL_GOING;
776
+ const STANDING_EXPIRED_NOTE = "this standing watch has expired and nothing is watching now; set a new one if the crew is still working";
777
+ const unreported = (condition, episode) => `NOT EXISTS (
778
+ SELECT 1 FROM wake_idle_notices n LEFT JOIN wakes nt ON nt.id = n.notice_wake_id
779
+ WHERE n.wake_id = ? AND n.agent_id = a.id AND n.condition = '${condition}' AND n.episode = ${episode}
780
+ AND NOT (nt.fired_at IS NOT NULL AND nt.typed_at IS NULL AND nt.cancelled_at IS NULL
781
+ AND nt.fired_at < datetime('now', '${NOTICE_RETRY_AFTER}')))`;
782
+ const CREW_COLUMNS = `a.id, a.name, a.actor_id, a.tmux_target, a.tmux_socket, a.agent_state,
783
+ a.state_changed_at, a.status, a.command, a.kind, a.resumed_at, a.closed_at,
784
+ a.cwd, a.session_id, a.transcript_path`;
785
+ // Scopes a standing watch to the OWNER's own crew (bound to owner, not deliver_actor - a watch can
786
+ // deliver elsewhere). Keeps a NULL parent_actor_id in rather than filtering it. Does not apply to
787
+ // stallCandidateRows below - see .claude/skills/hive-internals/references/worker-state.md.
788
+ export const OWNED_BY_WATCH = "AND (a.parent_actor_id = ? OR a.parent_actor_id IS NULL)";
789
+ function standingIdleRows(timer) {
790
+ return stmt(`SELECT ${CREW_COLUMNS}, a.state_changed_at AS episode
791
+ FROM agents a
792
+ WHERE a.project_id = ? AND a.kind = 'agent' AND a.status = 'running' AND a.actor_id != ?
793
+ AND a.agent_state = 'idle' AND a.state_changed_at IS NOT NULL
794
+ ${OWNED_BY_WATCH}
795
+ AND ${unreported(CONDITION_IDLE, "a.state_changed_at")}
796
+ ORDER BY a.id`).all(timer.project_id, timer.deliver_actor, timer.owner, timer.id).filter((row) => !awaitingFirstPrompt(row));
797
+ }
798
+ function standingGoneRows(timer) {
799
+ return stmt(`SELECT ${CREW_COLUMNS}, a.closed_at AS episode
800
+ FROM agents a
801
+ WHERE a.project_id = ? AND a.kind = 'agent' AND a.status = 'closed' AND a.actor_id != ?
802
+ AND (a.agent_state != 'idle' OR ${awaitingFirstPromptSql("a")})
803
+ AND a.parked_at = ''
804
+ AND a.closed_at IS NOT NULL
805
+ ${OWNED_BY_WATCH}
806
+ AND ${unreported(CONDITION_GONE, "a.closed_at")}
807
+ ORDER BY a.id`).all(timer.project_id, timer.deliver_actor, timer.owner, timer.id);
808
+ }
809
+ export function markGoneReported(agentId, projectId) {
810
+ stmt(`INSERT OR IGNORE INTO wake_idle_notices (wake_id, agent_id, condition, episode)
811
+ SELECT t.id, a.id, '${CONDITION_GONE}', a.closed_at
812
+ FROM wakes t
813
+ JOIN agents a ON a.id = ?
814
+ WHERE t.project_id = ? AND t.kind = 'idle_any' AND t.watch_scope IS NOT NULL
815
+ AND t.cancelled_at IS NULL AND t.fired_at IS NULL
816
+ AND a.closed_at IS NOT NULL`).run(agentId, projectId);
817
+ }
818
+ export function seedGoneCursor(timerId, projectId) {
819
+ stmt(`INSERT OR IGNORE INTO wake_idle_notices (wake_id, agent_id, condition, episode)
820
+ SELECT ?, a.id, '${CONDITION_GONE}', a.closed_at
821
+ FROM agents a
822
+ WHERE a.project_id = ? AND a.kind = 'agent' AND a.status = 'closed'
823
+ AND a.closed_at IS NOT NULL`).run(timerId, projectId);
824
+ }
825
+ function idleIsAFreshTransition(timerId, row) {
826
+ const previous = stmt(`SELECT MAX(episode) AS episode FROM wake_idle_notices
827
+ WHERE wake_id = ? AND agent_id = ? AND condition = '${CONDITION_IDLE}'`).get(timerId, row.id).episode;
828
+ if (previous === null)
829
+ return true;
830
+ const log = stmt("SELECT MIN(created_at) AS oldest, datetime(?, '+1 seconds') AS bound FROM agent_state_log").get(previous);
831
+ if (log.oldest === null || log.oldest >= log.bound)
832
+ return true;
833
+ return (stmt(`SELECT 1 AS hit FROM agent_state_log
834
+ WHERE actor_id = ? AND state IN ('working', 'waiting')
835
+ AND created_at > ? AND created_at < datetime(?, '+1 seconds') LIMIT 1`).get(row.actor_id, previous, row.episode) !== undefined);
836
+ }
837
+ // A GONE report whose underlying row has moved since it was claimed as GONE is a warning about a
838
+ // wrong fact already sitting in this same notice, not mere detail - it must stay visible even in the
839
+ // short lead-facing render below, or a lead reading only the one-line summary would trust a stale
840
+ // obituary with nothing telling it not to.
841
+ const isStaleGoneReport = (c) => c.condition === CONDITION_GONE && (c.row.status !== "closed" || c.row.closed_at !== c.row.episode);
842
+ // Shared by the crew render and the full render's own roster, so both agree on who a standing watch
843
+ // will ever report - OWNED_BY_WATCH excludes a grandchild the same way the finish rosters above do,
844
+ // or "N still going" promises an update the watch will never deliver.
845
+ function stillGoingRows(timer) {
846
+ try {
847
+ return stmt(`SELECT a.name, a.actor_id, a.agent_state, a.state_changed_at, a.status, a.command, a.kind, a.resumed_at
848
+ FROM agents a
849
+ WHERE a.project_id = ? AND a.kind = 'agent' AND a.status = 'running'
850
+ AND (a.agent_state != 'idle' OR ${awaitingFirstPromptSql("a")}) AND a.actor_id != ?
851
+ ${OWNED_BY_WATCH}
852
+ ORDER BY a.id`).all(timer.project_id, timer.deliver_actor, timer.owner);
853
+ }
854
+ catch {
855
+ return null;
856
+ }
857
+ }
858
+ // The Stop payload that latched THIS episode idle, and only that one: `stateFor("stop")` is the sole
859
+ // branch that can write idle, so the event filter names the writer rather than assuming it, and the
860
+ // floor AND ceiling together tie the row to the episode the notice is reporting (the notify branch's
861
+ // `unchanged` rows are what fooled issue #38). The ceiling is not symmetry for its own sake: this
862
+ // renders at DELIVERY time, so a held notice outlives the episode it claimed, and the newest stop row
863
+ // is then a different episode's - naming ITS tasks, or blanking the clause on a finish that really did
864
+ // leave a shell running. The 2s slack either side is clock granularity, the state write being seconds
865
+ // and the log write milliseconds later, and bounds nothing.
866
+ function liveTasksAtIdle(actorId, episode) {
867
+ if (episode === null)
868
+ return [];
869
+ try {
870
+ const row = stmt(`SELECT payload FROM agent_state_log
871
+ WHERE actor_id = ? AND event = 'stop'
872
+ AND created_at >= datetime(?, '-2 seconds') AND created_at <= datetime(?, '+2 seconds')
873
+ ORDER BY id DESC LIMIT 1`).get(actorId, episode, episode);
874
+ if (row === undefined)
875
+ return [];
876
+ return liveBackgroundTasks(JSON.parse(row.payload).background_tasks);
877
+ }
878
+ catch {
879
+ return [];
880
+ }
881
+ }
882
+ // A worker that backgrounded a shell and ended its turn IS idle - the latch is right - and it is also
883
+ // not finished. A GONE row gets no clause: its pane is gone, so what it was waiting on is not
884
+ // actionable. Both renders carry the fact: the crew render puts it on that worker's own line, the
885
+ // full one adds each task's own description.
886
+ function liveTasksForFinish(c) {
887
+ return c.condition === CONDITION_IDLE ? liveTasksAtIdle(c.row.actor_id, c.row.episode) : [];
888
+ }
889
+ // One entry per WORKER, in the state hive reads at DELIVERY time, however many turns that worker
890
+ // finished while the notice was held. The claim rows stay per-episode - they are what stops a finish
891
+ // being re-reported - and only this render collapses them, keeping the LAST claim per worker because
892
+ // that is the episode whose background tasks and whose state are still current.
893
+ function crewFromClaims(finished) {
894
+ const byAgent = new Map();
895
+ for (const c of finished)
896
+ byAgent.set(c.row.id, c);
897
+ return [...byAgent.values()];
898
+ }
899
+ // Every clause here is a fact about NOW, never about the episode that was claimed, so that a worker
900
+ // which took new work while the notice sat held reads as working rather than as a second crew member.
901
+ // The two states that are NOT forward progress get said in words rather than by their enum name: this
902
+ // render is the only text a lead reads, so `waiting` reading as work resumed would contradict, in the
903
+ // pane, the warning the full render prints - and a resumed worker has no state yet at all.
904
+ function crewStateClause(c) {
905
+ if (isStaleGoneReport(c)) {
906
+ return "STALE - its GONE report has moved since; verify with agent_output before trusting it";
907
+ }
908
+ if (c.row.status !== "running")
909
+ return "gone";
910
+ if (awaitingFirstPrompt(c.row))
911
+ return "resumed, awaiting its first assignment";
912
+ if (c.row.agent_state === "waiting")
913
+ return "waiting - may be stopped on a dialog; read its pane";
914
+ if (c.row.agent_state === "unknown")
915
+ return "state unknown - read its pane";
916
+ if (c.row.agent_state !== "idle")
917
+ return `${c.row.agent_state} again since it reported in`;
918
+ const live = liveTasksForFinish(c);
919
+ return live.length === 0 ? "idle" : `idle, ${describeLiveTasks(live)} running - may not be done`;
920
+ }
921
+ function contextClause(row) {
922
+ const kind = harnessFor(row.command).contextRecord;
923
+ if (row.kind !== "agent" || kind == null)
924
+ return "";
925
+ const fill = readContextFill(kind, row);
926
+ return fill === null ? ", context unavailable" : `, context ${fill.used_percent}%`;
927
+ }
928
+ // Rendered at DELIVERY time only, from the notice row's own wake_idle_notices claim rows - never at
929
+ // write time. The stored body (below) always carries the full text, so wake_get(noticeId) returns
930
+ // real detail. A stale-GONE diagnostic (isStaleGoneReport) stays inline: it corrects a fact THIS SAME
931
+ // line asserts, not detail deferrable to a lookup.
932
+ // The still-going tally counts only workers this render has NOT already described, and says "other"
933
+ // so the exclusion is on the page rather than inferred: a worker named above and also counted here
934
+ // read as a crew of two. It subtracts `shown`, never the whole crew: a worker past the cap was
935
+ // claimed but never described, so subtracting it can print "Nothing else is running" while it works.
936
+ function standingNoticeBodyShort(timer, finished, noticeId) {
937
+ const crew = crewFromClaims(finished);
938
+ const shown = crew.slice(0, FINISHED_SHOWN_CAP);
939
+ const lines = shown.map((c) => `${c.row.name}: ${crewStateClause(c)}${contextClause(c.row)}.`);
940
+ const reported = new Set(shown.map((c) => c.row.actor_id));
941
+ const rows = stillGoingRows(timer);
942
+ const others = rows === null ? null : rows.filter((r) => !reported.has(r.actor_id)).length;
943
+ const tail = [];
944
+ const omitted = crew.length - shown.length;
945
+ if (omitted > 0)
946
+ tail.push(`And ${omitted} more not shown.`);
947
+ if (others !== null) {
948
+ tail.push(others === 0 ? "Nothing else is running." : `${others} other${others === 1 ? "" : "s"} still going.`);
949
+ }
950
+ tail.push(`wake_get(${noticeId}) for detail.`);
951
+ return [...lines, tail.join(" ")].join("\n");
952
+ }
953
+ function backgroundTaskSentence(c) {
954
+ const live = liveTasksForFinish(c);
955
+ if (live.length === 0)
956
+ return "";
957
+ return (`. It went idle with ${describeLiveTasks(live)} still running ` +
958
+ `(${live.map(describeOneTask).join("; ")}), so it may be WAITING on that rather than finished - ` +
959
+ "read its pane before you act on this line.");
960
+ }
961
+ function standingNoticeBody(timer, finished, totalFinished, carriedTotal, span) {
962
+ const lines = [
963
+ `${totalFinished} worker(s) in this project have finished or gone away since standing watch ` +
964
+ `#${timer.id} last spoke:`,
965
+ ];
966
+ for (const c of finished) {
967
+ lines.push(c.condition === CONDITION_GONE
968
+ ?
969
+ isStaleGoneReport(c)
970
+ ? ` ${c.row.name}: was reported GONE earlier in this hold, but its row's state has moved since - ` +
971
+ `it may have been resumed, or closed again at a different time. Read its CURRENT state with ` +
972
+ `agent_output(name: "${c.row.name}") rather than trusting this line; do not treat it as gone ` +
973
+ "based on this notice alone."
974
+ :
975
+ reportsAgentStateLog(c.row) && c.row.state_changed_at === null
976
+ ? ` ${c.row.name}: GONE - hive last read it as ${c.row.agent_state}, and its row was closed at ` +
977
+ `${c.row.episode}. It was never given an assignment, so nothing was in flight.`
978
+ : ` ${c.row.name}: GONE - hive last read it as ${c.row.agent_state}, and its row was closed at ` +
979
+ `${c.row.episode}, so there is no terminal left to read. Check its branch, its todo and any ` +
980
+ "pad it was writing for what landed before it stopped."
981
+ : ` ${c.row.name}: ${stateNowClause(c.row)}${backgroundTaskSentence(c)}`);
982
+ }
983
+ if (totalFinished > finished.length) {
984
+ lines.push(`...and ${totalFinished - finished.length} more finish(es) not shown above.`);
985
+ }
986
+ if (carriedTotal > 0 && span !== null) {
987
+ lines.push(`This notice was updated in place rather than queued behind the one before it: ${carriedTotal} of the ` +
988
+ `${totalFinished} above were already known before this update. Every finish this notice covers spans ` +
989
+ `${span.lo} to ${span.hi}.`);
990
+ }
991
+ const rows = stillGoingRows(timer);
992
+ let shown = [];
993
+ let more = 0;
994
+ const asked = rows !== null;
995
+ if (rows !== null) {
996
+ more = Math.max(0, rows.length - ROSTER_STILL_GOING);
997
+ shown = rows
998
+ .slice(0, ROSTER_STILL_GOING)
999
+ .map((r) => awaitingFirstPrompt(r) && r.agent_state === "idle"
1000
+ ? `${r.name} (awaiting first assignment)`
1001
+ : `${r.name} (${stateNowClause(r)})`);
1002
+ }
1003
+ if (shown.length > 0) {
1004
+ lines.push(`Still going: ${shown.join("; ")}${more > 0 ? `, and ${more} more` : ""}.`);
1005
+ }
1006
+ else if (asked) {
1007
+ lines.push("Nothing else in this project is running right now.");
1008
+ }
1009
+ lines.push('Read each finished worker with agent_output(name: "<name>") before acting on it. hive fires this on ' +
1010
+ "each worker's own hook state, so a terminal still showing work means that worker is NOT finished. A " +
1011
+ "worker reading `waiting` may be stopped on a dialog nobody has answered; read its pane.");
1012
+ lines.push(`--- what you asked to be told when this happened ---\n${timer.body}`);
1013
+ lines.push(`Wake #${timer.id} is STILL WATCHING this project and speaks again on the next finish - you do not have ` +
1014
+ `to re-arm it. It expires at ${timer.max_wait_at ?? "an unrecorded time"}; stop it with ` +
1015
+ `wake_cancel(wake_id: ${timer.id}).`);
1016
+ return lines.join("\n");
1017
+ }
1018
+ function rearmSpentEpisode(timerId, agentId, condition, episode) {
1019
+ stmt(`DELETE FROM wake_idle_notices
1020
+ WHERE wake_id = ? AND agent_id = ? AND condition = ? AND episode = ?
1021
+ AND notice_wake_id IS NOT NULL
1022
+ AND EXISTS (SELECT 1 FROM wakes t WHERE t.id = wake_idle_notices.notice_wake_id
1023
+ AND t.fired_at IS NOT NULL AND t.typed_at IS NULL AND t.cancelled_at IS NULL
1024
+ AND t.fired_at < datetime('now', '${NOTICE_RETRY_AFTER}'))`).run(timerId, agentId, condition, episode);
1025
+ }
1026
+ function claimEpisode(timerId, agentId, condition, episode) {
1027
+ return (stmt(`INSERT OR IGNORE INTO wake_idle_notices (wake_id, agent_id, condition, episode)
1028
+ VALUES (?, ?, ?, ?)`).run(timerId, agentId, condition, episode).changes === 1);
1029
+ }
1030
+ function stampEpisodeNotice(noticeId, timerId, agentId, condition, episode) {
1031
+ stmt(`UPDATE wake_idle_notices SET notice_wake_id = ?
1032
+ WHERE wake_id = ? AND agent_id = ? AND condition = ? AND episode = ?`).run(noticeId, timerId, agentId, condition, episode);
1033
+ }
1034
+ function crewRowForRender(agentId, episode) {
1035
+ const row = stmt(`SELECT ${CREW_COLUMNS} FROM agents a WHERE a.id = ?`).get(agentId);
1036
+ return row === undefined ? null : { ...row, episode };
1037
+ }
1038
+ const claimStandingBatch = db.transaction((timer, candidates) => {
1039
+ if (!stillPending(timer.id))
1040
+ return false;
1041
+ const won = [];
1042
+ for (const c of candidates) {
1043
+ rearmSpentEpisode(timer.id, c.row.id, c.condition, c.row.episode);
1044
+ if (claimEpisode(timer.id, c.row.id, c.condition, c.row.episode))
1045
+ won.push(c);
1046
+ }
1047
+ if (won.length === 0)
1048
+ return false;
1049
+ const pending = pendingNoticeFor(timer.id, [CONDITION_IDLE, CONDITION_GONE]);
1050
+ if (pending !== undefined) {
1051
+ const priorStats = stmt(`SELECT COUNT(*) AS n, MIN(episode) AS lo, MAX(episode) AS hi
1052
+ FROM wake_idle_notices WHERE notice_wake_id = ?`).get(pending.id);
1053
+ const priorCap = Math.max(0, FINISHED_SHOWN_CAP - won.length);
1054
+ const prior = stmt(`SELECT agent_id, condition, episode FROM wake_idle_notices WHERE notice_wake_id = ?
1055
+ ORDER BY episode DESC LIMIT ?`).all(pending.id, priorCap);
1056
+ const carried = [];
1057
+ for (const p of prior) {
1058
+ const row = crewRowForRender(p.agent_id, p.episode);
1059
+ if (row !== null)
1060
+ carried.push({ condition: p.condition, row });
1061
+ }
1062
+ const finished = [...carried, ...won];
1063
+ const totalFinished = priorStats.n + won.length;
1064
+ const spanValues = [priorStats.lo, priorStats.hi, ...won.map((c) => c.row.episode)].filter((v) => v !== null);
1065
+ const span = spanValues.length > 0
1066
+ ? {
1067
+ lo: spanValues.reduce((a, b) => (a < b ? a : b)),
1068
+ hi: spanValues.reduce((a, b) => (a > b ? a : b)),
1069
+ }
1070
+ : null;
1071
+ if (updateNoticeInPlace(pending.id, standingNoticeBody(timer, finished, totalFinished, priorStats.n, span))) {
1072
+ for (const c of won)
1073
+ stampEpisodeNotice(pending.id, timer.id, c.row.id, c.condition, c.row.episode);
1074
+ return true;
1075
+ }
1076
+ }
1077
+ const noticeId = insertNotice(timer, timer.deliver_actor, timer.deliver_pane, standingNoticeBody(timer, won.slice(0, FINISHED_SHOWN_CAP), won.length, 0, null), timer.id);
1078
+ for (const c of won) {
1079
+ stampEpisodeNotice(noticeId, timer.id, c.row.id, c.condition, c.row.episode);
1080
+ }
1081
+ return true;
1082
+ });
1083
+ function noteStandingTransitions(timer, snapshot) {
1084
+ try {
1085
+ const pane = timer.deliver_pane;
1086
+ const candidates = [];
1087
+ for (const row of standingGoneRows(timer)) {
1088
+ candidates.push({ condition: CONDITION_GONE, row });
1089
+ }
1090
+ if (snapshot !== null) {
1091
+ for (const row of standingIdleRows(timer)) {
1092
+ if (row.tmux_target === pane)
1093
+ continue;
1094
+ if (rowAlive(row.tmux_socket, row.tmux_target, snapshot) !== true)
1095
+ continue;
1096
+ if (!idleIsAFreshTransition(timer.id, row))
1097
+ continue;
1098
+ candidates.push({ condition: CONDITION_IDLE, row });
1099
+ }
1100
+ }
1101
+ if (candidates.length === 0)
1102
+ return;
1103
+ claimStandingBatch.immediate(timer, candidates);
1104
+ }
1105
+ catch {
1106
+ }
1107
+ }
1108
+ const CONDITION_STALL = "stall";
1109
+ const STALL_BOUND_SQL = `-${STALL_BOUND_SECONDS} seconds`;
1110
+ function stallCandidateRows(timer, tellActor) {
1111
+ return stmt(`SELECT ${CREW_COLUMNS}, a.cwd, a.session_id, a.transcript_path, a.state_changed_at AS episode
1112
+ FROM agents a
1113
+ WHERE a.project_id = ? AND a.kind = 'agent' AND a.status = 'running' AND a.actor_id != ?
1114
+ AND a.agent_state IN ('working', 'waiting')
1115
+ AND a.state_changed_at IS NOT NULL
1116
+ AND a.state_changed_at < datetime('now', ?)
1117
+ AND ${unreported(CONDITION_STALL, "a.state_changed_at")}
1118
+ ORDER BY a.id`).all(timer.project_id, tellActor, STALL_BOUND_SQL, timer.id);
1119
+ }
1120
+ export function transcriptStaleness(row, now = Date.now()) {
1121
+ // Claude's transcript is a directory resolved from cwd; codex hands hive the exact file
1122
+ // through its own hook payload instead, stored on the row rather than resolved (todo 591) -
1123
+ // transcriptDirFor decides which source this row's harness actually has.
1124
+ const path = transcriptDirFor(row.command)
1125
+ ? join(transcriptDir(row.cwd), `${row.session_id}.jsonl`)
1126
+ : row.transcript_path;
1127
+ if (!path)
1128
+ return "never";
1129
+ try {
1130
+ return { seconds: Math.max(0, Math.round((now - statSync(path).mtimeMs) / 1000)) };
1131
+ }
1132
+ catch {
1133
+ return "never";
1134
+ }
1135
+ }
1136
+ export function describeStall(agentState, latchedSeconds, stale) {
1137
+ const latched = `has claimed \`${agentState}\` for ${humanizeAge(latchedSeconds)}`;
1138
+ if (stale === "never")
1139
+ return `${latched} and has never written a transcript at all.`;
1140
+ const quiet = `its transcript has not been written for ${humanizeAge(stale.seconds)}`;
1141
+ return agentState === "waiting"
1142
+ ? `${latched}, its pane shows no dialog, and ${quiet}.`
1143
+ : `${latched} and ${quiet}.`;
1144
+ }
1145
+ function stallNoticeBody(timer, stalled, observedAt) {
1146
+ const lines = [
1147
+ `${stalled.length} worker(s) in this project have stopped writing to their transcript while still ` +
1148
+ `claiming to be mid-turn. Observed at ${observedAt} (store time):`,
1149
+ ];
1150
+ for (const c of stalled) {
1151
+ lines.push(` ${c.row.name}: ${describeStall(c.row.agent_state, ageSecondsSince(c.row.episode), c.stale)} ` +
1152
+ `Read it with ${readPaneCall(c.row.name)}.`);
1153
+ }
1154
+ lines.push("hive is reporting what it OBSERVED and is NOT saying these workers are dead: a worker inside one very " +
1155
+ "long tool call looks identical from here. Read each pane before acting. If a turn really did die, send " +
1156
+ "that worker a message AND TELL IT WHAT STATE YOU FOUND - after an API error it does not reliably " +
1157
+ "remember what it was doing (.claude/rules/worker-state.md).");
1158
+ lines.push(`Standing watch #${timer.id} is UNAFFECTED by this notice: nothing was fired, held, cancelled or typed ` +
1159
+ "into any of the panes above, and no finish has been suppressed.");
1160
+ return lines.join("\n");
1161
+ }
1162
+ const claimStallBatch = db.transaction((timer, tell, candidates, observedAt) => {
1163
+ if (!stillPending(timer.id))
1164
+ return false;
1165
+ const won = [];
1166
+ for (const c of candidates) {
1167
+ if (c.row.agent_state === "waiting" && !claimBlockNotice(timer.id, c.row.id, c.row.episode))
1168
+ continue;
1169
+ rearmSpentEpisode(timer.id, c.row.id, CONDITION_STALL, c.row.episode);
1170
+ if (claimEpisode(timer.id, c.row.id, CONDITION_STALL, c.row.episode))
1171
+ won.push(c);
1172
+ }
1173
+ if (won.length === 0)
1174
+ return false;
1175
+ const noticeId = insertNotice(timer, tell.actor, tell.pane, stallNoticeBody(timer, won, observedAt), timer.id);
1176
+ for (const c of won) {
1177
+ stampEpisodeNotice(noticeId, timer.id, c.row.id, CONDITION_STALL, c.row.episode);
1178
+ }
1179
+ return true;
1180
+ });
1181
+ const storeNow = () => stmt("SELECT datetime('now') AS now").get().now;
1182
+ function noteStalledCrew(timer, snapshot, choices) {
1183
+ try {
1184
+ const tell = blockNoticeTarget(timer, snapshot);
1185
+ if (tell === null)
1186
+ return;
1187
+ const now = Date.now();
1188
+ const candidates = [];
1189
+ for (const row of stallCandidateRows(timer, tell.actor)) {
1190
+ if (row.tmux_target === tell.pane)
1191
+ continue;
1192
+ if (!reportsAgentStateLog(row))
1193
+ continue;
1194
+ if (row.session_id === "")
1195
+ continue;
1196
+ // Same reason as reportStalledWorkers (src/cli.ts): this report corroborates a latch against
1197
+ // transcript mtime, and a harness with no transcript signal at all would have nothing to read.
1198
+ if (!hasTranscriptSignal(row))
1199
+ continue;
1200
+ const stale = transcriptStaleness(row, now);
1201
+ // A stored-path harness (codex) with no readable file has nothing to report from - a missing
1202
+ // file is never treated as a stall for it, unlike claude's directory-resolved "never wrote"
1203
+ // (todo 591). LOAD-BEARING, do not remove: this SELECT's status='running' snapshot and the
1204
+ // statSync above can straddle a concurrent agent_close/agent_park in another process reaping
1205
+ // CODEX_HOME mid-tick, and an external deletion of the rollout file is not guarded against at
1206
+ // all - either way the file can be gone under a row this tick still sees as running.
1207
+ if (stale === "never" && !transcriptDirFor(row.command))
1208
+ continue;
1209
+ if (stale !== "never" && stale.seconds < STALL_BOUND_SECONDS)
1210
+ continue;
1211
+ if (row.agent_state === "waiting") {
1212
+ if (snapshot === null)
1213
+ continue;
1214
+ if (rowAlive(row.tmux_socket, row.tmux_target, snapshot) !== true)
1215
+ continue;
1216
+ if (awaitingChoice(row.tmux_target, row.command, choices) !== false)
1217
+ continue;
1218
+ }
1219
+ candidates.push({ row, stale });
1220
+ }
1221
+ if (candidates.length === 0)
1222
+ return;
1223
+ claimStallBatch.immediate(timer, tell, candidates, storeNow());
1224
+ }
1225
+ catch {
1226
+ }
1227
+ }
1228
+ function noticeDisposition(timer) {
1229
+ if (timer.parent_wake_id === null)
1230
+ return "deliver";
1231
+ try {
1232
+ const check = stmt(`SELECT (p.cancelled_at IS NULL) AS live,
1233
+ (p.fired_at IS NOT NULL AND p.repeat_every_ms IS NULL) AS parent_finished,
1234
+ (? >= datetime('now', ?)) AS fresh,
1235
+ EXISTS (SELECT 1 FROM wake_idle_notices n WHERE n.notice_wake_id = ?) AS finish_shaped
1236
+ FROM wakes p WHERE p.id = ?`).get(timer.created_at, NOTICE_MAX_AGE, timer.id, timer.parent_wake_id);
1237
+ if (check === undefined || check.live !== 1)
1238
+ return "orphaned";
1239
+ if (check.finish_shaped !== 1) {
1240
+ // A notice ABOUT a wake (modal-hold, unsubmitted input, a one-shot block) dies with that wake
1241
+ // in EVERY terminal state, not only cancellation: a one-shot parent that FIRED has nothing left
1242
+ // for the notice to report on either. A REPEATING parent's fired_at is not terminal - it only
1243
+ // means "fired at least once" - so only cancellation (above) ends one of those. Otherwise the
1244
+ // notice never ages on its own, however long the wake genuinely stays pending (todo 322).
1245
+ return check.parent_finished === 1 ? "orphaned" : "deliver";
1246
+ }
1247
+ // Finish-shaped (holds a wake_idle_notices claim): unchanged from before todo 322's fix round. A
1248
+ // standing watch that expired via max_wait_at is FIRED, not cancelled, and its finish notice must
1249
+ // still age out here with the correct wording, not be silently orphaned.
1250
+ return check.fresh === 1 ? "deliver" : "aged";
1251
+ }
1252
+ catch {
1253
+ return "deliver";
1254
+ }
1255
+ }
1256
+ const AGED_OUT_CONDITION = {
1257
+ [CONDITION_IDLE]: "finished",
1258
+ [CONDITION_GONE]: "gone",
1259
+ [CONDITION_STALL]: "stalled",
1260
+ };
1261
+ function agedOutBody(timer) {
1262
+ const rows = stmt(`SELECT a.name, n.condition FROM wake_idle_notices n JOIN agents a ON a.id = n.agent_id
1263
+ WHERE n.notice_wake_id = ? ORDER BY n.episode`).all(timer.id);
1264
+ const shown = rows
1265
+ .slice(0, FINISHED_SHOWN_CAP)
1266
+ .map((r) => `${r.name} (${AGED_OUT_CONDITION[r.condition] ?? r.condition})`);
1267
+ const omitted = rows.length - shown.length;
1268
+ const who = shown.length > 0
1269
+ ? `${rows.length} report(s) about your crew: ${shown.join(", ")}${omitted > 0 ? `, and ${omitted} more` : ""}`
1270
+ : "a report about your crew";
1271
+ return (`hive could not deliver ${who}. The notice sat over an hour waiting for this pane, which is past ` +
1272
+ "the age where hive will type one as news, so it was cancelled - and this line exists so that a " +
1273
+ "finish is never destroyed silently. Read each one with agent_output(name: \"<name>\") for its " +
1274
+ `CURRENT state rather than trusting the ages above; wake_get(wake_id: ${timer.id}) still holds the ` +
1275
+ `full text hive was going to type. ${watchStillWatchingClause(timer)}`);
1276
+ }
1277
+ // A watch that hit max_wait_at is FIRED, not cancelled, so it reaches here reading perfectly healthy.
1278
+ // Telling a lead it need not re-arm, in hive's own generated prose, when it must, is how a lane ends
1279
+ // up sitting finished and unnoticed - which this project has paid for twice.
1280
+ export function watchStillWatchingClause(timer) {
1281
+ const parent = stmt(`SELECT (cancelled_at IS NULL AND fired_at IS NULL
1282
+ AND (max_wait_at IS NULL OR max_wait_at > datetime('now'))) AS watching,
1283
+ (watch_scope IS NOT NULL) AS standing
1284
+ FROM wakes WHERE id = ?`).get(timer.parent_wake_id);
1285
+ // The parent is not always a standing watch: a modal-hold or one-shot block notice's parent is
1286
+ // the ORDINARY wake it is about (todo 322), which has no crew to re-arm a watch for.
1287
+ const label = parent?.standing === 1 ? `Standing watch #${timer.parent_wake_id}` : `Wake #${timer.parent_wake_id}`;
1288
+ return parent !== undefined && parent.watching === 1
1289
+ ? `${label} is unaffected and still active.`
1290
+ : `${label} is no longer active; set a new one if you still need it.`;
1291
+ }
1292
+ // Cancel and replacement are one transaction: if the replacement cannot be written the cancel rolls
1293
+ // back and the notice is retried, rather than the finish going missing after all. The replacement
1294
+ // carries NO parent, which is the branch above that exempts it - so the thing that reports an
1295
+ // age-out cannot itself age out, and nothing re-queues, so nothing loops.
1296
+ const ageOutNotice = db.transaction((timer) => {
1297
+ const cancelled = stmt("UPDATE wakes SET cancelled_at = datetime('now') WHERE id = ? AND created_at IS ? AND cancelled_at IS NULL")
1298
+ .run(timer.id, timer.created_at).changes === 1;
1299
+ if (!cancelled)
1300
+ return;
1301
+ // Parentless, so noticeDisposition can never age it out, and deliverable() holds it forever when
1302
+ // the target is unclassifiable: one immortal row per age-out, an hour apart, unbounded.
1303
+ if (!screenClassifiable(commandForActor(timer.deliver_actor)))
1304
+ return;
1305
+ insertNotice(timer, timer.deliver_actor, timer.deliver_pane, agedOutBody(timer), null);
1306
+ });
1307
+ function deliverable(timer, snapshot, choices) {
1308
+ const probe = snapshot
1309
+ ? rowAliveProbe(timer.deliver_socket, timer.deliver_pane, snapshot)
1310
+ : rowLiveProbe(timer.deliver_socket, timer.deliver_pane);
1311
+ const live = probe.live;
1312
+ if (live === null)
1313
+ return { ok: false };
1314
+ if (!live) {
1315
+ if (isLeadActorId(timer.deliver_actor)) {
1316
+ holdTimer(timer, HELD_REASON_LEAD_PANE_DEAD);
1317
+ }
1318
+ else {
1319
+ if (wasHeldForPaneReissue(timer.held_reason)) {
1320
+ holdTimer(timer, HELD_REASON_PANE_REISSUED_THEN_DEAD);
1321
+ }
1322
+ else {
1323
+ cancelTimer(timer.id);
1324
+ }
1325
+ }
1326
+ return { ok: false };
1327
+ }
1328
+ if (paneReissued(timer.deliver_pane_pid, probe)) {
1329
+ holdTimer(timer, isLeadActorId(timer.deliver_actor)
1330
+ ? HELD_REASON_PANE_REISSUED_LEAD
1331
+ : HELD_REASON_PANE_REISSUED_WORKER);
1332
+ return { ok: false };
1333
+ }
1334
+ // Above both pane reads: this population must not be captured at all, and the verdicts would be
1335
+ // wrong in opposite directions if they were.
1336
+ if (!screenClassifiable(timer.deliver_command)) {
1337
+ holdTimer(timer, HELD_REASON_UNCLASSIFIABLE_PANE);
1338
+ return { ok: false };
1339
+ }
1340
+ const dialog = awaitingChoice(timer.deliver_pane, timer.deliver_command, choices);
1341
+ if (dialog === true) {
1342
+ noteModalHold(timer, snapshot);
1343
+ return { ok: false };
1344
+ }
1345
+ if (inputBoxHoldsWake(timer.deliver_pane, timer.deliver_command, choices)) {
1346
+ noteUnsubmittedInputHold(timer, snapshot);
1347
+ return { ok: false };
1348
+ }
1349
+ // Same family as the two above: a fact about whether delivery is POSSIBLE. A
1350
+ // copy-mode pane accepts both tmux calls and reports success while stripping
1351
+ // the paste markers and eating the Enter.
1352
+ if (paneInCopyMode(timer.deliver_pane) === true) {
1353
+ holdTimer(timer, HELD_REASON_COPY_MODE);
1354
+ return { ok: false };
1355
+ }
1356
+ // The three checks above are facts about whether delivery is POSSIBLE (a dead pane, a reissued
1357
+ // pane, unsubmitted text sitting where the paste would land). This one is a fact about whether
1358
+ // delivery is WELCOME, which only makes sense to ask once delivery is otherwise clear to proceed -
1359
+ // so it sits after them. No notice-claim of its own: the human being held for is the human at the
1360
+ // keyboard, and the statusline is what tells them (see the two dead-ends on notice-claim latching
1361
+ // and on splitting a hold reason in two).
1362
+ if (isLeadActorId(timer.deliver_actor) && conversationHoldsWake(timer)) {
1363
+ holdTimer(timer, HELD_REASON_CONVERSATION);
1364
+ return { ok: false };
1365
+ }
1366
+ const pid = timer.deliver_pane_pid === "" || probe.pid === null
1367
+ ? "no-fact"
1368
+ : probe.pid === timer.deliver_pane_pid
1369
+ ? "ok"
1370
+ : "reissued";
1371
+ const dialogSeen = dialog === false ? "no" : "unknown";
1372
+ const boxState = cacheEntry(timer.deliver_pane, choices).box;
1373
+ const box = boxState === undefined ? "unknown" : boxState === null ? "absent" : boxState.state;
1374
+ return {
1375
+ ok: true,
1376
+ typedSeen: `live=yes pid=${pid} dialog=${dialogSeen} box=${box}`,
1377
+ firstHeldAt: timer.held_at != null ? timer.first_held_at : null,
1378
+ };
1379
+ }
1380
+ async function fireDelay(timer, snapshot, choices) {
1381
+ const disposition = noticeDisposition(timer);
1382
+ if (disposition === "aged") {
1383
+ try {
1384
+ ageOutNotice.immediate(timer);
1385
+ }
1386
+ catch {
1387
+ }
1388
+ return;
1389
+ }
1390
+ if (disposition === "orphaned") {
1391
+ bestEffortRun("UPDATE wakes SET cancelled_at = datetime('now') WHERE id = ? AND created_at IS ?", timer.id, timer.created_at);
1392
+ return;
1393
+ }
1394
+ const decision = deliverable(timer, snapshot, choices);
1395
+ if (!decision.ok)
1396
+ return;
1397
+ let claimed;
1398
+ if (timer.repeat_every_ms != null) {
1399
+ const seconds = Math.max(1, Math.round(timer.repeat_every_ms / 1000));
1400
+ claimed =
1401
+ stmt(`UPDATE wakes SET due_at = datetime('now', printf('+%d seconds', ?)),
1402
+ fired_at = datetime('now'), fire_count = fire_count + 1,
1403
+ typed_at = NULL, confirmed_at = NULL, held_at = NULL, held_reason = NULL, typed_busy = NULL,
1404
+ typed_seen = NULL, first_held_at = NULL
1405
+ WHERE id = ? AND due_at IS ? AND body IS ? AND repeat_every_ms IS ? AND cancelled_at IS NULL`).run(seconds, timer.id, timer.due_at, timer.body, timer.repeat_every_ms).changes === 1;
1406
+ }
1407
+ else {
1408
+ claimed = claimOneShot(timer);
1409
+ }
1410
+ if (claimed)
1411
+ await deliver(timer, "", choices, decision.typedSeen, decision.firstHeldAt);
1412
+ }
1413
+ function claimOneShot(timer) {
1414
+ return (stmt(`UPDATE wakes SET fired_at = datetime('now'), fire_count = fire_count + 1
1415
+ WHERE id = ? AND due_at IS ? AND body IS ? AND repeat_every_ms IS ?
1416
+ AND fired_at IS NULL AND cancelled_at IS NULL`).run(timer.id, timer.due_at, timer.body, timer.repeat_every_ms).changes === 1);
1417
+ }
1418
+ const GONE = { idle: true, gone: true, since: null };
1419
+ const UNKNOWN = { idle: false, gone: false, since: null };
1420
+ function watchedStates(timer, snapshot) {
1421
+ const ids = JSON.parse(timer.watch);
1422
+ return ids.map((id) => {
1423
+ const agent = stmt(`SELECT *, created_at < datetime('now', ?) AS settled FROM agents WHERE id = ?`).get(SETTLE_WINDOW, id);
1424
+ if (!agent || agent.status !== "running")
1425
+ return GONE;
1426
+ const alive = rowAlive(agent.tmux_socket, agent.tmux_target, snapshot);
1427
+ if (alive === null)
1428
+ return UNKNOWN;
1429
+ if (!alive) {
1430
+ if (!agent.settled)
1431
+ return UNKNOWN;
1432
+ return GONE;
1433
+ }
1434
+ return {
1435
+ idle: !awaitingFirstPrompt(agent) && agent.agent_state === "idle",
1436
+ gone: false,
1437
+ since: agent.state_changed_at,
1438
+ };
1439
+ });
1440
+ }
1441
+ async function maybeFireIdle(timer, snapshot, now, choices) {
1442
+ const timedOut = timer.max_wait_at != null && timer.max_wait_at <= now;
1443
+ if (isStandingWatch(timer)) {
1444
+ noteStandingTransitions(timer, snapshot);
1445
+ noteStalledCrew(timer, snapshot, choices);
1446
+ if (snapshot !== null && !timedOut)
1447
+ noteBlockedWatched(timer, snapshot, choices);
1448
+ if (timedOut) {
1449
+ const decision = deliverable(timer, snapshot, choices);
1450
+ if (decision.ok && claimOneShot(timer)) {
1451
+ await deliver(timer, STANDING_EXPIRED_NOTE, choices, decision.typedSeen, decision.firstHeldAt);
1452
+ }
1453
+ }
1454
+ return;
1455
+ }
1456
+ let ready = false;
1457
+ if (timedOut) {
1458
+ ready = true;
1459
+ }
1460
+ else {
1461
+ if (snapshot === null)
1462
+ return;
1463
+ const states = watchedStates(timer, snapshot);
1464
+ ready =
1465
+ timer.kind === "idle_any"
1466
+ ? states.some((s) => s.gone || (s.idle && s.since != null && s.since >= timer.created_at))
1467
+ : states.length > 0 && states.every((s) => s.idle);
1468
+ if (!ready)
1469
+ noteBlockedWatched(timer, snapshot, choices);
1470
+ }
1471
+ if (ready) {
1472
+ const decision = deliverable(timer, snapshot, choices);
1473
+ if (decision.ok && claimOneShot(timer)) {
1474
+ await deliver(timer, timedOut ? "max wait reached" : "", choices, decision.typedSeen, decision.firstHeldAt);
1475
+ }
1476
+ }
1477
+ }
1478
+ const TAIL_AGENTS = 3;
1479
+ function stateNowClause(agent) {
1480
+ if (!reportsAgentStateLog(agent))
1481
+ return agent.agent_state;
1482
+ let latchAge = "";
1483
+ try {
1484
+ if (agent.status === "running") {
1485
+ if (agent.state_changed_at) {
1486
+ const seconds = ageSecondsSince(agent.state_changed_at);
1487
+ latchAge = Number.isFinite(seconds) ? ` for ${humanizeAge(seconds)}` : " (latch age: unavailable)";
1488
+ }
1489
+ else {
1490
+ latchAge = " (latch age: no record)";
1491
+ }
1492
+ }
1493
+ }
1494
+ catch {
1495
+ latchAge = " (latch age: unavailable)";
1496
+ }
1497
+ let lastEvent = "";
1498
+ try {
1499
+ lastEvent = `, last log event: ${describeLastLogEvent(lastLogEvent(agent.actor_id))}`;
1500
+ }
1501
+ catch {
1502
+ }
1503
+ return `${agent.agent_state}${latchAge}${lastEvent}`;
1504
+ }
1505
+ function watchedTail(timer) {
1506
+ try {
1507
+ const ids = JSON.parse(timer.watch);
1508
+ if (ids.length === 0)
1509
+ return "";
1510
+ const shown = [];
1511
+ for (const id of ids.slice(0, TAIL_AGENTS)) {
1512
+ const agent = stmt("SELECT name, tmux_target, tmux_socket, agent_state, state_changed_at, status, actor_id, command, kind, cwd, session_id, transcript_path FROM agents WHERE id = ?").get(id);
1513
+ if (!agent)
1514
+ continue;
1515
+ const context = contextClause(agent);
1516
+ if (agent.status !== "running") {
1517
+ shown.push(`${agent.name} (hive state now: ${stateNowClause(agent)}${context}): closed, so there is no terminal left to read.`);
1518
+ continue;
1519
+ }
1520
+ if (foreignSocket(agent.tmux_socket)) {
1521
+ shown.push(`${agent.name} (hive state now: ${stateNowClause(agent)}${context}): its terminal lives on a different tmux ` +
1522
+ "socket than this process, so it cannot honestly be read from here.");
1523
+ continue;
1524
+ }
1525
+ let tail = "";
1526
+ try {
1527
+ tail = maskChoiceMarker(sanitizeTail(capturePane(agent.tmux_target, tailCaptureLines())));
1528
+ }
1529
+ catch {
1530
+ }
1531
+ const stateNow = stateNowClause(agent) + context;
1532
+ shown.push(tail
1533
+ ? `${agent.name} (hive state now: ${stateNow}), last lines of its terminal:\n${tail}`
1534
+ : `${agent.name} (hive state now: ${stateNow}): its terminal could not be read.`);
1535
+ }
1536
+ if (shown.length === 0)
1537
+ return "";
1538
+ const lines = ["--- what hive sees on the watched agents as this wake is delivered ---", ...shown];
1539
+ if (ids.length > TAIL_AGENTS) {
1540
+ lines.push(`(${ids.length - TAIL_AGENTS} more watched agent(s) not shown)`);
1541
+ }
1542
+ lines.push("hive fires this on each worker's own hook state. If a terminal above shows work still running, that worker is not finished: read agent_output before acting on it.");
1543
+ return `\n\n${lines.join("\n")}`;
1544
+ }
1545
+ catch {
1546
+ return "";
1547
+ }
1548
+ }
1549
+ function firstEpisodeFiledAt(noticeId) {
1550
+ return stmt("SELECT MIN(notified_at) AS t FROM wake_idle_notices WHERE notice_wake_id = ?").get(noticeId).t;
1551
+ }
1552
+ // One bound, used twice, and it is the conversation hold's own TTL because that hold is what produces
1553
+ // these delays: below it a notice cannot have gone stale, so the note is silent; and a content refresh
1554
+ // less than that after the hold began is not a fact a reader can act on separately, so the two clauses
1555
+ // collapse to one. At a one-second hold the pair printed the same timestamp and the same age twice.
1556
+ export function noticeStalenessNote(timer) {
1557
+ if (timer.parent_wake_id === null)
1558
+ return "";
1559
+ try {
1560
+ const heldSince = firstEpisodeFiledAt(timer.id) ?? timer.created_at;
1561
+ const heldSeconds = ageSecondsSince(heldSince);
1562
+ if (heldSeconds < CONVERSATION_HOLD_TTL_SECONDS)
1563
+ return "";
1564
+ const contentSeconds = ageSecondsSince(timer.created_at);
1565
+ if (heldSeconds - contentSeconds < CONVERSATION_HOLD_TTL_SECONDS) {
1566
+ return `\nHeld ${humanizeAge(heldSeconds)}: this reflects what hive knew at ${heldSince} UTC.`;
1567
+ }
1568
+ return (`\nHeld since ${heldSince} UTC (${humanizeAge(heldSeconds)} ago). Its content reflects what ` +
1569
+ `hive knew as of ${timer.created_at} UTC, ${humanizeAge(contentSeconds)} before this ` +
1570
+ "reached you.");
1571
+ }
1572
+ catch {
1573
+ return "";
1574
+ }
1575
+ }
1576
+ // A lead-bound notice types short; wake_get(noticeId) then returns the FULL body this function never
1577
+ // touches. Reconstructed from the notice row's own wake_idle_notices claim rows (agent_id, condition,
1578
+ // episode) rather than parsed back out of stored text. Returns null for anything that is not a
1579
+ // standing-watch finish notice (a self-addressed wake, a block notice, the expiry notice) - deliver()
1580
+ // falls back to the full body for those, unchanged.
1581
+ export function shortRenderForLeadDelivery(timer) {
1582
+ const rows = stmt(`SELECT agent_id, condition, episode FROM wake_idle_notices
1583
+ WHERE notice_wake_id = ? AND condition IN ('${CONDITION_IDLE}', '${CONDITION_GONE}')
1584
+ ORDER BY notified_at, agent_id, episode`).all(timer.id);
1585
+ if (rows.length === 0)
1586
+ return null;
1587
+ const candidates = [];
1588
+ for (const r of rows) {
1589
+ const row = crewRowForRender(r.agent_id, r.episode);
1590
+ if (row !== null)
1591
+ candidates.push({ condition: r.condition, row });
1592
+ }
1593
+ if (candidates.length === 0)
1594
+ return null;
1595
+ return standingNoticeBodyShort(timer, candidates, timer.id);
1596
+ }
1597
+ async function deliver(timer, note, choices, typedSeen, firstHeldAt) {
1598
+ const tail = watchedTail(timer);
1599
+ const prefix = `[hive wake #${timer.id}${note ? `, ${note}` : ""}] `;
1600
+ const body = (isLeadActorId(timer.deliver_actor) ? shortRenderForLeadDelivery(timer) : null) ?? timer.body;
1601
+ let typedBusy;
1602
+ try {
1603
+ const lastEvent = lastLogEvent(timer.deliver_actor);
1604
+ typedBusy = lastEvent == null ? null : lastEvent.state === "working" || lastEvent.state === "waiting" ? 1 : 0;
1605
+ }
1606
+ catch {
1607
+ typedBusy = null;
1608
+ }
1609
+ const recordTyped = () => bestEffortRun(`UPDATE wakes SET typed_at = strftime('%Y-%m-%d %H:%M:%f', 'now'), typed_busy = ?,
1610
+ typed_seen = ?, first_held_at = ?, held_at = NULL, held_reason = NULL, confirmed_at = NULL
1611
+ WHERE id = ?`, typedBusy, typedSeen, firstHeldAt, timer.id);
1612
+ const box = cacheEntry(timer.deliver_pane, choices).box;
1613
+ const strandedTextWouldHold = box !== undefined && box !== null && box.state !== "unknown";
1614
+ try {
1615
+ await sendText(timer.deliver_pane, prefix + body + noticeStalenessNote(timer) + tail, true, strandedTextWouldHold ? recordTyped : undefined);
1616
+ }
1617
+ finally {
1618
+ forgetPaneAnswers(timer.deliver_pane, choices);
1619
+ }
1620
+ if (!strandedTextWouldHold)
1621
+ recordTyped();
1622
+ }