teapot-coding-agent 0.37.58 → 0.37.60

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 (43) hide show
  1. package/dist/acp/adapter.js +4 -72
  2. package/dist/agent/agent.js +52 -2246
  3. package/dist/agent/compact-rules.js +0 -53
  4. package/dist/agent/editorconfig.js +4 -56
  5. package/dist/agent/goal-audit.js +0 -34
  6. package/dist/agent/key-pool.js +0 -52
  7. package/dist/agent/kill.js +1 -13
  8. package/dist/agent/llm.js +8 -309
  9. package/dist/agent/mcp-oauth-manager.js +2 -255
  10. package/dist/agent/mcp-registry.js +1 -188
  11. package/dist/agent/mcp.js +1 -85
  12. package/dist/agent/meta-tools.js +0 -26
  13. package/dist/agent/plugins.js +1 -81
  14. package/dist/agent/provider-headers.js +1 -28
  15. package/dist/agent/skills.js +8 -94
  16. package/dist/agent/tools.js +16 -809
  17. package/dist/bus.js +1 -2
  18. package/dist/config-schema.js +0 -40
  19. package/dist/home.js +1 -19
  20. package/dist/index.js +0 -136
  21. package/dist/log/events.js +3 -195
  22. package/dist/master.js +31 -1176
  23. package/dist/migrations/index.js +0 -55
  24. package/dist/migrations/providers.js +0 -13
  25. package/dist/migrations/registry.js +0 -9
  26. package/dist/migrations/session-titles.js +3 -43
  27. package/dist/migrations/sessions.js +2 -20
  28. package/dist/model-meta.js +2 -74
  29. package/dist/scheduler/cron.js +2 -17
  30. package/dist/server/api.js +12 -778
  31. package/dist/server/image-resize.js +1 -64
  32. package/dist/server/term-size.js +1 -120
  33. package/dist/server/terminals.js +0 -159
  34. package/dist/session-title.js +2 -35
  35. package/package.json +1 -1
  36. package/public/assets/index-DhJx7VDq.js +36 -0
  37. package/public/assets/index-DhJx7VDq.js.map +1 -0
  38. package/public/assets/{shiki-BaLUHSNL.js → shiki-LBMkaWYb.js} +2 -2
  39. package/public/assets/shiki-LBMkaWYb.js.map +1 -0
  40. package/public/index.html +1 -1
  41. package/public/assets/index-D_f1aal3.js +0 -36
  42. package/public/assets/index-D_f1aal3.js.map +0 -1
  43. package/public/assets/shiki-BaLUHSNL.js.map +0 -1
@@ -1,40 +1,3 @@
1
- /**
2
- * Ordered, one-way migrations for on-disk state.
3
- *
4
- * ## Why order and idempotency matter more than the transformations
5
- *
6
- * A single upgrade path is easy. The hard case is a machine that skips several releases: an
7
- * operator on v0.20 upgrading straight to v0.40 runs EVERY migration in between at once. Two
8
- * migrations that each look correct can then be wrong together — the canonical example is a future
9
- * `config.json` split combined with today's "store the session title in `agents[]`":
10
- *
11
- * split first → titles land in the new config directory, then the old file is read again and
12
- * the titles are written a second time, or to the wrong file;
13
- * titles first → the split then has to carry a field it never knew about.
14
- *
15
- * So each migration here:
16
- *
17
- * - is **idempotent** — running it twice is a no-op, because a crash mid-upgrade must be
18
- * resumable and a re-run must not double-apply;
19
- * - declares an **order** — `order` is ascending, and a later step may assume every earlier one
20
- * has run;
21
- * - never deletes data it does not fully replace (a rename, not a delete-then-write);
22
- * - reports what it did, so a boot log shows which steps actually ran.
23
- *
24
- * ## What this module is NOT
25
- *
26
- * It does not decide WHICH migrations exist — each one lives in its own file and is registered in
27
- * `index.ts`. Keeping them out of `master.ts` is the point: they were spread across the boot path,
28
- * where an ordering mistake is invisible.
29
- */
30
- /**
31
- * Run every registered migration in `order`, skipping nothing.
32
- *
33
- * There is no "already ran" gate on purpose: each step is idempotent, so the cheapest correct
34
- * policy is to always run them. A recorded version would have to be validated against a config the
35
- * operator may have hand-edited or restored from backup, and a wrong version claim is how a
36
- * migration silently does not run.
37
- */
38
1
  export async function runMigrations(migrations, ctx) {
39
2
  const applied = [];
40
3
  for (const m of [...migrations].sort((a, b) => a.order - b.order)) {
@@ -45,26 +8,8 @@ export async function runMigrations(migrations, ctx) {
45
8
  }
46
9
  }
47
10
  catch (err) {
48
- // One broken step must not block the boot: the session data is still readable, and refusing
49
- // to start would be worse than starting with one conversion unmade.
50
11
  ctx.log(`migration ${m.id} FAILED (${err.message}) — continuing without it`);
51
12
  }
52
13
  }
