2026-07-04 16:52:05 +05:30
# Cron Jobs — User & Operator Guide
Codeman's **Cron** feature lets you save named, recurring jobs that automatically
2026-08-13 19:40:54 +02:00
spin up a Claude (or shell / OpenCode / Codex / Antigravity / Gemini / Pi) session on a schedule and
2026-07-04 16:52:05 +05:30
feed it a prompt. Think "cron for agent sessions": _"every weekday at 3am, open a
Claude session in `~/proj` and tell it to update dependencies and open a PR."_
2026-10-10 09:27:54 +02:00
- **UI**: the ** ⏰ Cron** button in the bottom toolbar → the Cron Jobs modal (`#cronModal` ).
2026-07-04 16:52:05 +05:30
- **API**: `/api/cron/jobs*` and `/api/cron/runs` .
- **Code**: `src/cron/cron-service.ts` , `src/cron/cron-time.ts` , `src/cron/cron-input.ts` ,
types in `src/types/cron.ts` , routes in `src/web/routes/cron-routes.ts` ,
frontend in `src/web/public/cron-ui.js` .
> **Not to be confused with `ScheduledRun` (`/api/scheduled`).** That older,
> deliberately-separate concept is a _run-now, duration-bounded autonomous loop_
> (`{prompt, workingDir, durationMinutes}` → spawn/kill throwaway sessions until
> the duration elapses). It has no recurrence, no saved jobs, and no next-run
> calculation. The two systems never interact. This guide is only about **Cron
> jobs** (`Cron*`). See `docs/cron-discovery.md` §0.
---
## 1. Quick start
### In the browser
2026-10-10 09:27:54 +02:00
1. Click ** ⏰ Cron** in the bottom toolbar.
2026-07-04 16:52:05 +05:30
2. Click ** + New Job**.
3. Fill in a **name** , pick an **agent type** and **working directory** , choose a
**prompt** (inline text or a file path), pick a **schedule** , and leave
**Enabled** on.
4. **Save** . The job appears in the list with its computed **next run** .
5. Use **Run Now** to fire it immediately without waiting for the schedule.
### With curl
```bash
API = http://localhost:3000
# Create a daily job (03:00 server-local time)
curl -s -X POST " $API /api/cron/jobs" \
-H 'Content-Type: application/json' \
-d '{
"name": "nightly-deps",
"agentType": "claude",
"workingDir": "/home/me/proj",
"promptMode": "inline_text",
"promptText": "Update dependencies and open a PR",
"inputMode": "typed",
"scheduleType": "daily",
"dailyTime": "03:00",
"enabled": true,
"concurrencyPolicy": "warn_only"
}' | jq
# List jobs
curl -s " $API /api/cron/jobs" | jq
# Run one immediately
curl -s -X POST " $API /api/cron/jobs/<jobId>/run" | jq
# See a job's run history
curl -s " $API /api/cron/jobs/<jobId>/runs" | jq
```
---
## 2. Concepts
2026-07-12 19:25:42 +02:00
| Term | Meaning |
| -------------------------- | ------------------------------------------------------------------------------------------------ |
| **Cron job** (`CronJob` ) | A saved, named definition: what agent to launch, where, with what prompt, on what schedule. |
| **Run** (`CronJobRun` ) | One execution of a job — a history record with a status and a link to the session it created. |
| **Schedule type** | How fire times are computed: `once` , `interval` , `daily` , or `weekly` . |
2026-07-04 16:52:05 +05:30
| **Next run** (`nextRunAt` ) | Server-computed epoch-ms of the next fire. `null` when the job is disabled or has no future run. |
2026-07-12 19:25:42 +02:00
| **Due tick** | A background loop (every 30s) that launches any enabled job whose `nextRunAt` has passed. |
2026-07-04 16:52:05 +05:30
A job is essentially a **trigger + persistence + history layer on top of the
existing session primitives**. When a job fires, the cron service does exactly
what the "quick start" route does — `new Session(...)` → `addSession` →
`setupSessionListeners` → `startInteractive()` /`startShell()` → deliver the
prompt. It does **not** reimplement any tmux/PTY logic.
---
## 3. The job form — every field
These map 1:1 to `CronJobSchema` (`src/web/schemas.ts` ) and the `CronJob` type
(`src/types/cron.ts` ).
2026-07-12 19:25:42 +02:00
| Field | Required | Values / limits | Notes |
| -------------------------- | ----------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `name` | ✅ | 1– 200 chars | Display name; also used as the created session's name. |
2026-08-23 08:39:03 +02:00
| `agentType` | ✅ | `claude` \| `shell` \| `opencode` \| `codex` \| `gemini` \| `antigravity` \| `pi` \| `grok` | Reuses Codeman's `SessionMode` . `shell` = a plain terminal. ⚠️ A `pi` or `grok` job's readiness poll looks for `❯ ` /a token count, which neither CLI prints, so it burns the poll budget and then sends the prompt anyway (slower start, still works). |
2026-07-12 19:25:42 +02:00
| `workingDir` | ✅ | valid path (allowlist-validated) | Validated at **create/update** (must exist, be a directory, and not resolve into a blocked tree — `/etc` , `/root` , `/proc` , `/sys` , `/dev` , or `/` itself) and again **at fire time** . |
| `launchCommand` | — | ≤ 2000 chars, single line | `shell` mode only: sent as the **first input line** once the shell is up, before the prompt. Ignored for other agent types. |
| `promptMode` | ✅ | `inline_text` \| `prompt_file_path` | See §5. |
| `promptText` | conditional | ≤ 100000 chars, **single line** | Required when `promptMode = inline_text` . Newlines are rejected (see §6). |
| `promptFilePath` | conditional | valid path | Required when `promptMode = prompt_file_path` . Confined to `workingDir` (see §5). |
| `inputMode` | ✅ | `paste` \| `typed` | How the prompt is delivered. See §6. |
| `scheduleType` | ✅ | `once` \| `interval` \| `daily` \| `weekly` | See §4. |
| `runAt` | conditional | epoch-ms (positive int) | Required for `once` . |
| `intervalMinutes` | conditional | 1– 525600 (≤ 1 year) | Required for `interval` . |
| `dailyTime` | conditional | `HH:MM` (24h) | Required for `daily` . Server-local time. |
| `weeklyDays` | conditional | array of 1– 7 ints, each 0– 6 (0 = Sunday) | Required for `weekly` . |
| `weeklyTime` | conditional | `HH:MM` (24h) | Required for `weekly` . Server-local time. |
| `enabled` | ✅ | boolean | Disabled jobs never auto-fire (but **Run Now** still works). |
| `notes` | — | ≤ 2000 chars | Free-form. |
| `concurrencyPolicy` | ✅ | `warn_only` \| `skip_if_same_agent_running` | Applies to **automatic** runs only. See §7. |
| `autoClosePreviousSession` | — | boolean (default **true** ) | Recurring schedules only (ignored for `once` ): when the next run fires, the still-open session created by this job's **previous** run is closed first via the normal cleanup path. See §8. |
2026-07-04 16:52:05 +05:30
**Cross-field validation** (`refineCronJob` in `schemas.ts` ): the conditional
fields above are enforced by a Zod `superRefine` on create. A missing dependent
field (e.g. `scheduleType: "once"` with no `runAt` ) is rejected with
`INVALID_INPUT` and a field-specific message.
> ⚠️ **Update caveat.** `PUT /api/cron/jobs/:id` uses a `.partial()` schema that
> does **not** re-run the cross-field `superRefine`. To keep partial edits safe,
> `updateJob()` re-validates the **merged** job against the full `CronJobSchema`
> and throws `400` if the result is inconsistent (e.g. switching to `once`
> without a `runAt`). So the store is never left with a half-valid job.
---
## 4. Schedule types
Next-run math lives in `src/cron/cron-time.ts` (pure, unit-tested in
`test/cron-time.test.ts` ). **All wall-clock times use the server's local
timezone** (v0.1 decision).
### `once`
2026-07-12 19:25:42 +02:00
2026-07-04 16:52:05 +05:30
- Fires a single time at the absolute `runAt` epoch-ms.
- A **missed** one-time job (server was down at `runAt` ) **still fires once** on
the next tick — `computeNextRunAt` returns `runAt` even if it's in the past,
until the job has fired.
- After firing, the job **self-disables** : `completedOnce = true` , `enabled =
2026-07-12 19:25:42 +02:00
false`, ` nextRunAt = null`.
2026-07-04 16:52:05 +05:30
### ` interval`
2026-07-12 19:25:42 +02:00
2026-07-04 16:52:05 +05:30
- Fires every ` intervalMinutes`, computed as ` fireTime + intervalMinutes`.
- ⚠️ **Drift**: the next run re-anchors to the actual fire time, not to an ideal
cadence — a slow tick or restart shifts subsequent runs slightly later. This is
an accepted limitation.
### ` daily`
2026-07-12 19:25:42 +02:00
2026-07-04 16:52:05 +05:30
- Fires at ` dailyTime` (` HH:MM`) every day, server-local.
- If today's time has already passed, the next run is tomorrow at that time.
### ` weekly`
2026-07-12 19:25:42 +02:00
2026-07-04 16:52:05 +05:30
- Fires at ` weeklyTime` on each weekday in ` weeklyDays` (0 = Sunday … 6 =
Saturday), server-local.
- The next run is the soonest upcoming matching weekday/time within the next 7
days.
---
## 5. Prompt source (` promptMode`)
### ` inline_text`
2026-07-12 19:25:42 +02:00
2026-07-04 16:52:05 +05:30
The prompt is the literal ` promptText`. Simplest option.
### ` prompt_file_path`
2026-07-12 19:25:42 +02:00
2026-07-04 16:52:05 +05:30
The prompt is read from a file at fire time. **This path is security-hardened**
because a job config is attacker-controllable and the file's contents are
injected into an agent session (an exfiltration sink over SSE/terminal).
` resolveSafePromptPath()` enforces, in order:
2026-07-12 19:25:42 +02:00
1. **` realpath` resolution** — symlinks are resolved to their true target, for
the prompt file **and for ` workingDir` itself**.
2. **` workingDir` is not a trust boundary** — because it is user-supplied, the
resolved ` workingDir` is itself rejected if it is ` /` or resolves into a
blocked tree (` /etc`, ` /root`, operator extras) or a pseudo-filesystem
(` /proc`, ` /sys`, ` /dev`). This closes the ` workingDir: '/proc'` +
` promptFilePath: '/proc/self/environ'` env-exfil trick. The same rule is
enforced earlier, at job create/update.
3. **Blocklist** (defense-in-depth) — sensitive trees (` /etc`, ` /root`,
` /proc`, ` /sys`, ` /dev`, known secret locations) are rejected for the
resolved prompt file.
4. **Allowlist (primary gate)** — the resolved path **must live inside the job's
(resolved) ` workingDir`** (` validateSessionFilePath`). A symlink escaping the
workspace fails here.
5. **Regular-file check** — directories, FIFOs, and ` /dev/*` character devices
2026-07-04 16:52:05 +05:30
are rejected (they would hang or OOM an unbounded read).
2026-07-12 19:25:42 +02:00
6. **Size cap** — files larger than **1 MiB** (` MAX_PROMPT_FILE_BYTES`) are
2026-07-04 16:52:05 +05:30
rejected.
2026-07-12 19:25:42 +02:00
7. **Single-line check** — after trailing newlines are stripped, the file
content must be a single line (see §6).
2026-07-04 16:52:05 +05:30
If any check fails, the run is recorded as **` failed`** with the reason; no
session is created.
---
## 6. Prompt delivery (` inputMode`)
Once the CLI is ready (see §8), the prompt is written to the session with a
trailing carriage return:
2026-07-12 19:25:42 +02:00
| Mode | Mechanism | Use when |
| ------- | --------------------------------------------------------------- | ------------------------------------------------ |
2026-09-25 16:58:00 +02:00
| ` typed` | ` session.writeViaMux()` — the text as a tmux bracketed paste + Enter | Default; like a human pasting the prompt and pressing Enter. |
2026-07-12 19:25:42 +02:00
| ` paste` | ` session.write()` — writes directly to the PTY/mux | Bulk paste-style delivery. |
2026-07-04 16:52:05 +05:30
2026-07-12 19:25:42 +02:00
> ⚠️ **Single-line only — enforced.** Like all programmatic input in Codeman,
> multi-line delivery would be silently corrupted (Ink-based TUIs treat a
> newline as submit; typed mode fuses lines). So newlines are **rejected**: the
> schema and the form refuse a multi-line ` promptText`, and at fire time a
> prompt file whose content is multi-line (after stripping trailing newlines)
> fails the run with a clear ` errorMessage`. Put multi-line instructions in a
> file the agent is told to read itself (e.g. "read TASKS.md and do it").
2026-07-04 16:52:05 +05:30
---
## 7. Concurrency policy (automatic runs)
` concurrencyPolicy` governs what happens when a **scheduled** run is due and
sessions of the same ` agentType` already exist:
2026-07-12 19:25:42 +02:00
| Policy | Behavior |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| ` warn_only` | Always launch. (The count is surfaced but not blocking.) |
| ` skip_if_same_agent_running` | If ≥ 1 **other, live** session of that mode is active, **skip** this fire — record a ` skipped` run and (for recurring schedules) advance the schedule without launching. |
2026-07-04 16:52:05 +05:30
Notes on ` skip_if_same_agent_running`:
2026-07-12 19:25:42 +02:00
- Only **live** sessions block: a tab whose CLI already exited (status
` stopped`/` error`) does not count.
- Sessions created by **this job's own previous runs never block it** —
otherwise a recurring job would deadlock on the session it created last time
and fire exactly once.
- A skipped **` once`** job is **not consumed**: it stays armed and retries on
the next tick until the blocking session goes away, then fires its single run.
2026-07-04 16:52:05 +05:30
- A skip is **not** a run: it sets ` lastStatus = 'skipped'` but does **not**
advance ` lastRunAt`.
- Consecutive skips are **coalesced** — a perpetually-skipped interval job writes
**one** skip record per streak, not one every tick, so it can't bloat
` state.json`.
**Run Now ignores this policy on the server.** The browser shows a ` confirm()`
warning if same-type sessions are active, but if you proceed (or call the API
directly), the job launches unconditionally.
---
## 8. What happens when a job fires
Sequence in ` CronService.launch()`:
1. A ` CronJobRun` is created with status **` created`** and broadcast
(` cron:runCreated`).
2026-07-12 19:25:42 +02:00
2. The prompt is resolved (inline or file, single-line enforced). Failure →
**` failed`**.
2026-07-04 16:52:05 +05:30
3. ` workingDir` is checked (` statSync().isDirectory()`). Missing/not-a-dir →
**` failed`**.
2026-07-12 19:25:42 +02:00
4. **Auto-close previous session** (recurring schedules, unless
` autoClosePreviousSession: false`): any still-open session created by this
job's previous runs is closed via the normal session-cleanup path.
5. The global session cap is checked (` MAX_CONCURRENT_SESSIONS = 50`). At cap →
2026-07-04 16:52:05 +05:30
**` failed`**.
2026-07-12 19:25:42 +02:00
6. A ` Session` is created **with ` useMux: true`** (so it runs inside tmux),
2026-07-04 16:52:05 +05:30
registered, listeners attached, and started via ` startInteractive()`
(` startShell()` for ` shell` mode). Model/claudeMode come from global config.
Run status → **` session_started`**.
2026-07-12 19:25:42 +02:00
7. **Readiness wait** (async, non-blocking): for non-shell agents the service
2026-07-04 16:52:05 +05:30
polls the terminal buffer up to **60 × 500ms** for a ` ❯ ` prompt or the string
2026-07-12 19:25:42 +02:00
` tokens`, then settles **2000ms** (` CRON_READY_SETTLE_MS`). Shell mode waits
1000ms, then sends the optional ` launchCommand` as the first input line
(+1000ms settle).
8. The prompt is delivered (` typed`/` paste`, trailing ` \r`). Run status →
**` prompt_sent`**; ` finishedAt` stamped. Delivery failure (e.g. the mux
session is gone) → **` failed`**.
2026-07-04 16:52:05 +05:30
The created session is a **normal, persistent interactive session** — it appears
as its own tab and keeps running after the prompt is sent. The run's
` createdSessionUrl` is a deep link (` /?session=<id>`); the UI focuses it
automatically after **Run Now**.
2026-07-12 19:25:42 +02:00
> ⚠️ **Session-cap math if you disable auto-close.** With
> ` autoClosePreviousSession: false`, nothing ever closes the sessions a
> recurring job creates — an interval job every 30 min creates 48 tabs/day and
> hits the global 50-session cap in ~25 hours (sooner with existing tabs), after
> which **every** fire of **every** job fails with "Maximum concurrent sessions
> reached" until you delete tabs by hand. Leave auto-close on for unattended
> recurring jobs, or clean up sessions yourself.
2026-07-04 16:52:05 +05:30
### The background tick
` tickDueJobs()` runs every **30s** (` CRON_TICK_INTERVAL`, registered in
` server.ts`). For each enabled job whose ` nextRunAt ≤ now`:
- **Duplicate-launch guard**: ` lastDueKey = jobId:fireTime`. If this due time was
already consumed (overlap/restart), the job is just advanced, not relaunched.
- The schedule is **advanced _before_ launching** so a slow launch can't be
re-triggered by the next tick.
- On boot, ` init()` recomputes ` nextRunAt` for loaded jobs (dead ` once` jobs stay
dead).
---
## 9. Run history & statuses
Each job keeps a history of ` CronJobRun` records. Statuses (` CronJobRunStatus`):
2026-07-12 19:25:42 +02:00
| Status | Meaning |
| ----------------- | ------------------------------------------------------------- |
| ` created` | Run record created; prompt/session not yet started. |
| ` session_started` | Session launched successfully. |
| ` prompt_sent` | Prompt delivered — the happy-path terminal state. |
| ` failed` | Something went wrong (see ` errorMessage`). |
| ` skipped` | A scheduled fire was skipped by ` skip_if_same_agent_running`. |
2026-07-04 16:52:05 +05:30
Each run also records ` triggerType` (` scheduled` or ` manual_run_now`),
` sessionId`/` sessionName`, timestamps, and ` createdSessionUrl`.
**History is capped globally** at **500 records** (` MAX_CRON_RUN_HISTORY`); the
oldest are pruned first. Deleting a job also deletes its run records.
---
## 10. API reference
All responses use the standard ` ApiResponse<T>` envelope (` {success, data}` /
` {success, error, errorCode}`). ` /api/v1/*` is a stable alias.
2026-07-12 19:25:42 +02:00
| Method | Endpoint | Body | Returns |
| -------- | ---------------------------- | ---------------------- | --------------------------------- |
| ` GET` | ` /api/cron/jobs` | — | ` CronJob[]` |
| ` POST` | ` /api/cron/jobs` | ` CronJobSchema` | ` { job }` |
| ` GET` | ` /api/cron/jobs/:id` | — | ` CronJob` (404 if missing) |
| ` PUT` | ` /api/cron/jobs/:id` | partial ` CronJob` | ` { job }` (400 if merge invalid) |
| ` DELETE` | ` /api/cron/jobs/:id` | — | ` {}` |
| ` PUT` | ` /api/cron/jobs/:id/enabled` | ` { enabled: boolean }` | ` { job }` |
| ` POST` | ` /api/cron/jobs/:id/run` | — | ` { run, activeAgents }` |
| ` GET` | ` /api/cron/jobs/:id/runs` | — | ` CronJobRun[]` (newest first) |
| ` GET` | ` /api/cron/runs` | — | all ` CronJobRun[]` (newest first) |
2026-07-04 16:52:05 +05:30
---
## 11. SSE events
Emitted on ` /api/events`, mirrored in ` SSE_EVENTS` (` constants.js`):
2026-07-12 19:25:42 +02:00
| Event | Payload | When |
| ------------------ | ------------ | -------------------------------------------------------------------- |
| ` cron:jobsChanged` | ` { jobs }` | Any job created / updated / enabled / status change. |
| ` cron:jobDeleted` | ` { id }` | A job was deleted. |
| ` cron:runCreated` | ` CronJobRun` | A run (incl. skips) started. |
| ` cron:runUpdated` | ` CronJobRun` | A run advanced state (` session_started` / ` prompt_sent` / ` failed`). |
2026-07-04 16:52:05 +05:30
---
## 12. State & persistence
Persisted in ` ~/.codeman/state.json` via ` StateStore`:
- ` AppState.cronJobs` — map of ` id → CronJob`.
- ` AppState.cronJobRuns` — map of ` id → CronJobRun`.
Jobs and their schedules survive restarts; ` init()` recomputes ` nextRunAt` on
boot. Sessions the jobs create persist through the normal session-recovery path.
---
## 13. Limits & constants
2026-07-12 19:25:42 +02:00
| Constant | Value | Source |
| ------------------------ | --------------------- | ------------------------------------------------ |
| Due-tick interval | 30s | ` CRON_TICK_INTERVAL` (` config/server-timing.ts`) |
| Readiness poll | 60 × 500ms | ` CRON_READY_MAX_ATTEMPTS` |
| Readiness settle | 2000ms | ` CRON_READY_SETTLE_MS` |
| Run-history cap (global) | 500 | ` MAX_CRON_RUN_HISTORY` (` config/map-limits.ts`) |
| Saved-jobs cap | 100 | ` MAX_CRON_JOBS` (` config/map-limits.ts`) |
| Concurrent-session cap | 50 | ` MAX_CONCURRENT_SESSIONS` |
| Prompt-file size cap | 1 MiB | ` MAX_PROMPT_FILE_BYTES` (` cron-service.ts`) |
| ` name` length | 1– 200 | ` CronJobSchema` |
| ` promptText` length | ≤ 100000 | ` CronJobSchema` |
| ` intervalMinutes` | 1– 525600 | ` CronJobSchema` |
| ` weeklyDays` | 1– 7 entries, each 0– 6 | ` CronJobSchema` |
2026-07-04 16:52:05 +05:30
---
## 14. Known limitations
- **Server-local timezone only** — ` daily`/` weekly` times are interpreted in the
host's local time; there is no per-job timezone.
- **Interval drift** — ` interval` re-anchors to the actual fire time; long-running
intervals slowly shift.
2026-07-12 19:25:42 +02:00
- **Single-line prompts** — multi-line prompts are rejected (schema, form, and
at fire time for prompt files); tell the agent to read a file itself for
multi-line instructions.
2026-07-04 16:52:05 +05:30
- **` runNow` / tick race** — a manual Run Now firing at the same instant as a
scheduled tick is theoretically possible; benign (you may get two sessions).
- **` {enabled:true}` on a dead ` once` job** — re-enabling a fired one-time job
without changing its schedule leaves it enabled-but-dead (won't fire); change
the schedule to re-arm.
---
## 15. Troubleshooting
2026-07-12 19:25:42 +02:00
| Symptom | Likely cause | Fix |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| Job never fires | Disabled, or ` nextRunAt: null` | Check **Enabled**; verify the schedule fields are complete. |
| Run shows ` failed` immediately | Bad ` workingDir`, prompt-file rejected, or session cap hit | Read ` errorMessage` on the run; confirm the dir exists and the prompt file is inside it and < 1 MiB. |
| Run shows ` skipped` | ` skip_if_same_agent_running` + another live same-type session (this job's own sessions and dead tabs don't count) | Switch to ` warn_only`, or wait for the other session to end. |
| Run fails with "single line" | Multi-line prompt text / prompt file | Keep the prompt to one line; point the agent at a file to read for long instructions. |
| Sessions pile up between runs | ` autoClosePreviousSession: false` | Re-enable auto-close, or delete old tabs before the 50-session cap bites (see §8). |
| Wrong fire time | Timezone assumption | Times are **server-local** — check the host clock/TZ. |
| One-time job won't re-fire | ` completedOnce` set | Edit the schedule (any real schedule change re-arms it). |
2026-07-04 16:52:05 +05:30
---
## 16. Related docs
- ` docs/cron-discovery.md` — architecture / integration-point analysis (why the
feature reuses the session layer and stays distinct from ` ScheduledRun`).
- ` docs/cron-build-brief.md` — the original build brief / requirements.
- ` CLAUDE.md` → **Key Patterns → Cron** — the one-paragraph engineering summary.
- Tests: ` test/cron-time.test.ts` (schedule math), ` test/cron-service.test.ts`
(CRUD, tick, concurrency, security).