cronfish 0.12.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <h1 align="center">cronfish 🐟</h1>
6
6
 
7
- <p align="center"><strong>A file-based job scheduler for macOS. Drop a script in a folder; launchd runs it on a schedule.</strong></p>
7
+ <p align="center"><strong>Write a cron job in Markdown — an LLM runs it on a schedule. Or drop a <code>.ts</code>/<code>.sh</code> script for the deterministic stuff. launchd fires all three.</strong></p>
8
8
 
9
9
  <p align="center">
10
10
  <a href="https://www.npmjs.com/package/cronfish"><img src="https://img.shields.io/npm/v/cronfish?logo=npm" alt="npm"></a>
@@ -14,9 +14,24 @@
14
14
  <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License"></a>
15
15
  </p>
16
16
 
17
- Cronfish is for personal automation the small scheduled jobs you'd otherwise hand-roll as
18
- launchd plists or crontab lines. Drop a `.md` (Claude prompt), `.ts` (Bun program), or `.sh`
19
- (bash script) in `cron/`, run `cronfish sync`, and launchd takes it from there.
17
+ The pitch in one file. This is a complete, scheduled cron job:
18
+
19
+ ```markdown
20
+ ---
21
+ schedule: "every morning at 8"
22
+ model: sonnet
23
+ ---
24
+
25
+ Read my calendar for today, check the weather, and post a
26
+ one-paragraph brief to the #daily Slack channel.
27
+ ```
28
+
29
+ Drop it in `cron/`, run `cronfish sync`, and launchd runs it every morning — the body _is_ the job,
30
+ handed to an LLM at fire time. No script to write, no glue code. **Markdown is a valid cron job.**
31
+
32
+ When the work _is_ deterministic — a backup, a sync, a healthcheck — write it as `.ts` (Bun) or
33
+ `.sh` (bash) instead. Same frontmatter, same `cronfish sync`, no LLM in the loop. One scheduler,
34
+ three tiers; reach for the lightest that does the job.
20
35
 
21
36
  It makes the file the job: frontmatter is the schedule, the path is the slug — no hand-written
22
37
  plists, no registration step. You also get per-run logs, retries, concurrency guards, failure
@@ -31,20 +46,24 @@ every kind.
31
46
 
32
47
  ## Why cronfish?
33
48
 
34
- Claude's native scheduled agents are the obvious alternative, and for pure-LLM tasks they're great
35
- — zero setup, nothing to host. But everything runs on Anthropic's managed runtime: you pay their
36
- rates, and every job is a Claude job. Cronfish trades that convenience for control:
49
+ Two kinds of scheduler exist today, and neither does both halves:
50
+
51
+ - **cron / launchd** run scripts, never prose. A natural-language job isn't expressible.
52
+ - **Claude's native scheduled agents** run prose, but _only_ prose — every job is an LLM job on
53
+ Anthropic's managed runtime, at their rates.
54
+
55
+ cronfish runs both from one folder:
37
56
 
38
- - **Any job, not just LLM jobs.** Most scheduled work is plain bash or a TypeScript script — a
39
- backup, a sync, a healthcheck. Cronfish runs those first-class, alongside `.md` Claude prompts.
40
- - **Any harness.** `.md` jobs shell out to a CLI you chooseClaude Code by default, or another
41
- agent runner — so you're not locked to one model or one vendor.
42
- - **Local, so you control the bill.** Jobs run on your machine against whatever model you point
43
- them at (hosted Claude, a local Ollama model, a LAN LiteLLM box). No managed-runtime markup, and
44
- your data stays on your hardware.
57
+ - **Markdown jobs are natural-language cron.** The body is the instruction; cronfish hands it to an
58
+ agent CLI (Claude Code by default) at fire time. No other scheduler runs a prose job.
59
+ - **Script jobs stay deterministic.** `.ts` and `.sh` run code you wrotethe standard cron trust
60
+ model, first-class alongside `.md`.
61
+ - **Any harness, any model, local or hosted.** `.md` jobs shell out to a CLI you choose — point them
62
+ at hosted Claude, a local Ollama model, or a LAN LiteLLM box. No managed-runtime markup; your data
63
+ stays on your hardware.
45
64
 
46
65
  Reach for Claude's scheduler when you want zero-ops and a single LLM task. Reach for cronfish when
47
- you have a mix of scripts and agents, care about cost, or want to choose the model.
66
+ you want a mix of prose and scripts, care about cost, or want to choose the model.
48
67
 
49
68
  ## Quickstart
50
69
 
