@cmgmyr/hive 1.11.5 → 1.11.7

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 (51) hide show
  1. package/dist/agentKinds.js +6 -0
  2. package/dist/agentRecords.js +98 -0
  3. package/dist/backup.js +1 -1
  4. package/dist/build-info.json +1 -1
  5. package/dist/claudeWake.js +52 -4
  6. package/dist/cli.js +69 -43
  7. package/dist/context.js +2 -2
  8. package/dist/crew.js +5 -4
  9. package/dist/dashboard.js +10 -12
  10. package/dist/db.js +1 -1
  11. package/dist/errors.js +3 -0
  12. package/dist/etime.js +37 -0
  13. package/dist/firstPrompt.js +6 -0
  14. package/dist/globalConfig.js +1 -1
  15. package/dist/heldLabel.js +1 -1
  16. package/dist/kickoff.js +2 -2
  17. package/dist/leadHandoff.js +12 -11
  18. package/dist/leadHandoffCli.js +22 -1
  19. package/dist/leadHandoffState.js +1 -2
  20. package/dist/leadLaunch.js +1 -1
  21. package/dist/leadMessage.js +13 -0
  22. package/dist/leadMessageDelivery.js +14 -74
  23. package/dist/leadState.js +3 -2
  24. package/dist/leadWatch.js +1 -1
  25. package/dist/leaseStore.js +54 -0
  26. package/dist/pads.js +70 -0
  27. package/dist/portfolio.js +7 -3
  28. package/dist/projectRemove.js +1 -1
  29. package/dist/projectYml.js +1 -1
  30. package/dist/ptys.js +2 -9
  31. package/dist/queenAudit.js +2 -18
  32. package/dist/queenAuditStore.js +20 -0
  33. package/dist/queenDashboard.js +2 -1
  34. package/dist/questions.js +1 -1
  35. package/dist/result.js +2 -3
  36. package/dist/scheduler.js +42 -166
  37. package/dist/spawn.js +2 -6
  38. package/dist/staleServers.js +4 -28
  39. package/dist/stateProvenance.js +1 -3
  40. package/dist/storeClock.js +4 -0
  41. package/dist/teardown.js +2 -1
  42. package/dist/todoScope.js +1 -0
  43. package/dist/todos.js +90 -0
  44. package/dist/tools/agents.js +4 -96
  45. package/dist/tools/leases.js +5 -25
  46. package/dist/tools/pads.js +3 -82
  47. package/dist/tools/todos.js +6 -89
  48. package/dist/tools/wakes.js +12 -88
  49. package/dist/wakeSql.js +135 -0
  50. package/dist/wakeTarget.js +76 -0
  51. package/package.json +1 -1
@@ -3,6 +3,7 @@ import { join, sep } from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { db } from "./db.js";
5
5
  import { SECTION_STYLE, SPRITE, chevron, renderBoardPadsTodos, sectionScript } from "./dashboard.js";
6
+ import { parseStoreTimestamp } from "./storeClock.js";
6
7
  export const QUEEN_BRIEF_KEY = "queen:brief";
7
8
  const LANE_ORDER = ["waiting_on_you", "stuck", "moving", "quiet"];
8
9
  const LANE_RANK = { waiting_on_you: 0, stuck: 1, moving: 2, quiet: 3 };
@@ -103,7 +104,7 @@ const REASON_SEVERE = new Set([
103
104
  "wake_overdue_5m",
104
105
  ]);
105
106
  function parseUtc(ts) {
106
- return Date.parse(`${ts.replace(" ", "T")}Z`);
107
+ return parseStoreTimestamp(ts);
107
108
  }