53
14
  return { applied };
54
15
  }
55
- /**
56
- * ## What is deliberately NOT here
57
- *
58
- * Two migrations stay in `src/agent/agent.ts` because they are per-SESSION filesystem moves that
59
- * need the agent's own paths, and they already satisfy the rules above:
60
- *
61
- * - `migrateGoalFromWorkspace` — `<workspace>/GOAL.md` → the session's goal store. Deletes the
62
- * legacy file only after a successful write, so a failure re-runs.
63
- * - `migrateMemoryToAgentScope` — a session's `memory.md` → the agent-wide one. APPENDS (the
64
- * agent-wide file may already hold other sessions' notes), then removes the source. A missing
65
- * write leaves the session copy in place.
66
- *
67
- * Moving them here would mean handing this module session paths it has no business knowing. What
68
- * matters is that they obey the same contract: idempotent, no data destroyed that was not
69
- * replaced, and ordered after the config-level steps (they run at agent start, which is later).
70
- */
@@ -1,15 +1,4 @@
1
1
  import { migrateProviderKeys } from "../master.js";
2
- /**
3
- * Fold a legacy single `apiKey` into an `apiKeys` array.
4
- *
5
- * `apiKey` is KEPT in step with `apiKeys[0]` rather than deleted: other readers still expect it (the
6
- * setup wizard, `/api/providers`, hand-edited files), and a migration that removes a field the
7
- * operator can see is one they will report as data loss.
8
- *
9
- * Idempotent because `apiKeys` is AUTHORITATIVE once present: re-running on a migrated entry
10
- * produces the same list. The single `apiKey` only SEEDS the pool when there is no list — appending
11
- * it as well would resurrect a credential the operator had already replaced.
12
- */
13
2
  export const providerKeys = {
14
3
  order: 10,
15
4
  id: "providers-api-keys",
@@ -25,8 +14,6 @@ export const providerKeys = {
25
14
  continue;
26
15
  const p = entry;
27
16
  const before = JSON.stringify(p);
28
- // ONE implementation of the folding rule, shared with the runtime normaliser — a second copy
29
- // here is how the two drift and disagree about which key is authoritative.
30
17
  const next = migrateProviderKeys(p);
31
18
  for (const k of Object.keys(p))
32
19
  delete p[k];
@@ -1,14 +1,5 @@
1
1
  import { providerKeys } from "./providers.js";
2
2
  import { flatSessionLayout } from "./sessions.js";
3
3
  import { sessionTitles } from "./session-titles.js";
4
- /**
5
- * Every migration, in application order.
6
- *
7
- * Add new ones with a HIGHER `order` than everything here, and never renumber an existing one — a
8
- * machine that already ran it would skip it under the new number.
9
- *
10
- * Ordering rule: a migration may assume every lower `order` has run. That is what makes a
11
- * multi-release jump safe. If two migrations must not be reordered later, say so in both files.
12
- */
13
4
  export const MIGRATIONS = [providerKeys, flatSessionLayout, sessionTitles];
14
5
  export { runMigrations } from "./index.js";
@@ -1,20 +1,5 @@
1
1
  import { closeSync, openSync, readSync } from "node:fs";
2
2
  import path from "node:path";
3
- /**
4
- * Derive each agent's session title ONCE and store it in `config.agents[].sessionTitle`.
5
- *
6
- * Titles used to be recomputed on every `allSessions()` — opening up to 830 session logs and
7
- * allocating a 16KB buffer for each, MEASURED at 83.8ms, 8.1MB read and ~38MB of short-lived
8
- * garbage per scan, on every `/api/sessions`. Storing it makes listing a config lookup.
9
- *
10
- * Runs ONCE PER SESSION rather than once per process: the check is `sessionTitle` on the agent's
11
- * CURRENT session, so a machine that has already been through this does no file I/O at all. That
12
- * is what makes it idempotent — and why it must come AFTER any step that could change `agents[]`.
13
- *
14
- * A session whose first prompt is a HARNESS prompt yields no title: sub-agent sessions receive only
15
- * those and are labelled from their id, so inventing one would name a child after its parent's
16
- * instruction.
17
- */
18
3
  export const sessionTitles = {
19
4
  order: 30,
20
5
  id: "agents-session-title",
@@ -27,21 +12,11 @@ export const sessionTitles = {
27
12
  const session = typeof a.session === "string" ? a.session : "";
28
13
  if (!session)
29
14
  continue;
30
- // already stored (possibly hand-edited — a chosen name must never be overwritten)
31
15
  if (typeof a.sessionTitle === "string" && a.sessionTitle.trim())
32
16
  continue;
33
17
  if (session.includes("/") || session.includes(".."))
34
- continue; // never a path segment
18
+ continue;
35
19
  const title = firstUserPromptText(path.join(ctx.dataDir, "sessions", session, "chat.jsonl"));
36
- /**
37
- * The SESSION ID is the fallback, not a marker.
38
- *
39
- * A sub-agent session receives only harness prompts, so no title can ever be derived from it —
40
- * and a session id does not change either, so there is nothing to retry for. Writing the id
41
- * makes the field ALWAYS present, which is what makes the pass idempotent without an extra
42
- * flag: the next boot sees a title and skips. The sidebar already shows the id in this case,
43
- * so nothing visible changes.
44
- */
45
20
  a.sessionTitle = title ?? session;
46
21
  changed = true;
47
22
  }
@@ -50,19 +25,6 @@ export const sessionTitles = {
50
25
  return changed;
51
26
  },
52
27
  };
53
- /**
54
- * The first USER prompt in a log, trimmed to a title. `null` when there is none.
55
- *
56
- * Bounded (256KB) and stops at the first prompt, so one enormous log cannot dominate boot. A torn
57
- * final line at a chunk boundary is skipped rather than aborting the read.
58
- */
59
- /**
60
- * ONE reusable read buffer for the whole pass.
61
- *
62
- * `Buffer.alloc` per session is what made the first boot spike: 830 sessions x 16KB of short-lived
63
- * buffer, MEASURED as heap 28.2MB → 108.5MB during `start()` (33.4MB after GC). The scan is a
64
- * single-threaded loop, so sharing one buffer is safe and removes the churn entirely.
65
- */
66
28
  let scanBuffer;
67
29
  function firstUserPromptText(file) {
68
30
  let fd;
@@ -89,12 +51,12 @@ function firstUserPromptText(file) {
89
51
  e = JSON.parse(l);
90
52
  }
91
53
  catch {
92
- continue; // torn line at a chunk boundary
54
+ continue;
93
55
  }
94
56
  if (e.type !== "prompt")
95
57
  continue;
96
58
  if (e.data?.source !== "user")
97
- return null; // a harness prompt is not a label
59
+ return null;
98
60
  return titleOf(typeof e.data.text === "string" ? e.data.text : null);
99
61
  }
100
62
  }
@@ -109,12 +71,10 @@ function firstUserPromptText(file) {
109
71
  closeSync(fd);
110
72
  }
111
73
  catch {
112
- /* already closed */
113
74
  }
114
75
  }