@@ -79,7 +98,7 @@ you can document a folder of crons without the README getting parsed as a job.
79
98
  ```markdown
80
99
  ---
81
100
  schedule: "every 5 minutes" # see below for all accepted shapes
82
- model: haiku # claude alias | raw ID | local:<name>
101
+ model: haiku # claude alias | raw ID | local:<name> | subconscious/<name>
83
102
  enabled: true # default true
84
103
  timeout: 300 # seconds; runner kills past this
85
104
  retries: 0 # retry count on non-zero exit
@@ -129,7 +148,7 @@ invokes the file as `/bin/bash <path>` with `cwd = consumer repo root`; stdout/s
129
148
  per-run log. **A `.sh` file with no frontmatter block fails at discovery** — cronfish prints the
130
149
  error in `list`/`sync` so you know to add one.
131
150
 
132
- ## `model:` — claude alias, raw ID, or local
151
+ ## `model:` — claude alias, raw ID, local, or subconscious
133
152
 
134
153
  For Anthropic-hosted models, use the aliases `haiku` / `sonnet` / `opus` (resolve to the latest
135
154
  pinned IDs), or pass a raw ID like `claude-sonnet-4-6` verbatim.
@@ -153,6 +172,13 @@ will hallucinate tool calls. Use 14B+ for any agentic loop; 32B is the practical
153
172
  multi-step work. Local providers serve one request at a time — set `concurrency: queue` on
154
173
  overlapping jobs.
155
174
 
175
+ For a **[Subconscious](https://subconscious.dev)-hosted model**, use the id verbatim with its
176
+ `subconscious/` prefix — e.g. `subconscious/glm-5.2`. Same mechanics as `local:` (base URL +
177
+ slot overrides injected at spawn), pointed at `https://api.subconscious.dev` and authenticated
178
+ with `SUBCONSCIOUS_API_KEY` from the consumer `.env` (scope it into the job with
179
+ `env: [SUBCONSCIOUS_API_KEY]`). Override the endpoint with `SUBCONSCIOUS_BASE_URL`. The job
180
+ fails with a clear error if the key is unset.
181
+
156
182
  ## One-shot jobs — `cron/one-time/`
157
183
 
158
184
  Drop a `.md`, `.ts`, or `.sh` under `cron/one-time/` to schedule a job that
@@ -173,23 +199,34 @@ Sync behavior:
173
199
  | ----------------------------- | ------------------------------------------------- |
174
200
  | Future | plist installed with `StartCalendarInterval` for the exact minute |
175
201
  | Within `grace_seconds` of now | plist installed with `RunAtLoad: true` — fires on bootstrap |
176
- | Past `grace_seconds` | **refused**; sentinel written to `cron/.errors/` |
202
+ | Past `grace_seconds` | **refused**; sentinel written + file archived out of `cron/one-time/` |
177
203
  | `executed_at:` already set | skipped (file should already be archived) |
178
204
 
