@cmgmyr/hive 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/scheduler.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { QUEEN_AUDIT_RETENTION, QUEEN_AUDIT_MAX_ROWS, recentQueenAudit } from "./queenAudit.js";
2
- import { runningBuildChange, runningBuildNotice } from "./version.js";
2
+ import { runningBuildChange, runningBuildNotice, versionInfo } from "./version.js";
3
3
  import { existsSync, lstatSync, mkdirSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { createHash } from "node:crypto";
5
5
  import { dirname, join, sep } from "node:path";
@@ -19,12 +19,15 @@ import { describeLiveTasks, describeOneTask, liveBackgroundTasks, STALL_BOUND_SE
19
19
  import { MESSAGE_MAX_ROWS, MESSAGE_RETENTION } from "./leadMessage.js";
20
20
  import { CROSS_SESSION_CLOSE, postClaudeWake, senderAddress, SOCKET_WAKE_FOOTER } from "./claudeWake.js";
21
21
  import { readLeadTurnState } from "./leadState.js";
22
+ import { retryQuietLeadMessages } from "./leadMessageDelivery.js";
22
23
  import { harnessFor, hasTranscriptSignal, paneClassifierFor, screenClassifiable, transcriptDirFor } from "./harnesses.js";
23
24
  import { transcriptDir, readContextFill } from "./transcript.js";
24
25
  import { ageSecondsSince, describeLastLogEvent, humanizeAge, lastLogEvent, reportsAgentStateLog, } from "./stateProvenance.js";
25
26
  import { capturePane, foreignSocket, holdsHumanInput, liveTargets, maskChoiceMarker, observationFailed, paneInCopyMode, paneReissued, rowAlive, rowAliveProbe, rowLiveProbe, rowOwnership, renderTrailerView, sanitizeTail, sendText, tailCaptureLines, sleep, tmuxSocketPath, } from "./tmux.js";
26
27
  import { appendTeardown, NOT_ATTRIBUTED, sqlNow, teardownWindow, TEARDOWN_TRIGGER, } from "./teardown.js";
27
28
  export const WATCH_SCOPE_PROJECT = "project";
29
+ const DELIVERED_BY = `pid ${process.pid}, ${versionInfo().line}`;
30
+ const deliveredByForFiring = (timer) => timer.repeat_every_ms == null ? DELIVERED_BY : `${DELIVERED_BY}, firing #${timer.fire_count}`;
28
31
  const isStandingWatch = (timer) => timer.watch_scope === WATCH_SCOPE_PROJECT;
29
32
  export const DELIVER_SOCKET_JOIN = `LEFT JOIN agents ON agents.id = (
30
33
  SELECT a.id FROM agents a WHERE a.actor_id = wakes.deliver_actor
@@ -466,8 +469,13 @@ export function checkConfirmations() {
466
469
  WHERE ${sentAt} IS NOT NULL AND confirmed_at IS NULL AND ${sentAt} >= datetime('now', ?) LIMIT 1`).get(LOG_RETENTION);
467
470
  if (!pending)
468
471
  return;
472
+ stmt(`UPDATE wakes SET confirmed_at = (${repeatingEvidenceSql("MIN(ev.created_at)")})
473
+ WHERE ${REPEATING_SOCKET_FIRING} AND socket_attempt_at IS NOT NULL AND confirmed_at IS NULL
474
+ AND socket_attempt_at >= datetime('now', ?)
475
+ AND EXISTS (${repeatingEvidenceSql("1")})`).run(LOG_RETENTION);
469
476
  stmt(`UPDATE wakes SET confirmed_at = (${confirmationQuery})
470
477
  WHERE ${sentAt} IS NOT NULL AND confirmed_at IS NULL AND ${sentAt} >= datetime('now', ?)
478
+ AND NOT (${REPEATING_SOCKET_FIRING})
471
479
  AND EXISTS (${confirmationQuery.replace("MIN(created_at)", "1")})`).run(LOG_RETENTION);
472
480
  }
473
481
  catch {
@@ -513,6 +521,7 @@ export async function tick(snapshot) {
513
521
  await maybeFireIdle(timer, snapshot, now, choices);
514
522
  }
515
523
  await retryUnconfirmedSocketWakes(snapshot, choices);
524
+ await retryQuietLeadMessages(snapshot);
516
525
  }
517
526
  catch {
518
527
  }
@@ -569,11 +578,12 @@ function forgetPaneAnswers(pane, choices) {
569
578
  noDialogUntil.delete(pane);
570
579
  }