115
76
  }
116
77
  }
117
- /** the first usable line of a prompt, clipped — the sidebar shows one short label */
118
78
  function titleOf(text) {
119
79
  const line = (text ?? "")
120
80
  .split("\n")
@@ -1,28 +1,10 @@
1
1
  import { existsSync, mkdirSync, readdirSync, renameSync } from "node:fs";
2
2
  import path from "node:path";
3
- /**
4
- * Move sessions from the ≤0.5.0 flat layout (`<dataDir>/<id>.jsonl`) into
5
- * `<dataDir>/sessions/<id>/chat.jsonl`.
6
- *
7
- * A RENAME, not a copy-then-delete: a crash between the two would otherwise leave the history in
8
- * neither place. `rename` within one filesystem is atomic, so a re-run either finds the legacy file
9
- * (and moves it) or finds it already moved.
10
- *
11
- * Order 20: it must run before anything reads a session directory.
12
- */
13
3
  export const flatSessionLayout = {
14
4
  order: 20,
15
5
  id: "sessions-flat-layout",
16
6
  summary: "sessions/<id>.jsonl → sessions/<id>/chat.jsonl",
17
7
  run(ctx) {
18
- /**
19
- * Scan the DATA DIR, not `config.agents`.
20
- *
21
- * A legacy log can exist for an agent that is no longer in the config (archived, removed, or a
22
- * config that failed to load) — and those are exactly the histories worth rescuing. Iterating
23
- * the config also made this a no-op whenever the config listed no agents, which is how a test
24
- * with an empty agent list silently stopped exercising it.
25
- */
26
8
  let names;
27
9
  try {
28
10
  names = readdirSync(ctx.dataDir);
@@ -37,11 +19,11 @@ export const flatSessionLayout = {
37
19
  continue;
38
20
  const id = name.slice(0, -".jsonl".length);
39
21
  if (!id || id.includes("/") || id.includes(".."))
40
- continue; // never a path segment
22
+ continue;
41
23
  const legacy = path.join(ctx.dataDir, name);
42
24
  const target = path.join(root, id);
43
25
  if (existsSync(target))
44
- continue; // already migrated; the legacy file is a leftover
26
+ continue;
45
27
  mkdirSync(target, { recursive: true });
46
28
  renameSync(legacy, path.join(target, "chat.jsonl"));
47
29
  ctx.log(`${name} → sessions/${id}/chat.jsonl`);
@@ -1,39 +1,11 @@
1
- /**
2
- * Provider model metadata (context window sizes) with a small TTL cache.
3
- * Used to auto-populate contextWindowTokens so compaction budgets derive
4
- * from the real window instead of the safe default.
5
- */
6
1
  import { providerHeaders } from "./agent/provider-headers.js";
7
2
  const cache = new Map();
8
3
  const inFlight = new Map();
9
4
  const TTL = 10 * 60_000;
10
- /**
11
- * How long a FAILED lookup is remembered.
12
- *
13
- * A failure used to be dropped on the floor (the `catch` returned `[]` without
14
- * caching), so a dead, unauthorised, or simply slow endpoint was re-fetched for
15
- * EVERY agent pointed at it. `addAgent` awaits this twice per agent and races it
16
- * against a 1.5 s timeout, so with 493 agents across a handful of providers the
17
- * same failing request — and its timeout — repeated hundreds of times. MEASURED
18
- * at boot: 1883 ms of the 4.6 s was idle, waiting on exactly this.
19
- *
20
- * Negative results expire much sooner than positive ones: a provider coming back
21
- * up should be usable within a minute, not locked out for ten.
22
- */
23
5
  const NEGATIVE_TTL = 30_000;
24
- /** is this cached entry still valid? (failures expire sooner) */
25
6
  function fresh(entry) {
26
7
  return Date.now() - entry.at < (entry.negative ? NEGATIVE_TTL : TTL);
27
8
  }
28
- /**
29
- * The catalogue if it is ALREADY known, without starting a fetch (#143).
30
- *
31
- * Boot uses this so it never waits for metadata it can do without: the context
32
- * window only tunes the compaction budget, and `supportedParameters` only gates
33
- * the reasoning-effort controls (#47). `prefetchModelLists()` starts the real
34
- * lookups in parallel, so an endpoint that answers quickly is usually warm by the
35
- * time its first agent is created, and a slow or dead one costs no boot time.
36
- */
37
9
  export function peekModelList(baseUrl) {
38
10
  const root = baseUrl.replace(/\/+$/, "");
39
11
  const hit = cache.get(root);
@@ -43,15 +15,11 @@ export function peekModelList(baseUrl) {
43
15
  return undefined;
44
16
  return hit.negative ? [] : hit.list;
45
17
  }
46
- /** Fetch GET /models from an OpenAI-compatible endpoint (cached ~10 min). */
47
18
  export async function fetchModelList(baseUrl, apiKey) {
48
19
  const root = baseUrl.replace(/\/+$/, "");
49
20
  const hit = cache.get(root);
50
21
  if (hit && fresh(hit))
51
22
  return hit.list;
52
- // An in-flight lookup is SHARED: without this, two callers racing for the
53
- // same endpoint each paid the full timeout, and `newSession` in ACP mode
54
- // blocked on it before it could even reply to the editor (#15).
55
23
  const inflight = inFlight.get(root);
56
24
  if (inflight)
57
25
  return inflight;
@@ -60,10 +28,8 @@ export async function fetchModelList(baseUrl, apiKey) {
60
28
  const res = await fetch(`${root}/models`, {
61
29
  headers: {
62
30
  ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
63
- ...providerHeaders(baseUrl), // OpenRouter app attribution
31
+ ...providerHeaders(baseUrl),
64
32
  },
65
- // boot-time calls happen per agent; a slow/unroutable endpoint used to
66
- // stall startup for up to 15s PER AGENT before this was tightened
67
33
  signal: AbortSignal.timeout(3_000),
68
34
  });
69
35
  if (!res.ok)
@@ -73,42 +39,21 @@ export async function fetchModelList(baseUrl, apiKey) {
73
39
  .map((m) => ({
74
40
  id: typeof m.id === "string" ? m.id : "",
75
41
  contextLength: typeof m.context_length === "number" ? m.context_length : undefined,
76
- // OpenRouter-style USD per token (e.g. "0.000003") — powers the
77
- // runtime cost estimate. Providers without pricing stay undefined.
78
42
  pricing: m.pricing && (m.pricing.prompt !== undefined || m.pricing.completion !== undefined)
79
43
  ? {
80
44
  prompt: Number(m.pricing.prompt ?? 0),
81
45
  completion: Number(m.pricing.completion ?? 0),
82
46
  }
83
47
  : undefined,
84
- /**
85
- * `modalities` is only known when the provider PUBLISHES it — in practice OpenRouter's
86
- * `architecture.input_modalities`.
87
- *
88
- * Every other endpoint omits the field, so this stays `undefined` and the UI must treat it as
89
- * UNKNOWN rather than as "text only". Reported as "Command Code GOATのモデルカタログが
90
- * input/outputのフォーマットを適切に提供していない? OpenRouterしかちゃんと対応できてない可能性は
91
- * ある" — which is exactly right, and the code was right not to invent a value.
92
- *
93
- * The consequence to know about: without a modality list nothing can decide "this model
94
- * accepts an image", so an operator's screenshot reaches the model as a FILE PATH and an agent
95
- * that tries `read_file` gets bytes. The guard for that lives in `read_file` (it refuses a
96
- * binary file by name instead of returning mojibake), so the failure is at least legible.
97
- */
98
48
  modalities: Array.isArray(m.architecture?.input_modalities) && m.architecture.input_modalities.length
99
49
  ? {
100
50
  input: m.architecture.input_modalities,
101
51
  output: m.architecture.output_modalities ?? ["text"],
102
52
  }
103
53
  : undefined,
104
- // OpenRouter advertises per-model capabilities; other endpoints omit
105
- // the field entirely, which leaves support "unknown" rather than false.
106
54
  supportedParameters: Array.isArray(m.supported_parameters)
107
55
  ? m.supported_parameters.filter((p) => typeof p === "string")
108
56
  : undefined,
109
- // per-model reasoning levels when published (OpenRouter): the UI offers
110
- // exactly these, and the request is clamped into them — offering a level the
111
- // model does not have is a control that does nothing (#150)
112
57
  supportedEfforts: Array.isArray(m.reasoning?.supported_efforts)
113
58
  ? m.reasoning.supported_efforts.filter((e) => typeof e === "string")
114
59
  : undefined,
@@ -122,10 +67,8 @@ export async function fetchModelList(baseUrl, apiKey) {
122
67
  return list;
123
68
  }
124
69
  catch {
125
- // Remember the FAILURE (briefly). Dropping it meant every agent on this
126
- // endpoint paid the fetch — and up to the 1.5 s race timeout — again.
127
70
  cache.set(root, { at: Date.now(), list: [], negative: true });
128
- return []; // unreachable provider — caller treats as "unknown"
71
+ return [];
129
72
  }
130
73
  })();
131
74
  inFlight.set(root, p);
@@ -139,12 +82,10 @@ export async function fetchModelList(baseUrl, apiKey) {
139
82
  export function contextLengthFor(list, model) {
140
83
  return list.find((m) => m.id === model)?.contextLength;
141
84
  }
142
- /** Reasoning-effort levels OpenRouter accepts (`reasoning_effort`). */
143
85
  export const REASONING_EFFORTS = ["minimal", "low", "medium", "high"];
144
86
  export function isReasoningEffort(v) {
145
87
  return typeof v === "string" && REASONING_EFFORTS.includes(v);
146
88
  }
147
- /** True when the endpoint is OpenRouter (the only one that documents effort). */
148
89
  export function isOpenRouter(baseUrl) {
149
90
  try {
150
91
  return /(^|\.)openrouter\.ai$/i.test(new URL(String(baseUrl ?? "")).host);
@@ -153,25 +94,12 @@ export function isOpenRouter(baseUrl) {
153
94
  return false;
154
95
  }
155
96
  }
156
- /**
157
- * The `reasoning_effort` value to actually SEND, or undefined to omit it.
158
- *
159
- * Verified against the live OpenRouter /models endpoint (462 models): 196
160
- * advertise `reasoning_effort`, 331 the broader `reasoning`, and NONE a bare
161
- * top-level `effort`. Support is therefore far from universal, and sending the
162
- * parameter to a model that does not advertise it is how a request gets
163
- * rejected or a setting silently ignored (#47) — so it is sent only when the
164
- * endpoint is OpenRouter AND the model advertises it. Off by default: an absent
165
- * setting means "let the provider apply its own default".
166
- */
167
97
  export function resolveReasoningEffort(opts) {
168
98
  if (!isReasoningEffort(opts.effort))
169
99
  return undefined;
170
100
  if (!isOpenRouter(opts.baseUrl))
171
101
  return undefined;
172
102
  const meta = opts.list?.find((m) => m.id === opts.model);
173
- // No catalogue for this endpoint: we cannot verify support, so do not send.
174
- // A wrong body field is worse than an unset one.
175
103
  if (!meta?.supportedParameters)
176
104
  return undefined;
177
105
  if (!meta.supportedParameters.includes("reasoning_effort"))
@@ -1,20 +1,13 @@
1
- /**
2
- * Minimal 5-field cron matcher (minute hour day-of-month month day-of-week).
3
- * Also supports shorthand "every <n>s|m|h".
4
- * The master ticks once every 15s; matching is a handful of integer compares.
5
- */
6
1
  const BOUNDS = [
7
2
  [0, 59],
8
3
  [0, 23],
9
4
  [1, 31],
10
5
  [1, 12],
11
- [0, 7], // 0 and 7 are both Sunday
6
+ [0, 7],
12
7
  ];
13
8
  function parseField(part, min, max) {
14
9
  const out = new Set();
15
10
  const clampOrThrow = (v) => {
16
- // accepting an out-of-range value produces a set that never matches, i.e. a
17
- // cron that silently never fires ("99 * * * *")
18
11
  if (!Number.isInteger(v) || v < min || v > max)
19
12
  throw new Error(`bad cron field: ${part} (${min}-${max} expected)`);
20
13
  return v;
@@ -73,12 +66,10 @@ export function parseSchedule(spec) {
73
66
  fields: parts.map((p, i) => parseField(p, BOUNDS[i][0], BOUNDS[i][1])),
74
67
  };
75
68
  }
76
- /** Does this schedule fire at the given Date? */
77
69
  export function matches(schedule, d) {
78
70
  const values = [d.getMinutes(), d.getHours(), d.getDate(), d.getMonth() + 1, d.getDay()];
79
71
  for (let i = 0; i < 5; i++) {
80
72
  if (!schedule.fields[i].has(values[i])) {
81
- // Sunday alias: field 5 with 7 should also match day=0
82
73
  if (i === 4 && values[i] === 0 && schedule.fields[i].has(7))
83
74
  continue;
84
75
  return false;
@@ -86,14 +77,8 @@ export function matches(schedule, d) {
86
77
  }
87
78
  return true;
88
79
  }
89
- /**
90
- * Next fire time after `from`, scanning minute by minute (cheap: a handful of
91
- * integer compares per minute). The horizon is a year so an annual cron (a
92
- * December date seen in August) still resolves; a Feb-29-only cron can
93
- * legitimately report null most of the year.
94
- */
95
80
  export function nextFireAt(schedule, from = new Date()) {
96
- const startMinute = Math.floor(from.getTime() / 60_000) + 1; // next whole minute
81
+ const startMinute = Math.floor(from.getTime() / 60_000) + 1;
97
82
  const horizon = 60 * 24 * 366;
98
83
  for (let i = 0; i < horizon; i++) {
99
84
  const d = new Date((startMinute + i) * 60_000);