179
- After firing, the runner stamps `executed_at: <ISO>` and moves the file to
180
- `~/Library/Application Support/cronfish/done/` outside the repo, so the
181
- audit trail doesn't bloat git. A `flock` plus the `executed_at` re-check guard
182
- against double-fires. The next `cronfish sync` removes the orphaned plist.
205
+ After firing, the runner stamps `executed_at: <ISO>`, moves the file to
206
+ `~/Library/Application Support/cronfish/done/` (outside the repo, so the audit
207
+ trail doesn't bloat git), and **removes its own plist** so a reboot/login in
208
+ the window before the next `cronfish sync` can't reload and re-fire it. A
209
+ `flock` plus the `executed_at` re-check guard against double-fires.
210
+
211
+ The runner also **re-checks `grace_seconds` at fire time**: launchd runs a
212
+ `StartCalendarInterval` job once on wake if the machine slept through the
213
+ scheduled minute (a coalesced missed fire), which can land long after `run_at`.
214
+ A fire that arrives past grace is refused (sentinel) instead of running late.
183
215
 
184
- **One-time jobs must be idempotent.** launchd can re-fire on machine restart,
185
- system unsleep, or load spikes; the flock + `executed_at` guard catches the
216
+ **One-time jobs must be idempotent.** The guards above catch the common
186
217
  double-fire, but only after the file is stamped. Anything destructive between
187
218
  "start" and "stamp" can repeat. Write handlers that tolerate two invocations.
188
219
 
189
- **Failure surface — `cron/.errors/`.** Any sync-time refusal (past-grace, bad
190
- YAML, missing `run_at`) writes a sentinel file there with the slug, timestamp,
191
- and reason. Runner-side failures (archive failed, executed_at write failed)
192
- also land here. Wire a heartbeat cron to alert on non-empty.
220
+ **Failure surface — `cron/.errors/`.** Any refusal (past-grace, bad YAML,
221
+ missing `run_at`) and any runner-side failure (archive failed, `executed_at`
222
+ write failed) writes a sentinel there with slug, timestamp, and reason. Wire a
223
+ heartbeat cron to alert on non-empty. Two properties keep the folder bounded:
224
+ sentinels **dedup** (the same recurring error overwrites one file, not one per
225
+ sync), and sync-time sentinels **self-heal** — the next clean `cronfish sync`
226
+ clears any whose error no longer occurs. Inspect or clear by hand with
227
+ `cronfish errors` / `cronfish errors --clear [slug]`. cronfish only manages
228
+ files it wrote (`*.cronfish.txt`); a consumer can drop its own sentinels in the
229
+ same folder without them being reaped.
193
230
 
194
231
  Smoke-test template: `templates/_examples/one-time/echo-at.md`.
195
232
 
@@ -207,6 +244,11 @@ Smoke-test template: `templates/_examples/one-time/echo-at.md`.
207
244
  fires them. Use it for scheduling candidates — jobs you're staging in `cron/` before flipping on
208
245
  a real schedule. Pure on-demand scripts that aren't scheduling candidates belong outside `cron/`.
209
246
 
247
+ **Sub-10s schedules don't work.** launchd enforces a ~10s floor between relaunches of the same
248
+ job (its implicit `ThrottleInterval`). A `schedule:` faster than `10s` fires no quicker than every
249
+ 10s; `cronfish sync` warns when it sees one. Need true high-frequency work? Run a long-lived loop
250
+ as a single job instead of many fast fires.
251
+
210
252
  ## Config — `.cronfish.json` (optional, at repo root)
211
253
 
212
254
  ```json
@@ -245,6 +287,7 @@ cronfish enable <slug> flip enabled, then sync
245
287
  cronfish disable <slug> flip disabled, then sync
246
288
  cronfish delete <slug> --yes bootout + remove plist + job file
247
289
  cronfish status [slug] launchctl print + tail of latest log
290
+ cronfish errors [--clear] [slug] list error sentinels (cron/.errors/); --clear removes them
248
291
  cronfish run <slug> invoke runner directly (no launchd) — for testing
249
292
  cronfish watchdog detect missed schedules → fire alerts
250
293
  cronfish alerts test [adapter] send a test alert via the named (or default) adapter
@@ -531,10 +574,15 @@ via plist `WorkingDirectory`), so no shell wrapper is needed.
531
574
 
532
575
  - macOS (launchd). Linux (systemd) and Windows (Task Scheduler) are on the backlog.
533
576
  - Bun ≥ 1.0.
577
+ - **A logged-in GUI (Aqua) session.** cronfish installs per-user LaunchAgents under
578
+ `~/Library/LaunchAgents`, which only load while the user is logged into the desktop. On a
579
+ headless box reached only over SSH (no console login), agents never load and jobs never fire —
580
+ enable auto-login, or keep a desktop session active. (A system-wide `LaunchDaemon` backend that
581
+ runs without a login session is on the backlog.)
534
582
 
535
583
  ## Status
536
584
 
537
- v0.11 — used in production by the author. API may still break before v1. File issues if you hit
585
+ v0.x — used in production by the author. API may still break before v1. File issues if you hit
538
586
  something rough.
539
587
 
540
588
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cronfish",
3
- "version": "0.12.1",
3
+ "version": "0.14.0",
4
4
  "description": "A file-based job scheduler for macOS. Drop a script in a folder; launchd runs it on a schedule.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.ts CHANGED
@@ -15,7 +15,15 @@ import { basename, join } from "node:path";
15
15
  import { setFrontmatterKey, setShellFrontmatterKey } from "./frontmatter.ts";
16
16
  import { discoverJobs, findJobFile, loadJob, type JobMeta } from "./jobs.ts";
17
17
  import { dispatchSchedule, type Dispatched } from "./schedule.ts";
18
- import { resolveOneTime, writeSentinel } from "./oneTime.ts";
18
+ import {
19
+ archiveOneTime,
20
+ clearSentinels,
21
+ listSentinels,
22
+ reapStaleSentinels,
23
+ resolveOneTime,
24
+ writeSentinel,
25
+ } from "./oneTime.ts";
26
+ import { defaultBundlePrefix } from "./config.ts";
19
27
  import { platform } from "./platform/index.ts";
20
28
  import { loadState, rememberPrefix } from "./state.ts";
21
29
  import { dbPath, markDeleted, openDb, upsertJob } from "./db.ts";
@@ -97,7 +105,7 @@ function parseRetention(raw: unknown): RetentionConfig | undefined {
97
105
 
98
106
  function loadConfig(): CronfishConfig {
99
107
  const path = join(CONSUMER_ROOT, ".cronfish.json");
100
- const defaultPrefix = `com.cronfish.${basename(CONSUMER_ROOT)}`;
108
+ const defaultPrefix = defaultBundlePrefix(CONSUMER_ROOT);
101
109
  if (!existsSync(path)) return { bundle_prefix: defaultPrefix };
102
110
  let parsed: Partial<CronfishConfig>;
103
111
  try {
@@ -327,6 +335,9 @@ function loadRunnerNames(): Set<string> {
327
335
  function cmdSync(): void {
328
336
  const p = platform();
329
337
  const { jobs, errors } = discoverJobs(CRON_DIR);
338
+ // "sync"-class sentinel filenames written this run. Anything still on disk
339
+ // from a prior sync that ISN'T in here gets reaped at the end (self-heal).
340
+ const writtenSentinels = new Set<string>();
330
341
  for (const e of errors) {
331
342
  console.error(`[cronfish] ${e.path}: ${e.message}`);
332
343
  // Bad YAML / invalid run_at on a one-time file → sentinel. Any other
@@ -334,7 +345,13 @@ function cmdSync(): void {
334
345
  // cron/one-time/ since silent-skip is the failure mode we're killing.
335
346
  if (e.path.includes(`/${"one-time"}/`)) {
336
347
  const slug = e.path.split("/").pop() ?? "unknown";
337
- writeSentinel(CRON_DIR, slug, `discovery error: ${e.message}`);
348
+ const written = writeSentinel(
349
+ CRON_DIR,
350
+ slug,
351
+ `discovery error: ${e.message}`,
352
+ "sync",
353
+ );
354
+ writtenSentinels.add(basename(written));
338
355
  }
339
356
  }
340
357
 
@@ -349,6 +366,15 @@ function cmdSync(): void {
349
366
  `[cronfish] WARN ${j.slug}: runner "${j.runner}" not in .cronfish.json#runners — known: ${known}`,
350
367
  );