571
580
  function holdTimer(timer, reason) {
581
+ const count = timer.fire_count ?? null;
572
582
  bestEffortRun(`UPDATE wakes SET held_at = datetime('now'), held_reason = ?,
573
583
  first_held_at = COALESCE(CASE WHEN held_at IS NULL THEN NULL ELSE first_held_at END, datetime('now'))
574
584
  WHERE id = ? AND cancelled_at IS NULL
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);
585
+ AND (fired_at IS NULL OR (repeat_every_ms IS NOT NULL AND due_at = ? AND (? IS NULL OR fire_count = ?))
586
+ OR (${socketAwaitingWhere()}))`, reason, timer.id, timer.due_at, count, count);
577
587
  }
578
588
  const HELD_REASON_MODAL_CHOICE = "pane is awaiting a modal choice (folder-trust or /model picker)";
579
589
  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 " +
@@ -658,6 +668,8 @@ function conversationHoldsWake(timer, snapshot) {
658
668
  }
659
669
  return (stmt(`SELECT 1 AS hit FROM agent_state_log
660
670
  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:%'
671
+ AND CASE WHEN json_valid(payload) THEN COALESCE(json_extract(payload, '$.prompt'), payload) ELSE payload END NOT LIKE '<task-notification>%'
672
+ AND CASE WHEN json_valid(payload) THEN COALESCE(json_extract(payload, '$.prompt'), payload) ELSE payload END NOT LIKE '<cross-session-message from="%" from-name="hive">' || char(10) || '[hive:worker %] [message #%,%' || char(10) || '</cross-session-message>%'
661
673
  AND created_at >= datetime('now', ?)
662
674
  ORDER BY id DESC LIMIT 1`).get(timer.deliver_actor, CONVERSATION_HOLD_TTL) !== undefined);
663
675
  }
@@ -1610,28 +1622,33 @@ async function fireDelay(timer, snapshot, choices) {
1610
1622
  bestEffortRun("UPDATE wakes SET cancelled_at = datetime('now') WHERE id = ? AND created_at IS ?", timer.id, timer.created_at);
1611
1623
  return;
1612
1624
  }
1625
+ if (timer.repeat_every_ms != null && repeatingFiringPending(timer.id))
1626
+ return;
1613
1627
  await withPaneClaim(timer, () => fireDelayClaimed(timer, snapshot, choices));
1614
1628
  }
