daily-focus 1.1.1-dev.2 → 1.2.0-dev.10

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.
@@ -1,26 +1,33 @@
1
1
  import {
2
2
  ADAPTERS,
3
- Assistant
4
- } from "./chunk-GFCWPYEI.js";
3
+ Assistant,
4
+ AutoAsk,
5
+ CodexGate,
6
+ codexVersionWarning,
7
+ countBoard,
8
+ resolveBoard
9
+ } from "./chunk-ODJGQCM3.js";
5
10
  import {
6
11
  FOCUS_TEMPLATE,
7
12
  sourcesTemplate
8
13
  } from "./chunk-V2UN3JYL.js";
14
+ import {
15
+ TimeZoneKeeper,
16
+ validZone
17
+ } from "./chunk-Z4SFMMRQ.js";
9
18
  import {
10
19
  describeLink,
11
20
  linkStore,
12
21
  storeLinkWarning
13
22
  } from "./chunk-QKT2FAB5.js";
14
23
  import {
24
+ ACTION_TYPES,
15
25
  Store,
16
26
  canonicalId,
17
27
  foldActionLog,
18
- reconcileSession,
19
- startSession,
20
- stopSession,
21
28
  tempPathFor,
22
29
  writeJsonAtomic
23
- } from "./chunk-UM2EPNWR.js";
30
+ } from "./chunk-RZNQJ6UG.js";
24
31
  import {
25
32
  CLI_NAMES,
26
33
  LAUNCH_SETTINGS,
@@ -32,11 +39,10 @@ import {
32
39
  layerEnv,
33
40
  loadConfig,
34
41
  nextScheduledRun,
35
- parseISO,
36
42
  readDotEnv,
37
43
  resolveSchedule,
38
44
  scheduledRunDue
39
- } from "./chunk-OPASVYLR.js";
45
+ } from "./chunk-J7ZYVUG6.js";
40
46
  import {
41
47
  PACKAGED,
42
48
  command,
@@ -493,9 +499,9 @@ function checkSlot(node) {
493
499
  return `check\0${node.name ?? ""}`;
494
500
  }
495
501
  function checkGeneration(node) {
496
- const run6 = node.checkSuite?.workflowRun;
497
- const sequence = run6?.runNumber ?? run6?.databaseId;
498
- if (typeof sequence === "number") return [sequence, run6?.runAttempt ?? 0];
502
+ const run5 = node.checkSuite?.workflowRun;
503
+ const sequence = run5?.runNumber ?? run5?.databaseId;
504
+ if (typeof sequence === "number") return [sequence, run5?.runAttempt ?? 0];
499
505
  const at = node.completedAt ?? node.startedAt ?? node.createdAt ?? null;
500
506
  const time = at ? Date.parse(at) : Number.NaN;
501
507
  return [Number.isFinite(time) ? time : 0, 0];
@@ -811,6 +817,7 @@ async function fetchPulls(token, scope, expectedLogin, signal) {
811
817
  for (const [nodeId, head] of checks.heads) {
812
818
  const pull = found.get(nodeId);
813
819
  if (!pull) continue;
820
+ pull.headSha = head.oid;
814
821
  applyChecks(pull, head.rollup);
815
822
  if (head.rollup && rollupTruncated(head.rollup)) {
816
823
  partial.push({ id: nodeId, pull, oid: head.oid, rollup: head.rollup });
@@ -865,131 +872,84 @@ async function fetchPulls(token, scope, expectedLogin, signal) {
865
872
  }
866
873
  return { login, pulls, warnings, orgs, rateLimitRemaining };
867
874
  }
868
-
869
- // src/prs.ts
870
- var NUDGE_AFTER_MS = 24 * 36e5;
871
- var STALE_DRAFT_AFTER_MS = 14 * 864e5;
872
- var COURT_ORDER = ["you", "ready", "reviewers", "gate", "blocked", "checks", "draft"];
873
- function courtOrder(court) {
874
- return COURT_ORDER.indexOf(court);
875
- }
876
- function ms(iso) {
877
- const parsed = parseISO(iso);
878
- return parsed ? parsed.getTime() : null;
875
+ var TICKET_PAGE_SIZE = 25;
876
+ var TICKET_MAX_PAGES = 12;
877
+ var TICKET_COMMITS = 100;
878
+ var TICKET_SEARCH_QUERY = `
879
+ query($q: String!, $first: Int!, $after: String) {
880
+ search(query: $q, type: ISSUE, first: $first, after: $after) {
881
+ pageInfo { hasNextPage endCursor }
882
+ nodes {
883
+ ... on PullRequest {
884
+ number
885
+ title
886
+ url
887
+ state
888
+ isDraft
889
+ closedAt
890
+ mergedAt
891
+ headRefName
892
+ body
893
+ repository { nameWithOwner }
894
+ commits(first: ${TICKET_COMMITS}) { nodes { commit { message } } }
895
+ }
896
+ }
897
+ }
898
+ }`;
899
+ function buildTicketSearchQueries(scope, closedSince) {
900
+ return [
901
+ ["is:pr", "author:@me", "is:open", ...scope].join(" "),
902
+ ["is:pr", "author:@me", "is:closed", `closed:>=${closedSince}`, ...scope].join(" ")
903
+ ];
879
904
  }
880
- function deriveDecision(pr) {
881
- if (pr.reviewDecision) return pr.reviewDecision;
882
- if (pr.reviews.some((review) => review.state === "CHANGES_REQUESTED")) return "CHANGES_REQUESTED";
883
- if (pr.reviews.some((review) => review.state === "APPROVED")) return "APPROVED";
905
+ function toLinkedState(state, isDraft) {
906
+ if (state === "MERGED") return "merged";
907
+ if (state === "CLOSED") return "closed";
908
+ if (state === "OPEN") return isDraft === true ? "draft" : "open";
884
909
  return null;
885
910
  }
886
- function matchMergeGates(pr, gates) {
887
- if (gates.length === 0) return [];
888
- return (pr.pendingChecks ?? []).filter((check) => gates.includes(check.name));
889
- }
890
- function newest(pr, state) {
891
- return pr.reviews.filter((review) => review.state === state).sort((a, b) => a.at < b.at ? 1 : -1)[0];
892
- }
893
- function judge(pr, now, lastNoteAt, gates = []) {
894
- const decision = deriveDecision(pr);
895
- const you = pr.lastActivityByYou ?? null;
896
- const others = pr.lastActivityByOthers ?? null;
897
- const mergeState = pr.mergeStateStatus ?? null;
898
- if (pr.isDraft) {
899
- const touched = you ?? pr.createdAt;
900
- const idle = now.getTime() - (ms(touched) ?? now.getTime());
901
- return { court: "draft", decision, reasons: [], stale: idle >= STALE_DRAFT_AFTER_MS, nudge: false, since: touched };
902
- }
903
- const reasons = [];
904
- if (decision === "CHANGES_REQUESTED") {
905
- const request = newest(pr, "CHANGES_REQUESTED");
906
- reasons.push({ kind: "changes-requested", login: request?.login, at: request?.at });
907
- }
908
- if (pr.checks === "failure") reasons.push({ kind: "ci-failing" });
909
- if (pr.mergeable === "CONFLICTING" || mergeState === "DIRTY") reasons.push({ kind: "conflicts" });
910
- if (mergeState === "BEHIND") reasons.push({ kind: "behind" });
911
- const theirMove = others !== null && (you === null || others.at > you);
912
- const approval = newest(pr, "APPROVED");
913
- const afterApproval = others !== null && approval !== void 0 && others.at > approval.at;
914
- const approved = decision === "APPROVED" && !afterApproval;
915
- if (theirMove && others && !approved) {
916
- reasons.push({ kind: "activity", login: others.login, at: others.at, activity: others.kind });
917
- }
918
- if (reasons.length > 0) {
919
- const since = theirMove && others ? others.at : pr.updatedAt;
920
- return { court: "you", decision, reasons, stale: false, nudge: false, since };
921
- }
922
- const waitingSince = laterISO(pr.readyAt, you) ?? pr.createdAt;
923
- const settled = (court, reason) => ({
924
- court,
925
- decision,
926
- reasons: [reason],
927
- stale: false,
928
- nudge: false,
929
- since: waitingSince
930
- });
931
- if (decision !== "REVIEW_REQUIRED") {
932
- const gate = matchMergeGates(pr, gates);
933
- if (gate.length > 0) return settled("gate", { kind: "merge-gate", checks: gate });
934
- const pending = pr.pendingChecks ?? [];
935
- const running = { kind: "checks-pending" };
936
- if (pending.length > 0) running.checks = pending;
937
- if (mergeState === "BLOCKED" && pending.length === 0) {
938
- return settled("blocked", { kind: "merge-blocked" });
939
- }
940
- if (mergeState === "BLOCKED" || mergeState === "UNSTABLE") return settled("checks", running);
941
- if (mergeState === "UNKNOWN") return settled("checks", { kind: "mergeability-unknown" });
942
- if (approved) {
943
- if (mergeState === "CLEAN" || mergeState === "HAS_HOOKS") {
944
- return { court: "ready", decision, reasons: [], stale: false, nudge: false, since: approval?.at ?? pr.updatedAt };
911
+ function normalizeTicketPull(raw) {
912
+ const repo = raw.repository?.nameWithOwner;
913
+ const url = safeUrl(raw.url);
914
+ const state = toLinkedState(raw.state, raw.isDraft);
915
+ if (typeof raw.number !== "number" || typeof repo !== "string" || typeof raw.title !== "string" || !url || !state) {
916
+ return null;
917
+ }
918
+ const closedAt = state === "merged" ? raw.mergedAt ?? raw.closedAt ?? null : state === "closed" ? raw.closedAt ?? null : null;
919
+ const text = [raw.title, raw.headRefName ?? "", raw.body ?? ""];
920
+ for (const node of raw.commits?.nodes ?? []) {
921
+ const message = node?.commit?.message;
922
+ if (typeof message === "string") text.push(message);
923
+ }
924
+ return { pull: { repo, number: raw.number, title: raw.title, url, state, closedAt }, text };
925
+ }
926
+ async function fetchTicketPulls(token, scope, closedSince, signal) {
927
+ const pulls = [];
928
+ let complete = true;
929
+ for (const q of buildTicketSearchQueries(scope, closedSince)) {
930
+ let after = null;
931
+ for (let page = 0; ; page++) {
932
+ const result = await graphql(token, TICKET_SEARCH_QUERY, { q, first: TICKET_PAGE_SIZE, after }, signal);
933
+ const fatal = result.errors.filter((error) => !isSamlError(error));
934
+ if (result.data?.search === void 0 && fatal.length > 0) {
935
+ throw new GitHubRequestError(`GitHub search failed: ${fatal.map((error) => error.message).join("; ")}`);
945
936
  }
946
- if (mergeState === null) {
947
- const unfinished = pr.checks === "pending" || pending.length > 0;
948
- if (!unfinished) {
949
- return { court: "ready", decision, reasons: [], stale: false, nudge: false, since: approval?.at ?? pr.updatedAt };
950
- }
951
- return settled("checks", running);
937
+ if (result.errors.length > 0) complete = false;
938
+ for (const node of result.data?.search?.nodes ?? []) {
939
+ const found = node ? normalizeTicketPull(node) : null;
940
+ if (found) pulls.push(found);
941
+ else complete = false;
952
942
  }
943
+ const pageInfo = result.data?.search?.pageInfo;
944
+ if (!pageInfo?.hasNextPage || !pageInfo.endCursor) break;
945
+ if (page === TICKET_MAX_PAGES - 1) {
946
+ complete = false;
947
+ break;
948
+ }
949
+ after = pageInfo.endCursor;
953
950
  }
954
951
  }
955
- const quietSince = laterISO(waitingSince, lastNoteAt) ?? waitingSince;
956
- const waited = now.getTime() - (ms(quietSince) ?? now.getTime());
957
- return { court: "reviewers", decision, reasons, stale: false, nudge: waited >= NUDGE_AFTER_MS, since: waitingSince };
958
- }
959
- function resolveBoard(pulls, actions, now, gates = []) {
960
- const folded = foldActionLog(actions, now);
961
- const rows = pulls.map((pr) => {
962
- const log = folded.get(canonicalId(pr.id));
963
- const notes = log?.notes ?? [];
964
- const lastNoteAt = notes.length > 0 ? notes[notes.length - 1].at : null;
965
- const verdict = judge(pr, now, lastNoteAt, gates);
966
- const row = { ...pr, ...verdict, status: log?.status === "snoozed" ? "snoozed" : "open", notes };
967
- if (row.status === "snoozed" && log?.snoozedUntil) row.snoozedUntil = log.snoozedUntil;
968
- return row;
969
- });
970
- return rows.sort((a, b) => {
971
- const court = courtOrder(a.court) - courtOrder(b.court);
972
- if (court !== 0) return court;
973
- if (a.since !== b.since) return a.since < b.since ? -1 : 1;
974
- return a.id < b.id ? -1 : 1;
975
- });
976
- }
977
- function countBoard(rows) {
978
- const counts = {
979
- you: 0,
980
- ready: 0,
981
- reviewers: 0,
982
- gate: 0,
983
- blocked: 0,
984
- checks: 0,
985
- draft: 0,
986
- parked: 0
987
- };
988
- for (const row of rows) {
989
- if (row.status === "snoozed") counts.parked++;
990
- else counts[row.court]++;
991
- }
992
- return counts;
952
+ return { pulls, complete };
993
953
  }
994
954
 
995
955
  // src/board.ts
@@ -1353,6 +1313,9 @@ function normalizeStoredPull(raw) {
1353
1313
  autoMerge: raw.autoMerge === true,
1354
1314
  requestedReviewers: strings(raw.requestedReviewers),
1355
1315
  reviews,
1316
+ // Kept, or a restart would hand the assistant's answers ahead about failing
1317
+ // checks a key the live read doesn't, and they would be asked again.
1318
+ headSha: typeof raw.headSha === "string" ? raw.headSha : null,
1356
1319
  lastActivityByYou: typeof raw.lastActivityByYou === "string" ? raw.lastActivityByYou : null,
1357
1320
  lastActivityByOthers
1358
1321
  };
@@ -1488,7 +1451,8 @@ async function runHelper(appPath, selfAddresses) {
1488
1451
  const out = join(tmpdir(), `daily-focus-calendar-${process.pid}-${Date.now()}.json`);
1489
1452
  try {
1490
1453
  try {
1491
- await run2("/usr/bin/open", ["-n", "-a", appPath, "--args", out, selfAddresses.join(",")], {
1454
+ const zone = process.env.TZ ? ["--env", `TZ=${process.env.TZ}`] : [];
1455
+ await run2("/usr/bin/open", ["-n", "-a", appPath, ...zone, "--args", out, selfAddresses.join(",")], {
1492
1456
  timeout: HELPER_TIMEOUT_MS
1493
1457
  });
1494
1458
  } catch (err) {
@@ -1687,6 +1651,87 @@ import { readFile as readFile5 } from "node:fs/promises";
1687
1651
  // src/jira.ts
1688
1652
  import { execFile as execFile3 } from "node:child_process";
1689
1653
  import { promisify as promisify3 } from "node:util";
1654
+
1655
+ // src/linked.ts
1656
+ var CLOSED_LOOKBACK_DAYS = 60;
1657
+ var KEYISH = /(?<![A-Za-z0-9])([A-Za-z][A-Za-z0-9_]*)-(\d+)/g;
1658
+ function issueKeysIn(text) {
1659
+ const keys = /* @__PURE__ */ new Set();
1660
+ for (const match of text.matchAll(KEYISH)) {
1661
+ const parts = match[1].split("_");
1662
+ for (let i = 0; i < parts.length; i++) {
1663
+ const project = parts.slice(i).join("_");
1664
+ if (/^[A-Za-z]/.test(project)) keys.add(`${project.toUpperCase()}-${match[2]}`);
1665
+ }
1666
+ }
1667
+ return keys;
1668
+ }
1669
+ var realDeps3 = { token: ghToken, search: fetchTicketPulls };
1670
+ var CONSEQUENCE = "so a merged pull request counts only once Jira has it";
1671
+ async function readLinkedPulls(github, now, signal, deps = realDeps3) {
1672
+ const closedSince = new Date(now.getTime() - CLOSED_LOOKBACK_DAYS * 864e5).toISOString().slice(0, 10);
1673
+ const logins = github.accounts.length > 0 ? [...github.accounts] : [null];
1674
+ const results = await Promise.allSettled(
1675
+ logins.map(async (login) => deps.search(await deps.token(github.ghPath, login), github.scope, closedSince, signal))
1676
+ );
1677
+ if (results.every((result) => result.status === "rejected")) {
1678
+ throw results[0].reason;
1679
+ }
1680
+ const warnings = [];
1681
+ let complete = true;
1682
+ const byKey = /* @__PURE__ */ new Map();
1683
+ const seen = /* @__PURE__ */ new Set();
1684
+ results.forEach((result, i) => {
1685
+ const who = logins[i] ? ` as ${logins[i]}` : "";
1686
+ if (result.status === "rejected") {
1687
+ complete = false;
1688
+ warnings.push(`Could not ask GitHub${who} about these tickets' pull requests (${result.reason.message}), ${CONSEQUENCE}.`);
1689
+ return;
1690
+ }
1691
+ if (!result.value.complete) {
1692
+ complete = false;
1693
+ warnings.push(`GitHub did not list every pull request${who}, ${CONSEQUENCE}.`);
1694
+ }
1695
+ for (const { pull, text } of result.value.pulls) {
1696
+ if (seen.has(pull.url)) continue;
1697
+ seen.add(pull.url);
1698
+ const keys = /* @__PURE__ */ new Set();
1699
+ for (const piece of text) for (const key of issueKeysIn(piece)) keys.add(key);
1700
+ for (const key of keys) byKey.set(key, [...byKey.get(key) ?? [], pull]);
1701
+ }
1702
+ });
1703
+ return { byKey, complete, warnings };
1704
+ }
1705
+ function isOpen(pull) {
1706
+ return pull.state === "open" || pull.state === "draft";
1707
+ }
1708
+ function byRecency(a, b) {
1709
+ if (isOpen(a) !== isOpen(b)) return isOpen(a) ? -1 : 1;
1710
+ return (b.closedAt ?? "").localeCompare(a.closedAt ?? "");
1711
+ }
1712
+ function linkedTo(ticket, linked) {
1713
+ return linked?.byKey.get(ticket.key.toUpperCase()) ?? [];
1714
+ }
1715
+ function needsJiraCount(ticket, linked) {
1716
+ const found = linkedTo(ticket, linked);
1717
+ return Boolean(linked?.complete) && found.length > 0 && !found.some(isOpen) && ticket.hasAnyPr && !ticket.allPrsClosed;
1718
+ }
1719
+ function reconcile(ticket, linked, jiraCount) {
1720
+ const found = [...linkedTo(ticket, linked)].sort(byRecency);
1721
+ if (found.length === 0) return ticket;
1722
+ const jira = { hasAnyPr: ticket.hasAnyPr, hasOpenPr: ticket.hasOpenPr, allPrsClosed: ticket.allPrsClosed };
1723
+ let reading = jira;
1724
+ if (found.some(isOpen)) {
1725
+ reading = { hasAnyPr: true, hasOpenPr: true, allPrsClosed: false };
1726
+ } else if (linked?.complete && !ticket.allPrsClosed) {
1727
+ const known = ticket.hasAnyPr ? jiraCount : 0;
1728
+ if (known !== null && found.length >= known) reading = { hasAnyPr: true, hasOpenPr: false, allPrsClosed: true };
1729
+ }
1730
+ const changed = reading.hasAnyPr !== jira.hasAnyPr || reading.hasOpenPr !== jira.hasOpenPr || reading.allPrsClosed !== jira.allPrsClosed;
1731
+ return { ...ticket, ...reading, pulls: found, ...changed ? { fromGitHub: true } : {} };
1732
+ }
1733
+
1734
+ // src/jira.ts
1690
1735
  var run3 = promisify3(execFile3);
1691
1736
  var ACLI_TIMEOUT_MS = 45e3;
1692
1737
  var PAGE_LIMIT = 200;
@@ -1970,22 +2015,23 @@ async function fetchTickets(acliPath, opts) {
1970
2015
  if (unreadable > 0) {
1971
2016
  warnings.push(`Skipped ${unreadable} work ${unreadable === 1 ? "item" : "items"} Jira described in a way this board could not read.`);
1972
2017
  }
1973
- const candidates = tickets.filter((ticket) => ticket.hasAnyPr && !ticket.hasOpenPr);
1974
- const panels = await mapLimit(
1975
- candidates,
1976
- DEV_CONCURRENCY,
1977
- (ticket) => fetchDevPullRequests(acliPath, ticket.key).then(
1978
- (dev) => ({ asked: true, dev }),
1979
- () => ({ asked: false, dev: null })
1980
- )
2018
+ const readPanel = (ticket) => fetchDevPullRequests(acliPath, ticket.key).then(
2019
+ (dev) => ({ asked: true, dev }),
2020
+ () => ({ asked: false, dev: null })
1981
2021
  );
2022
+ const candidates = tickets.filter((ticket) => ticket.hasAnyPr && !ticket.hasOpenPr);
2023
+ const reading = mapLimit(candidates, DEV_CONCURRENCY, readPanel);
2024
+ const linked = await opts.linked ?? null;
2025
+ const panels = await reading;
2026
+ const counts = /* @__PURE__ */ new Map();
1982
2027
  const unanswered = [];
1983
2028
  const unaccounted = [];
1984
2029
  candidates.forEach((ticket, i) => {
1985
- const { asked, dev } = panels[i];
2030
+ const { asked: asked2, dev } = panels[i];
2031
+ if (dev) counts.set(ticket.key, dev.count);
1986
2032
  if (allPullRequestsClosed(dev)) {
1987
2033
  ticket.allPrsClosed = true;
1988
- } else if (!asked) {
2034
+ } else if (!asked2) {
1989
2035
  unanswered.push(ticket.key);
1990
2036
  } else if (dev === null) {
1991
2037
  unaccounted.push(ticket.key);
@@ -1993,19 +2039,30 @@ async function fetchTickets(acliPath, opts) {
1993
2039
  ticket.hasOpenPr = true;
1994
2040
  }
1995
2041
  });
2042
+ const asked = new Set(candidates);
2043
+ const behind = tickets.filter((ticket) => !asked.has(ticket) && needsJiraCount(ticket, linked));
2044
+ const extra = await mapLimit(behind, DEV_CONCURRENCY, readPanel);
2045
+ behind.forEach((ticket, i) => {
2046
+ const dev = extra[i].dev;
2047
+ if (dev) counts.set(ticket.key, dev.count);
2048
+ });
2049
+ const reconciled = tickets.map((ticket) => reconcile(ticket, linked, counts.get(ticket.key) ?? null));
2050
+ const undecided = new Set(reconciled.filter((ticket) => !ticket.hasOpenPr && !ticket.allPrsClosed).map((ticket) => ticket.key));
2051
+ const unansweredNow = unanswered.filter((key) => undecided.has(key));
2052
+ const unaccountedNow = unaccounted.filter((key) => undecided.has(key));
1996
2053
  const listed = (keys2) => keys2.map((key) => {
1997
2054
  const url = browseUrl(opts.site, key);
1998
2055
  return url === null ? key : `[${key}](${url})`;
1999
2056
  }).join(", ");
2000
2057
  const heldOut = (keys2) => `${keys2.length === 1 ? "it is" : "they are"} held out of "no open pull requests left" rather than guessed at`;
2001
- if (unanswered.length > 0) {
2058
+ if (unansweredNow.length > 0) {
2002
2059
  warnings.push(
2003
- `Could not read Jira's development panel for ${listed(unanswered)}, so ${heldOut(unanswered)}. The next read may well manage it.`
2060
+ `Could not read Jira's development panel for ${listed(unansweredNow)}, so ${heldOut(unansweredNow)}. The next read may well manage it.`
2004
2061
  );
2005
2062
  }
2006
- if (unaccounted.length > 0) {
2063
+ if (unaccountedNow.length > 0) {
2007
2064
  warnings.push(
2008
- `Jira's search says ${listed(unaccounted)} ${unaccounted.length === 1 ? "has" : "have"} pull requests, but its own development panel names none, so ${heldOut(unaccounted)}. Jira is disagreeing with itself; the row comes back when one of its caches catches up.`
2065
+ `Jira's search says ${listed(unaccountedNow)} ${unaccountedNow.length === 1 ? "has" : "have"} pull requests, but its own development panel names none, so ${heldOut(unaccountedNow)}. Jira is disagreeing with itself; the row comes back when one of its caches catches up.`
2009
2066
  );
2010
2067
  }
2011
2068
  if (!opts.site) {
@@ -2022,7 +2079,7 @@ async function fetchTickets(acliPath, opts) {
2022
2079
  );
2023
2080
  }
2024
2081
  return {
2025
- tickets,
2082
+ tickets: reconciled,
2026
2083
  statuses: statusesByProject(base, ...finished.map((batch) => batch ?? [])),
2027
2084
  warnings
2028
2085
  };
@@ -2094,16 +2151,16 @@ function readTransitionReport(stdout, key, status) {
2094
2151
  }
2095
2152
 
2096
2153
  // src/tickets.ts
2097
- var COURT_ORDER2 = ["settled", "started", "idle"];
2154
+ var COURT_ORDER = ["settled", "started", "idle"];
2098
2155
  function ticketCourtOrder(court) {
2099
- return COURT_ORDER2.indexOf(court);
2156
+ return COURT_ORDER.indexOf(court);
2100
2157
  }
2101
2158
  function inStatus(ticket, statuses) {
2102
2159
  const status = ticket.workflowStatus.trim().toLowerCase();
2103
2160
  if (status === "") return false;
2104
2161
  return statuses.some((listed) => listed.trim().toLowerCase() === status);
2105
2162
  }
2106
- function judge2(ticket, holdStatuses = []) {
2163
+ function judge(ticket, holdStatuses = []) {
2107
2164
  if (ticket.statusCategory === "done") return null;
2108
2165
  if (ticket.hasAnyPr && !ticket.hasOpenPr && ticket.allPrsClosed) return "settled";
2109
2166
  if (ticket.statusCategory === "new" && ticket.hasOpenPr) return "started";
@@ -2114,7 +2171,7 @@ function resolveTickets(tickets, actions, now, holdStatuses = []) {
2114
2171
  const folded = foldActionLog(actions, now);
2115
2172
  const rows = [];
2116
2173
  for (const ticket of tickets) {
2117
- const court = judge2(ticket, holdStatuses);
2174
+ const court = judge(ticket, holdStatuses);
2118
2175
  if (!court) continue;
2119
2176
  const log = folded.get(canonicalId(ticket.id));
2120
2177
  const row = {
@@ -2143,7 +2200,13 @@ function countTickets(rows) {
2143
2200
 
2144
2201
  // src/ticketboard.ts
2145
2202
  var MAX_BACKOFF_MS3 = 30 * 6e4;
2146
- var realDeps3 = { identity: acliIdentity, fetch: fetchTickets, transition: transitionTicket };
2203
+ var GITHUB_DEADLINE_MS = 9e4;
2204
+ var realDeps4 = {
2205
+ identity: acliIdentity,
2206
+ fetch: fetchTickets,
2207
+ transition: transitionTicket,
2208
+ linked: (github, now, signal) => readLinkedPulls(github, now, signal)
2209
+ };
2147
2210
  var TicketBoard = class {
2148
2211
  #config;
2149
2212
  #deps;
@@ -2160,7 +2223,8 @@ var TicketBoard = class {
2160
2223
  #audience = 0;
2161
2224
  #stopped = false;
2162
2225
  #lastReadAt = 0;
2163
- constructor(config, onChange, deps = realDeps3) {
2226
+ #abort = new AbortController();
2227
+ constructor(config, onChange, deps = realDeps4) {
2164
2228
  this.#config = config;
2165
2229
  this.#deps = deps;
2166
2230
  this.#onChange = onChange;
@@ -2174,10 +2238,12 @@ var TicketBoard = class {
2174
2238
  this.#file = await readTicketsFile(this.#config.ticketsFile);
2175
2239
  await this.refresh();
2176
2240
  }
2241
+ /** Stop reading, and cancel a GitHub request in flight so nothing lands after close. */
2177
2242
  stop() {
2178
2243
  this.#stopped = true;
2179
2244
  if (this.#timer) clearTimeout(this.#timer);
2180
2245
  this.#timer = null;
2246
+ this.#abort.abort();
2181
2247
  }
2182
2248
  /** How many browsers are watching. Polling pauses at zero. */
2183
2249
  setAudience(count) {
@@ -2292,8 +2358,24 @@ var TicketBoard = class {
2292
2358
  return;
2293
2359
  }
2294
2360
  const site = configuredSite ?? identity2.site;
2361
+ const githubWarnings = [];
2362
+ const linked = this.#config.github.enabled ? this.#deps.linked(this.#config.github, /* @__PURE__ */ new Date(), AbortSignal.any([this.#abort.signal, AbortSignal.timeout(GITHUB_DEADLINE_MS)])).then(
2363
+ (read) => {
2364
+ githubWarnings.push(...read.warnings);
2365
+ return read;
2366
+ },
2367
+ (err) => {
2368
+ githubWarnings.push(
2369
+ `Could not ask GitHub about these tickets' pull requests (${err.message}), so they are as Jira has them, which can be days behind.`
2370
+ );
2371
+ return null;
2372
+ }
2373
+ ) : Promise.resolve(null);
2295
2374
  try {
2296
- const { tickets, statuses, warnings } = await this.#deps.fetch(acliPath, { projects, site });
2375
+ const fetched = await this.#deps.fetch(acliPath, { projects, site, linked });
2376
+ await linked;
2377
+ const { tickets, statuses } = fetched;
2378
+ const warnings = [...fetched.warnings, ...githubWarnings];
2297
2379
  this.#reason = null;
2298
2380
  this.#warnings = warnings;
2299
2381
  this.#streak = null;
@@ -2329,7 +2411,7 @@ function normalizeStoredTicket(raw) {
2329
2411
  const { key, url, workflowStatus, statusCategory, issueType, summary } = raw;
2330
2412
  if (typeof key !== "string" || key === "") return null;
2331
2413
  const site = typeof url === "string" ? /^https:\/\/([^/]+)\/browse\//.exec(url)?.[1] ?? null : null;
2332
- return toTicket(
2414
+ const ticket = toTicket(
2333
2415
  {
2334
2416
  key,
2335
2417
  fields: {
@@ -2346,6 +2428,25 @@ function normalizeStoredTicket(raw) {
2346
2428
  // files are exactly the ones whose settled court counted drafts as merges.
2347
2429
  raw.allPrsClosed === true
2348
2430
  );
2431
+ if (!ticket) return null;
2432
+ const pulls = storedPulls(raw.pulls);
2433
+ if (pulls.length > 0) {
2434
+ ticket.pulls = pulls;
2435
+ if (raw.fromGitHub === true) ticket.fromGitHub = true;
2436
+ }
2437
+ return ticket;
2438
+ }
2439
+ var PULL_STATES = ["open", "draft", "merged", "closed"];
2440
+ function storedPulls(value) {
2441
+ if (!Array.isArray(value)) return [];
2442
+ return value.flatMap((entry) => {
2443
+ if (!isRecord3(entry)) return [];
2444
+ const { repo, number, title, url, state, closedAt } = entry;
2445
+ if (typeof repo !== "string" || typeof number !== "number" || typeof title !== "string") return [];
2446
+ if (typeof url !== "string" || !/^https?:\/\//i.test(url)) return [];
2447
+ if (typeof state !== "string" || !PULL_STATES.includes(state)) return [];
2448
+ return [{ repo, number, title, url, state, closedAt: typeof closedAt === "string" ? closedAt : null }];
2449
+ });
2349
2450
  }
2350
2451
  async function readTicketsFile(path) {
2351
2452
  let text;
@@ -2446,11 +2547,11 @@ function foldAgentLog(text) {
2446
2547
  });
2447
2548
  continue;
2448
2549
  }
2449
- const run6 = runs.get(record.run);
2450
- if (!run6) continue;
2550
+ const run5 = runs.get(record.run);
2551
+ if (!run5) continue;
2451
2552
  if (record.event === "asked") {
2452
2553
  if (typeof record.turn !== "string" || typeof record.question !== "string") continue;
2453
- run6.turns.push({
2554
+ run5.turns.push({
2454
2555
  id: record.turn,
2455
2556
  question: record.question,
2456
2557
  startedAt: record.at,
@@ -2462,7 +2563,7 @@ function foldAgentLog(text) {
2462
2563
  continue;
2463
2564
  }
2464
2565
  if (record.event === "replied" || record.event === "turn-failed" || record.event === "turn-aborted") {
2465
- const turn = run6.turns.find((candidate) => candidate.id === record.turn);
2566
+ const turn = run5.turns.find((candidate) => candidate.id === record.turn);
2466
2567
  if (!turn) continue;
2467
2568
  turn.reply = typeof record.reply === "string" ? record.reply : turn.reply;
2468
2569
  turn.endedAt = record.at;
@@ -2477,18 +2578,18 @@ function foldAgentLog(text) {
2477
2578
  }
2478
2579
  continue;
2479
2580
  }
2480
- run6.sessionId = typeof record.sessionId === "string" ? record.sessionId : run6.sessionId;
2481
- run6.report = typeof record.report === "string" ? record.report : run6.report;
2482
- run6.messages = Array.isArray(record.messages) ? record.messages.filter((message) => typeof message === "string") : run6.report ? [run6.report] : [];
2483
- run6.endedAt = record.at;
2581
+ run5.sessionId = typeof record.sessionId === "string" ? record.sessionId : run5.sessionId;
2582
+ run5.report = typeof record.report === "string" ? record.report : run5.report;
2583
+ run5.messages = Array.isArray(record.messages) ? record.messages.filter((message) => typeof message === "string") : run5.report ? [run5.report] : [];
2584
+ run5.endedAt = record.at;
2484
2585
  if (record.event === "finished") {
2485
- run6.status = "done";
2586
+ run5.status = "done";
2486
2587
  } else if (record.event === "failed") {
2487
- run6.status = "failed";
2488
- run6.error = record.error;
2588
+ run5.status = "failed";
2589
+ run5.error = record.error;
2489
2590
  } else if (record.event === "aborted") {
2490
- run6.status = "aborted";
2491
- run6.error = record.reason;
2591
+ run5.status = "aborted";
2592
+ run5.error = record.reason;
2492
2593
  }
2493
2594
  }
2494
2595
  return [...runs.values()].sort((a, b) => a.startedAt.localeCompare(b.startedAt));
@@ -2501,10 +2602,13 @@ var AgentRunner = class {
2501
2602
  config;
2502
2603
  hooks;
2503
2604
  spawnFn;
2504
- constructor(config, hooks, spawnFn = spawn) {
2605
+ /** Refuses a Codex too old to hold to the connector rules; see `codex.ts`. */
2606
+ codexGate;
2607
+ constructor(config, hooks, spawnFn = spawn, execFn) {
2505
2608
  this.config = config;
2506
2609
  this.hooks = hooks;
2507
2610
  this.spawnFn = spawnFn;
2611
+ this.codexGate = new CodexGate(config.agent.binPath, execFn);
2508
2612
  }
2509
2613
  get enabled() {
2510
2614
  return this.config.agent.cli !== null;
@@ -2525,19 +2629,19 @@ var AgentRunner = class {
2525
2629
  this.runs = foldAgentLog(text);
2526
2630
  const at = (/* @__PURE__ */ new Date()).toISOString();
2527
2631
  const reason = "the dashboard restarted while this was running";
2528
- for (const run6 of this.runs) {
2529
- if (run6.status === "running") {
2530
- run6.status = "aborted";
2531
- run6.error = reason;
2532
- run6.endedAt = at;
2533
- await this.append({ event: "aborted", run: run6.id, sessionId: run6.sessionId, reason, report: run6.report, messages: run6.messages, at });
2632
+ for (const run5 of this.runs) {
2633
+ if (run5.status === "running") {
2634
+ run5.status = "aborted";
2635
+ run5.error = reason;
2636
+ run5.endedAt = at;
2637
+ await this.append({ event: "aborted", run: run5.id, sessionId: run5.sessionId, reason, report: run5.report, messages: run5.messages, at });
2534
2638
  }
2535
- for (const turn of run6.turns) {
2639
+ for (const turn of run5.turns) {
2536
2640
  if (turn.status !== "running") continue;
2537
2641
  turn.status = "aborted";
2538
2642
  turn.error = reason;
2539
2643
  turn.endedAt = at;
2540
- await this.append({ event: "turn-aborted", run: run6.id, turn: turn.id, reason, reply: turn.reply, at });
2644
+ await this.append({ event: "turn-aborted", run: run5.id, turn: turn.id, reason, reply: turn.reply, at });
2541
2645
  }
2542
2646
  }
2543
2647
  }
@@ -2550,7 +2654,7 @@ var AgentRunner = class {
2550
2654
  return this.runs.at(-1) ?? null;
2551
2655
  }
2552
2656
  view(now = /* @__PURE__ */ new Date()) {
2553
- const runs = this.runs.slice(-RUNS_SHOWN).reverse().map((run6) => ({ ...run6, resumable: this.resumable(run6) }));
2657
+ const runs = this.runs.slice(-RUNS_SHOWN).reverse().map((run5) => ({ ...run5, resumable: this.resumable(run5) }));
2554
2658
  return {
2555
2659
  enabled: this.enabled,
2556
2660
  cli: this.config.agent.cli,
@@ -2604,7 +2708,7 @@ var AgentRunner = class {
2604
2708
  );
2605
2709
  }
2606
2710
  await this.start();
2607
- const run6 = {
2711
+ const run5 = {
2608
2712
  id: randomUUID(),
2609
2713
  cli,
2610
2714
  trigger,
@@ -2618,8 +2722,8 @@ var AgentRunner = class {
2618
2722
  turns: [],
2619
2723
  resumable: false
2620
2724
  };
2621
- this.runs.push(run6);
2622
- await this.append({ event: "started", run: run6.id, cli, trigger, at: run6.startedAt });
2725
+ this.runs.push(run5);
2726
+ await this.append({ event: "started", run: run5.id, cli, trigger, at: run5.startedAt });
2623
2727
  const adapter = ADAPTERS[cli];
2624
2728
  const args = adapter.args({
2625
2729
  prompt: agentPrompt(displayPath(this.config.promptFile)),
@@ -2627,10 +2731,12 @@ var AgentRunner = class {
2627
2731
  model: this.config.agent.model,
2628
2732
  effort: this.config.agent.effort,
2629
2733
  tools: [...briefWritingTools(), ...this.config.agent.tools],
2630
- denyEdits: false
2734
+ denyEdits: false,
2735
+ drafts: false,
2736
+ codexConfig: []
2631
2737
  });
2632
- await this.spawnInto(run6, null, args, RUN_TIMEOUT_MS, "gave up after an hour");
2633
- return run6;
2738
+ await this.spawnInto(run5, null, args, RUN_TIMEOUT_MS, "gave up after an hour");
2739
+ return run5;
2634
2740
  }
2635
2741
  /**
2636
2742
  * Ask a finished run a question, in its own session. Returns once the process
@@ -2640,16 +2746,16 @@ var AgentRunner = class {
2640
2746
  const cli = this.config.agent.cli;
2641
2747
  if (!cli) throw new Error("running the morning agent is off; set DAILY_FOCUS_AGENT to codex or claude");
2642
2748
  await this.start();
2643
- const run6 = this.runs.find((candidate) => candidate.id === runId);
2644
- if (!run6) throw new Error("no such run");
2749
+ const run5 = this.runs.find((candidate) => candidate.id === runId);
2750
+ if (!run5) throw new Error("no such run");
2645
2751
  if (this.running) {
2646
2752
  throw new Error(
2647
2753
  this.running.turn ? "the morning agent is still answering a question" : "the morning agent is still running"
2648
2754
  );
2649
2755
  }
2650
- if (!this.resumable(run6)) {
2756
+ if (!this.resumable(run5)) {
2651
2757
  throw new Error(
2652
- run6.sessionId === null ? "this run left no session to continue" : `this run was made by ${run6.cli}, and the agent is ${cli} now`
2758
+ run5.sessionId === null ? "this run left no session to continue" : `this run was made by ${run5.cli}, and the agent is ${cli} now`
2653
2759
  );
2654
2760
  }
2655
2761
  const text = question.trim();
@@ -2663,19 +2769,21 @@ var AgentRunner = class {
2663
2769
  reply: "",
2664
2770
  error: null
2665
2771
  };
2666
- run6.turns.push(turn);
2667
- await this.append({ event: "asked", run: run6.id, turn: turn.id, question: text, at: turn.startedAt });
2772
+ run5.turns.push(turn);
2773
+ await this.append({ event: "asked", run: run5.id, turn: turn.id, question: text, at: turn.startedAt });
2668
2774
  const adapter = ADAPTERS[cli];
2669
2775
  const args = adapter.args({
2670
2776
  prompt: followUpPrompt(text),
2671
- sessionId: run6.sessionId,
2777
+ sessionId: run5.sessionId,
2672
2778
  model: this.config.agent.model,
2673
2779
  effort: this.config.agent.effort,
2674
2780
  // The sources, and not the brief: a chat is given nothing to write.
2675
2781
  tools: this.config.agent.tools,
2676
- denyEdits: true
2782
+ denyEdits: true,
2783
+ drafts: false,
2784
+ codexConfig: []
2677
2785
  });
2678
- await this.spawnInto(run6, turn, args, TURN_TIMEOUT_MS, "gave up after ten minutes");
2786
+ await this.spawnInto(run5, turn, args, TURN_TIMEOUT_MS, "gave up after ten minutes");
2679
2787
  return turn;
2680
2788
  }
2681
2789
  /**
@@ -2683,10 +2791,15 @@ var AgentRunner = class {
2683
2791
  * run when there is none. The store in both cases: Claude Code files a
2684
2792
  * session under the directory it was started in, and looks for it there.
2685
2793
  */
2686
- async spawnInto(run6, turn, args, timeoutMs, timeoutReason) {
2687
- const cli = run6.cli;
2794
+ async spawnInto(run5, turn, args, timeoutMs, timeoutReason) {
2795
+ const cli = run5.cli;
2688
2796
  const adapter = ADAPTERS[cli];
2689
2797
  await mkdir(this.config.dataDir, { recursive: true });
2798
+ const refusal2 = cli === "codex" ? await this.codexGate.refusal() : null;
2799
+ if (refusal2) {
2800
+ await this.finish(run5, turn, { ok: false, error: refusal2 });
2801
+ return;
2802
+ }
2690
2803
  let child;
2691
2804
  try {
2692
2805
  child = this.spawnFn(this.config.agent.binPath, args, {
@@ -2696,12 +2809,12 @@ var AgentRunner = class {
2696
2809
  env: process.env
2697
2810
  });
2698
2811
  } catch (err) {
2699
- await this.finish(run6, turn, { ok: false, error: this.spawnError(err) });
2812
+ await this.finish(run5, turn, { ok: false, error: this.spawnError(err) });
2700
2813
  return;
2701
2814
  }
2702
2815
  const timer = setTimeout(() => void this.stop(timeoutReason), timeoutMs);
2703
2816
  timer.unref();
2704
- const running = { run: run6, turn, child, timer, abortReason: null };
2817
+ const running = { run: run5, turn, child, timer, abortReason: null };
2705
2818
  this.running = running;
2706
2819
  this.hooks.onChange();
2707
2820
  let done;
@@ -2715,15 +2828,15 @@ var AgentRunner = class {
2715
2828
  lines.on("line", (line) => {
2716
2829
  const event = adapter.parse(line);
2717
2830
  if (!event) return;
2718
- if (event.sessionId && !turn) run6.sessionId = event.sessionId;
2831
+ if (event.sessionId && !turn) run5.sessionId = event.sessionId;
2719
2832
  if (event.text) {
2720
2833
  if (turn) {
2721
2834
  turn.reply = turn.reply ? `${turn.reply}
2722
2835
 
2723
2836
  ${event.text}` : event.text;
2724
2837
  } else {
2725
- run6.messages.push(event.text);
2726
- run6.report = event.text;
2838
+ run5.messages.push(event.text);
2839
+ run5.report = event.text;
2727
2840
  }
2728
2841
  this.hooks.onChange();
2729
2842
  }
@@ -2740,7 +2853,7 @@ ${event.text}` : event.text;
2740
2853
  ok: false,
2741
2854
  error: `${cli} exited with code ${code}${stderr.length ? `: ${stderr.join("").trim().split("\n").slice(-3).join(" ")}` : ""}`
2742
2855
  };
2743
- void this.finish(run6, turn, outcome, running.abortReason !== null);
2856
+ void this.finish(run5, turn, outcome, running.abortReason !== null);
2744
2857
  });
2745
2858
  }
2746
2859
  /**
@@ -2757,45 +2870,45 @@ ${event.text}` : event.text;
2757
2870
  async close() {
2758
2871
  await this.stop("the dashboard shut down");
2759
2872
  }
2760
- resumable(run6) {
2761
- return run6.status !== "running" && run6.sessionId !== null && run6.cli === this.config.agent.cli;
2873
+ resumable(run5) {
2874
+ return run5.status !== "running" && run5.sessionId !== null && run5.cli === this.config.agent.cli;
2762
2875
  }
2763
- async finish(run6, turn, outcome, aborted = false) {
2876
+ async finish(run5, turn, outcome, aborted = false) {
2764
2877
  const at = (/* @__PURE__ */ new Date()).toISOString();
2765
2878
  if (turn) {
2766
2879
  const reply = outcome.reply ?? turn.reply;
2767
2880
  turn.reply = reply;
2768
2881
  turn.endedAt = at;
2769
2882
  if (outcome.ok) {
2770
- await this.append({ event: "replied", run: run6.id, turn: turn.id, reply, at });
2883
+ await this.append({ event: "replied", run: run5.id, turn: turn.id, reply, at });
2771
2884
  turn.status = "done";
2772
2885
  } else if (aborted) {
2773
2886
  turn.error = outcome.error ?? "stopped";
2774
- await this.append({ event: "turn-aborted", run: run6.id, turn: turn.id, reason: turn.error, reply, at });
2887
+ await this.append({ event: "turn-aborted", run: run5.id, turn: turn.id, reason: turn.error, reply, at });
2775
2888
  turn.status = "aborted";
2776
2889
  } else {
2777
2890
  turn.error = outcome.error ?? "failed";
2778
- await this.append({ event: "turn-failed", run: run6.id, turn: turn.id, error: turn.error, reply, at });
2891
+ await this.append({ event: "turn-failed", run: run5.id, turn: turn.id, error: turn.error, reply, at });
2779
2892
  turn.status = "failed";
2780
2893
  }
2781
2894
  this.hooks.onChange();
2782
2895
  return;
2783
2896
  }
2784
- const report = outcome.reply ?? run6.report;
2785
- run6.report = report;
2786
- run6.endedAt = at;
2787
- const messages = run6.messages;
2897
+ const report = outcome.reply ?? run5.report;
2898
+ run5.report = report;
2899
+ run5.endedAt = at;
2900
+ const messages = run5.messages;
2788
2901
  if (outcome.ok) {
2789
- await this.append({ event: "finished", run: run6.id, sessionId: run6.sessionId, report, messages, at });
2790
- run6.status = "done";
2902
+ await this.append({ event: "finished", run: run5.id, sessionId: run5.sessionId, report, messages, at });
2903
+ run5.status = "done";
2791
2904
  } else if (aborted) {
2792
- run6.error = outcome.error ?? "stopped";
2793
- await this.append({ event: "aborted", run: run6.id, sessionId: run6.sessionId, reason: run6.error, report, messages, at });
2794
- run6.status = "aborted";
2905
+ run5.error = outcome.error ?? "stopped";
2906
+ await this.append({ event: "aborted", run: run5.id, sessionId: run5.sessionId, reason: run5.error, report, messages, at });
2907
+ run5.status = "aborted";
2795
2908
  } else {
2796
- run6.error = outcome.error ?? "failed";
2797
- await this.append({ event: "failed", run: run6.id, sessionId: run6.sessionId, error: run6.error, report, messages, at });
2798
- run6.status = "failed";
2909
+ run5.error = outcome.error ?? "failed";
2910
+ await this.append({ event: "failed", run: run5.id, sessionId: run5.sessionId, error: run5.error, report, messages, at });
2911
+ run5.status = "failed";
2799
2912
  }
2800
2913
  this.hooks.onChange();
2801
2914
  }
@@ -2909,33 +3022,6 @@ function refusal(req, listenHost) {
2909
3022
  return null;
2910
3023
  }
2911
3024
 
2912
- // src/presence.ts
2913
- import { execFile as execFile4 } from "node:child_process";
2914
- import { platform } from "node:os";
2915
- import { promisify as promisify4 } from "node:util";
2916
- var run4 = promisify4(execFile4);
2917
- var PROBE_TIMEOUT_MS = 4e3;
2918
- var HID_IDLE = /"HIDIdleTime"\s*=\s*(\d+)/;
2919
- var readIdleSeconds = async () => {
2920
- if (platform() !== "darwin") return null;
2921
- try {
2922
- const { stdout } = await run4("ioreg", ["-c", "IOHIDSystem", "-d", "1", "-r"], {
2923
- timeout: PROBE_TIMEOUT_MS,
2924
- maxBuffer: 1024 * 1024
2925
- });
2926
- return parseIdleSeconds(stdout);
2927
- } catch {
2928
- return null;
2929
- }
2930
- };
2931
- function parseIdleSeconds(ioregOutput) {
2932
- const match = HID_IDLE.exec(ioregOutput);
2933
- if (!match) return null;
2934
- const nanoseconds = Number(match[1]);
2935
- if (!Number.isFinite(nanoseconds) || nanoseconds < 0) return null;
2936
- return nanoseconds / 1e9;
2937
- }
2938
-
2939
3025
  // src/settings.ts
2940
3026
  var SETTING_GROUPS = [
2941
3027
  { id: "instance", title: "This dashboard" },
@@ -2944,8 +3030,7 @@ var SETTING_GROUPS = [
2944
3030
  { id: "github", title: "Pull requests" },
2945
3031
  { id: "jira", title: "Jira tickets" },
2946
3032
  { id: "calendar", title: "Calendar" },
2947
- { id: "day", title: "Working day" },
2948
- { id: "sessions", title: "Focus sessions" }
3033
+ { id: "day", title: "Working day" }
2949
3034
  ];
2950
3035
  var OFF_OR_CLI = [
2951
3036
  { value: "off", label: "Off" },
@@ -2959,7 +3044,7 @@ var SETTINGS = [
2959
3044
  key: "DAILY_FOCUS_PROFILE",
2960
3045
  group: "instance",
2961
3046
  label: "What this dashboard briefs",
2962
- help: "Work or home. Picks the morning prompt and the defaults below: the personal profile switches off Jira, free time and away detection.",
3047
+ help: "Work or home. Picks the morning prompt and the defaults below: the personal profile switches off Jira and free time.",
2963
3048
  kind: "choice",
2964
3049
  choices: PROFILES.map((value) => ({ value, label: value === "work" ? "Work" : "Personal life" })),
2965
3050
  fallback: always("Work")
@@ -3010,7 +3095,7 @@ var SETTINGS = [
3010
3095
  key: "DAILY_FOCUS_AGENT_TOOLS",
3011
3096
  group: "agent",
3012
3097
  label: "Tools Claude Code may use",
3013
- help: "In Claude Code's permission syntax, comma-separated. Add your calendar, chat and document connectors by name, or the brief reports them as unreachable. Ignored by Codex.",
3098
+ help: "In Claude Code's permission syntax, comma-separated. Add your calendar, chat and document connectors by name, or the brief reports them as unreachable. The default allows every Gmail tool, sending included; name the tools you want instead to narrow it. Ignored by Codex.",
3014
3099
  kind: "list",
3015
3100
  fallback: always("Bash(gh *), Bash(acli *), WebFetch, mcp__claude_ai_Gmail"),
3016
3101
  advanced: true
@@ -3037,7 +3122,7 @@ var SETTINGS = [
3037
3122
  key: "DAILY_FOCUS_ASSISTANT",
3038
3123
  group: "assistant",
3039
3124
  label: "Answer questions with",
3040
- help: "Puts an assistant in every item's panel. It reads and drafts, and never sends, posts or edits.",
3125
+ help: "Puts an assistant in every item's panel, told to read and draft and never to send, post or edit. Under Codex, every connector write but a Gmail draft is refused.",
3041
3126
  kind: "choice",
3042
3127
  choices: OFF_OR_CLI,
3043
3128
  fallback: always("Off")
@@ -3063,11 +3148,36 @@ var SETTINGS = [
3063
3148
  key: "DAILY_FOCUS_ASSISTANT_TOOLS",
3064
3149
  group: "assistant",
3065
3150
  label: "Tools Claude Code may use",
3066
- help: "In Claude Code's permission syntax, comma-separated. Editing tools are denied whatever this says. Ignored by Codex.",
3151
+ help: "In Claude Code's permission syntax, comma-separated. Editing tools are denied whatever this says. The default allows every Gmail tool, sending included; name the tools you want instead to narrow it. Ignored by Codex.",
3067
3152
  kind: "list",
3068
3153
  fallback: always("Bash(gh *), Bash(acli *), WebFetch, mcp__claude_ai_Gmail"),
3069
3154
  advanced: true
3070
3155
  },
3156
+ {
3157
+ key: "DAILY_FOCUS_ASSISTANT_COMPUTER_USE",
3158
+ group: "assistant",
3159
+ label: "Computer use",
3160
+ help: "Codex only. On lets the assistant use Codex's computer-use and browser plugins, which act in your apps and browser with your sign-ins. Off switches them off for its runs. Claude Code uses only the tools in its list.",
3161
+ kind: "flag",
3162
+ fallback: always("Off")
3163
+ },
3164
+ {
3165
+ key: "DAILY_FOCUS_ASSISTANT_AUTO",
3166
+ group: "assistant",
3167
+ label: "Answer ahead on pull requests",
3168
+ help: "When a pull request starts waiting on you because of a review or a failing check, asks the assistant about it before you do, so the answer is on the row when you look. Only reads; it never drafts. It spends the CLI\u2019s usage without you pressing anything.",
3169
+ kind: "flag",
3170
+ fallback: always("Off")
3171
+ },
3172
+ {
3173
+ key: "DAILY_FOCUS_ASSISTANT_AUTO_LIMIT",
3174
+ group: "assistant",
3175
+ label: "Answer ahead at most",
3176
+ help: "Times a day the assistant may start on its own, counted on the dashboard\u2019s calendar.",
3177
+ kind: "number",
3178
+ fallback: always("10 a day"),
3179
+ advanced: true
3180
+ },
3071
3181
  {
3072
3182
  key: "DAILY_FOCUS_ASSISTANT_BIN",
3073
3183
  group: "assistant",
@@ -3081,7 +3191,7 @@ var SETTINGS = [
3081
3191
  key: "DAILY_FOCUS_GITHUB",
3082
3192
  group: "github",
3083
3193
  label: "Pull request board",
3084
- help: "Reads your open pull requests through the GitHub CLI. Sign in with gh auth login.",
3194
+ help: "Reads your open pull requests through the GitHub CLI. Sign in with gh auth login. The Jira ticket board uses it too, to catch merges Jira hasn't heard about yet.",
3085
3195
  kind: "flag",
3086
3196
  fallback: always("On")
3087
3197
  },
@@ -3260,22 +3370,6 @@ var SETTINGS = [
3260
3370
  help: "Minutes a gap has to last to count as time for focused work.",
3261
3371
  kind: "number",
3262
3372
  fallback: always("45 minutes")
3263
- },
3264
- {
3265
- key: "DAILY_FOCUS_SESSION_MINUTES",
3266
- group: "sessions",
3267
- label: "Session length",
3268
- help: "Minutes a focus session is set for when you start one.",
3269
- kind: "number",
3270
- fallback: always("25 minutes")
3271
- },
3272
- {
3273
- key: "DAILY_FOCUS_AWAY_AFTER",
3274
- group: "sessions",
3275
- label: "End a session when away for",
3276
- help: "Minutes without touching the Mac before a session is closed where you left. 0 turns it off.",
3277
- kind: "number",
3278
- fallback: (profile) => profile === "personal" ? "0, off" : "10 minutes"
3279
3373
  }
3280
3374
  ];
3281
3375
  var BY_KEY = new Map(SETTINGS.map((def) => [def.key, def]));
@@ -3338,11 +3432,11 @@ async function saveSettings(path, settings) {
3338
3432
  }
3339
3433
 
3340
3434
  // src/setup.ts
3341
- import { execFile as execFile5 } from "node:child_process";
3435
+ import { execFile as execFile4 } from "node:child_process";
3342
3436
  import { access, constants, readdir as readdir2, readFile as readFile7, stat as stat3 } from "node:fs/promises";
3343
3437
  import { delimiter, isAbsolute, join as join3 } from "node:path";
3344
- import { promisify as promisify5 } from "node:util";
3345
- var run5 = promisify5(execFile5);
3438
+ import { promisify as promisify4 } from "node:util";
3439
+ var run4 = promisify4(execFile4);
3346
3440
  var PLACEHOLDERS = [
3347
3441
  "Your Name",
3348
3442
  "you@example.com",
@@ -3432,11 +3526,11 @@ var identity = null;
3432
3526
  function detectIdentity(config) {
3433
3527
  identity ??= (async () => {
3434
3528
  const [user, email] = await Promise.all([
3435
- run5(config.github.ghPath, ["api", "user", "--jq", '[.login, .name // ""] | join("\\n")'], { timeout: 5e3 }).then(
3529
+ run4(config.github.ghPath, ["api", "user", "--jq", '[.login, .name // ""] | join("\\n")'], { timeout: 5e3 }).then(
3436
3530
  ({ stdout }) => stdout.split("\n"),
3437
3531
  () => []
3438
3532
  ),
3439
- run5("git", ["config", "--global", "user.email"], { timeout: 2e3 }).then(
3533
+ run4("git", ["config", "--global", "user.email"], { timeout: 2e3 }).then(
3440
3534
  ({ stdout }) => stdout.trim() || null,
3441
3535
  () => null
3442
3536
  )
@@ -3485,8 +3579,8 @@ var defaultExec = (file, args, { timeout }) => new Promise((resolve2, reject) =>
3485
3579
  child.on("error", (error) => finish(error));
3486
3580
  child.on("close", () => finish(null));
3487
3581
  });
3488
- function shellCandidates(envShell, loginShell, platform2) {
3489
- const fallback = platform2 === "darwin" ? "/bin/zsh" : platform2 === "linux" ? "/bin/bash" : void 0;
3582
+ function shellCandidates(envShell, loginShell, platform) {
3583
+ const fallback = platform === "darwin" ? "/bin/zsh" : platform === "linux" ? "/bin/bash" : void 0;
3490
3584
  const shells = [];
3491
3585
  for (const shell of [envShell, loginShell, fallback]) {
3492
3586
  const trimmed = shell?.trim();
@@ -3531,11 +3625,11 @@ function currentLoginShell() {
3531
3625
  return void 0;
3532
3626
  }
3533
3627
  }
3534
- async function hydratePath(env = process.env, platform2 = process.platform, exec = defaultExec, loginShell = currentLoginShell()) {
3535
- if (platform2 === "win32") return null;
3628
+ async function hydratePath(env = process.env, platform = process.platform, exec = defaultExec, loginShell = currentLoginShell()) {
3629
+ if (platform === "win32") return null;
3536
3630
  let found = null;
3537
3631
  let from = null;
3538
- for (const shell of shellCandidates(env.SHELL, loginShell, platform2)) {
3632
+ for (const shell of shellCandidates(env.SHELL, loginShell, platform)) {
3539
3633
  try {
3540
3634
  found = await readLoginShellPath(shell, exec);
3541
3635
  } catch {
@@ -3545,7 +3639,7 @@ async function hydratePath(env = process.env, platform2 = process.platform, exec
3545
3639
  break;
3546
3640
  }
3547
3641
  }
3548
- if (!found && platform2 === "darwin") {
3642
+ if (!found && platform === "darwin") {
3549
3643
  found = await readLaunchctlPath(exec);
3550
3644
  if (found) from = "launchctl";
3551
3645
  }
@@ -3571,15 +3665,8 @@ var TEXT_FILES = {
3571
3665
  "/api/text/focus": { file: "focusFile", name: "focus.md" },
3572
3666
  "/api/text/sources": { file: "sourcesFile", name: "sources.md" }
3573
3667
  };
3574
- var VALID_ACTIONS = /* @__PURE__ */ new Set([
3575
- "done",
3576
- "snooze",
3577
- "dismiss",
3578
- "reopen",
3579
- "note"
3580
- ]);
3668
+ var VALID_ACTIONS = new Set(ACTION_TYPES);
3581
3669
  var MAX_BODY_BYTES = 64 * 1024;
3582
- var PRESENCE_POLL_MS = 3e4;
3583
3670
  var AGENT_CLOCK_MS = 3e4;
3584
3671
  function sendJSON(res, status, body) {
3585
3672
  const payload = JSON.stringify(body);
@@ -3686,6 +3773,8 @@ async function startServer(env, options = {}) {
3686
3773
  const store = new Store(config);
3687
3774
  await store.ensureDataDir();
3688
3775
  const storeId = await store.fingerprint();
3776
+ const zone = new TimeZoneKeeper(config.timeZoneFile, options.readSystemZone);
3777
+ await zone.start();
3689
3778
  for (const outcome of await linkStore(config)) {
3690
3779
  if (outcome.result === "linked") console.log(`[daily-focus] linked ${describeLink(config, outcome)}`);
3691
3780
  if (outcome.result === "failed") console.warn(`[daily-focus] could not link ${outcome.name}: ${outcome.detail}`);
@@ -3700,21 +3789,48 @@ async function startServer(env, options = {}) {
3700
3789
  });
3701
3790
  const subscribers = /* @__PURE__ */ new Set();
3702
3791
  let assetVersion = await computeAssetVersion(PUBLIC_DIR);
3703
- const board = new Board(config, () => void broadcast(), options.integrations?.github);
3792
+ let autoAsk = null;
3793
+ const board = new Board(
3794
+ config,
3795
+ () => {
3796
+ void broadcast();
3797
+ autoAsk?.poke();
3798
+ },
3799
+ options.integrations?.github
3800
+ );
3704
3801
  const calendar = new CalendarBoard(config, () => void broadcast());
3705
3802
  const tickets = new TicketBoard(config, () => void broadcast(), options.integrations?.jira);
3706
3803
  const assistant = new Assistant(config, {
3707
3804
  onChange: () => {
3708
3805
  void broadcast();
3709
3806
  restartWhenIdle();
3807
+ autoAsk?.poke();
3710
3808
  }
3711
3809
  });
3810
+ autoAsk = new AutoAsk({
3811
+ config,
3812
+ assistant,
3813
+ rows: async () => board.view(await store.readActions(), /* @__PURE__ */ new Date()).rows,
3814
+ context: async (id) => contextFor(id, await buildState()),
3815
+ // Nothing new starts while saved settings wait on the restart that applies them.
3816
+ canStart: () => restart !== "waiting"
3817
+ });
3818
+ const ahead = autoAsk;
3712
3819
  const agent = new AgentRunner(config, {
3713
3820
  onChange: () => {
3714
3821
  void broadcast();
3715
3822
  restartWhenIdle();
3716
3823
  }
3717
3824
  });
3825
+ let codexWarnings = [];
3826
+ const codexBins = /* @__PURE__ */ new Set();
3827
+ if (config.agent.cli === "codex") codexBins.add(config.agent.binPath);
3828
+ if (config.assistant.agent === "codex") codexBins.add(config.assistant.binPath);
3829
+ void Promise.all([...codexBins].map((bin) => codexVersionWarning(bin))).then((found) => {
3830
+ codexWarnings = [...new Set(found.filter((warning) => warning !== null))];
3831
+ for (const warning of codexWarnings) console.warn(`[daily-focus] ${warning}`);
3832
+ if (codexWarnings.length > 0) void broadcast();
3833
+ });
3718
3834
  function requestRestart() {
3719
3835
  restart = options.onRestart ? "waiting" : "manual";
3720
3836
  restartWhenIdle();
@@ -3736,19 +3852,27 @@ async function startServer(env, options = {}) {
3736
3852
  const setup = await setupState(config, {
3737
3853
  profileChosen: Boolean(layerEnv(sources).DAILY_FOCUS_PROFILE?.trim())
3738
3854
  });
3855
+ const pulls = board.view(actions, now);
3739
3856
  return {
3740
3857
  ...state,
3741
- board: board.view(actions, now),
3858
+ board: pulls,
3742
3859
  tickets: tickets.view(actions, now),
3743
- assistant: assistant.view(),
3860
+ assistant: { ...assistant.view(), auto: ahead.view(pulls.rows) },
3744
3861
  agentRun: agent.view(now),
3745
- setupWarnings: [...configWarnings, ...linkWarning ? [linkWarning] : []],
3862
+ setupWarnings: [...configWarnings, ...codexWarnings, ...linkWarning ? [linkWarning] : []],
3746
3863
  restart,
3747
3864
  setup,
3748
3865
  assetVersion,
3749
3866
  storeId
3750
3867
  };
3751
3868
  }
3869
+ function contextFor(id, current) {
3870
+ return {
3871
+ item: current.items.find((item) => item.id === id),
3872
+ pull: current.board.rows.find((row) => row.id === id),
3873
+ ticket: current.tickets.rows.find((row) => row.id === id) ?? current.tickets.inProgress.find((row) => row.id === id)
3874
+ };
3875
+ }
3752
3876
  async function broadcast(prebuilt) {
3753
3877
  if (subscribers.size === 0) return;
3754
3878
  let payload;
@@ -3770,17 +3894,17 @@ data: ${payload}
3770
3894
  () => {
3771
3895
  void store.archiveCurrentBrief().then(() => broadcast());
3772
3896
  },
3773
- // The timer re-stamps its own file every poll and the two boards rewrite
3774
- // prs.json and tickets.json every fetch; that's this process talking to
3775
- // itself, and all three already broadcast when something actually changed.
3897
+ // The two boards rewrite prs.json and tickets.json every fetch; that's this
3898
+ // process talking to itself, and both already broadcast when something
3899
+ // actually changed.
3776
3900
  {
3777
3901
  ignore: [
3778
- basename(config.sessionFile),
3779
- basename(tempPathFor(config.sessionFile)),
3780
3902
  basename(config.pullsFile),
3781
3903
  basename(tempPathFor(config.pullsFile)),
3782
3904
  basename(config.ticketsFile),
3783
3905
  basename(tempPathFor(config.ticketsFile)),
3906
+ basename(config.timeZoneFile),
3907
+ basename(tempPathFor(config.timeZoneFile)),
3784
3908
  // Appended by this process, which broadcasts on its own.
3785
3909
  basename(config.assistantLogFile),
3786
3910
  basename(config.agentLogFile)
@@ -3794,47 +3918,34 @@ data: ${payload}
3794
3918
  void broadcast();
3795
3919
  });
3796
3920
  });
3797
- let sessionTick = null;
3798
- function scheduleSessionTick(endsAt) {
3799
- if (sessionTick) clearTimeout(sessionTick);
3800
- sessionTick = null;
3801
- if (!endsAt) return;
3802
- const delay = new Date(endsAt).getTime() - Date.now();
3803
- if (delay <= 0 || delay > 36e5) return;
3804
- sessionTick = setTimeout(() => void broadcast(), delay);
3805
- sessionTick.unref();
3806
- }
3807
- async function checkPresence() {
3808
- let closed;
3809
- try {
3810
- closed = await reconcileSession(config, readIdleSeconds);
3811
- } catch (err) {
3812
- console.error(`[daily-focus] presence check failed: ${err.message}`);
3813
- return;
3814
- }
3815
- if (!closed) return;
3816
- console.log(
3817
- `[daily-focus] stopped "${closed.title}" at ${closed.endedAt} after ${closed.actualMinutes} min \u2014 machine untouched since then`
3818
- );
3819
- scheduleSessionTick(null);
3820
- await broadcast();
3821
- }
3822
- await checkPresence();
3823
3921
  await assistant.start();
3824
3922
  await agent.start();
3825
- async function clockTick() {
3923
+ let ticks = Promise.resolve();
3924
+ function clockTick() {
3925
+ ticks = ticks.then(tickOnce).catch((err) => {
3926
+ console.error(`[daily-focus] the morning clock failed: ${err.message}`);
3927
+ });
3928
+ return ticks;
3929
+ }
3930
+ async function tickOnce() {
3931
+ if (await zone.checkSystem()) zoneMoved();
3826
3932
  if (restart === "waiting" || await firstBriefPending(config)) return;
3827
3933
  await agent.tick();
3828
3934
  }
3935
+ function zoneMoved() {
3936
+ console.log(`[daily-focus] time zone is now ${zone.current}`);
3937
+ void calendar.refresh();
3938
+ void broadcast();
3939
+ }
3829
3940
  void clockTick();
3830
3941
  const agentClock = setInterval(() => {
3831
3942
  void clockTick();
3832
3943
  }, AGENT_CLOCK_MS);
3833
3944
  agentClock.unref();
3834
- scheduleSessionTick((await buildState()).session.active?.endsAt ?? null);
3835
3945
  void board.start().catch((err) => {
3836
3946
  console.error(`[daily-focus] pull request board failed to start: ${err.message}`);
3837
3947
  });
3948
+ ahead.poke();
3838
3949
  void calendar.start().catch((err) => {
3839
3950
  console.error(`[daily-focus] live agenda failed to start: ${err.message}`);
3840
3951
  });
@@ -3845,10 +3956,6 @@ data: ${payload}
3845
3956
  void broadcast();
3846
3957
  }, 6e4);
3847
3958
  heartbeat.unref();
3848
- const presencePoll = setInterval(() => {
3849
- void checkPresence();
3850
- }, PRESENCE_POLL_MS);
3851
- presencePoll.unref();
3852
3959
  const server = createServer((req, res) => {
3853
3960
  void handle(req, res).catch((err) => {
3854
3961
  console.error(`[daily-focus] ${req.method} ${req.url} failed:`, err);
@@ -3909,6 +4016,26 @@ data: ${JSON.stringify(await buildState())}
3909
4016
  sendState(res, await buildState());
3910
4017
  return;
3911
4018
  }
4019
+ if (path === "/api/timezone" && req.method === "POST") {
4020
+ let body;
4021
+ try {
4022
+ body = JSON.parse(await readBody(req));
4023
+ } catch (err) {
4024
+ sendJSON(res, 400, { error: `invalid JSON body: ${err.message}` });
4025
+ return;
4026
+ }
4027
+ const timeZone = validZone(body?.timeZone);
4028
+ if (!timeZone) {
4029
+ sendJSON(res, 400, { error: '"timeZone" must be an IANA time zone, such as Europe/Copenhagen' });
4030
+ return;
4031
+ }
4032
+ if (await zone.report(timeZone)) {
4033
+ zoneMoved();
4034
+ await clockTick();
4035
+ }
4036
+ sendState(res, await buildState());
4037
+ return;
4038
+ }
3912
4039
  if (path === "/api/calendar/refresh" && req.method === "POST") {
3913
4040
  await calendar.refresh();
3914
4041
  sendState(res, await buildState());
@@ -3975,12 +4102,7 @@ data: ${JSON.stringify(await buildState())}
3975
4102
  sendJSON(res, 409, { error: "the assistant is off; set DAILY_FOCUS_ASSISTANT to claude or codex" });
3976
4103
  return;
3977
4104
  }
3978
- const current = await buildState();
3979
- const context = {
3980
- item: current.items.find((item) => item.id === id),
3981
- pull: current.board.rows.find((row) => row.id === id),
3982
- ticket: current.tickets.rows.find((row) => row.id === id) ?? current.tickets.inProgress.find((row) => row.id === id)
3983
- };
4105
+ const context = contextFor(id, await buildState());
3984
4106
  if (!context.item && !context.pull && !context.ticket) {
3985
4107
  sendJSON(res, 404, { error: `no item with id ${id}` });
3986
4108
  return;
@@ -4016,8 +4138,8 @@ data: ${JSON.stringify(await buildState())}
4016
4138
  sendJSON(res, 400, { error: `invalid JSON body: ${err.message}` });
4017
4139
  return;
4018
4140
  }
4019
- const { run: run6, text } = typeof body === "object" && body !== null ? body : {};
4020
- if (typeof run6 !== "string" || run6 === "") {
4141
+ const { run: run5, text } = typeof body === "object" && body !== null ? body : {};
4142
+ if (typeof run5 !== "string" || run5 === "") {
4021
4143
  sendJSON(res, 400, { error: "run must be a run id" });
4022
4144
  return;
4023
4145
  }
@@ -4026,7 +4148,7 @@ data: ${JSON.stringify(await buildState())}
4026
4148
  return;
4027
4149
  }
4028
4150
  try {
4029
- await agent.ask(run6, text);
4151
+ await agent.ask(run5, text);
4030
4152
  } catch (err) {
4031
4153
  sendJSON(res, 409, { error: err.message });
4032
4154
  return;
@@ -4096,53 +4218,6 @@ data: ${JSON.stringify(await buildState())}
4096
4218
  void broadcast(state);
4097
4219
  return;
4098
4220
  }
4099
- if (path === "/api/session" && req.method === "POST") {
4100
- let body;
4101
- try {
4102
- body = JSON.parse(await readBody(req));
4103
- } catch (err) {
4104
- sendJSON(res, 400, { error: `invalid JSON body: ${err.message}` });
4105
- return;
4106
- }
4107
- if (typeof body !== "object" || body === null) {
4108
- sendJSON(res, 400, { error: "body must be an object" });
4109
- return;
4110
- }
4111
- const { action, id, minutes } = body;
4112
- if (action === "stop") {
4113
- await stopSession(config);
4114
- } else if (action === "start") {
4115
- if (typeof id !== "string" || id === "") {
4116
- sendJSON(res, 400, { error: '"id" is required to start a session' });
4117
- return;
4118
- }
4119
- const { brief } = await store.readBrief();
4120
- const item = brief?.items.find((candidate) => candidate.id === id);
4121
- if (!item) {
4122
- sendJSON(res, 404, { error: `no item with id ${id}` });
4123
- return;
4124
- }
4125
- const requested = typeof minutes === "number" && Number.isFinite(minutes) ? Math.round(minutes) : config.sessionMinutes;
4126
- if (requested < 1 || requested > 120) {
4127
- sendJSON(res, 400, { error: '"minutes" must be between 1 and 120' });
4128
- return;
4129
- }
4130
- await startSession(config, {
4131
- id,
4132
- title: item.title,
4133
- minutes: requested,
4134
- advancesObjective: item.advancesObjective === true
4135
- });
4136
- } else {
4137
- sendJSON(res, 400, { error: '"action" must be "start" or "stop"' });
4138
- return;
4139
- }
4140
- const state = await buildState();
4141
- sendState(res, state);
4142
- scheduleSessionTick(state.session.active?.endsAt ?? null);
4143
- void broadcast(state);
4144
- return;
4145
- }
4146
4221
  if (path === "/api/settings" && req.method === "GET") {
4147
4222
  sendJSON(res, 200, { ...describeSettings(readSources()), restart });
4148
4223
  return;
@@ -4270,9 +4345,8 @@ ${entry.source}`)).map(({ title, source }) => ({ title, source })).sort((a, b) =
4270
4345
  }
4271
4346
  async function stopEverything() {
4272
4347
  clearInterval(heartbeat);
4273
- clearInterval(presencePoll);
4274
4348
  clearInterval(agentClock);
4275
- if (sessionTick) clearTimeout(sessionTick);
4349
+ ahead.close();
4276
4350
  board.stop();
4277
4351
  calendar.stop();
4278
4352
  tickets.stop();