351
368
  }
369
+ // launchd enforces a ~10s floor between relaunches (its implicit
370
+ // ThrottleInterval). A faster `schedule:` silently fires no quicker than
371
+ // every 10s, so warn rather than let it look like it works.
372
+ const d = safeDispatch(j.schedule);
373
+ if (d.kind === "seconds" && d.value < 10) {
374
+ console.error(
375
+ `[cronfish] WARN ${j.slug}: schedule ${d.value}s is below launchd's ~10s relaunch floor — it will fire no faster than every 10s`,
376
+ );
377
+ }
352
378
  }
353
379
 
354
380
  const state = rememberPrefix(CONSUMER_ROOT, PREFIX);
@@ -367,7 +393,20 @@ function cmdSync(): void {
367
393
  ) {
368
394
  console.error(`[cronfish] ${j.slug}: ${decision.reason}`);
369
395
  if (j.oneTime && decision.reason?.startsWith("one-time past grace:")) {
370
- writeSentinel(CRON_DIR, j.slug, decision.reason);
396
+ // A past-grace one-time file would otherwise be re-discovered on
397
+ // EVERY sync and re-write a sentinel forever. Record one durable
398
+ // ("run"-class, never reaped) sentinel so the missed window is
399
+ // visible, then archive the file out of cron/one-time/ so it stops
400
+ // recurring.
401
+ writeSentinel(CRON_DIR, j.slug, decision.reason, "run");
402
+ try {
403
+ const dest = archiveOneTime(j.path);
404
+ console.error(`[cronfish] ${j.slug}: archived past-grace one-time → ${dest}`);
405
+ } catch (e) {
406
+ console.error(
407
+ `[cronfish] ${j.slug}: archive of past-grace one-time failed: ${(e as Error).message}`,
408
+ );
409
+ }
371
410
  }
372
411
  }
373
412
  }
@@ -440,6 +479,11 @@ function cmdSync(): void {
440
479
  }
441
480
  }
442
481
 
482
+ // Self-heal: drop any "sync"-class sentinel that was NOT re-written this
483
+ // run (its error no longer occurs). "run"-class + foreign files survive.
484
+ const reaped = reapStaleSentinels(CRON_DIR, writtenSentinels);
485
+ if (reaped > 0) console.log(`[cronfish] cleared ${reaped} resolved sentinel(s)`);
486
+
443
487
  console.log("[cronfish] sync complete");
444
488
  }
445
489
 
@@ -949,6 +993,28 @@ function cmdInit(): void {
949
993
  );
950
994
  }
951
995
 