108
109
  function age(from, asOf) {
109
110
  const seconds = Math.max(0, Math.floor((parseUtc(asOf) - parseUtc(from)) / 1000));
package/dist/questions.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { db } from "./db.js";
2
2
  import { HUMAN_ACTOR_PREFIX } from "./context.js";
3
- import { assertSafeWakeBody, resolveWakeDelivery } from "./tools/wakes.js";
3
+ import { assertSafeWakeBody, resolveWakeDelivery } from "./wakeTarget.js";
4
4
  const QUESTION_STATE_CONFLICT = "QUESTION_STATE_CONFLICT";
5
5
  const HEADER_MAX_CHARS = 12;
6
6
  const MIN_OPTIONS = 2;
package/dist/result.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import { runningBuildChange, runningBuildNotice } from "./version.js";
2
- export function errorMessage(e) {
3
- return e instanceof Error ? e.message : String(e);
4
- }
2
+ import { errorMessage } from "./errors.js";
3
+ export { errorMessage } from "./errors.js";
5
4
  function ok(data) {
6
5
  const text = typeof data === "string" ? data : JSON.stringify(data, null, 1);
7
6
  return { content: [{ type: "text", text }] };
package/dist/scheduler.js CHANGED
@@ -1,4 +1,7 @@
1
1
  import { QUEEN_AUDIT_RETENTION, QUEEN_AUDIT_MAX_ROWS, recentQueenAudit } from "./queenAudit.js";
2
+ import { ACTIVE_TIMER_WHERE, DELIVERY_METHOD, FIRING_DROPPED_NOTE, HELD_REASON_ACTOR_CLOSED, HELD_REASON_CONVERSATION, HELD_REASON_COPY_MODE, HELD_REASON_HANDOFF, HELD_REASON_LEAD_BOOT_INPUT_BOX, HELD_REASON_LEAD_PANE_DEAD, HELD_REASON_LEAD_ROW_CLOSED, HELD_REASON_MODAL_CHOICE, HELD_REASON_OWNERSHIP_LOST_AFTER_CLAIM, HELD_REASON_PANE_REISSUED_LEAD, HELD_REASON_PANE_REISSUED_WORKER, HELD_REASON_PANE_UNKNOWN_LEAD, HELD_REASON_PANE_UNKNOWN_WORKER, HELD_REASON_UNCLASSIFIABLE_PANE, HELD_REASON_UNSUBMITTED_INPUT, HELD_REASON_UNSUBMITTED_INPUT_OWNER_UNRESOLVED, LOG_RETENTION, activeWakeWhere, holdableWakeWhere, identityThenDead, pendingOneShotWhere, repeatingFiringPendingWhere, repeatingSocketAwaitingWhere, repeatingSocketFiringWhere, schedulerCandidateWhere, sentWakeAtSql, socketAwaitingWhere, spentUntypedNotice, wasHeldForPaneIdentity, watchWatchingWhere, } from "./wakeSql.js";
3
+ export { ACTIVE_TIMER_WHERE, DELIVERY_METHOD, HELD_REASON_ACTOR_CLOSED, HELD_REASON_CONVERSATION, HELD_REASON_COPY_MODE, HELD_REASON_LEAD_PANE_DEAD, HELD_REASON_UNCLASSIFIABLE_PANE, HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX, HELD_REASON_UNSUBMITTED_INPUT, HELD_REASON_UNSUBMITTED_INPUT_PREFIX, isLeadRowClosedHold, isUnclassifiablePaneHold, isUnsubmittedInputHold, socketAwaitingWhere, wasHeldForPaneIdentity, wasHeldForPaneReissue, } from "./wakeSql.js";
4
+ import { parseStoreTimestamp } from "./storeClock.js";
2
5
  import { runningBuildChange, runningBuildNotice, versionInfo } from "./version.js";
3
6
  import { existsSync, lstatSync, mkdirSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
7
  import { createHash } from "node:crypto";
@@ -13,14 +16,16 @@ import { getProjectByPath, listProjects, queenHomeDir } from "./context.js";
13
16
  import { collectPortfolio } from "./portfolio.js";
14
17
  import { QUEEN_GENERATED_MARKER, readQueenBrief, renderQueenDashboard } from "./queenDashboard.js";
15
18
  import { COMMAND_KIND, stoppingMarkerLive } from "./processes.js";
16
- import { closeAgentRow, isLeadActorId, LEAD_ACTOR_PREFIX, LEAD_KIND, PaneOwnershipLost, reapCodexHomeForClosedAgent, requireStillOwned, } from "./spawn.js";
17
- import { awaitingFirstPrompt, awaitingFirstPromptSql } from "./firstPrompt.js";
19
+ import { closeAgentRow, PaneOwnershipLost, reapCodexHomeForClosedAgent, requireStillOwned, } from "./spawn.js";
20
+ import { isLeadActorId, LEAD_ACTOR_PREFIX, LEAD_KIND, } from "./agentKinds.js";
21
+ import { awaitingFirstPrompt, finishedIdle, finishedIdleSql } from "./firstPrompt.js";
18
22
  import { evaluateLeadWatch, leadSubscription } from "./leadWatch.js";
19
23
  import { describeLiveTasks, describeOneTask, liveBackgroundTasks, STALL_BOUND_SECONDS, } from "./backgroundTasks.js";
20
24
  import { MESSAGE_MAX_ROWS, MESSAGE_RETENTION } from "./leadMessage.js";
21
- import { CROSS_SESSION_CLOSE, postClaudeWake, senderAddress, SOCKET_WAKE_FOOTER } from "./claudeWake.js";
25
+ import { PANE_LEASE_SETTLE_MS, releasePaneLease, tryAcquirePaneLease } from "./leaseStore.js";
26
+ import { CROSS_SESSION_CLOSE, postClaudeWake, promptMarkerEvidenceSql, quietMessagingOn, socketGraceDue, socketSenderAddress, SOCKET_WAKE_FOOTER, } from "./claudeWake.js";
22
27
  import { driveLeadHandoff } from "./leadHandoff.js";
23
- import { handoffHoldsWake, HELD_REASON_HANDOFF } from "./leadHandoffState.js";
28
+ import { handoffHoldsWake } from "./leadHandoffState.js";
24
29
  import { FIRST_MESSAGE_LOG_STATE, humanPromptSql, readLeadTurnState } from "./leadState.js";
25
30
  import { retryQuietLeadMessages } from "./leadMessageDelivery.js";
26
31
  import { harnessFor, hasTranscriptSignal, paneClassifierFor, screenClassifiable, transcriptDirFor } from "./harnesses.js";
@@ -56,7 +61,6 @@ function deliveryOwnership(row, snapshot) {
56
61
  return "closed";
57
62
  return rowOwnership(deliveryIdentity(row), snapshot);
58
63
  }
59
- export const ACTIVE_TIMER_WHERE = "cancelled_at IS NULL AND (fired_at IS NULL OR repeat_every_ms IS NOT NULL)";
60
64
  const SETTLE_WINDOW = "-15 seconds";
61
65
  const prepared = new Map();
62
66
  function stmt(sql) {
@@ -280,7 +284,7 @@ function cancelTimer(timerId) {
280
284
  function cancelWithReason(timerId, reason) {
281
285
  bestEffortRun("UPDATE wakes SET cancelled_at = datetime('now'), held_reason = ? WHERE id = ? AND cancelled_at IS NULL", reason, timerId);
282
286
  }
283
- export const LOG_RETENTION = "-7 days";
287
+ export { LOG_RETENTION } from "./wakeSql.js";
284
288
  const LOG_MAX_ROWS = 20_000;
285
289
  function pruneStateLog() {
286
290
  try {
@@ -468,7 +472,7 @@ function maybeGenerateDashboards() {
468
472
  }
469
473
  }
470
474
  export function checkConfirmations() {
471
- const sentAt = "COALESCE(wakes.socket_attempt_at, wakes.typed_at)";
475
+ const sentAt = sentWakeAtSql("wakes.");
472
476
  const confirmationQuery = promptEvidenceSql(sentAt);
473
477
  try {
474
478
  const pending = stmt(`SELECT 1 AS hit FROM wakes
@@ -540,11 +544,7 @@ export async function tick(snapshot) {
540
544
  const now = stmt("SELECT datetime('now') AS now").get().now;
541
545
  const candidates = stmt(`SELECT wakes.*, ${DELIVER_ROW_COLUMNS}
542
546
  FROM wakes ${DELIVER_SOCKET_JOIN}
543
- WHERE wakes.cancelled_at IS NULL AND (
544
- (wakes.kind = 'delay' AND wakes.due_at <= datetime('now')
545
- AND (wakes.fired_at IS NULL OR wakes.repeat_every_ms IS NOT NULL))
546
- OR (wakes.kind != 'delay' AND wakes.fired_at IS NULL)
547
- )`).all();
547
+ WHERE ${schedulerCandidateWhere("wakes.")}`).all();
548
548
  const choices = new Map();
549
549
  for (const timer of candidates) {
550
550
  await isolated(`timer ${timer.id}`, async () => {
@@ -620,82 +620,14 @@ function holdTimer(timer, reason) {
620
620
  const count = timer.fire_count ?? null;
621
621
  bestEffortRun(`UPDATE wakes SET held_at = datetime('now'), held_reason = ?,
622
622
  first_held_at = COALESCE(CASE WHEN held_at IS NULL THEN NULL ELSE first_held_at END, datetime('now'))
623
- WHERE id = ? AND cancelled_at IS NULL
624
- AND (fired_at IS NULL OR (repeat_every_ms IS NOT NULL AND due_at = ? AND (? IS NULL OR fire_count = ?))
625
- OR (${socketAwaitingWhere()}))`, reason, timer.id, timer.due_at, count, count);
623
+ WHERE id = ? AND ${holdableWakeWhere()}`, reason, timer.id, timer.due_at, count, count);
626
624
  }
627
- const HELD_REASON_MODAL_CHOICE = "pane is awaiting a modal choice (folder-trust or /model picker)";
628
625
  const LEAD_BOOT_BOX_WAIT_MS = 60_000;
629
- const HELD_REASON_LEAD_BOOT_INPUT_BOX = "the lead's input box is not on screen yet; holding while its session starts";
630
- 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 " +
631
- "cancellation for this alone, so it is held rather than lost";
632
- 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 " +
633
- "against (its pid no longer matches, most likely a tmux server restart reissuing the id); held " +
634
- "rather than typed into the wrong pane - ";
635
- const HELD_REASON_PANE_REISSUED_LEAD = `${HELD_REASON_PANE_REISSUED_PREFIX}run \`hive lead\` to re-point it at the live one`;
636
- const HELD_REASON_PANE_REISSUED_WORKER = `${HELD_REASON_PANE_REISSUED_PREFIX}nothing re-points a worker's wake automatically, so cancel ` +
637
- "it with wake_cancel and set a fresh one once the worker's pane is confirmed live, or leave it: " +
638
- "it will keep holding rather than deliver wrongly";
639
- const HELD_REASON_PANE_REISSUED_THEN_DEAD = `${HELD_REASON_PANE_REISSUED_PREFIX}the pane it was reissued to has since gone dead too; nothing ` +
640
- "re-points a worker's wake automatically, so cancel it with wake_cancel (any running lead may do " +
641
- "this even though the wake is not theirs) if it is no longer needed";
642
- export function wasHeldForPaneReissue(heldReason) {
643
- return heldReason != null && heldReason.startsWith(HELD_REASON_PANE_REISSUED_PREFIX);
644
- }
645
- const HELD_REASON_PANE_UNKNOWN_PREFIX = "pane ownership for this wake's row cannot be verified (no pane pid was recorded for it, its row now " +
646
- "records a different pane, or the pane is on another tmux socket); held rather than typed into a pane " +
647
- "that may not be its own - ";
648
- const HELD_REASON_PANE_UNKNOWN_LEAD = `${HELD_REASON_PANE_UNKNOWN_PREFIX}run \`hive lead\` to start or adopt a lead with a recorded pane and re-point it`;
649
- const HELD_REASON_PANE_UNKNOWN_WORKER = `${HELD_REASON_PANE_UNKNOWN_PREFIX}nothing re-points a worker's wake automatically, so cancel it with ` +
650
- "wake_cancel (any running lead may, even though the wake is not theirs), or leave it: it keeps holding " +
651
- "rather than deliver wrongly";
652
- const HELD_REASON_PANE_UNKNOWN_THEN_DEAD = `${HELD_REASON_PANE_UNKNOWN_PREFIX}that pane has since gone dead too; nothing re-points a worker's wake ` +
653
- "automatically, so cancel it with wake_cancel (any running lead may) if it is no longer needed";
654
- // One family for both identity holds, so a later dead pane re-holds them instead of cancelling.
655
- export function wasHeldForPaneIdentity(heldReason) {
656
- return wasHeldForPaneReissue(heldReason) || (heldReason?.startsWith(HELD_REASON_PANE_UNKNOWN_PREFIX) ?? false);
657
- }
658
- const identityThenDead = (heldReason) => wasHeldForPaneReissue(heldReason) ? HELD_REASON_PANE_REISSUED_THEN_DEAD : HELD_REASON_PANE_UNKNOWN_THEN_DEAD;
659
- export const HELD_REASON_ACTOR_CLOSED = "delivery actor closed: the row this wake was bound to is closed, so nothing was typed and the wake was " +
660
- "cancelled rather than delivered into a pane that row no longer owns";
661
- const HELD_REASON_LEAD_ROW_CLOSED = "the lead's row is closed, so no pane is this wake's to type into; held until `hive lead` starts or adopts a " +
662
- "lead and re-points it";
663
- export function isLeadRowClosedHold(heldReason) {
664
- return heldReason === HELD_REASON_LEAD_ROW_CLOSED;
665
- }
666
- const HELD_REASON_OWNERSHIP_LOST_AFTER_CLAIM = "delivery stopped after claim: ";
667
- export const HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX = "the pane's harness is not one hive can classify; ";
668
- export const HELD_REASON_UNCLASSIFIABLE_PANE = `${HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX}neither the dialog guard nor the unsubmitted-text ` +
669
- "guard can read this screen and delivery would type blind. wake_set and wake_when_idle refuse such a " +
670
- "target outright now, so this wake predates that or its pane's command changed underneath it. Nothing " +
671
- "hive does on its own clears this: it lifts only when that pane is replaced by one hive starts itself " +
672
- "(`hive lead` after the pane exits). Cancel it with wake_cancel (any running lead may, even though the " +
673
- "wake is not theirs) and drive that pane with agent_send(keys: [...])";
674
- export function isUnclassifiablePaneHold(heldReason) {
675
- return heldReason != null && heldReason.startsWith(HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX);
676
- }
677
- 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 " +
678
- "with no paste markers (losing everything before the last 1022-byte write) and the Enter would be " +
679
- "eaten by the mode instead of submitting - both silently, with tmux reporting success. It delivers " +
680
- "once whoever is reading that pane leaves copy mode";
681
- export const HELD_REASON_UNSUBMITTED_INPUT_PREFIX = "the pane's input box has unsubmitted human text; ";
682
- export const HELD_REASON_UNSUBMITTED_INPUT = `${HELD_REASON_UNSUBMITTED_INPUT_PREFIX}delivering now would paste the wake body onto it and submit ` +
683
- "both as one message";
684
- // Distinct from HELD_REASON_UNSUBMITTED_INPUT so the claim's debounce doesn't latch a transient
685
- // pane-resolution failure shut forever - the next tick retries instead of giving up permanently.
686
- 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 ` +
687
- "this tick - retrying, since that may be transient rather than permanent";
688
- export function isUnsubmittedInputHold(heldReason) {
689
- return heldReason != null && heldReason.startsWith(HELD_REASON_UNSUBMITTED_INPUT_PREFIX);
690
- }
691
- export const HELD_REASON_CONVERSATION = "a human talked to this lead more recently than the conversation-hold window; holding so a wake " +
692
- "does not split an in-progress discussion - it delivers once the window passes or the hold's own " +
693
- "ceiling is reached, whichever comes first";
694
626
  function leadBootSessionIsFresh(agentId, panePid) {
695
627
  const row = stmt("SELECT pane_started_at FROM agents WHERE id = ? AND pane_pid = ?").get(agentId, panePid);
696
628
  if (!row?.pane_started_at)
697
629
  return false;
698
- const timestamp = Date.parse(`${row.pane_started_at.replace(" ", "T")}Z`);
630
+ const timestamp = parseStoreTimestamp(row.pane_started_at);
699
631
  return Number.isFinite(timestamp) && Date.now() - timestamp < LEAD_BOOT_BOX_WAIT_MS;
700
632
  }
701
633
  function leadInputBoxObservation(pane, command, cache) {
@@ -826,7 +758,7 @@ function insertNotice(timer, deliverActor, pane, body, parentTimerId) {
826
758
  function pendingNoticeFor(parentTimerId, conditions) {
827
759
  const placeholders = conditions.map(() => "?").join(", ");
828
760
  return stmt(`SELECT t.id FROM wakes t
829
- WHERE t.parent_wake_id = ? AND t.fired_at IS NULL AND t.cancelled_at IS NULL
761
+ WHERE t.parent_wake_id = ? AND ${pendingOneShotWhere("t.")}
830
762
  AND EXISTS (
831
763
  SELECT 1 FROM wake_idle_notices n WHERE n.notice_wake_id = t.id AND n.condition IN (${placeholders})
832
764
  )
@@ -834,7 +766,7 @@ function pendingNoticeFor(parentTimerId, conditions) {
834
766
  }
835
767
  function updateNoticeInPlace(noticeId, body) {
836
768
  return (stmt(`UPDATE wakes SET body = ?, created_at = datetime('now')
837
- WHERE id = ? AND fired_at IS NULL AND cancelled_at IS NULL`).run(body, noticeId).changes === 1);
769
+ WHERE id = ? AND ${pendingOneShotWhere()}`).run(body, noticeId).changes === 1);
838
770
  }
839
771
  function claimBlockNotice(timerId, agentId, blockedSince) {
840
772
  return (stmt(`INSERT OR IGNORE INTO wake_block_notices (wake_id, agent_id, blocked_since)
@@ -843,9 +775,8 @@ function claimBlockNotice(timerId, agentId, blockedSince) {
843
775
  const claimModalHoldWithNotice = db.transaction((timer, pane, body, target) => {
844
776
  const claimed = stmt(`UPDATE wakes SET held_at = datetime('now'), held_reason = ?,
845
777
  first_held_at = COALESCE(CASE WHEN held_at IS NULL THEN NULL ELSE first_held_at END, datetime('now'))
846
- WHERE id = ? AND cancelled_at IS NULL
778
+ WHERE id = ? AND (${activeWakeWhere()})
847
779
  AND due_at IS ? AND body IS ? AND repeat_every_ms IS ?
848
- AND (fired_at IS NULL OR repeat_every_ms IS NOT NULL)
849
780
  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;
850
781
  if (!claimed)
851
782
  return false;
@@ -876,9 +807,8 @@ function noteModalHold(timer, snapshot) {
876
807
  const claimUnsubmittedInputHoldWithNotice = db.transaction((timer, pane, body) => {
877
808
  const claimed = stmt(`UPDATE wakes SET held_at = datetime('now'), held_reason = ?,
878
809
  first_held_at = COALESCE(CASE WHEN held_at IS NULL THEN NULL ELSE first_held_at END, datetime('now'))
879
- WHERE id = ? AND cancelled_at IS NULL
810
+ WHERE id = ? AND (${activeWakeWhere()})
880
811
  AND due_at IS ? AND body IS ? AND repeat_every_ms IS ?
881
- AND (fired_at IS NULL OR repeat_every_ms IS NOT NULL)
882
812
  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;
883
813
  if (!claimed)
884
814
  return false;
@@ -940,7 +870,7 @@ function blockedWatchedAgents(timer, snapshot, tellActor) {
940
870
  blockedSince: r.episode,
941
871
  }));
942
872
  }
943
- 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;
873
+ const stillPending = (timerId) => stmt(`SELECT 1 AS hit FROM wakes WHERE id = ? AND ${pendingOneShotWhere()}`).get(timerId) !== undefined;
944
874
  const claimBlockNoticeWithNotice = db.transaction((timer, agentId, blockedSince, tell, body) => {
945
875
  if (!stillPending(timer.id))
946
876
  return false;
@@ -996,9 +926,6 @@ function alreadyToldAbout(timerId, agentId, blockedSince) {
996
926
  }
997
927
  const CONDITION_IDLE = "idle";
998
928
  const CONDITION_GONE = "gone";
999
- const NOTICE_RETRY_AFTER = "-60 seconds";
1000
- const spentUntypedNotice = (alias) => `${alias}.fired_at IS NOT NULL AND COALESCE(${alias}.typed_at, ${alias}.socket_attempt_at) IS NULL ` +
1001
- `AND ${alias}.cancelled_at IS NULL AND ${alias}.fired_at < datetime('now', '${NOTICE_RETRY_AFTER}')`;
1002
929
  const NOTICE_MAX_AGE = "-1 hours";
1003
930
  const ROSTER_STILL_GOING = 8;
1004
931
  const FINISHED_SHOWN_CAP = ROSTER_STILL_GOING;
@@ -1021,13 +948,13 @@ function standingIdleRows(timer) {
1021
948
  AND a.agent_state = 'idle' AND a.state_changed_at IS NOT NULL
1022
949
  ${OWNED_BY_WATCH}
1023
950
  AND ${unreported(CONDITION_IDLE, "a.state_changed_at")}
1024
- ORDER BY a.id`).all(timer.project_id, timer.deliver_actor, timer.owner, timer.id).filter((row) => !awaitingFirstPrompt(row));
951
+ ORDER BY a.id`).all(timer.project_id, timer.deliver_actor, timer.owner, timer.id).filter(finishedIdle);
1025
952
  }
1026
953
  function standingGoneRows(timer) {
1027
954
  return stmt(`SELECT ${CREW_COLUMNS}, a.closed_at AS episode
1028
955
  FROM agents a
1029
956
  WHERE a.project_id = ? AND a.kind = 'agent' AND a.status = 'closed' AND a.actor_id != ?
1030
- AND (a.agent_state != 'idle' OR ${awaitingFirstPromptSql("a")})
957
+ AND NOT (${finishedIdleSql("a")})
1031
958
  AND a.parked_at = ''
1032
959
  AND a.closed_at IS NOT NULL
1033
960
  ${OWNED_BY_WATCH}
@@ -1040,7 +967,7 @@ export function markGoneReported(agentId, projectId) {
1040
967
  FROM wakes t
1041
968
  JOIN agents a ON a.id = ?
1042
969
  WHERE t.project_id = ? AND t.kind = 'idle_any' AND t.watch_scope IS NOT NULL
1043
- AND t.cancelled_at IS NULL AND t.fired_at IS NULL
970
+ AND ${pendingOneShotWhere("t.")}
1044
971
  AND a.closed_at IS NOT NULL`).run(agentId, projectId);
1045
972
  }
1046
973
  export function seedGoneCursor(timerId, projectId) {
@@ -1075,7 +1002,7 @@ function stillGoingRows(timer) {
1075
1002
  return stmt(`SELECT a.name, a.actor_id, a.agent_state, a.state_changed_at, a.status, a.command, a.kind, a.resumed_at
1076
1003
  FROM agents a
1077
1004
  WHERE a.project_id = ? AND a.kind = 'agent' AND a.status = 'running'
1078
- AND (a.agent_state != 'idle' OR ${awaitingFirstPromptSql("a")}) AND a.actor_id != ?
1005
+ AND NOT (${finishedIdleSql("a")}) AND a.actor_id != ?
1079
1006
  ${OWNED_BY_WATCH}
1080
1007
  ORDER BY a.id`).all(timer.project_id, timer.deliver_actor, timer.owner);
1081
1008
  }
@@ -1224,7 +1151,7 @@ function standingNoticeBody(timer, finished, totalFinished, carriedTotal, span)
1224
1151
  more = Math.max(0, rows.length - ROSTER_STILL_GOING);
1225
1152
  shown = rows
1226
1153
  .slice(0, ROSTER_STILL_GOING)
1227
- .map((r) => awaitingFirstPrompt(r) && r.agent_state === "idle"
1154
+ .map((r) => r.agent_state === "idle" && !finishedIdle(r)
1228
1155
  ? `${r.name} (awaiting first assignment)`
1229
1156
  : `${r.name} (${stateNowClause(r)})`);
1230
1157
  }
@@ -1509,8 +1436,7 @@ function agedOutBody(timer) {
1509
1436
  // Telling a lead it need not re-arm, in hive's own generated prose, when it must, is how a lane ends
1510
1437
  // up sitting finished and unnoticed - which this project has paid for twice.
1511
1438
  export function watchStillWatchingClause(timer) {
1512
- const parent = stmt(`SELECT (cancelled_at IS NULL AND fired_at IS NULL
1513
- AND (max_wait_at IS NULL OR max_wait_at > datetime('now'))) AS watching,
1439
+ const parent = stmt(`SELECT (${watchWatchingWhere()}) AS watching,
1514
1440
  (watch_scope IS NOT NULL) AS standing
1515
1441
  FROM wakes WHERE id = ?`).get(timer.parent_wake_id);
1516
1442
  // The parent is not always a standing watch: a modal-hold or one-shot block notice's parent is
@@ -1658,39 +1584,28 @@ function deliverable(timer, snapshot, choices) {
1658
1584
  owner,
1659
1585
  };
1660
1586
  }
1661
- const PANE_CLAIM_TTL_SECONDS = 6;
1662
- const PANE_CLAIM_SETTLE_MS = 500;
1663
1587
  const PANE_CLAIM_OWNER = "hive:scheduler";
1664
- function acquirePaneClaim(projectId, key) {
1665
- try {
1666
- stmt("INSERT OR IGNORE INTO actors (id, name, kind) VALUES (?, 'hive scheduler', 'scheduler')").run(PANE_CLAIM_OWNER);
1667
- stmt("DELETE FROM leases WHERE project_id = ? AND lock_key = ? AND expires_at < datetime('now')").run(projectId, key);
1668
- const row = stmt(`INSERT INTO leases (project_id, lock_key, owner, expires_at)
1669
- VALUES (?, ?, ?, datetime('now', printf('+%d seconds', ?)))
1670
- ON CONFLICT(project_id, lock_key) DO NOTHING
1671
- RETURNING expires_at`).get(projectId, key, PANE_CLAIM_OWNER, PANE_CLAIM_TTL_SECONDS);
1672
- return row?.expires_at ?? null;
1673
- }
1674
- catch {
1675
- return null;
1676
- }
1677
- }
1678
1588
  // Serializes check-then-type per pane ACROSS scheduler instances: the per-wake claim is atomic but two
1679
1589
  // instances claiming different wakes for one pane would otherwise both paste before either submits.
1680
1590
  // A loser skips this tick and its wake stays unclaimed.
1681
1591
  async function withPaneClaim(timer, fn) {
1682
- const key = `wake-pane:${timer.deliver_socket}:${timer.deliver_pane}`;
1683
- const held = timer.deliver_pane ? acquirePaneClaim(timer.project_id, key) : null;
1592
+ const held = timer.deliver_pane
1593
+ ? tryAcquirePaneLease(timer.project_id, timer.deliver_socket, timer.deliver_pane, {
1594
+ id: PANE_CLAIM_OWNER,
1595
+ name: "hive scheduler",
1596
+ kind: "scheduler",
1597
+ })
1598
+ : null;
1684
1599
  if (held === null)
1685
1600
  return;
1686
1601
  try {
1687
1602
  // Settle: a slow TUI still showing the pasted text would make the next instance hold instead of type.
1688
1603
  if (await fn())
1689
- await sleep(PANE_CLAIM_SETTLE_MS);
1604
+ await sleep(PANE_LEASE_SETTLE_MS);
1690
1605
  }
1691
1606
  finally {
1692
1607
  // Match this acquire's expires_at: after a TTL takeover the owner is shared, so owner alone would delete the taker's row.
1693
- bestEffortRun("DELETE FROM leases WHERE project_id = ? AND lock_key = ? AND expires_at = ?", timer.project_id, key, held);
1608
+ releasePaneLease(timer.project_id, timer.deliver_socket, timer.deliver_pane, held);
1694
1609
  }
1695
1610
  }
1696
1611
  async function fireDelay(timer, snapshot, choices) {
@@ -1743,7 +1658,7 @@ async function fireDelayClaimed(timer, snapshot, choices) {
1743
1658
  function claimOneShot(timer) {
1744
1659
  return (stmt(`UPDATE wakes SET fired_at = datetime('now'), fire_count = fire_count + 1
1745
1660
  WHERE id = ? AND due_at IS ? AND body IS ? AND repeat_every_ms IS ?
1746
- AND fired_at IS NULL AND cancelled_at IS NULL`).run(timer.id, timer.due_at, timer.body, timer.repeat_every_ms).changes === 1);
1661
+ AND ${pendingOneShotWhere()}`).run(timer.id, timer.due_at, timer.body, timer.repeat_every_ms).changes === 1);
1747
1662
  }
1748
1663
  const GONE = { idle: true, gone: true, since: null };
1749
1664
  const UNKNOWN = { idle: false, gone: false, since: null };
@@ -1762,7 +1677,7 @@ function watchedStates(timer, snapshot) {
1762
1677
  return GONE;
1763
1678
  }
1764
1679
  return {
1765
- idle: !awaitingFirstPrompt(agent) && agent.agent_state === "idle",
1680
+ idle: finishedIdle(agent),
1766
1681
  gone: false,
1767
1682
  since: agent.state_changed_at,
1768
1683
  };
@@ -2001,24 +1916,8 @@ function recordLostAfterClaim(timer, pasted, why, firing = null) {
2001
1916
  cancelled_at = CASE WHEN repeat_every_ms IS NULL THEN datetime('now') ELSE cancelled_at END
2002
1917
  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);
2003
1918
  }
2004
- export const DELIVERY_METHOD = {
2005
- socket: "socket",
2006
- socketRepeating: "socket-repeating",
2007
- pty: "pty",
2008
- ptyAfterSocketTimeout: "pty-after-socket-timeout",
2009
- };
2010
- 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`;
2011
- // An attempt older than the row's latest claim belongs to an earlier firing (an old-build claim leaves it behind).
2012
- 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
2013
- AND ${p}fired_at IS NOT NULL AND ${p}socket_attempt_at >= ${p}fired_at`;
2014
- const repeatingSocketAwaitingWhere = (prefix = "") => `${prefix}delivery_method = '${DELIVERY_METHOD.socketRepeating}' AND ${currentAttempt(prefix)}`;
2015
- const FIRING_DROPPED_NOTE = "dropped";
2016
- const repeatingFallbackClaimedWhere = (prefix = "") => `${prefix}repeat_every_ms IS NOT NULL AND ${prefix}delivery_method = '${DELIVERY_METHOD.ptyAfterSocketTimeout}' AND ${currentAttempt(prefix)}
2017
- AND ${prefix}socket_delivery_note IS NOT '${FIRING_DROPPED_NOTE}'`;
2018
- // Bounded like retry and confirmation: past retention neither can settle it, so it must stop deferring.
2019
- const REPEATING_FIRING_PENDING = `((${repeatingSocketAwaitingWhere()}) OR (${repeatingFallbackClaimedWhere()}))
2020
- AND socket_attempt_at >= datetime('now', '${LOG_RETENTION}')`;
2021
- const REPEATING_SOCKET_FIRING = `repeat_every_ms IS NOT NULL AND COALESCE(delivery_method, '') IN ('${DELIVERY_METHOD.socketRepeating}', '${DELIVERY_METHOD.ptyAfterSocketTimeout}')`;
1919
+ const REPEATING_FIRING_PENDING = repeatingFiringPendingWhere();
1920
+ const REPEATING_SOCKET_FIRING = repeatingSocketFiringWhere();
2022
1921
  function repeatingFiringPending(id) {
2023
1922
  try {
2024
1923
  return stmt(`SELECT 1 AS hit FROM wakes WHERE id = ? AND (${REPEATING_FIRING_PENDING})`).get(id) !== undefined;
@@ -2028,34 +1927,19 @@ function repeatingFiringPending(id) {
2028
1927
  }
2029
1928
  }
2030
1929
  const firingMarker = (timer) => `[hive wake #${timer.id} firing #${timer.fire_count}`;
2031
- const SOCKET_CONFIRM_GRACE_SECONDS = 60;
2032
1930
  const withReDelivered = (note) => (note ? `${note}, re-delivered` : "re-delivered");
2033
1931
  const promptEvidenceSql = (lowerBound) => `
2034
1932
  SELECT MIN(created_at) FROM agent_state_log
2035
1933
  WHERE actor_id = wakes.deliver_actor AND event = 'prompt' AND created_at >= ${lowerBound}
2036
1934
  AND (payload LIKE '%[hive wake #' || wakes.id || ']%'
2037
1935
  OR payload LIKE '%[hive wake #' || wakes.id || ',%')`;
2038
- const ENVELOPE_OPEN = '<cross-session-message from="';
2039
- const ENVELOPE_SENDER_CLOSE = '" from-name="hive">';
2040
- const EV_PROMPT = "(CASE WHEN json_valid(ev.payload) THEN COALESCE(json_extract(ev.payload, '$.prompt'), '') ELSE ev.payload END)";
2041
1936
  const EV_MARKER = "('[hive wake #' || wakes.id || ' firing #' || wakes.fire_count)";
2042
- const markerAt = (start) => `substr(${EV_PROMPT}, ${start}, length(${EV_MARKER}) + 1) IN (${EV_MARKER} || ']', ${EV_MARKER} || ',')`;
2043
- const EV_HEADER_END = `instr(${EV_PROMPT}, char(10))`;
1937
+ const markerAt = (promptSql, startSql) => `substr(${promptSql}, ${startSql}, length(${EV_MARKER}) + 1) IN (${EV_MARKER} || ']', ${EV_MARKER} || ',')`;
2044
1938
  // The current firing's exact marker, at prompt offset 0 (typed) or opening hive's envelope body (socket).
2045
1939
  const repeatingEvidenceSql = (select) => `
2046
1940
  SELECT ${select} FROM agent_state_log ev
2047
1941
  WHERE ev.actor_id = wakes.deliver_actor AND ev.event = 'prompt' AND ev.created_at >= wakes.socket_attempt_at
2048
- AND (${markerAt("1")}
2049
- OR (${EV_HEADER_END} > ${ENVELOPE_OPEN.length + ENVELOPE_SENDER_CLOSE.length}
2050
- AND substr(${EV_PROMPT}, 1, ${ENVELOPE_OPEN.length}) = '${ENVELOPE_OPEN}'
2051
- AND substr(${EV_PROMPT}, ${EV_HEADER_END} - ${ENVELOPE_SENDER_CLOSE.length}, ${ENVELOPE_SENDER_CLOSE.length}) = '${ENVELOPE_SENDER_CLOSE}'
2052
- AND instr(substr(${EV_PROMPT}, ${ENVELOPE_OPEN.length + 1}, ${EV_HEADER_END} - ${ENVELOPE_OPEN.length + ENVELOPE_SENDER_CLOSE.length + 1}), '"') = 0
2053
- AND ${markerAt(`${EV_HEADER_END} + 1`)}
2054
- AND instr(${EV_PROMPT}, char(10) || '${CROSS_SESSION_CLOSE}') > ${EV_HEADER_END}))`;
2055
- function quietMessagingOn(projectId) {
2056
- const project = stmt("SELECT path FROM projects WHERE id = ?").get(projectId);
2057
- return project !== undefined && loadProjectYml(project.path).config?.quiet_messaging === true;
2058
- }
1942
+ AND ${promptMarkerEvidenceSql(markerAt)}`;
2059
1943
  // Only a wake to a live, row-owned Claude lead whose socket was registered by this pane epoch.
2060
1944
  function socketRoute(timer, owner) {
2061
1945
  return (owner !== null &&
@@ -2139,7 +2023,7 @@ async function deliver(timer, note, choices, typedSeen, firstHeldAt, owner, fall
2139
2023
  }
2140
2024
  const posted = await postClaudeWake({
2141
2025
  socketPath: timer.deliver_messaging_socket,
2142
- senderAddress: senderAddress(join(dataDir, "wake-sender.sock")),
2026
+ senderAddress: socketSenderAddress(dataDir),
2143
2027
  text,
2144
2028
  beforeWrite: () => socketStillRegistered(timer, owner),
2145
2029
  });
@@ -2218,15 +2102,7 @@ async function deliver(timer, note, choices, typedSeen, firstHeldAt, owner, fall
2218
2102
  }
2219
2103
  // Measured from the lead's first turn end after the post, because a busy lead reads the post only then.
2220
2104
  function socketFallbackDue(timer) {
2221
- const firstStop = stmt("SELECT MIN(created_at) AS t FROM agent_state_log WHERE actor_id = ? AND event = 'stop' AND created_at >= ?").get(timer.deliver_actor, timer.socket_attempt_at).t;
2222
- let from = firstStop;
2223
- if (from === null) {
2224
- const turn = timer.deliver_row_id === null ? null : readLeadTurnState(timer.deliver_row_id);
2225
- if (turn !== null && turn.state === "working" && turn.pane_pid === timer.deliver_pane_pid)
2226
- return false;
2227
- from = timer.socket_attempt_at;
2228
- }
2229
- return stmt("SELECT (julianday('now') - julianday(?)) * 86400 >= ? AS due").get(from, SOCKET_CONFIRM_GRACE_SECONDS).due === 1;
2105
+ return socketGraceDue(timer.deliver_actor, timer.socket_attempt_at, timer.deliver_pane_pid, () => timer.deliver_row_id === null ? null : readLeadTurnState(timer.deliver_row_id));
2230
2106
  }
2231
2107
  async function fallbackClaimed(timer, snapshot, choices) {
2232
2108
  const decision = deliverable(timer, snapshot, choices);
package/dist/spawn.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { preservedRolloutPath, reapCodexHome } from "./codexHome.js";
3
3
  import { getProject } from "./context.js";
4
+ import { LEAD_KIND, LEAD_NAME } from "./agentKinds.js";
4
5
  import { gitOut } from "./git.js";
5
6
  import { dataDir, db } from "./db.js";
6
7
  import { loadProjectYml } from "./projectYml.js";
@@ -113,13 +114,8 @@ export function asNameClash(e, name) {
113
114
  }
114
115
  return e;
115
116
  }
116
- export const LEAD_KIND = "lead";
117
- export const LEAD_ACTOR_PREFIX = `${LEAD_KIND}:`;
118
- export const isLeadActorId = (actorId) => actorId.startsWith(LEAD_ACTOR_PREFIX);
119
- export const mintLeadActorId = (agentId) => `${LEAD_ACTOR_PREFIX}${agentId}`;
117
+ export { LEAD_ACTOR_PREFIX, LEAD_KIND, LEAD_NAME, isLeadActorId, isReservedAgentName, mintLeadActorId } from "./agentKinds.js";
120
118
  export const isRunningLeadActor = (actorId) => !!db.prepare("SELECT 1 FROM agents WHERE actor_id = ? AND kind = ? AND status = 'running'").get(actorId, LEAD_KIND);
121
- export const LEAD_NAME = LEAD_KIND;
122
- export const isReservedAgentName = (name) => name.toLowerCase() === LEAD_NAME;
123
119
  export function upsertActor(actorId, name, kind) {
124
120
  db.prepare(`INSERT INTO actors (id, name, kind) VALUES (?, ?, ?)
125
121
  ON CONFLICT(id) DO UPDATE SET name = excluded.name, last_seen_at = datetime('now')`).run(actorId, name, kind);
@@ -3,6 +3,8 @@ import { realpathSync } from "node:fs";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { DEFAULT_DATA_DIR } from "./dataDir.js";
6
+ import { parseEtimeSeconds } from "./etime.js";
7
+ import { parseStoreTimestamp } from "./storeClock.js";
6
8
  export const STALE_SERVER_BOUNDARY_SLACK_MS = 3000;
7
9
  export function readProcessEnvironment(pid) {
8
10
  try {
@@ -18,37 +20,11 @@ export function readProcessEnvironment(pid) {
18
20
  }
19
21
  }
20
22
  const serverEntrypoint = resolve(dirname(fileURLToPath(import.meta.url)), "index.js");
21
- export function elapsedSeconds(value) {
22
- const dayForm = /^(\d+)-(\d{1,2}):(\d{2}):(\d{2})$/.exec(value);
23
- const fields = dayForm ? dayForm.slice(2).map(Number) : value.split(":").map(Number);
24
- if ((!dayForm && fields.length !== 2 && fields.length !== 3) || fields.some((field) => !Number.isSafeInteger(field) || field < 0)) {
25
- return null;
26
- }
27
- let total;
28
- if (dayForm) {
29
- const [hours, minutes, seconds] = fields;
30
- if (hours > 23 || minutes > 59 || seconds > 59)
31
- return null;
32
- total = (Number(dayForm[1]) * 24 + hours) * 3600 + minutes * 60 + seconds;
33
- }
34
- else if (fields.length === 2) {
35
- const [minutes, seconds] = fields;
36
- if (seconds > 59)
37
- return null;
38
- total = minutes * 60 + seconds;
39
- }
40
- else {
41
- const [hours, minutes, seconds] = fields;
42
- if (minutes > 59 || seconds > 59)
43
- return null;
44
- total = hours * 3600 + minutes * 60 + seconds;
45
- }
46
- return Number.isSafeInteger(total) ? total : null;
47
- }
23
+ export const elapsedSeconds = parseEtimeSeconds;
48
24
  function appliedAtInstant(appliedAt) {
49
25
  if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(appliedAt))
50
26
  return null;
51
- const instant = Date.parse(`${appliedAt.replace(" ", "T")}Z`);
27
+ const instant = parseStoreTimestamp(appliedAt);
52
28
  return Number.isFinite(instant) ? instant : null;
53
29
  }
54
30
  function runsThisServer(command) {
@@ -2,6 +2,7 @@ import { db } from "./db.js";
2
2
  import { awaitingFirstPrompt } from "./firstPrompt.js";
3
3
  import { harnessFor } from "./harnesses.js";
4
4
  import { sanitizeEventForDisplay } from "./tmux.js";
5
+ import { parseStoreTimestamp } from "./storeClock.js";
5
6
  const prepared = new Map();
6
7
  function stmt(sql) {
7
8
  let s = prepared.get(sql);
@@ -11,9 +12,6 @@ function stmt(sql) {
11
12
  }
12
13
  return s;
13
14
  }
14
- function parseStoreTimestamp(ts) {
15
- return new Date(`${ts.replace(" ", "T")}Z`).getTime();
16
- }
17
15
  export function ageSecondsSince(storeTimestamp, now = Date.now()) {
18
16
  return Math.max(0, Math.round((now - parseStoreTimestamp(storeTimestamp)) / 1000));
19
17
  }
@@ -0,0 +1,4 @@
1
+ export function parseStoreTimestamp(value) {
2
+ const normalized = /(Z|[+-]\d\d:?\d\d)$/.test(value) ? value : `${value.replace(" ", "T")}Z`;
3
+ return new Date(normalized).getTime();
4
+ }
package/dist/teardown.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { appendFileSync, existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { parseStoreTimestamp } from "./storeClock.js";
3
4
  export const TEARDOWN_LOG = "teardowns.jsonl";
4
5
  // A backstop against a runaway writer, not a retention policy.
5
6
  export const TEARDOWN_MAX_RECORDS = 200;
@@ -11,7 +12,7 @@ export function teardownLogPath(dataDir) {
11
12
  export function sqlNow(now = new Date()) {
12
13
  return now.toISOString().replace("T", " ").slice(0, 23);
13
14
  }
14
- const sqlToDate = (ts) => new Date(`${ts.replace(" ", "T")}Z`);
15
+ const sqlToDate = (ts) => new Date(parseStoreTimestamp(ts));
15
16
  export function teardownWindow(from, to, basis) {
16
17
  const seconds = (sqlToDate(to).getTime() - sqlToDate(from).getTime()) / 1000;
17
18
  return { from, to, basis, width_seconds: Number.isFinite(seconds) ? Math.max(0, seconds) : 0 };
package/dist/todoScope.js CHANGED
@@ -1,3 +1,4 @@
1
+ export const TODO_STATUSES = ["open", "in_progress", "backlog", "completed"];
1
2
  export const ACTIVE_TODO_SQL = "t.status IN ('open', 'in_progress') AND t.archived_at IS NULL";
2
3
  export const NOT_COMPLETED_TODO_SQL = "status != 'completed'";
3
4
  export const OPEN_BLOCKERS_FROM_WHERE = `FROM todo_blockers b JOIN todos bt ON bt.id = b.blocker_id