1615
1629
  async function fireDelayClaimed(timer, snapshot, choices) {
1616
1630
  const decision = deliverable(timer, snapshot, choices);
1617
1631
  if (!decision.ok)
1618
1632
  return false;
1619
- let claimed;
1633
+ let firing = timer;
1620
1634
  if (timer.repeat_every_ms != null) {
1621
1635
  const seconds = Math.max(1, Math.round(timer.repeat_every_ms / 1000));
1622
- claimed =
1623
- stmt(`UPDATE wakes SET due_at = datetime('now', printf('+%d seconds', ?)),
1624
- fired_at = datetime('now'), fire_count = fire_count + 1,
1625
- typed_at = NULL, confirmed_at = NULL, held_at = NULL, held_reason = NULL, typed_busy = NULL,
1626
- typed_seen = NULL, first_held_at = NULL
1627
- 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;
1628
- }
1629
- else {
1630
- claimed = claimOneShot(timer);
1636
+ const claimed = stmt(`UPDATE wakes SET due_at = datetime('now', printf('+%d seconds', ?)),
1637
+ fired_at = datetime('now'), fire_count = fire_count + 1,
1638
+ typed_at = NULL, confirmed_at = NULL, held_at = NULL, held_reason = NULL, typed_busy = NULL,
1639
+ typed_seen = NULL, first_held_at = NULL,
1640
+ socket_attempt_at = NULL, socket_delivery_note = NULL, delivery_method = NULL, delivered_by = NULL
1641
+ WHERE id = ? AND due_at IS ? AND body IS ? AND repeat_every_ms IS ? AND cancelled_at IS NULL
1642
+ AND fire_count = ? AND NOT COALESCE((${REPEATING_FIRING_PENDING}), 0)
1643
+ RETURNING fire_count, fired_at`).get(seconds, timer.id, timer.due_at, timer.body, timer.repeat_every_ms, timer.fire_count);
1644
+ if (claimed === undefined)
1645
+ return false;
1646
+ firing = { ...timer, fire_count: claimed.fire_count, fired_at: claimed.fired_at, socket_attempt_at: null, socket_delivery_note: null, delivery_method: null };
1631
1647
  }
1632
- if (!claimed)
1648
+ else if (!claimOneShot(timer)) {
1633
1649
  return false;
1634
- await deliver(timer, "", choices, decision.typedSeen, decision.firstHeldAt, decision.owner);
1650
+ }
1651
+ await deliver(firing, "", choices, decision.typedSeen, decision.firstHeldAt, decision.owner);
1635
1652
  return true;
1636
1653
  }
1637
1654
  function claimOneShot(timer) {
@@ -1889,14 +1906,39 @@ export function shortRenderForLeadDelivery(timer) {
1889
1906
  return standingNoticeBodyShort(timer, candidates, timer.id);
1890
1907
  }
1891
1908
  // The claim already happened, so this records why and never re-arms or replays the wake.
1892
- function recordLostAfterClaim(timer, pasted, why) {
1909
+ function recordLostAfterClaim(timer, pasted, why, firing = null) {
1893
1910
  bestEffortRun(`UPDATE wakes SET held_reason = ?,
1894
1911
  typed_at = CASE WHEN ? = 1 THEN strftime('%Y-%m-%d %H:%M:%f', 'now') ELSE typed_at END,
1895
1912
  cancelled_at = CASE WHEN repeat_every_ms IS NULL THEN datetime('now') ELSE cancelled_at END
1896
- WHERE id = ?`, HELD_REASON_OWNERSHIP_LOST_AFTER_CLAIM + why + (pasted ? " The body was pasted; no Enter was sent." : ""), pasted ? 1 : 0, timer.id);
1913
+ WHERE id = ? AND (? IS NULL OR fire_count = ?)`, HELD_REASON_OWNERSHIP_LOST_AFTER_CLAIM + why + (pasted ? " The body was pasted; no Enter was sent." : ""), pasted ? 1 : 0, timer.id, firing, firing);
1897
1914
  }
1898
- export const DELIVERY_METHOD = { socket: "socket", pty: "pty", ptyAfterSocketTimeout: "pty-after-socket-timeout" };
1915
+ export const DELIVERY_METHOD = {
1916
+ socket: "socket",
1917
+ socketRepeating: "socket-repeating",
1918
+ pty: "pty",
1919
+ ptyAfterSocketTimeout: "pty-after-socket-timeout",
1920
+ };
1899
1921
  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`;
1922
+ // An attempt older than the row's latest claim belongs to an earlier firing (an old-build claim leaves it behind).
1923
+ const currentAttempt = (p) => `${p}socket_attempt_at IS NOT NULL AND ${p}confirmed_at IS NULL AND ${p}typed_at IS NULL AND ${p}cancelled_at IS NULL
1924
+ AND ${p}fired_at IS NOT NULL AND ${p}socket_attempt_at >= ${p}fired_at`;
1925
+ const repeatingSocketAwaitingWhere = (prefix = "") => `${prefix}delivery_method = '${DELIVERY_METHOD.socketRepeating}' AND ${currentAttempt(prefix)}`;
1926
+ const FIRING_DROPPED_NOTE = "dropped";
1927
+ const repeatingFallbackClaimedWhere = (prefix = "") => `${prefix}repeat_every_ms IS NOT NULL AND ${prefix}delivery_method = '${DELIVERY_METHOD.ptyAfterSocketTimeout}' AND ${currentAttempt(prefix)}
1928
+ AND ${prefix}socket_delivery_note IS NOT '${FIRING_DROPPED_NOTE}'`;
1929
+ // Bounded like retry and confirmation: past retention neither can settle it, so it must stop deferring.
1930
+ const REPEATING_FIRING_PENDING = `((${repeatingSocketAwaitingWhere()}) OR (${repeatingFallbackClaimedWhere()}))
1931
+ AND socket_attempt_at >= datetime('now', '${LOG_RETENTION}')`;
1932
+ const REPEATING_SOCKET_FIRING = `repeat_every_ms IS NOT NULL AND COALESCE(delivery_method, '') IN ('${DELIVERY_METHOD.socketRepeating}', '${DELIVERY_METHOD.ptyAfterSocketTimeout}')`;
1933
+ function repeatingFiringPending(id) {
1934
+ try {
1935
+ return stmt(`SELECT 1 AS hit FROM wakes WHERE id = ? AND (${REPEATING_FIRING_PENDING})`).get(id) !== undefined;
1936
+ }
1937
+ catch {
1938
+ return true;
1939
+ }
1940
+ }
1941
+ const firingMarker = (timer) => `[hive wake #${timer.id} firing #${timer.fire_count}`;
1900
1942
  const SOCKET_CONFIRM_GRACE_SECONDS = 60;
1901
1943
  const withReDelivered = (note) => (note ? `${note}, re-delivered` : "re-delivered");
1902
1944
  const promptEvidenceSql = (lowerBound) => `
@@ -1904,14 +1946,30 @@ const promptEvidenceSql = (lowerBound) => `
1904
1946
  WHERE actor_id = wakes.deliver_actor AND event = 'prompt' AND created_at >= ${lowerBound}
1905
1947
  AND (payload LIKE '%[hive wake #' || wakes.id || ']%'
1906
1948
  OR payload LIKE '%[hive wake #' || wakes.id || ',%')`;
1949
+ const ENVELOPE_OPEN = '<cross-session-message from="';
1950
+ const ENVELOPE_SENDER_CLOSE = '" from-name="hive">';
1951
+ const EV_PROMPT = "(CASE WHEN json_valid(ev.payload) THEN COALESCE(json_extract(ev.payload, '$.prompt'), '') ELSE ev.payload END)";
1952
+ const EV_MARKER = "('[hive wake #' || wakes.id || ' firing #' || wakes.fire_count)";
1953
+ const markerAt = (start) => `substr(${EV_PROMPT}, ${start}, length(${EV_MARKER}) + 1) IN (${EV_MARKER} || ']', ${EV_MARKER} || ',')`;
1954
+ const EV_HEADER_END = `instr(${EV_PROMPT}, char(10))`;
1955
+ // The current firing's exact marker, at prompt offset 0 (typed) or opening hive's envelope body (socket).
1956
+ const repeatingEvidenceSql = (select) => `
1957
+ SELECT ${select} FROM agent_state_log ev
1958
+ WHERE ev.actor_id = wakes.deliver_actor AND ev.event = 'prompt' AND ev.created_at >= wakes.socket_attempt_at
1959
+ AND (${markerAt("1")}
1960
+ OR (${EV_HEADER_END} > ${ENVELOPE_OPEN.length + ENVELOPE_SENDER_CLOSE.length}
1961
+ AND substr(${EV_PROMPT}, 1, ${ENVELOPE_OPEN.length}) = '${ENVELOPE_OPEN}'
1962
+ AND substr(${EV_PROMPT}, ${EV_HEADER_END} - ${ENVELOPE_SENDER_CLOSE.length}, ${ENVELOPE_SENDER_CLOSE.length}) = '${ENVELOPE_SENDER_CLOSE}'
1963
+ AND instr(substr(${EV_PROMPT}, ${ENVELOPE_OPEN.length + 1}, ${EV_HEADER_END} - ${ENVELOPE_OPEN.length + ENVELOPE_SENDER_CLOSE.length + 1}), '"') = 0
1964
+ AND ${markerAt(`${EV_HEADER_END} + 1`)}
1965
+ AND instr(${EV_PROMPT}, char(10) || '${CROSS_SESSION_CLOSE}') > ${EV_HEADER_END}))`;
1907
1966
  function quietMessagingOn(projectId) {
1908
1967
  const project = stmt("SELECT path FROM projects WHERE id = ?").get(projectId);
1909
1968
  return project !== undefined && loadProjectYml(project.path).config?.quiet_messaging === true;
1910
1969
  }
1911
- // Only a one-shot wake to a live, row-owned Claude lead whose socket was registered by this pane epoch.
1970
+ // Only a wake to a live, row-owned Claude lead whose socket was registered by this pane epoch.
1912
1971
  function socketRoute(timer, owner) {
1913
1972
  return (owner !== null &&
1914
- timer.repeat_every_ms == null &&
1915
1973
  isLeadActorId(timer.deliver_actor) &&
1916
1974
  harnessFor(timer.deliver_command).name === "claude" &&
1917
1975
  timer.deliver_messaging_socket !== "" &&
@@ -1924,23 +1982,72 @@ function socketStillRegistered(timer, owner) {
1924
1982
  requireStillOwned(owner.rowId, owner.identity, `wake #${timer.id} socket delivery`);
1925
1983
  const row = stmt(`SELECT claude_messaging_socket AS socket, claude_messaging_pane_pid AS pid, pane_pid FROM agents
1926
1984
  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);
1985
+ const wake = timer.repeat_every_ms == null
1986
+ ? stmt("SELECT 1 AS live FROM wakes WHERE id = ? AND cancelled_at IS NULL").get(timer.id)
1987
+ : stmt("SELECT 1 AS live FROM wakes WHERE id = ? AND cancelled_at IS NULL AND fire_count = ?").get(timer.id, timer.fire_count);
1928
1988
  return (wake !== undefined &&
1929
1989
  row !== undefined &&
1930
1990
  row.socket === timer.deliver_messaging_socket &&
1931
1991
  row.pid === timer.deliver_messaging_pane_pid &&
1932
1992
  row.pid === row.pane_pid);
1933
1993
  }
1994
+ // A failed stamp means the firing was cancelled or superseded, so nothing is posted.
1995
+ function stampRepeatingAttempt(timer, note, firstHeldAt) {
1996
+ try {
1997
+ return (stmt(`UPDATE wakes SET socket_attempt_at = strftime('%Y-%m-%d %H:%M:%f', 'now'), socket_delivery_note = ?,
1998
+ delivery_method = ?, delivered_by = ?, first_held_at = ?, held_at = NULL, held_reason = NULL, confirmed_at = NULL
1999
+ WHERE id = ? AND fire_count = ? AND cancelled_at IS NULL AND typed_at IS NULL AND socket_attempt_at IS NULL`).run(note, DELIVERY_METHOD.socketRepeating, deliveredByForFiring(timer), firstHeldAt, timer.id, timer.fire_count).changes === 1);
2000
+ }
2001
+ catch {
2002
+ return false;
2003
+ }
2004
+ }
2005
+ // At most one fallback per (id, firing): the method leaves socket-repeating here and never returns to it.
2006
+ function claimRepeatingFallback(timer) {
2007
+ try {
2008
+ return (stmt(`UPDATE wakes SET delivery_method = ?, delivered_by = ?
2009
+ WHERE id = ? AND fire_count = ? AND ${repeatingSocketAwaitingWhere()}
2010
+ AND NOT EXISTS (${repeatingEvidenceSql("1")})`).run(DELIVERY_METHOD.ptyAfterSocketTimeout, deliveredByForFiring(timer), timer.id, timer.fire_count).changes === 1);
2011
+ }
2012
+ catch {
2013
+ return false;
2014
+ }
2015
+ }
2016
+ class FiringSuperseded extends Error {
2017
+ }
2018
+ // Nothing reached the pane, so this firing is settled as dropped: the next one delivers, nothing replays it.
2019
+ // The marker lives in socket_delivery_note because hive lead's re-point clears held_reason.
2020
+ function dropRepeatingFiring(timer, firing, err) {
2021
+ const why = err instanceof Error ? err.message : String(err);
2022
+ bestEffortRun(`UPDATE wakes SET socket_delivery_note = ?, held_reason = ?
2023
+ WHERE id = ? AND fire_count = ? AND delivery_method = ? AND typed_at IS NULL`, FIRING_DROPPED_NOTE, `${HELD_REASON_OWNERSHIP_LOST_AFTER_CLAIM}${why} Firing #${firing} was not delivered; the next firing goes out on schedule.`, timer.id, firing, DELIVERY_METHOD.ptyAfterSocketTimeout);
2024
+ }
2025
+ function requireCurrentFiring(id, firing) {
2026
+ const row = stmt("SELECT 1 AS hit FROM wakes WHERE id = ? AND fire_count = ? AND cancelled_at IS NULL").get(id, firing);
2027
+ if (row === undefined)
2028
+ throw new FiringSuperseded(`wake #${id} firing #${firing} was superseded`);
2029
+ }
1934
2030
  async function deliver(timer, note, choices, typedSeen, firstHeldAt, owner, fallback = false) {
1935
2031
  const tail = watchedTail(timer);
1936
2032
  const body = (isLeadActorId(timer.deliver_actor) ? shortRenderForLeadDelivery(timer) : null) ?? timer.body;
1937
2033
  const staleness = noticeStalenessNote(timer);
2034
+ // A repeating wake that took the socket keeps its firing number in every marker and every write.
2035
+ let firing = fallback && timer.delivery_method === DELIVERY_METHOD.socketRepeating ? timer.fire_count : null;
1938
2036
  if (!fallback && socketRoute(timer, owner)) {
1939
- const text = `[hive wake #${timer.id}${note ? `, ${note}` : ""}] ${body + staleness + tail}\n\n${SOCKET_WAKE_FOOTER}`;
2037
+ const repeating = timer.repeat_every_ms != null;
2038
+ const head = repeating ? firingMarker(timer) : `[hive wake #${timer.id}`;
2039
+ const text = `${head}${note ? `, ${note}` : ""}] ${body + staleness + tail}\n\n${SOCKET_WAKE_FOOTER}`;
1940
2040
  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);
2041
+ if (repeating) {
2042
+ if (!stampRepeatingAttempt(timer, note, firstHeldAt))
2043
+ return;
2044
+ firing = timer.fire_count;
2045
+ }
2046
+ else {
2047
+ bestEffortRun(`UPDATE wakes SET socket_attempt_at = strftime('%Y-%m-%d %H:%M:%f', 'now'), socket_delivery_note = ?,
2048
+ delivery_method = ?, delivered_by = ?, first_held_at = ?, held_at = NULL, held_reason = NULL, confirmed_at = NULL
2049
+ WHERE id = ?`, note, DELIVERY_METHOD.socket, deliveredByForFiring(timer), firstHeldAt, timer.id);
2050
+ }
1944
2051
  const posted = await postClaudeWake({
1945
2052
  socketPath: timer.deliver_messaging_socket,
1946
2053
  senderAddress: senderAddress(join(dataDir, "wake-sender.sock")),
@@ -1951,12 +2058,14 @@ async function deliver(timer, note, choices, typedSeen, firstHeldAt, owner, fall
1951
2058
  forgetPaneAnswers(timer.deliver_pane, choices);
1952
2059
  return;
1953
2060
  }
2061
+ if (firing !== null && !claimRepeatingFallback(timer))
2062
+ return;
1954
2063
  note = withReDelivered(note);
1955
2064
  fallback = true;
1956
2065
  }
1957
2066
  }
1958
- const prefix = `[hive wake #${timer.id}${note ? `, ${note}` : ""}] ` +
1959
- (fallback ? `If you already handled wake #${timer.id}, ignore this.\n` : "");
2067
+ const named = firing === null ? `wake #${timer.id}` : `wake #${timer.id} firing #${firing}`;
2068
+ const prefix = `[hive ${named}${note ? `, ${note}` : ""}] ` + (fallback ? `If you already handled ${named}, ignore this.\n` : "");
1960
2069
  const method = fallback ? DELIVERY_METHOD.ptyAfterSocketTimeout : DELIVERY_METHOD.pty;
1961
2070
  let typedBusy;
1962
2071
  try {
@@ -1967,9 +2076,9 @@ async function deliver(timer, note, choices, typedSeen, firstHeldAt, owner, fall
1967
2076
  typedBusy = null;
1968
2077
  }
1969
2078
  const recordTyped = () => bestEffortRun(`UPDATE wakes SET typed_at = strftime('%Y-%m-%d %H:%M:%f', 'now'), typed_busy = ?,
1970
- typed_seen = ?, first_held_at = ?, held_at = NULL, held_reason = NULL, delivery_method = ?,
2079
+ typed_seen = ?, first_held_at = ?, held_at = NULL, held_reason = NULL, delivery_method = ?, delivered_by = ?,
1971
2080
  confirmed_at = CASE WHEN socket_attempt_at IS NULL THEN NULL ELSE confirmed_at END
1972
- WHERE id = ?`, typedBusy, typedSeen, firstHeldAt, method, timer.id);
2081
+ WHERE id = ? AND (? IS NULL OR fire_count = ?)`, typedBusy, typedSeen, firstHeldAt, method, deliveredByForFiring(timer), timer.id, firing, firing);
1973
2082
  const box = cacheEntry(timer.deliver_pane, choices).box;
1974
2083
  const strandedTextWouldHold = box !== undefined && box !== null && box.state !== "unknown";
1975
2084
  let pasted = false;
@@ -1978,15 +2087,27 @@ async function deliver(timer, note, choices, typedSeen, firstHeldAt, owner, fall
1978
2087
  pasted = true;
1979
2088
  if (strandedTextWouldHold)
1980
2089
  recordTyped();
1981
- }, undefined, owner ? () => requireStillOwned(owner.rowId, owner.identity, `wake #${timer.id} delivery`) : undefined);
2090
+ }, undefined, owner || firing !== null
2091
+ ? () => {
2092
+ if (owner)
2093
+ requireStillOwned(owner.rowId, owner.identity, `wake #${timer.id} delivery`);
2094
+ if (firing !== null)
2095
+ requireCurrentFiring(timer.id, firing);
2096
+ }
2097
+ : undefined);
1982
2098
  }
1983
2099
  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);