996
+ function cmdErrors(clear: boolean, slug?: string): void {
997
+ if (clear) {
998
+ const n = clearSentinels(CRON_DIR, slug);
999
+ console.log(
1000
+ `[cronfish] cleared ${n} sentinel(s)${slug ? ` for ${slug}` : ""}`,
1001
+ );
1002
+ return;
1003
+ }
1004
+ const files = listSentinels(CRON_DIR);
1005
+ if (files.length === 0) {
1006
+ console.log("[cronfish] no error sentinels");
1007
+ return;
1008
+ }
1009
+ console.log(
1010
+ `[cronfish] ${files.length} error sentinel(s) in cron/.errors/ (clear with \`cronfish errors --clear\`):\n`,
1011
+ );
1012
+ for (const f of files.sort()) {
1013
+ const body = readFileSync(join(CRON_DIR, ".errors", f), "utf-8").trim();
1014
+ console.log(`--- ${f} ---\n${body}\n`);
1015
+ }
1016
+ }
1017
+
952
1018
  function usage(): void {
953
1019
  console.log(
954
1020
  `cronfish ${VERSION} — drop a file, schedule it.
@@ -964,6 +1030,7 @@ usage:
964
1030
  cronfish disable <slug> flip disabled, then sync
965
1031
  cronfish delete <slug> --yes bootout + remove plist + job file
966
1032
  cronfish status [slug] all jobs (no arg) or one slug's launchctl + log tail
1033
+ cronfish errors [--clear] [slug] list error sentinels (cron/.errors/); --clear removes them
967
1034
  cronfish run <slug> invoke runner directly (no launchd)
968
1035
  cronfish watchdog check enabled jobs for missed schedules → fire alerts
969
1036
  cronfish alerts test [adapter] send a test alert via the named (or default) adapter
@@ -1055,6 +1122,12 @@ async function main(): Promise<void> {
1055
1122
  case "status":
1056
1123
  cmdStatus(rest[0]);
1057
1124
  return;
1125
+ case "errors": {
1126
+ const clear = rest.includes("--clear");
1127
+ const slug = rest.find((a) => !a.startsWith("-"));
1128
+ cmdErrors(clear, slug);
1129
+ return;
1130
+ }
1058
1131
  case "run":
1059
1132
  if (!rest[0]) throw new Error("usage: cronfish run <slug>");
1060
1133
  await cmdRun(rest[0]);
package/src/config.ts ADDED
@@ -0,0 +1,29 @@
1
+ // Shared config helpers. The bundle-prefix formula has to be identical
2
+ // everywhere a launchd label is computed — cli.ts (install/bootout) and
3
+ // runner.ts (one-time self-removal) both derive labels from it, so it lives
4
+ // here rather than being duplicated and drifting.
5
+
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { basename, join } from "node:path";
8
+
9
+ export function defaultBundlePrefix(consumerRoot: string): string {
10
+ return `com.cronfish.${basename(consumerRoot)}`;
11
+ }
12
+
13
+ // Tolerant prefix read for non-CLI callers (the runner). Returns the default
14
+ // on a missing/unparseable .cronfish.json — the strict validation that the
15
+ // CLI applies at install time has already gated the value by the time a job
16
+ // is running, so the runner only needs to reproduce the same string.
17
+ export function loadBundlePrefix(consumerRoot: string): string {
18
+ const def = defaultBundlePrefix(consumerRoot);
19
+ const path = join(consumerRoot, ".cronfish.json");
20
+ if (!existsSync(path)) return def;
21
+ try {
22
+ const parsed = JSON.parse(readFileSync(path, "utf-8")) as {
23
+ bundle_prefix?: string;
24
+ };
25
+ return (parsed.bundle_prefix ?? "").trim() || def;
26
+ } catch {
27
+ return def;
28
+ }
29
+ }
package/src/models.ts CHANGED
@@ -8,8 +8,12 @@
8
8
  // Messages natively; LiteLLM proxies it for everything else). The model
9
9
  // ID after the `local:` prefix is passed verbatim to `--model` and as
10
10
  // the three slot overrides so sub-agents route locally too.
11
+ //
12
+ // `subconscious/<id>` models route the same way to api.subconscious.dev
13
+ // (a hosted Anthropic-compatible endpoint), authenticated with
14
+ // SUBCONSCIOUS_API_KEY from the job env.
11
15
 
12
- export type Provider = "anthropic" | "local";
16
+ export type Provider = "anthropic" | "local" | "subconscious";
13
17
 
14
18
  export interface Resolved {
15
19
  provider: Provider;
@@ -27,6 +31,11 @@ export function resolveModel(input: string | undefined): Resolved {
27
31
  if (raw.startsWith("local:")) {
28
32
  return { provider: "local", id: raw.slice("local:".length) };
29
33
  }
34
+ if (raw.startsWith("subconscious/")) {
35
+ // Subconscious's model namespace includes the prefix (e.g.
36
+ // subconscious/glm-5.2) — pass the id through verbatim.
37
+ return { provider: "subconscious", id: raw };
38
+ }
30
39
  if (ALIASES[raw]) return { provider: "anthropic", id: ALIASES[raw] };
31
40
  return { provider: "anthropic", id: raw };
32
41
  }
@@ -39,6 +48,38 @@ export function localClaudeEnv(modelId: string): Record<string, string> {
39
48
  const baseUrl =
40
49
  process.env.CRONFISH_LOCAL_BASE_URL ?? "http://localhost:11434";
41
50
  const authToken = process.env.CRONFISH_LOCAL_AUTH_TOKEN ?? "ollama";
51
+ return backendClaudeEnv(baseUrl, authToken, modelId);
52
+ }
53
+
54
+ // Env block for a `subconscious/*` model — api.subconscious.dev speaks the
55
+ // Anthropic Messages API. Requires SUBCONSCIOUS_API_KEY in the job env
56
+ // (consumer .env; scope with frontmatter `env: [SUBCONSCIOUS_API_KEY]`).
57
+ export function subconsciousClaudeEnv(modelId: string): Record<string, string> {
58
+ const baseUrl = (
59
+ process.env.SUBCONSCIOUS_BASE_URL ?? "https://api.subconscious.dev"
60
+ ).replace(/\/$/, "");
61
+ const authToken = process.env.SUBCONSCIOUS_API_KEY;
62
+ if (!authToken) {
63
+ throw new Error(
64
+ "[models] SUBCONSCIOUS_API_KEY is not set — required for subconscious/* models",
65
+ );
66
+ }
67
+ return backendClaudeEnv(baseUrl, authToken, modelId);
68
+ }
69
+
70
+ // Env block for spawning `claude` against the given provider, or undefined
71
+ // for api.anthropic.com models. Single dispatch point for the runner.
72
+ export function claudeEnvFor(r: Resolved): Record<string, string> | undefined {
73
+ if (r.provider === "local") return localClaudeEnv(r.id);
74
+ if (r.provider === "subconscious") return subconsciousClaudeEnv(r.id);
75
+ return undefined;
76
+ }
77
+
78
+ function backendClaudeEnv(
79
+ baseUrl: string,
80
+ authToken: string,
81
+ modelId: string,
82
+ ): Record<string, string> {
42
83
  return {
43
84
  ANTHROPIC_BASE_URL: baseUrl,
44
85
  ANTHROPIC_AUTH_TOKEN: authToken,
package/src/oneTime.ts CHANGED
@@ -13,12 +13,15 @@ import {
13
13
  mkdirSync,
14
14
  openSync,
15
15
  readFileSync,
16
+ readdirSync,
16
17
  renameSync,
18
+ rmSync,
17
19
  statSync,
18
20
  writeFileSync,
19
21
  writeSync,
20
22
  fsyncSync,
21
23
  } from "node:fs";
24
+ import { createHash } from "node:crypto";
22
25
  import { homedir } from "node:os";
23
26
  import { basename, join } from "node:path";
24
27
  import { dlopen, FFIType, suffix } from "bun:ffi";
@@ -111,23 +114,105 @@ export function resolveOneTime(
111
114
 
112
115
  // --- Error sentinel surface ---
113
116
  //
114
- // Any sync-time failure for a one-time job writes a sentinel here. The
115
- // heartbeat cron (Phase 2) sweeps this folder and alerts on non-empty.
117
+ // A failure for a one-time job writes a sentinel here; consumers (e.g. a
118
+ // heartbeat job) sweep the folder and alert on non-empty. Two invariants
119
+ // keep the folder from running away:
120
+ //
121
+ // 1. Dedup — the filename is derived from (slug, hash(reason)), so the
122
+ // SAME error recurring on every sync overwrites one file instead of
123
+ // writing a new one each minute. An unfixed config error is 1 file,
124
+ // not thousands.
125
+ // 2. Ownership + class — cronfish-written files carry a `.<class>.cronfish.txt`
126
+ // suffix so a consumer can drop its own sentinels alongside without
127
+ // cronfish reaping them. Class "sync" = re-derived every reconcile
128
+ // (self-heals: reapStaleSentinels removes it once the error stops
129
+ // recurring). Class "run" = a one-shot runtime failure that is NOT
130
+ // re-detected at sync, so it persists until `cronfish errors --clear`.
131
+
132
+ export type SentinelClass = "sync" | "run";
133
+
134
+ const SENTINEL_EXT = ".cronfish.txt";
116
135
 
117
- export function writeSentinel(cronDir: string, slug: string, reason: string): string {
136
+ function safeSlug(slug: string): string {
137
+ return slug.replace(/[^A-Za-z0-9._-]/g, "_");
138
+ }
139
+
140
+ // Deterministic dedup key: same (slug, reason) → same filename → overwrite.
141
+ export function sentinelFilename(
142
+ slug: string,
143
+ reason: string,
144
+ cls: SentinelClass,
145
+ ): string {
146
+ const hash = createHash("sha1").update(reason).digest("hex").slice(0, 8);
147
+ return `${safeSlug(slug)}--${hash}.${cls}${SENTINEL_EXT}`;
148
+ }
149
+
150
+ export function writeSentinel(
151
+ cronDir: string,
152
+ slug: string,
153
+ reason: string,
154
+ cls: SentinelClass = "run",
155
+ ): string {
118
156
  const dir = errorsDir(cronDir);
119
157
  mkdirSync(dir, { recursive: true });
120
- const ts = Date.now();
121
- const safeSlug = slug.replace(/[^A-Za-z0-9._-]/g, "_");
122
- const path = join(dir, `${ts}-${safeSlug}.txt`);
158
+ const path = join(dir, sentinelFilename(slug, reason, cls));
123
159
  const body =
124
160
  `slug: ${slug}\n` +
125
- `at: ${new Date(ts).toISOString()}\n` +
161
+ `at: ${new Date().toISOString()}\n` +
126
162
  `reason:\n${reason}\n`;
127
163
  writeFileSync(path, body, "utf-8");
128
164
  return path;
129
165
  }
130
166
 
167
+ // Every file currently in cron/.errors/ (basenames), or [] when absent.
168
+ export function listSentinels(cronDir: string): string[] {
169
+ const dir = errorsDir(cronDir);
170
+ if (!existsSync(dir)) return [];
171
+ return readdirSync(dir).filter((f) => f.endsWith(".txt"));
172
+ }
173
+
174
+ // Remove sentinels. With no slug, clears the whole folder (cronfish-owned
175
+ // AND any legacy/consumer .txt files — the manual `errors --clear` escape
176
+ // hatch). With a slug, clears only files for that slug. Returns the count.
177
+ export function clearSentinels(cronDir: string, slug?: string): number {
178
+ const dir = errorsDir(cronDir);
179
+ if (!existsSync(dir)) return 0;
180
+ const prefix = slug ? `${safeSlug(slug)}--` : null;
181
+ let n = 0;
182
+ for (const f of readdirSync(dir)) {
183
+ if (!f.endsWith(".txt")) continue;
184
+ if (prefix && !f.startsWith(prefix)) continue;
185
+ try {
186
+ rmSync(join(dir, f));
187
+ n++;
188
+ } catch {}
189
+ }
190
+ return n;
191
+ }
192
+
193
+ // Self-heal for "sync"-class sentinels: after a reconcile, delete any
194
+ // cronfish "sync" sentinel whose filename was NOT (re)written this run —
195
+ // i.e. the error it recorded no longer occurs. "run"-class and foreign
196
+ // files are never touched here.
197
+ export function reapStaleSentinels(
198
+ cronDir: string,
199
+ keep: Set<string>,
200
+ ): number {
201
+ const dir = errorsDir(cronDir);
202
+ if (!existsSync(dir)) return 0;
203
+ const suffix = `.sync${SENTINEL_EXT}`;
204
+ let n = 0;
205
+ for (const f of readdirSync(dir)) {
206
+ if (!f.endsWith(suffix)) continue;
207
+ if (keep.has(f)) continue;
208
+ try {
209
+ rmSync(join(dir, f));
210
+ n++;
211
+ } catch {}
212
+ }
213
+ return n;
214
+ }
215
+
131
216
  // --- flock-based re-fire guard ---
132
217
  //
133
218
  // Real OS-level advisory lock via libc flock(2). Returns the fd on success
@@ -360,6 +360,25 @@ export function uninstall(prefix: string, slug: string): boolean {
360
360
  return existed;
361
361
  }
362
362
 
363
+ // Self-removal for a one-time job from inside its own runner process. The
364
+ // runner IS the launchd-managed process here, so `bootout` may SIGTERM us
365
+ // mid-call — remove the plist FILE first so that even if we're killed before
366
+ // bootout returns, launchd can't reload (and RunAtLoad-refire) the job on the
367
+ // next login/reboot. The live bootout is best-effort cleanup of the already-
368
+ // fired service.
369
+ export function removeOneTimeSelf(prefix: string, slug: string): boolean {
370
+ const label = labelFor(prefix, slug);
371
+ const dest = plistPathFor(label);
372
+ const existed = existsSync(dest) || isLoaded(label);
373
+ if (existsSync(dest)) {
374
+ try {
375
+ rmSync(dest);
376
+ } catch {}
377
+ }
378
+ sh(["launchctl", "bootout", `${gui()}/${label}`]);
379
+ return existed;
380
+ }
381
+
363
382
  export function statusOf(prefix: string, slug: string): string {
364
383
  const label = labelFor(prefix, slug);
365
384
  const { code, out, err } = sh(["launchctl", "print", `${gui()}/${label}`]);
package/src/runner.ts CHANGED
@@ -35,16 +35,20 @@ import {
35
35
  type InvocationTrigger,
36
36
  } from "./db.ts";
37
37
  import { loadJob, slugFromPath, type JobMeta } from "./jobs.ts";
38
- import { resolveModel, localClaudeEnv } from "./models.ts";
38
+ import { resolveModel, claudeEnvFor } from "./models.ts";
39
39
  import {
40
+ DEFAULT_GRACE_SECONDS,
40
41
  archiveOneTime,
41
42
  releaseFlock,
43
+ resolveOneTime,
42
44
  setTsExecutedAt,
43
45
  tryFlockExclusive,
44
46
  writeAndFsync,
45
47
  writeSentinel,
46
48
  type FlockHandle,
47
49
  } from "./oneTime.ts";
50
+ import { loadBundlePrefix } from "./config.ts";
51
+ import { platform } from "./platform/index.ts";
48
52
  import { setFrontmatterKey, setShellFrontmatterKey } from "./frontmatter.ts";
49
53
  import { parseLastResult } from "./result.ts";
50
54
  import {
@@ -363,11 +367,11 @@ async function execMarkdown(
363
367
  appendLog(fd, `[runner] read-only: deny [${READ_ONLY_DENY.join(", ")}]`);
364
368
  }
365
369
  const cmd = buildClaudeArgs(CLAUDE_BIN, job, model.id, prompt);
366
- const env = model.provider === "local" ? localClaudeEnv(model.id) : undefined;
370
+ const env = claudeEnvFor(model);
367
371
  if (env) {
368
372
  appendLog(
369
373
  fd,
370
- `[runner] local base_url=${env.ANTHROPIC_BASE_URL} model=${model.id}`,
374
+ `[runner] ${model.provider} base_url=${env.ANTHROPIC_BASE_URL} model=${model.id}`,
371
375
  );
372
376
  }
373
377
  return runSpawn({ cmd, cwd: consumerRoot(), env }, fd, timeoutS);
@@ -550,6 +554,27 @@ function stampExecutedAt(job: JobMeta, iso: string): void {
550
554
  writeAndFsync(job.path, next);
551
555
  }
552
556
 
557
+ // Remove this one-time job's own launchd plist after it has fired. Without
558
+ // this, a fire-now plist (RunAtLoad=true, no calendar) lingers in
559
+ // ~/Library/LaunchAgents until the NEXT `cronfish sync` boots it out — and a
560
+ // reboot/login in that window reloads it and re-fires (against an already-
561
+ // archived file). Self-removing here closes that window regardless of when
562
+ // the consumer next syncs. Best-effort: failures never block completion.
563
+ function selfRemoveOneTimePlist(job: JobMeta): void {
564
+ // Logs to console (→ launchd.out/err), not the per-run fd: this runs as the
565
+ // last act after the log fd is already closed, and the bootout it performs
566
+ // may terminate this process, so it must follow all ledger/alert work.
567
+ try {
568
+ const prefix = loadBundlePrefix(consumerRoot());
569
+ const existed = platform().removeOneTimeSelf(prefix, job.slug);
570
+ console.log(`[runner] one-time: removed own plist (existed=${existed})`);
571
+ } catch (e) {
572
+ console.error(
573
+ `[runner] one-time: self-remove plist failed: ${(e as Error).message}`,
574
+ );
575
+ }
576
+ }
577
+
553
578
  function completeOneTime(job: JobMeta, fd: number): void {
554
579
  if (!job.oneTime) return;
555
580
  const iso = new Date().toISOString();
@@ -642,6 +667,33 @@ async function main(): Promise<void> {
642
667
  } catch {
643
668
  // re-parse failed; proceed with the original meta.
644
669
  }
670
+
671
+ // Re-assert grace at RUN time. launchd fires a StartCalendarInterval job
672
+ // once on wake if the machine was asleep/off through the scheduled minute
673
+ // (coalesced) — which can land long past run_at. Grace is checked at sync
674
+ // time, but nothing re-checks it when launchd actually fires. Refuse a
675
+ // stale wake-up fire so a one-time job can't run hours/days late, and
676
+ // remove the plist so it won't try again on the next wake.
677
+ if (job.runAtMs !== undefined) {
678
+ const grace = job.graceSeconds ?? DEFAULT_GRACE_SECONDS;
679
+ const status = resolveOneTime(job.runAtMs, grace, Date.now(), undefined);
680
+ if (status.kind === "past-grace") {
681
+ console.error(
682
+ `[runner] one-time: ${job.slug} fired past grace — refusing. ${status.reason}`,
683
+ );
684
+ try {
685
+ writeSentinel(
686
+ join(consumerRoot(), "cron"),
687
+ job.slug,
688
+ `runtime past grace: ${status.reason}`,
689
+ "run",
690
+ );
691
+ } catch {}
692
+ if (oneTimeLock) releaseFlock(oneTimeLock);
693
+ selfRemoveOneTimePlist(job);
694
+ process.exit(0);
695
+ }
696
+ }
645
697
  }
646
698
 
647
699
  if (job.concurrency) {
@@ -761,6 +813,9 @@ async function main(): Promise<void> {
761
813
  try {
762
814
  db?.close();
763
815
  } catch {}
816
+ // LAST act for a one-time job: remove its own plist. May SIGTERM us via
817
+ // bootout, so everything above (ledger, alert) is already done.
818
+ if (job.oneTime) selfRemoveOneTimePlist(job);
764
819
  }
765
820
  process.exit(lastResult.code);
766
821
  }