2100
+ if (fallback && !pasted && firing === null) {
2101
+ bestEffortRun("UPDATE wakes SET delivery_method = ?, delivered_by = ? WHERE id = ? AND typed_at IS NULL", DELIVERY_METHOD.socket, deliveredByForFiring(timer), timer.id);
1986
2102
  }
2103
+ if (err instanceof FiringSuperseded)
2104
+ return;
2105
+ if (firing !== null && !pasted)
2106
+ dropRepeatingFiring(timer, firing, err);
1987
2107
  if (!(err instanceof PaneOwnershipLost))
1988
2108
  throw err;
1989
- recordLostAfterClaim(timer, pasted, err.message);
2109
+ if (firing === null || pasted)
2110
+ recordLostAfterClaim(timer, pasted, err.message, firing);
1990
2111
  return;
1991
2112
  }
1992
2113
  finally {
@@ -2011,9 +2132,11 @@ async function fallbackClaimed(timer, snapshot, choices) {
2011
2132
  const decision = deliverable(timer, snapshot, choices);
2012
2133
  if (!decision.ok)
2013
2134
  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;
2135
+ const claimed = timer.delivery_method === DELIVERY_METHOD.socketRepeating
2136
+ ? claimRepeatingFallback(timer)
2137
+ : stmt(`UPDATE wakes SET delivery_method = ?, delivered_by = ?
2138
+ WHERE id = ? AND ${socketAwaitingWhere()}
2139
+ AND NOT EXISTS (${promptEvidenceSql("wakes.socket_attempt_at").replace("MIN(created_at)", "1")})`).run(DELIVERY_METHOD.ptyAfterSocketTimeout, deliveredByForFiring(timer), timer.id).changes === 1;
2017
2140
  if (!claimed)
2018
2141
  return false;
2019
2142
  await deliver(timer, withReDelivered(timer.socket_delivery_note), choices, decision.typedSeen, decision.firstHeldAt, decision.owner, true);
@@ -2022,7 +2145,7 @@ async function fallbackClaimed(timer, snapshot, choices) {
2022
2145
  async function retryUnconfirmedSocketWakes(snapshot, choices) {
2023
2146
  const awaiting = stmt(`SELECT wakes.*, ${DELIVER_ROW_COLUMNS}
2024
2147
  FROM wakes ${DELIVER_SOCKET_JOIN}
2025
- WHERE ${socketAwaitingWhere("wakes.")}
2148
+ WHERE ((${socketAwaitingWhere("wakes.")}) OR (${repeatingSocketAwaitingWhere("wakes.")}))
2026
2149
  AND wakes.socket_attempt_at >= datetime('now', ?)`).all(LOG_RETENTION);
2027
2150
  for (const timer of awaiting) {
2028
2151
  if (!socketFallbackDue(timer))
package/dist/spawn.js CHANGED
@@ -171,11 +171,11 @@ export function launchAgent(spec) {
171
171
  let info;
172
172
  try {
173
173
  info = db
174
- .prepare("INSERT INTO agents (project_id, name, command, cwd, kind, parent_actor_id, tmux_socket, session_id, codex_home, model, extra_args) " +
175
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
174
+ .prepare("INSERT INTO agents (project_id, name, command, cwd, kind, parent_actor_id, tmux_socket, session_id, codex_home, model, extra_args, todo_id) " +
175
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
176
176
  .run(spec.projectId, spec.name, typeof spec.commandString === "string" ? spec.commandString : "", spec.cwd, spec.kind, spec.parentActor, socket, spec.sessionId ?? "", spec.codexHome ?? "", spec.model ?? null, spec.readOnly
177
177
  ? JSON.stringify({ args: spec.extraArgs ?? [], read_only: true })
178
- : spec.extraArgs ? JSON.stringify(spec.extraArgs) : null);
178
+ : spec.extraArgs ? JSON.stringify(spec.extraArgs) : null, spec.todoId ?? null);
179
179
  }
180
180
  catch (e) {
181
181
  throw asNameClash(e, spec.name);
package/dist/tmux.js CHANGED
@@ -123,7 +123,11 @@ function tmuxWithin(timeoutMs, ...args) {
123
123
  throw new TmuxError(`tmux ${args[0]} failed${detail ? `: ${detail}` : ""}`, detail ?? "");
124
124
  }
125
125
  }
126
- const NOTHING_THERE = /no server running|error connecting to|no current target|can't find (pane|window|session)/;
126
+ const NOTHING_THERE = /no server running|no current target|can't find (pane|window|session)/;
127
+ // A connect failure is an empty server only when the errno proves nothing listens; EACCES/EPERM
128
+ // (a sandboxed caller) and any unreadable reason mean nobody answered.
129
+ const CONNECT_FAILED = /error connecting to .*\(([^)]*)\)\s*$/;
130
+ const NO_LISTENER = /^(No such file or directory|Connection refused)$/;
127
131
  // "no server running" is tmux ANSWERING that the socket is empty. A missing binary is nobody
128
132
  // answering at all, and the two must never read the same to anything that reports a death.
129
133
  export function tmuxNotInstalled(e) {
@@ -136,6 +140,9 @@ export function tmuxSaysNothingThere(e) {
136
140
  return false;
137
141
  if (e.notInstalled)
138
142
  return true;
143
+ const reason = CONNECT_FAILED.exec(e.stderr)?.[1];
144
+ if (reason !== undefined)
145
+ return NO_LISTENER.test(reason);
139
146
  return NOTHING_THERE.test(e.stderr);
140
147
  }
141
148
  function quietTmux(...args) {
@@ -854,7 +861,12 @@ function leadingRunIsFaint(s) {
854
861
  faint = false;
855
862
  continue;
856
863
  }
857
- for (const p of params) {
864
+ for (let i = 0; i < params.length; i++) {
865
+ const p = params[i];
866
+ if (p === "38" || p === "48" || p === "58") {
867
+ i += params[i + 1] === "5" ? 2 : params[i + 1] === "2" ? 4 : 0;
868
+ continue;
869
+ }
858
870
  if (p === "0")
859
871
  faint = false;
860
872
  else if (p === "2")
@@ -922,19 +934,42 @@ function classifyInputBox(rows, promptRowIndex, boxBottom) {
922
934
  .slice(0, TAIL_LINE_CHARS);
923
935
  return { state: text === "" ? "empty" : dim ? "ghost" : "pending", text };
924
936
  }
925
- export function inputBoxState(target) {
937
+ const SNAPSHOT_ROWS = 10;
938
+ const SNAPSHOT_ROW_CHARS = 120;
939
+ const snapshotRow = (row) => stripControlBytes(stripSgr(row))
940
+ .trimEnd()
941
+ .replace(/─{4,}/g, (run) => `─×${run.length}`)
942
+ .slice(0, SNAPSHOT_ROW_CHARS);
943
+ function snapshotRows(rows, anchor) {
944
+ if (anchor === null) {
945
+ return rows.map(snapshotRow).filter((row) => row !== "").slice(-SNAPSHOT_ROWS);
946
+ }
947
+ return rows.slice(anchor.top, anchor.bottom + 1).map(snapshotRow).slice(0, SNAPSHOT_ROWS);
948
+ }
949
+ function promptSgrOf(rows, anchor) {
950
+ if (anchor === null || anchor.prompt === null)
951
+ return "";
952
+ const row = rows[anchor.prompt];
953
+ const after = row.slice(row.indexOf(PROMPT_GLYPH_NBSP) + PROMPT_GLYPH_NBSP.length);
954
+ return (/^(?:\x1b\[[0-9;]*m)*/.exec(after)?.[0] ?? "").replace(/\x1b/g, "").slice(0, 60);
955
+ }
956
+ export function inputBoxSnapshot(target) {
926
957
  try {
927
958
  const raw = tmux("capture-pane", "-p", "-e", "-t", target, "-S", `-${tailCaptureLines()}`);
928
959
  const rows = raw.split("\n");
929
- const box = findInputBox(rows);
930
- if (box === null)
931
- return null;
932
- return box.prompt === null ? { state: "unknown", text: "" } : classifyInputBox(rows, box.prompt, box.bottom);
960
+ const anchor = findInputBox(rows);
961
+ const box = anchor === null
962
+ ? null
963
+ : anchor.prompt === null
964
+ ? { state: "unknown", text: "" }
965
+ : classifyInputBox(rows, anchor.prompt, anchor.bottom);
966
+ return { box, rows: snapshotRows(rows, anchor), promptSgr: promptSgrOf(rows, anchor) };
933
967
  }
934
968
  catch {
935
969
  return null;
936
970
  }
937
971
  }
972
+ export const inputBoxState = (target) => inputBoxSnapshot(target)?.box ?? null;
938
973
  const NOISE_ROW = /^[\s\u2500-\u257F\u2580-\u259F\u2800-\u28FF]*$/;
939
974
  const FOOTER_KEEP = /esc to interrupt|\d+ (?:shell|background|task|monitor|local agent|team)s?\b|remote dynamic workflow|cloud session|ultraplan needs your input/i;
940
975
  const OSC_SEQUENCE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;