Leandro von Werra commited on
Commit
5e808b7
·
unverified ·
2 Parent(s): 7e743084a7d688

Merge pull request #97 from huggingface/feat/cron-jobs

Browse files
README.md CHANGED
@@ -108,13 +108,14 @@ api.set_space_volumes(
108
  api.restart_space(space_id)
109
  ```
110
 
111
- Everything durable lives under `/data`: `sessions.json`, `groups.json`,
112
  `workspaces/<path>/` (agent working dirs + shared `skills/`), and each
113
  CLI's closed state checkpoints under `/data/state`. Active harness state lives
114
  on local POSIX storage and is restored/checkpointed by
115
  [`scripts/agent-state.sh`](scripts/agent-state.sh); SQLite harnesses use online
116
  database backups rather than copying live WAL files. See
117
  [`docs/agent-state-checkpoints.md`](docs/agent-state-checkpoints.md).
 
118
 
119
  ## Architecture
120
 
 
108
  api.restart_space(space_id)
109
  ```
110
 
111
+ Everything durable lives under `/data`: `sessions.json`, `groups.json`, `crons.json`,
112
  `workspaces/<path>/` (agent working dirs + shared `skills/`), and each
113
  CLI's closed state checkpoints under `/data/state`. Active harness state lives
114
  on local POSIX storage and is restored/checkpointed by
115
  [`scripts/agent-state.sh`](scripts/agent-state.sh); SQLite harnesses use online
116
  database backups rather than copying live WAL files. See
117
  [`docs/agent-state-checkpoints.md`](docs/agent-state-checkpoints.md).
118
+ Scheduled prompts are documented in [`docs/cron-jobs.md`](docs/cron-jobs.md).
119
 
120
  ## Architecture
121
 
docs/cron-jobs.md ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Cron jobs
2
+
3
+ Cron jobs send ordinary Agent Manager prompts on a five-field cron schedule.
4
+ They are durable configuration, not terminal processes: definitions and their
5
+ last delivery result live in `DATA_DIR/crons.json` (the mounted bucket in a
6
+ Space), while timers exist only in the current server process.
7
+
8
+ The same feature is available in **Settings → Cron** and over HTTP. As with
9
+ other mutating APIs, an agent passes `?from=$AM_ID`; the browser supplies the
10
+ operator origin automatically.
11
+
12
+ ## Create and list
13
+
14
+ ```sh
15
+ curl -sS --fail -X POST \
16
+ "http://localhost:${AM_PORT:-7860}/api/crons?from=$AM_ID" \
17
+ -H 'content-type: application/json' -d '{
18
+ "name": "nightly deploy check",
19
+ "agent": { "name": "nightly-index", "cli": "claude" },
20
+ "prompt": "Check last night’s deploy log and report regressions.",
21
+ "schedule": { "cron": "0 9 * * *", "tz": "Europe/Zurich" },
22
+ "runOnRestart": true
23
+ }'
24
+ ```
25
+
26
+ The response is the stored job, including a generated `cron_…` id, `state`
27
+ (`running` initially), and `next` as an ISO UTC instant. `schedule.tz` is a
28
+ required IANA timezone; it is applied when calculating occurrences, including
29
+ daylight-saving transitions. Expressions use exactly the conventional five
30
+ fields: minute, hour, day of month, month, day of week.
31
+
32
+ ```sh
33
+ curl -s "http://localhost:${AM_PORT:-7860}/api/crons" | jq .crons
34
+ ```
35
+
36
+ Each listed job includes:
37
+
38
+ ```json
39
+ {
40
+ "id": "cron_7f3a…",
41
+ "name": "nightly deploy check",
42
+ "agent": { "name": "nightly-index", "cli": "claude" },
43
+ "prompt": "…",
44
+ "schedule": { "cron": "0 9 * * *", "tz": "Europe/Zurich" },
45
+ "runOnRestart": true,
46
+ "state": "running",
47
+ "next": "2026-08-20T07:00:00.000Z",
48
+ "last": {
49
+ "at": "2026-08-19T07:00:00.000Z",
50
+ "status": "ok",
51
+ "durationMs": 83,
52
+ "trigger": "schedule"
53
+ }
54
+ }
55
+ ```
56
+
57
+ `last.status` reports whether Agent Manager delivered the prompt, or why it
58
+ could not (for example, a CLI no longer installed on the Space). Its duration
59
+ is delivery time, **not the agent task's runtime**. The supported CLIs do not
60
+ provide one shared, trustworthy task-success signal, and an agent can accept
61
+ overlapping prompts, so claiming task success here would be false.
62
+
63
+ ## Run, edit, stop, and delete
64
+
65
+ ```sh
66
+ # Fire outside the schedule. 202 means accepted for delivery.
67
+ curl -sS --fail -X POST \
68
+ "http://localhost:${AM_PORT:-7860}/api/crons/$ID/run?from=$AM_ID"
69
+
70
+ # Stop keeps the definition and last result, but removes its next occurrence.
71
+ curl -sS --fail -X PUT \
72
+ "http://localhost:${AM_PORT:-7860}/api/crons/$ID?from=$AM_ID" \
73
+ -H 'content-type: application/json' -d '{"state":"stopped"}'
74
+
75
+ # Start again. PUT also accepts any of the create fields for editing.
76
+ curl -sS --fail -X PUT \
77
+ "http://localhost:${AM_PORT:-7860}/api/crons/$ID?from=$AM_ID" \
78
+ -H 'content-type: application/json' -d '{"state":"running"}'
79
+
80
+ curl -sS --fail -X DELETE \
81
+ "http://localhost:${AM_PORT:-7860}/api/crons/$ID?from=$AM_ID"
82
+ ```
83
+
84
+ Manual Run now works for stopped jobs too; stopped governs future schedule
85
+ fires, not whether the definition may be invoked explicitly.
86
+
87
+ ## Agent creation and identity
88
+
89
+ At fire time Agent Manager looks for an existing session with the exact agent
90
+ name. If found, it reuses that session; the job's `agent.cli` is only the type
91
+ to use when creation is needed. If absent, the server synchronously creates one
92
+ session in `workspaces/<slugged-agent-name>` before prompt delivery. Because
93
+ lookup and creation happen without an asynchronous gap, two jobs firing for the
94
+ same missing name cannot create two agents in this server process.
95
+
96
+ The target sees a normal prompt prefixed with:
97
+
98
+ ```text
99
+ [message from cron "nightly deploy check":]
100
+ ```
101
+
102
+ Scheduled and restart fires enter the durable operations log with origin
103
+ `{id: "cron:<job-id>", type: "cron", name: "<job-name>"}`. API calls that
104
+ create, edit, manually run, or delete jobs retain the operator/agent identity
105
+ that made the call.
106
+
107
+ ## Restart and overlap semantics
108
+
109
+ On server start, every running job gets a newly calculated future occurrence.
110
+ Persisted times that passed while the Space was asleep are not replayed. A
111
+ running job with `runOnRestart: true` also fires once shortly after the server
112
+ starts. If its next scheduled occurrence falls in that same short startup
113
+ window, the scheduled occurrence substitutes for the restart fire so one boot
114
+ cannot send the prompt twice. Stopped jobs do not run on restart.
115
+
116
+ There is deliberately no overlap guard and no spend ceiling. If a target is
117
+ already working, another prompt may land in its input and be handled mid-task.
118
+ Use Stop or Delete when a schedule should no longer spend tokens.
server/package-lock.json CHANGED
@@ -10,6 +10,7 @@
10
  "license": "Apache-2.0",
11
  "dependencies": {
12
  "@coder/libghostty-vt-node": "0.1.0-beta.0",
 
13
  "express": "^4.19.2",
14
  "node-pty": "^1.0.0",
15
  "web-push": "^3.6.7",
@@ -206,6 +207,18 @@
206
  "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
207
  "license": "MIT"
208
  },
 
 
 
 
 
 
 
 
 
 
 
 
209
  "node_modules/debug": {
210
  "version": "2.6.9",
211
  "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -609,6 +622,15 @@
609
  "safe-buffer": "^5.0.1"
610
  }
611
  },
 
 
 
 
 
 
 
 
 
612
  "node_modules/math-intrinsics": {
613
  "version": "1.1.0",
614
  "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
 
10
  "license": "Apache-2.0",
11
  "dependencies": {
12
  "@coder/libghostty-vt-node": "0.1.0-beta.0",
13
+ "cron-parser": "^5.10.0",
14
  "express": "^4.19.2",
15
  "node-pty": "^1.0.0",
16
  "web-push": "^3.6.7",
 
207
  "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
208
  "license": "MIT"
209
  },
210
+ "node_modules/cron-parser": {
211
+ "version": "5.10.0",
212
+ "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.10.0.tgz",
213
+ "integrity": "sha512-izNAxJyRWUP8ljBoDSub5WyrVOUlT4SLGShswE7eoRBpp6QUsSycYxLBMJlbshgPBMcPT/nrfgjNY2918ayv2A==",
214
+ "license": "MIT",
215
+ "dependencies": {
216
+ "luxon": "^3.7.2"
217
+ },
218
+ "engines": {
219
+ "node": ">=18"
220
+ }
221
+ },
222
  "node_modules/debug": {
223
  "version": "2.6.9",
224
  "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
 
622
  "safe-buffer": "^5.0.1"
623
  }
624
  },
625
+ "node_modules/luxon": {
626
+ "version": "3.7.2",
627
+ "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
628
+ "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
629
+ "license": "MIT",
630
+ "engines": {
631
+ "node": ">=12"
632
+ }
633
+ },
634
  "node_modules/math-intrinsics": {
635
  "version": "1.1.0",
636
  "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
server/package.json CHANGED
@@ -23,6 +23,7 @@
23
  },
24
  "dependencies": {
25
  "@coder/libghostty-vt-node": "0.1.0-beta.0",
 
26
  "express": "^4.19.2",
27
  "node-pty": "^1.0.0",
28
  "web-push": "^3.6.7",
 
23
  },
24
  "dependencies": {
25
  "@coder/libghostty-vt-node": "0.1.0-beta.0",
26
+ "cron-parser": "^5.10.0",
27
  "express": "^4.19.2",
28
  "node-pty": "^1.0.0",
29
  "web-push": "^3.6.7",
server/src/crons.js ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { CronExpressionParser } from 'cron-parser';
5
+ import { DATA_DIR } from './config.js';
6
+
7
+ export const CRONS_FILE = path.join(DATA_DIR, 'crons.json');
8
+ const MAX_TIMER_MS = 2_147_000_000;
9
+ const VALID_STATES = new Set(['running', 'stopped']);
10
+
11
+ let jobs = [];
12
+ let fireJob = null;
13
+ const timers = new Map();
14
+
15
+ const cleanText = (value, field, max = 160) => {
16
+ if (typeof value !== 'string' || !value.trim()) throw new Error(`${field} required`);
17
+ const text = value.trim();
18
+ if (text.length > max) throw new Error(`${field} is too long (max ${max} characters)`);
19
+ return text;
20
+ };
21
+
22
+ const clone = (value) => JSON.parse(JSON.stringify(value));
23
+
24
+ function persist() {
25
+ try {
26
+ fs.mkdirSync(path.dirname(CRONS_FILE), { recursive: true });
27
+ const tmp = `${CRONS_FILE}.tmp`;
28
+ fs.writeFileSync(tmp, JSON.stringify(jobs, null, 2), { mode: 0o600 });
29
+ fs.renameSync(tmp, CRONS_FILE);
30
+ } catch (e) {
31
+ // A transient bucket/FUSE write must not take down the process that owns
32
+ // every live terminal. Match the session store's failure posture.
33
+ console.error('[crons.persist]', e && e.message);
34
+ }
35
+ }
36
+
37
+ export function validateSchedule(value, id = '') {
38
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('schedule required');
39
+ const cron = cleanText(value.cron, 'schedule.cron', 120).replace(/\s+/g, ' ');
40
+ if (cron.split(' ').length !== 5) throw new Error('schedule.cron must use the standard five fields: minute hour day month weekday');
41
+ const tz = cleanText(value.tz, 'schedule.tz', 100);
42
+ try {
43
+ // Intl is the runtime authority for IANA zone names; cron-parser then
44
+ // applies that zone (including DST) when it advances the expression.
45
+ new Intl.DateTimeFormat('en', { timeZone: tz }).format(new Date());
46
+ CronExpressionParser.parse(cron, { tz, hashSeed: id || 'agent-manager-cron' }).next();
47
+ } catch (e) {
48
+ throw new Error(`invalid schedule: ${e && e.message ? e.message : e}`);
49
+ }
50
+ return { cron, tz };
51
+ }
52
+
53
+ export function nextOccurrence(schedule, after = new Date(), id = '') {
54
+ const valid = validateSchedule(schedule, id);
55
+ return CronExpressionParser.parse(valid.cron, {
56
+ currentDate: after,
57
+ tz: valid.tz,
58
+ hashSeed: id || 'agent-manager-cron',
59
+ }).next().toISOString();
60
+ }
61
+
62
+ function normalizeInput(input, existing = null) {
63
+ const src = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
64
+ const merged = existing ? {
65
+ ...existing,
66
+ ...src,
67
+ agent: src.agent === undefined ? existing.agent : src.agent,
68
+ schedule: src.schedule === undefined ? existing.schedule : src.schedule,
69
+ } : src;
70
+ const agent = merged.agent;
71
+ if (!agent || typeof agent !== 'object' || Array.isArray(agent)) throw new Error('agent required');
72
+ const state = merged.state === undefined ? 'running' : merged.state;
73
+ if (!VALID_STATES.has(state)) throw new Error("state must be 'running' or 'stopped'");
74
+ const id = existing?.id || `cron_${crypto.randomBytes(5).toString('hex')}`;
75
+ return {
76
+ ...(existing || {}),
77
+ id,
78
+ name: cleanText(merged.name, 'name'),
79
+ agent: {
80
+ name: cleanText(agent.name, 'agent.name'),
81
+ cli: cleanText(agent.cli, 'agent.cli', 64),
82
+ },
83
+ prompt: cleanText(merged.prompt, 'prompt', 100_000),
84
+ schedule: validateSchedule(merged.schedule, id),
85
+ runOnRestart: merged.runOnRestart === true,
86
+ state,
87
+ };
88
+ }
89
+
90
+ function clearTimer(id) {
91
+ const timer = timers.get(id);
92
+ if (timer) clearTimeout(timer);
93
+ timers.delete(id);
94
+ }
95
+
96
+ function armExisting(job) {
97
+ clearTimer(job.id);
98
+ if (!fireJob || job.state !== 'running' || !job.next) return;
99
+ const target = Date.parse(job.next);
100
+ if (!Number.isFinite(target)) return;
101
+ const delay = target - Date.now();
102
+ const timer = setTimeout(() => {
103
+ timers.delete(job.id);
104
+ const current = jobs.find((candidate) => candidate.id === job.id);
105
+ if (!current || current.state !== 'running' || current.next !== job.next) return;
106
+ if (Date.now() + 250 < target) {
107
+ armExisting(current);
108
+ return;
109
+ }
110
+ // Advance before dispatch. If delivery is slow, fails, or overlaps another
111
+ // run, this occurrence is still consumed exactly once. Computing from now
112
+ // deliberately skips times missed while the process was unavailable.
113
+ current.next = nextOccurrence(current.schedule, new Date(Math.max(Date.now(), target)), current.id);
114
+ persist();
115
+ armExisting(current);
116
+ Promise.resolve(fireJob(current.id, 'schedule')).catch((e) =>
117
+ console.error('[crons.fire]', current.id, e && e.message));
118
+ }, Math.max(0, Math.min(MAX_TIMER_MS, delay)));
119
+ timer.unref?.();
120
+ timers.set(job.id, timer);
121
+ }
122
+
123
+ function resetNext(job, now = new Date()) {
124
+ job.next = job.state === 'running' ? nextOccurrence(job.schedule, now, job.id) : null;
125
+ }
126
+
127
+ export function init(now = new Date()) {
128
+ for (const id of timers.keys()) clearTimer(id);
129
+ fireJob = null;
130
+ try {
131
+ const parsed = JSON.parse(fs.readFileSync(CRONS_FILE, 'utf8'));
132
+ jobs = Array.isArray(parsed) ? parsed : [];
133
+ } catch {
134
+ jobs = [];
135
+ }
136
+ const valid = [];
137
+ for (const raw of jobs) {
138
+ try {
139
+ const job = normalizeInput(raw, raw && raw.id ? raw : null);
140
+ job.createdAt = raw.createdAt || now.toISOString();
141
+ job.updatedAt = raw.updatedAt || job.createdAt;
142
+ if (raw.last && typeof raw.last === 'object') job.last = raw.last;
143
+ resetNext(job, now); // stale persisted times are never replayed
144
+ valid.push(job);
145
+ } catch (e) {
146
+ console.error('[crons.load]', raw && raw.id, e && e.message);
147
+ }
148
+ }
149
+ jobs = valid;
150
+ persist();
151
+ return list();
152
+ }
153
+
154
+ export function list() {
155
+ return jobs.map(clone);
156
+ }
157
+
158
+ export function get(id) {
159
+ const job = jobs.find((candidate) => candidate.id === id);
160
+ return job ? clone(job) : null;
161
+ }
162
+
163
+ export function create(input, now = new Date()) {
164
+ const job = normalizeInput(input);
165
+ job.createdAt = now.toISOString();
166
+ job.updatedAt = job.createdAt;
167
+ resetNext(job, now);
168
+ jobs.push(job);
169
+ persist();
170
+ armExisting(job);
171
+ return clone(job);
172
+ }
173
+
174
+ export function update(id, patch, now = new Date()) {
175
+ const index = jobs.findIndex((job) => job.id === id);
176
+ if (index < 0) return null;
177
+ const before = jobs[index];
178
+ const job = normalizeInput(patch, before);
179
+ job.updatedAt = now.toISOString();
180
+ const scheduleChanged = job.schedule.cron !== before.schedule.cron || job.schedule.tz !== before.schedule.tz;
181
+ const resumed = before.state !== 'running' && job.state === 'running';
182
+ if (job.state !== 'running') job.next = null;
183
+ else if (scheduleChanged || resumed || !before.next) resetNext(job, now);
184
+ jobs[index] = job;
185
+ persist();
186
+ armExisting(job);
187
+ return clone(job);
188
+ }
189
+
190
+ export function remove(id) {
191
+ const before = jobs.length;
192
+ jobs = jobs.filter((job) => job.id !== id);
193
+ if (jobs.length === before) return false;
194
+ clearTimer(id);
195
+ persist();
196
+ return true;
197
+ }
198
+
199
+ export function recordLast(id, last) {
200
+ const job = jobs.find((candidate) => candidate.id === id);
201
+ if (!job) return null; // deleting a firing job must not recreate it
202
+ // Overlap is allowed. Completion order therefore need not be start order;
203
+ // never let an older, slower delivery replace the genuinely latest fire.
204
+ if (job.last && Date.parse(job.last.at) > Date.parse(last.at)) return clone(job);
205
+ job.last = clone(last);
206
+ persist();
207
+ return clone(job);
208
+ }
209
+
210
+ export function startScheduler(handler, { restartDelayMs = 1_500 } = {}) {
211
+ fireJob = handler;
212
+ const restartAt = Date.now() + restartDelayMs;
213
+ // Capture the boot-time occurrence before arming it. By the restart callback
214
+ // it may already have fired and advanced `next`, which would hide the very
215
+ // collision this check prevents.
216
+ const restartJobs = jobs
217
+ .filter((job) => job.state === 'running' && job.runOnRestart)
218
+ .map((job) => ({ id: job.id, scheduledAt: Date.parse(job.next) }));
219
+ for (const job of jobs) armExisting(job);
220
+ if (restartJobs.length) {
221
+ const timer = setTimeout(() => {
222
+ for (const { id, scheduledAt } of restartJobs) {
223
+ const current = jobs.find((job) => job.id === id);
224
+ if (!current || current.state !== 'running' || !current.runOnRestart) continue;
225
+ // One boot intent must not become two prompts. A scheduled occurrence
226
+ // within one restart-delay of the planned restart fire substitutes for
227
+ // it; this is startup de-duplication, not an overlap guard for ordinary
228
+ // runs. Use the captured time so this still holds if schedule fired first.
229
+ if (Number.isFinite(scheduledAt) && Math.abs(scheduledAt - restartAt) <= restartDelayMs) continue;
230
+ Promise.resolve(fireJob(id, 'restart')).catch((e) =>
231
+ console.error('[crons.restart]', id, e && e.message));
232
+ }
233
+ }, restartDelayMs);
234
+ timer.unref?.();
235
+ }
236
+ }
server/src/index.js CHANGED
@@ -17,6 +17,7 @@ import * as groups from './groups.js';
17
  import * as order from './order.js';
18
  import * as demo from './demo.js';
19
  import * as hidden from './hidden.js';
 
20
  import {
21
  attach, agentInfo, deriveState, stop, stopAll, ensureRunning, sendInput, pasteInput, isRunning,
22
  waitForInputReady, capturePane, ghosttyReady, ghosttyError,
@@ -52,6 +53,7 @@ installSlowFsProbe();
52
  ensureDirs();
53
  refreshVersions();
54
  store.init();
 
55
  pruneAttachmentDirs(store.list().map((session) => session.id))
56
  .catch((e) => console.error('[attachments.prune]', e && e.message));
57
  groups.init();
@@ -210,6 +212,10 @@ const resolveOperationOrigin = (raw, req) => {
210
  return { id: 'operator', type: 'operator', name: process.env.SPACE_AUTHOR_NAME || process.env.AM_USER || 'operator' };
211
  }
212
  if (raw) {
 
 
 
 
213
  const session = store.get(raw);
214
  if (session) return { id: session.id, type: 'agent', name: session.name, cli: session.cli };
215
  if (raw.startsWith('remote:')) {
@@ -1284,6 +1290,33 @@ To reconstruct recent manager operations (newest first):
1284
  curl -s "http://localhost:\${AM_PORT:-${PORT}}/api/operations?limit=100" | jq .operations
1285
  \`\`\`
1286
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1287
  Your session exports the port as \`$AM_PORT\` — read it rather than trusting a
1288
  number you remember, and check it before you believe an empty answer: \`curl -s\`
1289
  to a port nothing is listening on prints **nothing at all**, and in some agent
@@ -2207,6 +2240,128 @@ app.post('/api/sessions', (req, res) => {
2207
  res.status(201).json({ ...s, running: false, state: 'stopped' });
2208
  });
2209
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2210
  // Rename = display label only. Folders are never renamed or moved.
2211
  app.put('/api/sessions/:id', (req, res) => {
2212
  const name = (req.body || {}).name;
@@ -2789,4 +2944,16 @@ server.listen(PORT, () => {
2789
  console.log(`Agent Manager :${PORT} engine=libghostty${ghosttyReady() ? '' : ' (UNAVAILABLE)'} data=${DATA_DIR}`);
2790
  console.log('⚠ No authentication: this app trusts whoever can reach it.');
2791
  console.log(' Keep this Space PRIVATE — a public instance gives anyone a shell + your logged-in agents.');
 
 
 
 
 
 
 
 
 
 
 
 
2792
  });
 
17
  import * as order from './order.js';
18
  import * as demo from './demo.js';
19
  import * as hidden from './hidden.js';
20
+ import * as crons from './crons.js';
21
  import {
22
  attach, agentInfo, deriveState, stop, stopAll, ensureRunning, sendInput, pasteInput, isRunning,
23
  waitForInputReady, capturePane, ghosttyReady, ghosttyError,
 
53
  ensureDirs();
54
  refreshVersions();
55
  store.init();
56
+ crons.init();
57
  pruneAttachmentDirs(store.list().map((session) => session.id))
58
  .catch((e) => console.error('[attachments.prune]', e && e.message));
59
  groups.init();
 
212
  return { id: 'operator', type: 'operator', name: process.env.SPACE_AUTHOR_NAME || process.env.AM_USER || 'operator' };
213
  }
214
  if (raw) {
215
+ if (raw.startsWith('cron:')) {
216
+ const job = crons.get(raw.slice('cron:'.length));
217
+ if (job) return { id: raw, type: 'cron', name: job.name };
218
+ }
219
  const session = store.get(raw);
220
  if (session) return { id: session.id, type: 'agent', name: session.name, cli: session.cli };
221
  if (raw.startsWith('remote:')) {
 
1290
  curl -s "http://localhost:\${AM_PORT:-${PORT}}/api/operations?limit=100" | jq .operations
1291
  \`\`\`
1292
 
1293
+ ### Schedule recurring prompts
1294
+ A cron job sends a normal prompt on a five-field cron schedule. It survives
1295
+ Space restarts in durable storage; if the named agent does not exist when it
1296
+ fires, the manager creates it in \`workspaces/<agent-name>\` first. The timezone
1297
+ is required because the Space clock is UTC. Create one with your own id so the
1298
+ operation log records who asked:
1299
+
1300
+ \`\`\`sh
1301
+ curl -sS --fail -X POST "http://localhost:\${AM_PORT:-${PORT}}/api/crons?from=$AM_ID" \\
1302
+ -H 'content-type: application/json' -d '{
1303
+ "name":"weekday issue triage",
1304
+ "agent":{"name":"triage","cli":"claude"},
1305
+ "prompt":"Triage newly opened issues and report anything urgent.",
1306
+ "schedule":{"cron":"0 9 * * 1-5","tz":"Europe/Zurich"},
1307
+ "runOnRestart":true
1308
+ }'
1309
+ curl -s "http://localhost:\${AM_PORT:-${PORT}}/api/crons" | jq .crons
1310
+ \`\`\`
1311
+
1312
+ Use \`POST /api/crons/$ID/run?from=$AM_ID\` to run now,
1313
+ \`PUT /api/crons/$ID?from=$AM_ID\` with \`{"state":"stopped"}\` (or
1314
+ \`"running"\`) to stop/start it, and \`DELETE /api/crons/$ID?from=$AM_ID\` to
1315
+ remove it. Run-on-restart is one fresh fire, never a replay of missed times.
1316
+ There is deliberately no overlap or spend guard: stop or delete jobs you no
1317
+ longer want. See \`docs/cron-jobs.md\` in the Agent Manager source for the full
1318
+ request and response shapes.
1319
+
1320
  Your session exports the port as \`$AM_PORT\` — read it rather than trusting a
1321
  number you remember, and check it before you believe an empty answer: \`curl -s\`
1322
  to a port nothing is listening on prints **nothing at all**, and in some agent
 
2240
  res.status(201).json({ ...s, running: false, state: 'stopped' });
2241
  });
2242
 
2243
+ // ---------- scheduled prompts (/api/crons) ----------
2244
+ //
2245
+ // A cron's agent type is only used when its named agent does not exist yet.
2246
+ // Existing names are deliberately reused (even if the session was created with
2247
+ // another CLI): the name is the idempotency key promised by the Settings form.
2248
+ const cronCliError = (cli) => {
2249
+ const def = cliById(cli);
2250
+ if (!def || !isAgentCli(cli) || isRemote(cli)) {
2251
+ return `unknown agent type '${cli}' — use an agent from GET /api/clis (not shell, files, trace, or remote)`;
2252
+ }
2253
+ return null;
2254
+ };
2255
+ const cronAgentFolder = (name) => {
2256
+ const readable = slugify(name);
2257
+ if (readable) return readable;
2258
+ // A perfectly valid display name can contain no ASCII characters. Do not
2259
+ // collapse those agents into the workspaces root (or one shared `agent/`
2260
+ // folder); a tiny deterministic suffix keeps the promised private folder.
2261
+ let hash = 2_166_136_261;
2262
+ for (const character of name) hash = Math.imul(hash ^ character.codePointAt(0), 16_777_619);
2263
+ return `agent-${(hash >>> 0).toString(16).padStart(8, '0')}`;
2264
+ };
2265
+
2266
+ function beginCronFire(job, trigger) {
2267
+ const at = new Date();
2268
+ const started = Date.now();
2269
+ const fail = (error) => {
2270
+ const message = String(error && error.message || error).slice(0, 500);
2271
+ crons.recordLast(job.id, {
2272
+ at: at.toISOString(), status: 'failed', durationMs: Date.now() - started, trigger, error: message,
2273
+ });
2274
+ return message;
2275
+ };
2276
+
2277
+ try {
2278
+ let session = store.list().find((candidate) => candidate.name === job.agent.name) || null;
2279
+ let agentCreated = false;
2280
+ if (!session) {
2281
+ const invalid = cronCliError(job.agent.cli);
2282
+ if (invalid) throw new Error(invalid);
2283
+ const catalog = cliCatalog().find((candidate) => candidate.id === job.agent.cli);
2284
+ if (!catalog?.available) throw new Error(`${cliById(job.agent.cli).label} is not installed on this Space`);
2285
+ session = createSession({
2286
+ name: job.agent.name,
2287
+ cli: job.agent.cli,
2288
+ // Cron-created agents own a predictable workspace. A second job with
2289
+ // the same name sees the session synchronously and cannot create it
2290
+ // again, even while the first prompt is still being delivered.
2291
+ path: cronAgentFolder(job.agent.name),
2292
+ });
2293
+ if (!session) throw new Error('could not create the agent workspace');
2294
+ if (session.error) throw new Error(session.error);
2295
+ agentCreated = true;
2296
+ }
2297
+ if (!promptable(session) || isRemote(session.cli)) {
2298
+ throw new Error(`the existing '${session.name}' session (${session.cli}) cannot receive scheduled prompts`);
2299
+ }
2300
+ const text = `[message from cron "${job.name}":] ${job.prompt}`;
2301
+ const completion = deliver(session, { text }, `cron: ${job.name}`)
2302
+ .then(() => {
2303
+ crons.recordLast(job.id, {
2304
+ at: at.toISOString(), status: 'ok', durationMs: Date.now() - started, trigger,
2305
+ });
2306
+ })
2307
+ .catch((error) => { fail(error); });
2308
+ return { agentCreated, completion };
2309
+ } catch (error) {
2310
+ const message = fail(error);
2311
+ throw Object.assign(new Error(message), { statusCode: 409 });
2312
+ }
2313
+ }
2314
+
2315
+ const validateCronCli = (body) => {
2316
+ const cli = body?.agent?.cli;
2317
+ return typeof cli === 'string' ? cronCliError(cli.trim()) : null;
2318
+ };
2319
+
2320
+ app.get('/api/crons', (_req, res) => res.json({ crons: crons.list() }));
2321
+
2322
+ app.post('/api/crons', (req, res) => {
2323
+ const cliError = validateCronCli(req.body);
2324
+ if (cliError) return res.status(400).json({ error: cliError });
2325
+ try {
2326
+ const job = crons.create(req.body || {});
2327
+ return res.status(201).json(job);
2328
+ } catch (e) {
2329
+ return res.status(400).json({ error: String(e.message || e) });
2330
+ }
2331
+ });
2332
+
2333
+ app.put('/api/crons/:id', (req, res) => {
2334
+ if (!crons.get(req.params.id)) return res.status(404).json({ error: 'not found' });
2335
+ const cliError = validateCronCli(req.body);
2336
+ if (cliError) return res.status(400).json({ error: cliError });
2337
+ try {
2338
+ return res.json(crons.update(req.params.id, req.body || {}));
2339
+ } catch (e) {
2340
+ return res.status(400).json({ error: String(e.message || e) });
2341
+ }
2342
+ });
2343
+
2344
+ app.delete('/api/crons/:id', (req, res) => {
2345
+ if (!crons.remove(req.params.id)) return res.status(404).json({ error: 'not found' });
2346
+ return res.json({ ok: true });
2347
+ });
2348
+
2349
+ app.post('/api/crons/:id/run', (req, res) => {
2350
+ const job = crons.get(req.params.id);
2351
+ if (!job) return res.status(404).json({ error: 'not found' });
2352
+ const requested = String(req.query.trigger || '');
2353
+ const trigger = req.operationOrigin?.type === 'cron' && (requested === 'schedule' || requested === 'restart')
2354
+ ? requested : 'manual';
2355
+ try {
2356
+ const run = beginCronFire(job, trigger);
2357
+ // 202 means the prompt was accepted for delivery, not that the agent's work
2358
+ // has finished. `last` is updated when delivery itself succeeds or fails.
2359
+ return res.status(202).json({ ok: true, agentCreated: run.agentCreated });
2360
+ } catch (e) {
2361
+ return res.status(e.statusCode || 409).json({ error: String(e.message || e) });
2362
+ }
2363
+ });
2364
+
2365
  // Rename = display label only. Folders are never renamed or moved.
2366
  app.put('/api/sessions/:id', (req, res) => {
2367
  const name = (req.body || {}).name;
 
2944
  console.log(`Agent Manager :${PORT} engine=libghostty${ghosttyReady() ? '' : ' (UNAVAILABLE)'} data=${DATA_DIR}`);
2945
  console.log('⚠ No authentication: this app trusts whoever can reach it.');
2946
  console.log(' Keep this Space PRIVATE — a public instance gives anyone a shell + your logged-in agents.');
2947
+ // Scheduled fires use the public cron-run route too. That keeps one execution
2948
+ // path and gives the operations log a first-class `cron:<id>` origin instead
2949
+ // of inventing a session that does not exist.
2950
+ crons.startScheduler(async (id, trigger) => {
2951
+ const response = await fetch(`http://127.0.0.1:${PORT}/api/crons/${encodeURIComponent(id)}/run?trigger=${trigger}`, {
2952
+ method: 'POST', headers: { 'x-am-origin': `cron:${id}` },
2953
+ });
2954
+ if (!response.ok) {
2955
+ const body = await response.json().catch(() => ({}));
2956
+ throw new Error(body.error || `cron run returned HTTP ${response.status}`);
2957
+ }
2958
+ });
2959
  });
server/test/cron-api.test.mjs ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Cron API, durable boot behavior, scheduled identity, and same-name creation.
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { spawn } from 'node:child_process';
6
+
7
+ const PORT = 7902;
8
+ const API = `http://127.0.0.1:${PORT}`;
9
+ const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'am-cron-api-'));
10
+ const bin = path.join(DATA_DIR, 'bin');
11
+ fs.mkdirSync(bin, { recursive: true });
12
+ const fakeClaude = path.join(bin, 'claude');
13
+ fs.writeFileSync(fakeClaude, `#!/bin/sh
14
+ printf '%s\\n' "$*" >> "$DATA_DIR/fake-claude.log"
15
+ sleep 120
16
+ `, { mode: 0o755 });
17
+
18
+ const seed = (id, name) => ({
19
+ id, name,
20
+ agent: { name: 'shared-cron-agent', cli: 'claude' },
21
+ prompt: `Run ${name}.`,
22
+ schedule: { cron: '0 * * * *', tz: 'UTC' },
23
+ runOnRestart: true,
24
+ state: 'running',
25
+ createdAt: '2026-08-19T00:00:00.000Z',
26
+ updatedAt: '2026-08-19T00:00:00.000Z',
27
+ });
28
+ fs.writeFileSync(path.join(DATA_DIR, 'crons.json'), JSON.stringify([
29
+ seed('cron_boot_one', 'boot one'), seed('cron_boot_two', 'boot two'),
30
+ ]));
31
+
32
+ let pass = 0; let fail = 0;
33
+ const check = (name, ok, detail = '') => {
34
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` ${detail}` : ''}`);
35
+ ok ? pass++ : fail++;
36
+ };
37
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
38
+ const { SPACE_ID, AM_DISTRIBUTE_SKILLS, ...BASE_ENV } = process.env;
39
+ const server = spawn('node', ['src/index.js'], {
40
+ env: {
41
+ ...BASE_ENV,
42
+ PATH: `${bin}:${BASE_ENV.PATH || ''}`,
43
+ PORT: String(PORT), DATA_DIR, HOME: path.join(DATA_DIR, 'home'),
44
+ CLAUDE_CONFIG_DIR: path.join(DATA_DIR, 'home', '.claude'),
45
+ AM_BASHRC: '/nonexistent',
46
+ },
47
+ stdio: ['ignore', 'pipe', 'pipe'],
48
+ });
49
+ let log = '';
50
+ server.stdout.on('data', (chunk) => { log += chunk; });
51
+ server.stderr.on('data', (chunk) => { log += chunk; });
52
+
53
+ const api = async (route, init = {}) => {
54
+ const headers = new Headers(init.headers || {});
55
+ if (init.body && !headers.has('content-type')) headers.set('content-type', 'application/json');
56
+ if (init.method && init.method !== 'GET') headers.set('x-am-origin', 'operator');
57
+ const response = await fetch(`${API}${route}`, { ...init, headers });
58
+ const body = await response.json().catch(() => null);
59
+ return { status: response.status, body };
60
+ };
61
+
62
+ try {
63
+ for (let i = 0; i < 80; i++) {
64
+ if (await fetch(`${API}/api/health`).then((r) => r.ok).catch(() => false)) break;
65
+ await sleep(250);
66
+ }
67
+
68
+ // Both boot jobs fire, but name lookup + synchronous creation gives them one
69
+ // shared agent. This is the race the feature must be idempotent across.
70
+ let sessions = [];
71
+ let operations = [];
72
+ for (let i = 0; i < 60; i++) {
73
+ sessions = (await api('/api/sessions')).body || [];
74
+ operations = (await api('/api/operations?limit=50')).body?.operations || [];
75
+ const cronOps = operations.filter((row) => row.origin?.type === 'cron');
76
+ if (sessions.length === 1 && cronOps.length >= 2) break;
77
+ await sleep(250);
78
+ }
79
+ check('two restart jobs naming one agent create exactly one session', sessions.length === 1, `sessions ${sessions.length}`);
80
+ check('the cron-created workspace follows workspaces/<agent-name>', sessions[0]?.path === 'shared-cron-agent', `path ${sessions[0]?.path}`);
81
+ const cronOps = operations.filter((row) => row.origin?.type === 'cron');
82
+ check('scheduled runs are attributed to cron identities in operations',
83
+ cronOps.length >= 2 && cronOps.every((row) => row.origin.id.startsWith('cron:') && row.origin.name.startsWith('boot')),
84
+ JSON.stringify(cronOps.map((row) => row.origin)));
85
+
86
+ let listed = await api('/api/crons');
87
+ for (let i = 0; i < 80 && !listed.body?.crons?.every((job) => job.last); i++) {
88
+ await sleep(250);
89
+ listed = await api('/api/crons');
90
+ }
91
+ check('GET lists both durable jobs', listed.status === 200 && listed.body?.crons?.length === 2);
92
+ check('run-on-restart records delivery outcomes', listed.body.crons.every((job) => job.last?.status === 'ok'),
93
+ JSON.stringify(listed.body.crons.map((job) => job.last)));
94
+
95
+ const manual = await api('/api/crons/cron_boot_one/run', { method: 'POST' });
96
+ check('Run now is accepted and reuses the agent', manual.status === 202 && manual.body?.agentCreated === false,
97
+ JSON.stringify(manual.body));
98
+
99
+ const stopped = await api('/api/crons/cron_boot_one', {
100
+ method: 'PUT', body: JSON.stringify({ state: 'stopped' }),
101
+ });
102
+ check('stop keeps the job and clears its next fire', stopped.status === 200 && stopped.body.state === 'stopped' && stopped.body.next === null);
103
+
104
+ const created = await api('/api/crons', {
105
+ method: 'POST', body: JSON.stringify({
106
+ name: 'Zurich morning', agent: { name: 'morning', cli: 'claude' }, prompt: 'Good morning.',
107
+ schedule: { cron: '0 9 * * 1-5', tz: 'Europe/Zurich' }, runOnRestart: false,
108
+ }),
109
+ });
110
+ check('POST stores the timezone and returns the next UTC instant',
111
+ created.status === 201 && created.body.schedule.tz === 'Europe/Zurich' && /Z$/.test(created.body.next),
112
+ JSON.stringify(created.body));
113
+
114
+ const invalid = await api('/api/crons', {
115
+ method: 'POST', body: JSON.stringify({
116
+ name: 'bad zone', agent: { name: 'bad', cli: 'claude' }, prompt: 'No.',
117
+ schedule: { cron: '0 9 * * *', tz: 'Moon/Sea' },
118
+ }),
119
+ });
120
+ check('invalid timezone is a readable 400', invalid.status === 400 && /invalid schedule/.test(invalid.body?.error || ''), JSON.stringify(invalid.body));
121
+
122
+ const deleted = await api('/api/crons/cron_boot_two', { method: 'DELETE' });
123
+ check('delete is distinct from stop and removes the job', deleted.status === 200
124
+ && !(await api('/api/crons')).body.crons.some((job) => job.id === 'cron_boot_two'));
125
+
126
+ const disk = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'crons.json'), 'utf8'));
127
+ check('the final state is on DATA_DIR, not local process memory',
128
+ disk.some((job) => job.id === 'cron_boot_one' && job.state === 'stopped')
129
+ && !disk.some((job) => job.id === 'cron_boot_two'));
130
+ } catch (error) {
131
+ check(`suite threw: ${error && error.message}`, false, log.slice(-1200));
132
+ } finally {
133
+ server.kill('SIGTERM');
134
+ await Promise.race([new Promise((resolve) => server.once('exit', resolve)), sleep(3000)]);
135
+ fs.rmSync(DATA_DIR, { recursive: true, force: true });
136
+ }
137
+
138
+ console.log(`\n${pass} passed, ${fail} failed`);
139
+ process.exit(fail ? 1 : 0);
server/test/crons.test.mjs ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import test from 'node:test';
6
+
7
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'am-crons-unit-'));
8
+ process.env.DATA_DIR = root;
9
+ const crons = await import('../src/crons.js');
10
+
11
+ test.after(() => fs.rmSync(root, { recursive: true, force: true }));
12
+
13
+ test('timezone is part of the calculation, including seasonal offsets', () => {
14
+ const schedule = { cron: '0 9 * * *', tz: 'Europe/Zurich' };
15
+ assert.equal(crons.nextOccurrence(schedule, new Date('2026-08-19T21:30:00Z'), 'daily'), '2026-08-20T07:00:00.000Z');
16
+ assert.equal(crons.nextOccurrence(schedule, new Date('2027-01-02T10:00:00Z'), 'daily'), '2027-01-03T08:00:00.000Z');
17
+ });
18
+
19
+ test('only five-field cron and real IANA timezones are accepted', () => {
20
+ assert.throws(() => crons.validateSchedule({ cron: '0 0 9 * * *', tz: 'UTC' }), /five fields/);
21
+ assert.throws(() => crons.validateSchedule({ cron: '0 9 * * *', tz: 'Moon\/Tranquility' }), /invalid schedule/);
22
+ assert.throws(() => crons.validateSchedule({ cron: '70 9 * * *', tz: 'UTC' }), /invalid schedule/);
23
+ });
24
+
25
+ test('jobs persist, stop without deletion, resume from now, and never replay stale next times', () => {
26
+ crons.init(new Date('2026-08-19T12:00:00Z'));
27
+ const job = crons.create({
28
+ name: 'daily index',
29
+ agent: { name: 'indexer', cli: 'claude' },
30
+ prompt: 'Refresh the index.',
31
+ schedule: { cron: '0 9 * * *', tz: 'UTC' },
32
+ runOnRestart: true,
33
+ }, new Date('2026-08-19T12:00:00Z'));
34
+ assert.equal(job.next, '2026-08-20T09:00:00.000Z');
35
+ assert.ok(fs.existsSync(path.join(root, 'crons.json')));
36
+
37
+ const stopped = crons.update(job.id, { state: 'stopped' }, new Date('2026-08-19T13:00:00Z'));
38
+ assert.equal(stopped.next, null);
39
+ assert.equal(stopped.prompt, 'Refresh the index.');
40
+ assert.equal(crons.list().length, 1);
41
+
42
+ const resumed = crons.update(job.id, { state: 'running' }, new Date('2026-08-21T10:00:00Z'));
43
+ assert.equal(resumed.next, '2026-08-22T09:00:00.000Z');
44
+
45
+ // Loading after downtime ignores the persisted Aug 22 occurrence and moves
46
+ // directly to the next future one. Missed occurrences are not catch-up work.
47
+ crons.init(new Date('2026-08-25T10:00:00Z'));
48
+ assert.equal(crons.get(job.id).next, '2026-08-26T09:00:00.000Z');
49
+ });
50
+
51
+ test('run on restart fires once for enabled running jobs, not stopped ones', async () => {
52
+ const running = crons.get(crons.list()[0].id);
53
+ assert.equal(running.runOnRestart, true);
54
+ const stopped = crons.create({
55
+ name: 'off', agent: { name: 'off-agent', cli: 'codex' }, prompt: 'No.',
56
+ schedule: { cron: '0 * * * *', tz: 'UTC' }, runOnRestart: true, state: 'stopped',
57
+ });
58
+ const fired = [];
59
+ crons.startScheduler((id, trigger) => { fired.push({ id, trigger }); }, { restartDelayMs: 5 });
60
+ await new Promise((resolve) => setTimeout(resolve, 40));
61
+ assert.deepEqual(fired, [{ id: running.id, trigger: 'restart' }]);
62
+ assert.equal(crons.get(stopped.id).state, 'stopped');
63
+ });
64
+
65
+ test('an older overlapping delivery cannot overwrite the newer last run', () => {
66
+ const id = crons.list()[0].id;
67
+ crons.recordLast(id, { at: '2026-08-25T12:02:00.000Z', status: 'ok', durationMs: 20 });
68
+ crons.recordLast(id, { at: '2026-08-25T12:01:00.000Z', status: 'failed', durationMs: 80_000, error: 'late failure' });
69
+ assert.equal(crons.get(id).last.status, 'ok');
70
+ assert.equal(crons.get(id).last.at, '2026-08-25T12:02:00.000Z');
71
+ });
72
+
73
+ test('run on restart coalesces with a schedule due in the same startup window', () => {
74
+ for (const job of crons.list()) crons.remove(job.id);
75
+ const boot = Date.parse('2026-08-19T21:54:57.600Z'); // 2.4s before the minute
76
+ crons.init(new Date(boot));
77
+ const job = crons.create({
78
+ name: 'minute boundary', agent: { name: 'boundary', cli: 'claude' }, prompt: 'Once.',
79
+ schedule: { cron: '* * * * *', tz: 'UTC' }, runOnRestart: true,
80
+ }, new Date(boot));
81
+ assert.equal(Date.parse(job.next) - boot, 2_400);
82
+
83
+ const originalNow = Date.now;
84
+ const originalSetTimeout = globalThis.setTimeout;
85
+ const originalClearTimeout = globalThis.clearTimeout;
86
+ let now = boot;
87
+ let serial = 0;
88
+ const queued = [];
89
+ Date.now = () => now;
90
+ globalThis.setTimeout = (callback, delay = 0) => {
91
+ const timer = { id: ++serial, at: now + Number(delay), callback, canceled: false, unref() {} };
92
+ queued.push(timer);
93
+ return timer;
94
+ };
95
+ globalThis.clearTimeout = (timer) => { if (timer) timer.canceled = true; };
96
+ const fires = [];
97
+ try {
98
+ crons.startScheduler((id, trigger) => { fires.push({ id, trigger, at: now }); }, { restartDelayMs: 1_500 });
99
+ const end = boot + 2_500;
100
+ while (true) {
101
+ const due = queued.filter((timer) => !timer.canceled && timer.at <= end).sort((a, b) => a.at - b.at)[0];
102
+ if (!due) break;
103
+ due.canceled = true;
104
+ now = due.at;
105
+ due.callback();
106
+ }
107
+ assert.deepEqual(fires, [{ id: job.id, trigger: 'schedule', at: boot + 2_400 }]);
108
+ } finally {
109
+ Date.now = originalNow;
110
+ globalThis.setTimeout = originalSetTimeout;
111
+ globalThis.clearTimeout = originalClearTimeout;
112
+ }
113
+ });
web/src/App.tsx CHANGED
@@ -50,7 +50,7 @@ function autoGrid(n: number): GridSpec {
50
  return { cols: 3, rows: 3 };
51
  }
52
 
53
- type SettingsPage = 'general' | 'usage' | 'skills';
54
  const ROOT_PATH = '.';
55
  const WARM_TERMINAL_LIMIT = 12;
56
  const normalizePath = (p?: string | null) => (p && p.trim() ? p : ROOT_PATH);
 
50
  return { cols: 3, rows: 3 };
51
  }
52
 
53
+ type SettingsPage = 'general' | 'usage' | 'skills' | 'cron';
54
  const ROOT_PATH = '.';
55
  const WARM_TERMINAL_LIMIT = 12;
56
  const normalizePath = (p?: string | null) => (p && p.trim() ? p : ROOT_PATH);
web/src/api.ts CHANGED
@@ -120,6 +120,38 @@ export const getConfig = (): Promise<AmConfig> => fetch('/api/config').then(json
120
  export const saveConfig = (c: AmConfig) =>
121
  fetch('/api/config', { method: 'PUT', headers: HEADERS, body: JSON.stringify(c) }).then(json);
122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  // ---- bucket backup: a Job on the Hub does the copying (docs/bucket-backup.md) ----
124
  export type BackupEvery = 'never' | '1h' | '3h' | '24h';
125
  export interface BackupStatus {
 
120
  export const saveConfig = (c: AmConfig) =>
121
  fetch('/api/config', { method: 'PUT', headers: HEADERS, body: JSON.stringify(c) }).then(json);
122
 
123
+ // ---- durable scheduled prompts ----
124
+ export type CronState = 'running' | 'stopped';
125
+ export interface CronJob {
126
+ id: string;
127
+ name: string;
128
+ agent: { name: string; cli: string };
129
+ prompt: string;
130
+ schedule: { cron: string; tz: string };
131
+ runOnRestart: boolean;
132
+ state: CronState;
133
+ createdAt: string;
134
+ updatedAt: string;
135
+ next: string | null;
136
+ last?: {
137
+ at: string;
138
+ status: 'ok' | 'failed';
139
+ durationMs: number;
140
+ trigger?: 'schedule' | 'restart' | 'manual';
141
+ error?: string;
142
+ };
143
+ }
144
+ export type CronDraft = Pick<CronJob, 'name' | 'agent' | 'prompt' | 'schedule' | 'runOnRestart'>;
145
+ export const getCrons = (): Promise<{ crons: CronJob[] }> => fetch('/api/crons').then(jsonOrError);
146
+ export const createCron = (job: CronDraft): Promise<CronJob> =>
147
+ fetch('/api/crons', { method: 'POST', headers: HEADERS, body: JSON.stringify(job) }).then(jsonOrError);
148
+ export const updateCron = (id: string, patch: Partial<CronDraft & { state: CronState }>): Promise<CronJob> =>
149
+ fetch(`/api/crons/${encodeURIComponent(id)}`, { method: 'PUT', headers: HEADERS, body: JSON.stringify(patch) }).then(jsonOrError);
150
+ export const runCron = (id: string): Promise<{ ok: boolean; agentCreated: boolean }> =>
151
+ fetch(`/api/crons/${encodeURIComponent(id)}/run`, { method: 'POST' }).then(jsonOrError);
152
+ export const deleteCron = (id: string): Promise<{ ok: boolean }> =>
153
+ fetch(`/api/crons/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(jsonOrError);
154
+
155
  // ---- bucket backup: a Job on the Hub does the copying (docs/bucket-backup.md) ----
156
  export type BackupEvery = 'never' | '1h' | '3h' | '24h';
157
  export interface BackupStatus {
web/src/components/CronSettings.tsx ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useMemo, useRef, useState, type FormEvent, type KeyboardEvent } from 'react';
2
+ import * as api from '../api';
3
+ import type { Cli, Session } from '../types';
4
+ import Logo from './Logo';
5
+
6
+ type Preset = 'hourly' | 'daily' | 'weekdays' | 'weekly' | 'custom';
7
+ const EXCLUDED_CLIS = new Set(['shell', 'files', 'trace', 'remote']);
8
+ const DAYS = [
9
+ { value: '1', short: 'Mon', label: 'Monday' }, { value: '2', short: 'Tue', label: 'Tuesday' },
10
+ { value: '3', short: 'Wed', label: 'Wednesday' }, { value: '4', short: 'Thu', label: 'Thursday' },
11
+ { value: '5', short: 'Fri', label: 'Friday' }, { value: '6', short: 'Sat', label: 'Saturday' },
12
+ { value: '0', short: 'Sun', label: 'Sunday' },
13
+ ];
14
+
15
+ const browserZone = () => {
16
+ try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch { return 'UTC'; }
17
+ };
18
+ const agentFolder = (value: string) => {
19
+ const readable = value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
20
+ if (readable) return readable;
21
+ if (!value) return '<agent-name>';
22
+ let hash = 2_166_136_261;
23
+ for (const character of value) hash = Math.imul(hash ^ (character.codePointAt(0) || 0), 16_777_619);
24
+ return `agent-${(hash >>> 0).toString(16).padStart(8, '0')}`;
25
+ };
26
+ const timeParts = (time: string) => {
27
+ const [hour = '9', minute = '0'] = time.split(':');
28
+ return { hour: String(Number(hour)), minute: String(Number(minute)) };
29
+ };
30
+ const cronFor = (preset: Preset, time: string, weekday: string, custom: string) => {
31
+ const { hour, minute } = timeParts(time);
32
+ if (preset === 'hourly') return '0 * * * *';
33
+ if (preset === 'daily') return `${minute} ${hour} * * *`;
34
+ if (preset === 'weekdays') return `${minute} ${hour} * * 1-5`;
35
+ if (preset === 'weekly') return `${minute} ${hour} * * ${weekday}`;
36
+ return custom.trim().replace(/\s+/g, ' ');
37
+ };
38
+ const fieldsForCron = (cron: string): { preset: Preset; time: string; weekday: string; custom: string } => {
39
+ let match;
40
+ if (cron === '0 * * * *') return { preset: 'hourly', time: '09:00', weekday: '1', custom: cron };
41
+ if ((match = cron.match(/^(\d+) (\d+) \* \* 1-5$/))) {
42
+ return { preset: 'weekdays', time: `${match[2].padStart(2, '0')}:${match[1].padStart(2, '0')}`, weekday: '1', custom: cron };
43
+ }
44
+ if ((match = cron.match(/^(\d+) (\d+) \* \* ([0-6])$/))) {
45
+ return { preset: 'weekly', time: `${match[2].padStart(2, '0')}:${match[1].padStart(2, '0')}`, weekday: match[3], custom: cron };
46
+ }
47
+ if ((match = cron.match(/^(\d+) (\d+) \* \* \*$/))) {
48
+ return { preset: 'daily', time: `${match[2].padStart(2, '0')}:${match[1].padStart(2, '0')}`, weekday: '1', custom: cron };
49
+ }
50
+ return { preset: 'custom', time: '09:00', weekday: '1', custom: cron };
51
+ };
52
+ const intervalName = (job: api.CronJob) => {
53
+ const c = job.schedule.cron;
54
+ let match;
55
+ if (c === '0 * * * *') return 'hourly';
56
+ if ((match = c.match(/^(\d+) (\d+) \* \* \*$/))) return `every day ${match[2].padStart(2, '0')}:${match[1].padStart(2, '0')}`;
57
+ if ((match = c.match(/^(\d+) (\d+) \* \* 1-5$/))) return `weekdays ${match[2].padStart(2, '0')}:${match[1].padStart(2, '0')}`;
58
+ if ((match = c.match(/^(\d+) (\d+) \* \* ([0-6])$/))) {
59
+ const day = DAYS.find((candidate) => candidate.value === match[3])?.label || match[3];
60
+ return `${day}s ${match[2].padStart(2, '0')}:${match[1].padStart(2, '0')}`;
61
+ }
62
+ return c;
63
+ };
64
+ const duration = (ms: number) => {
65
+ if (ms < 1000) return `${ms}ms`;
66
+ const seconds = Math.round(ms / 1000);
67
+ if (seconds < 60) return `${seconds}s`;
68
+ return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, '0')}s`;
69
+ };
70
+ const when = (iso: string | null, zone: string, now: number) => {
71
+ if (!iso) return '—';
72
+ const value = Date.parse(iso);
73
+ const delta = value - now;
74
+ if (delta > 0 && delta < 60 * 60_000) return `in ${Math.max(1, Math.round(delta / 60_000))}m`;
75
+ if (delta > 0 && delta < 24 * 60 * 60_000) return `in ${Math.max(1, Math.round(delta / 3_600_000))}h`;
76
+ return compactWhen(iso, zone);
77
+ };
78
+ const compactWhen = (iso: string, zone: string) => {
79
+ const value = Date.parse(iso);
80
+ try {
81
+ const parts = new Intl.DateTimeFormat('en-GB', {
82
+ timeZone: zone, month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
83
+ }).formatToParts(new Date(value));
84
+ const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((candidate) => candidate.type === type)?.value || '';
85
+ return `${part('day')} ${part('month')} ${part('hour')}:${part('minute')}`;
86
+ } catch { return new Date(value).toISOString().slice(5, 16).replace('T', ' '); }
87
+ };
88
+
89
+ export default function CronSettings({ clis }: { clis: Cli[] }) {
90
+ const agents = useMemo(() => clis.filter((cli) => !EXCLUDED_CLIS.has(cli.id)), [clis]);
91
+ const [jobs, setJobs] = useState<api.CronJob[]>([]);
92
+ const [sessions, setSessions] = useState<Session[]>([]);
93
+ const [loading, setLoading] = useState(true);
94
+ const [busy, setBusy] = useState<string | null>(null);
95
+ const [message, setMessage] = useState('');
96
+ const [now, setNow] = useState(Date.now());
97
+ const [editingId, setEditingId] = useState<string | null>(null);
98
+ const formRef = useRef<HTMLFormElement>(null);
99
+ const [jobName, setJobName] = useState('');
100
+ const [agentName, setAgentName] = useState('');
101
+ const [cli, setCli] = useState('');
102
+ const [prompt, setPrompt] = useState('');
103
+ const [preset, setPreset] = useState<Preset>('daily');
104
+ const [time, setTime] = useState('09:00');
105
+ const [weekday, setWeekday] = useState('1');
106
+ const [custom, setCustom] = useState('0 9 * * *');
107
+ const [tz, setTz] = useState(browserZone);
108
+ const [runOnRestart, setRunOnRestart] = useState(true);
109
+
110
+ const load = async () => {
111
+ try {
112
+ const [cronData, tree] = await Promise.all([api.getCrons(), api.getTree()]);
113
+ setJobs(cronData.crons);
114
+ setSessions(tree.sessions);
115
+ } catch (error) {
116
+ setMessage(error instanceof Error ? error.message : 'Could not load cron jobs.');
117
+ } finally { setLoading(false); }
118
+ };
119
+ useEffect(() => {
120
+ load();
121
+ const poll = window.setInterval(load, 15_000);
122
+ const clock = window.setInterval(() => setNow(Date.now()), 30_000);
123
+ return () => { window.clearInterval(poll); window.clearInterval(clock); };
124
+ }, []);
125
+ useEffect(() => {
126
+ if (!cli || !agents.some((agent) => agent.id === cli)) {
127
+ setCli(agents.find((agent) => agent.available)?.id || '');
128
+ }
129
+ }, [agents, cli]);
130
+
131
+ const scheduleCron = cronFor(preset, time, weekday, custom);
132
+ const existing = sessions.find((session) => session.name === agentName.trim());
133
+ const selected = agents.find((agent) => agent.id === cli);
134
+ const reset = () => {
135
+ setJobName(''); setAgentName(''); setPrompt(''); setPreset('daily'); setTime('09:00');
136
+ setWeekday('1'); setCustom('0 9 * * *'); setTz(browserZone()); setRunOnRestart(true);
137
+ setCli(agents.find((agent) => agent.available)?.id || ''); setEditingId(null); setMessage('');
138
+ };
139
+ const edit = (job: api.CronJob) => {
140
+ const schedule = fieldsForCron(job.schedule.cron);
141
+ setEditingId(job.id); setJobName(job.name); setAgentName(job.agent.name); setCli(job.agent.cli);
142
+ setPrompt(job.prompt); setPreset(schedule.preset); setTime(schedule.time); setWeekday(schedule.weekday);
143
+ setCustom(schedule.custom); setTz(job.schedule.tz); setRunOnRestart(job.runOnRestart); setMessage('');
144
+ window.requestAnimationFrame(() => {
145
+ formRef.current?.scrollIntoView({ block: 'start' });
146
+ formRef.current?.querySelector<HTMLInputElement>('#cron-job-name')?.focus({ preventScroll: true });
147
+ });
148
+ };
149
+ const editFromKeyboard = (event: KeyboardEvent<HTMLTableRowElement>, job: api.CronJob) => {
150
+ if (event.key !== 'Enter' && event.key !== ' ') return;
151
+ event.preventDefault();
152
+ edit(job);
153
+ };
154
+ const save = async (event: FormEvent) => {
155
+ event.preventDefault();
156
+ if (!jobName.trim() || !agentName.trim() || !cli || !prompt.trim() || !scheduleCron) return;
157
+ const draft: api.CronDraft = {
158
+ name: jobName.trim(), agent: { name: agentName.trim(), cli }, prompt: prompt.trim(),
159
+ schedule: { cron: scheduleCron, tz: tz.trim() }, runOnRestart,
160
+ };
161
+ setBusy('save'); setMessage('');
162
+ try {
163
+ if (editingId) await api.updateCron(editingId, draft);
164
+ else await api.createCron(draft);
165
+ reset();
166
+ await load();
167
+ } catch (error) {
168
+ setMessage(error instanceof Error ? error.message : `Could not ${editingId ? 'update' : 'create'} the job.`);
169
+ } finally { setBusy(null); }
170
+ };
171
+ const act = async (id: string, action: () => Promise<unknown>) => {
172
+ setBusy(id); setMessage('');
173
+ try {
174
+ await action();
175
+ await load();
176
+ window.setTimeout(load, 750); // delivery may finish just after a 202 Run-now response
177
+ } catch (error) {
178
+ setMessage(error instanceof Error ? error.message : 'The cron action failed.');
179
+ } finally { setBusy(null); }
180
+ };
181
+ const zones = useMemo(() => {
182
+ try {
183
+ const withValues = Intl as typeof Intl & { supportedValuesOf?: (key: 'timeZone') => string[] };
184
+ const values = withValues.supportedValuesOf?.('timeZone') || [];
185
+ return values.includes('UTC') ? values : ['UTC', ...values];
186
+ } catch { return ['UTC']; }
187
+ }, []);
188
+
189
+ return (
190
+ <>
191
+ <form className="cron-form" onSubmit={save} ref={formRef}>
192
+ <label htmlFor="cron-job-name">Job name</label>
193
+ <div>
194
+ <input id="cron-job-name" value={jobName} onChange={(event) => setJobName(event.target.value)} placeholder="nightly deploy check" required />
195
+ <p>Names the job in this list, the operations log, and failures. It is separate from the agent.</p>
196
+ </div>
197
+
198
+ <label htmlFor="cron-agent-name">Agent name</label>
199
+ <div>
200
+ <input id="cron-agent-name" value={agentName} onChange={(event) => setAgentName(event.target.value)} placeholder="nightly-index" required />
201
+ <p>{existing
202
+ ? `Existing ${existing.name} session will be reused; its current ${existing.cli} type is kept.`
203
+ : `Created on first fire in workspaces/${agentFolder(agentName)}. An existing exact name is reused.`}</p>
204
+ </div>
205
+
206
+ <span className="cron-label" id="cron-cli-label">Agent type</span>
207
+ <div>
208
+ <div className="cron-clis" role="group" aria-labelledby="cron-cli-label">
209
+ {agents.map((agent) => (
210
+ <button
211
+ type="button" key={agent.id} className={cli === agent.id ? 'on' : ''}
212
+ disabled={!agent.available} aria-pressed={cli === agent.id}
213
+ title={agent.available ? agent.label : `${agent.label} is not installed on this Space`}
214
+ onClick={() => setCli(agent.id)}
215
+ >
216
+ <Logo cli={agent.id} size={13} /> {agent.label}
217
+ </button>
218
+ ))}
219
+ </div>
220
+ <p>{selected?.available
221
+ ? 'Used only if the named agent must be created.'
222
+ : editingId && selected ? 'This saved type is unavailable here; updating preserves it.' : 'Unavailable agent types cannot be selected.'}</p>
223
+ </div>
224
+
225
+ <label htmlFor="cron-prompt">Prompt</label>
226
+ <div>
227
+ <textarea id="cron-prompt" rows={4} value={prompt} onChange={(event) => setPrompt(event.target.value)} placeholder="Check last night’s deploy log…" required />
228
+ <p>Sent as a normal prompt. The agent keeps its conversation history between runs.</p>
229
+ </div>
230
+
231
+ <span className="cron-label" id="cron-schedule-label">Schedule</span>
232
+ <div>
233
+ <div className="seg cron-presets" role="group" aria-labelledby="cron-schedule-label">
234
+ {([
235
+ ['hourly', 'Hourly'], ['daily', 'Every day'], ['weekdays', 'Weekdays'], ['weekly', 'Weekly'], ['custom', 'Custom cron'],
236
+ ] as [Preset, string][]).map(([value, label]) => (
237
+ <button type="button" key={value} className={preset === value ? 'on' : ''} aria-pressed={preset === value} onClick={() => setPreset(value)}>{label}</button>
238
+ ))}
239
+ </div>
240
+ <div className="cron-schedule-fields">
241
+ {preset !== 'hourly' && preset !== 'custom' && (
242
+ <label>at <input type="time" value={time} onChange={(event) => setTime(event.target.value)} required /></label>
243
+ )}
244
+ {preset === 'weekly' && (
245
+ <label>on <select value={weekday} onChange={(event) => setWeekday(event.target.value)}>{DAYS.map((day) => <option value={day.value} key={day.value}>{day.label}</option>)}</select></label>
246
+ )}
247
+ {preset === 'custom' && (
248
+ <label className="cron-expression">cron <input className="mono" value={custom} onChange={(event) => setCustom(event.target.value)} placeholder="0 9 * * *" required /></label>
249
+ )}
250
+ <label className="cron-zone">timezone <input list="cron-timezones" value={tz} onChange={(event) => setTz(event.target.value)} required /></label>
251
+ <datalist id="cron-timezones">{zones.map((zone) => <option value={zone} key={zone} />)}</datalist>
252
+ </div>
253
+ <p><span className="mono">{scheduleCron || '—'}</span> · stored with <span className="mono">{tz || 'timezone required'}</span>; missed fires while the Space is down are not replayed.</p>
254
+ </div>
255
+
256
+ <span className="cron-label" id="cron-restart-label">Run on restart</span>
257
+ <div>
258
+ <div className="seg" role="group" aria-labelledby="cron-restart-label">
259
+ <button type="button" className={runOnRestart ? 'on' : ''} aria-pressed={runOnRestart} onClick={() => setRunOnRestart(true)}>Yes</button>
260
+ <button type="button" className={!runOnRestart ? 'on' : ''} aria-pressed={!runOnRestart} onClick={() => setRunOnRestart(false)}>No</button>
261
+ </div>
262
+ <p>Runs once when the app comes back. This is a fresh fire, not catch-up.</p>
263
+ </div>
264
+
265
+ <div className="cron-form-actions">
266
+ <button className="btn-primary" type="submit" disabled={busy === 'save' || !selected || (!editingId && !selected.available)}>
267
+ {busy === 'save' ? (editingId ? 'Updating…' : 'Creating…') : (editingId ? 'Update job' : 'Create job')}
268
+ </button>
269
+ <button className="btn-ghost" type="button" onClick={reset}>Cancel</button>
270
+ </div>
271
+ </form>
272
+
273
+ {message && <div className="s-warn cron-message" role="alert">{message}</div>}
274
+
275
+ <h3>Scheduled jobs</h3>
276
+ {loading ? <div className="s-muted">Loading…</div> : jobs.length === 0 ? (
277
+ <div className="s-muted">No cron jobs yet.</div>
278
+ ) : (
279
+ <div className="table-scroll cron-table-wrap">
280
+ <table className="cron-table">
281
+ <colgroup>
282
+ <col className="cron-col-job" /><col className="cron-col-agent" /><col className="cron-col-type" />
283
+ <col className="cron-col-interval" /><col className="cron-col-state" /><col className="cron-col-next" />
284
+ <col className="cron-col-last" /><col className="cron-col-actions" /><col className="cron-col-fill" />
285
+ </colgroup>
286
+ <thead><tr><th>Job</th><th>Agent</th><th>Type</th><th>Interval</th><th>State</th><th>Next</th><th>Last run</th><th>Actions</th><th className="cron-fill" aria-hidden="true" /></tr></thead>
287
+ <tbody>{jobs.map((job) => {
288
+ const type = clis.find((candidate) => candidate.id === job.agent.cli)?.label || job.agent.cli;
289
+ const lastTitle = job.last
290
+ ? [duration(job.last.durationMs), job.last.error].filter(Boolean).join(' · ')
291
+ : '';
292
+ return (
293
+ <tr
294
+ key={job.id} className={editingId === job.id ? 'editing' : ''} tabIndex={0}
295
+ aria-selected={editingId === job.id}
296
+ onClick={() => edit(job)} onKeyDown={(event) => editFromKeyboard(event, job)}
297
+ >
298
+ <td className="cron-name" title={job.name}>{job.name}</td>
299
+ <td className="cron-name" title={job.agent.name}>{job.agent.name}</td>
300
+ <td className="cron-type" aria-label={type} title={type}><Logo cli={job.agent.cli} size={14} /></td>
301
+ <td>{intervalName(job)}</td>
302
+ <td className={`cron-state ${job.state}`}>{job.state}</td>
303
+ <td className="cron-dim">{when(job.next, job.schedule.tz, now)}</td>
304
+ <td title={lastTitle}>{job.last ? <><span className={`cron-last ${job.last.status}`}>{job.last.status}</span> <span className="cron-dim">· {compactWhen(job.last.at, job.schedule.tz)}</span></> : <span className="cron-dim">—</span>}</td>
305
+ <td onClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}><span className="cron-actions">
306
+ <button className="btn-ghost" disabled={busy === job.id} onClick={() => act(job.id, () => api.runCron(job.id))}>Run now</button>
307
+ <button className="btn-ghost" disabled={busy === job.id} onClick={() => act(job.id, () => api.updateCron(job.id, { state: job.state === 'running' ? 'stopped' : 'running' }))}>{job.state === 'running' ? 'Stop' : 'Start'}</button>
308
+ <button className="btn-ghost danger" disabled={busy === job.id} onClick={() => act(job.id, async () => {
309
+ await api.deleteCron(job.id);
310
+ if (editingId === job.id) reset();
311
+ })}>Delete</button>
312
+ </span></td>
313
+ <td className="cron-fill" aria-hidden="true" />
314
+ </tr>
315
+ );
316
+ })}</tbody>
317
+ </table>
318
+ </div>
319
+ )}
320
+ </>
321
+ );
322
+ }
web/src/components/SettingsView.tsx CHANGED
@@ -3,14 +3,16 @@ import { isPassive, isRemote, type Cli } from '../types';
3
  import * as api from '../api';
4
  import SkillsEditor from './SkillsEditor';
5
  import UsagePanel from './UsagePanel';
 
6
  import { SunGlyph, MoonGlyph, RefreshGlyph, InfoGlyph } from './icons';
7
  import Logo from './Logo';
8
 
9
- type Page = 'general' | 'usage' | 'skills';
10
  const PAGES: { id: Page; label: string }[] = [
11
  { id: 'general', label: 'General' },
12
  { id: 'usage', label: 'Usage' },
13
  { id: 'skills', label: 'Skills' },
 
14
  ];
15
 
16
  interface Info { dataDir?: string; home?: string; spaceId?: string | null; spaceHost?: string | null; engine?: string; ghostty?: boolean; canRelaunch?: boolean; secrets?: string[]; bucketUnverified?: boolean; }
@@ -782,6 +784,14 @@ export default function SettingsView({
782
  <SkillsEditor />
783
  </div>
784
  )}
 
 
 
 
 
 
 
 
785
  </div>
786
  </div>
787
  );
 
3
  import * as api from '../api';
4
  import SkillsEditor from './SkillsEditor';
5
  import UsagePanel from './UsagePanel';
6
+ import CronSettings from './CronSettings';
7
  import { SunGlyph, MoonGlyph, RefreshGlyph, InfoGlyph } from './icons';
8
  import Logo from './Logo';
9
 
10
+ type Page = 'general' | 'usage' | 'skills' | 'cron';
11
  const PAGES: { id: Page; label: string }[] = [
12
  { id: 'general', label: 'General' },
13
  { id: 'usage', label: 'Usage' },
14
  { id: 'skills', label: 'Skills' },
15
+ { id: 'cron', label: 'Cron' },
16
  ];
17
 
18
  interface Info { dataDir?: string; home?: string; spaceId?: string | null; spaceHost?: string | null; engine?: string; ghostty?: boolean; canRelaunch?: boolean; secrets?: string[]; bucketUnverified?: boolean; }
 
784
  <SkillsEditor />
785
  </div>
786
  )}
787
+
788
+ {page === 'cron' && (
789
+ <div className="settings-page wide cron-page">
790
+ <h2>Cron</h2>
791
+ <p className="s-help cron-intro">Send a prompt to an agent on a schedule. Jobs persist across Space restarts; if the named agent does not exist when a job fires, it is created first.</p>
792
+ <CronSettings clis={clis} />
793
+ </div>
794
+ )}
795
  </div>
796
  </div>
797
  );
web/src/styles.css CHANGED
@@ -1400,6 +1400,68 @@ a.btn-ghost { text-decoration: none; }
1400
 
1401
  /* settings: skills editor */
1402
  .settings-page.wide { max-width: 980px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1403
  /* natural height — the settings page scrolls, not the preview */
1404
  .skills { display: flex; gap: 14px; margin-top: 12px; align-items: flex-start; }
1405
  .skills-list { width: 220px; flex: none; display: flex; flex-direction: column; gap: 4px; overflow-y: auto; border-right: 1px solid var(--border); padding-right: 10px; }
 
1400
 
1401
  /* settings: skills editor */
1402
  .settings-page.wide { max-width: 980px; }
1403
+
1404
+ /* settings: cron jobs — a labelled form followed by the operational table from
1405
+ the approved mock. State is plain coloured text; controls are controls, never
1406
+ status pills, so "running" cannot be mistaken for something clickable. */
1407
+ .cron-intro { max-width: 680px; margin: -8px 0 18px; }
1408
+ .cron-form { display: grid; grid-template-columns: 128px minmax(0, 1fr); column-gap: 20px; row-gap: 16px; padding: 16px 2px 20px; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
1409
+ .cron-form > label, .cron-label { padding-top: 8px; font-size: 13px; font-weight: 600; }
1410
+ .cron-form > div { min-width: 0; }
1411
+ .cron-form input, .cron-form textarea, .cron-form select { padding: 7px 9px; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--panel); color: var(--text); font: inherit; font-size: 12.5px; }
1412
+ .cron-form input:focus, .cron-form textarea:focus, .cron-form select:focus { outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent); border-color: var(--accent); }
1413
+ .cron-form > div > input, .cron-form textarea { display: block; width: 100%; }
1414
+ .cron-form textarea { resize: vertical; min-height: 82px; line-height: 1.45; }
1415
+ .cron-form p { margin: 5px 0 0; color: var(--muted); font-size: 11.5px; line-height: 1.45; }
1416
+ .cron-clis { display: flex; flex-wrap: wrap; gap: 5px; }
1417
+ .cron-clis button { min-height: 30px; display: inline-flex; align-items: center; gap: 6px; padding: 5px 8px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--panel); color: var(--muted); font: 11.5px var(--font-mono); cursor: pointer; }
1418
+ .cron-clis button:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text); }
1419
+ .cron-clis button.on { border-color: var(--accent); color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, var(--panel)); }
1420
+ .cron-clis button:disabled { opacity: 0.34; cursor: not-allowed; }
1421
+ .cron-presets { display: flex; flex-wrap: wrap; border: 0; border-radius: 0; overflow: visible; gap: 4px; }
1422
+ .cron-presets button { border: 1px solid var(--border); border-radius: var(--r-sm); padding: 5px 9px; }
1423
+ .cron-schedule-fields { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; margin-top: 9px; }
1424
+ .cron-schedule-fields label { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 11.5px; }
1425
+ .cron-schedule-fields input[type='time'] { width: 132px; font-family: var(--font-mono); }
1426
+ .cron-schedule-fields select { min-width: 112px; }
1427
+ .cron-schedule-fields .cron-expression { flex: 1 1 210px; }
1428
+ .cron-expression input { flex: 1; min-width: 140px; }
1429
+ .cron-schedule-fields .cron-zone { flex: 1 1 230px; }
1430
+ .cron-zone input { flex: 1; min-width: 160px; }
1431
+ .cron-form-actions { grid-column: 2; display: flex; gap: 8px; }
1432
+ .cron-message { margin-top: 14px; }
1433
+ .cron-page h3 { margin-top: 24px; }
1434
+ .cron-table-wrap { margin-top: 8px; border-top: 1px solid var(--border); }
1435
+ .cron-table { width: 100%; border-collapse: collapse; table-layout: auto; font-size: 11.5px; }
1436
+ /* Every data column takes its intrinsic one-line width. The final empty column
1437
+ owns all surplus, keeping the useful columns grouped instead of stretching
1438
+ Job and Agent across the table. */
1439
+ .cron-col-job, .cron-col-agent, .cron-col-type, .cron-col-interval,
1440
+ .cron-col-state, .cron-col-next, .cron-col-last, .cron-col-actions { width: 1%; }
1441
+ .cron-col-fill { width: auto; }
1442
+ .cron-table th { padding: 7px 5px; border-bottom: 1px solid var(--border); color: var(--muted); font: 500 10px var(--font-mono); letter-spacing: 0.045em; text-align: left; text-transform: uppercase; white-space: nowrap; }
1443
+ .cron-table td { padding: 7px 5px; border-bottom: 1px solid var(--border); color: var(--text); font-family: var(--font-mono); vertical-align: middle; white-space: nowrap; }
1444
+ .cron-table .cron-fill { padding: 0; }
1445
+ .cron-table tbody tr { cursor: pointer; }
1446
+ .cron-table tbody tr:hover, .cron-table tbody tr.editing { background: var(--panel-2); }
1447
+ .cron-table tbody tr:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; }
1448
+ .cron-table tbody tr.editing { box-shadow: inset 2px 0 var(--accent); }
1449
+ .cron-type .cli-logo { display: flex; }
1450
+ .cron-dim { color: var(--muted); }
1451
+ .cron-state.running, .cron-last.ok { color: var(--go); }
1452
+ .cron-state.stopped { color: var(--muted); }
1453
+ .cron-last.failed { color: var(--danger); }
1454
+ .cron-actions { display: inline-flex; gap: 4px; white-space: nowrap; }
1455
+ .cron-actions .btn-ghost { padding: 4px 5px; border-radius: var(--r-sm); background: var(--panel); font: 500 10.5px var(--font-mono); }
1456
+ .cron-actions .btn-ghost:disabled { cursor: wait; }
1457
+
1458
+ @container (max-width: 560px) {
1459
+ .cron-form { grid-template-columns: minmax(0, 1fr); row-gap: 5px; padding-left: 0; padding-right: 0; }
1460
+ .cron-form > label, .cron-label { padding-top: 10px; }
1461
+ .cron-form-actions { grid-column: 1; margin-top: 10px; }
1462
+ .cron-schedule-fields { align-items: flex-start; flex-direction: column; }
1463
+ .cron-schedule-fields .cron-expression, .cron-schedule-fields .cron-zone { flex: none; width: 100%; }
1464
+ }
1465
  /* natural height — the settings page scrolls, not the preview */
1466
  .skills { display: flex; gap: 14px; margin-top: 12px; align-items: flex-start; }
1467
  .skills-list { width: 220px; flex: none; display: flex; flex-direction: column; gap: 4px; overflow-y: auto; border-right: 1px solid var(--border); padding-right: 10px; }
web/test/cronSettings.test.mjs ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // The Cron Settings contract in a real browser: labelled controls, correct
2
+ // request construction, distinct actions, plain-text state, and phone overflow.
3
+ import assert from 'node:assert/strict';
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { build } from 'esbuild';
9
+ import { chromium } from 'playwright';
10
+ import { chromiumLaunchOptions } from '../../scripts/test-chromium.mjs';
11
+
12
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
13
+ const WEB = path.join(HERE, '..');
14
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cron-settings-'));
15
+ const bundle = path.join(tmp, 'app.js');
16
+ const stub = path.join(tmp, 'api-stub.ts');
17
+ fs.writeFileSync(stub, `
18
+ export * from ${JSON.stringify(path.join(WEB, 'src/api.ts'))};
19
+ let jobs = [{
20
+ id: 'cron_one', name: 'morning check', agent: { name: 'triage', cli: 'codex' }, prompt: 'Check.',
21
+ schedule: { cron: '0 9 * * *', tz: 'Europe/Zurich' }, runOnRestart: true,
22
+ state: 'running', createdAt: '2026-08-19T00:00:00Z', updatedAt: '2026-08-19T00:00:00Z',
23
+ next: '2026-08-20T07:00:00Z', last: { at: '2026-08-19T07:00:00Z', status: 'ok', durationMs: 258 },
24
+ }];
25
+ window.__cronCalls = [];
26
+ export const getCrons = () => Promise.resolve({ crons: structuredClone(jobs) });
27
+ export const getTree = () => Promise.resolve({ order: [], groups: [], hidden: [], sessions: [] });
28
+ export const createCron = (draft) => { window.__cronCalls.push(['create', structuredClone(draft)]); jobs.push({ ...draft, id: 'cron_new', state: 'running', next: '2026-08-21T07:00:00Z', createdAt: '', updatedAt: '' }); return Promise.resolve(jobs.at(-1)); };
29
+ export const updateCron = (id, patch) => { window.__cronCalls.push(['update', id, structuredClone(patch)]); jobs = jobs.map((job) => job.id === id ? { ...job, ...patch, next: patch.state === 'stopped' ? null : job.next } : job); return Promise.resolve(jobs.find((job) => job.id === id)); };
30
+ export const runCron = (id) => { window.__cronCalls.push(['run', id]); return Promise.resolve({ ok: true, agentCreated: false }); };
31
+ export const deleteCron = (id) => { window.__cronCalls.push(['delete', id]); jobs = jobs.filter((job) => job.id !== id); return Promise.resolve({ ok: true }); };
32
+ export const getConfig = () => Promise.resolve(null);
33
+ export const getSecrets = () => Promise.resolve({ detected: [], notes: {} });
34
+ export const backupStatus = () => Promise.resolve(null);
35
+ export const checkUpdate = () => Promise.resolve({ ok: true });
36
+ `);
37
+
38
+ await build({
39
+ stdin: {
40
+ resolveDir: WEB, loader: 'tsx', contents: `
41
+ import React from 'react';
42
+ import { createRoot } from 'react-dom/client';
43
+ import SettingsView from './src/components/SettingsView.tsx';
44
+ const clis = [
45
+ { id: 'shell', label: 'Shell', color: '#888', available: true },
46
+ { id: 'files', label: 'Files', color: '#888', available: true },
47
+ { id: 'trace', label: 'Trace', color: '#888', available: true },
48
+ { id: 'remote', label: 'Remote agent', color: '#888', available: true },
49
+ { id: 'claude', label: 'Claude Code', color: '#d97757', available: true },
50
+ { id: 'codex', label: 'Codex', color: '#5eb6a6', available: false },
51
+ ];
52
+ createRoot(document.getElementById('root')).render(
53
+ <SettingsView page="cron" onPage={() => {}} onClose={() => {}} theme="light"
54
+ onToggleTheme={() => {}} clis={clis} info={{ dataDir: '/data' }} />,
55
+ );
56
+ `,
57
+ },
58
+ outfile: bundle, bundle: true, format: 'iife', platform: 'browser', logLevel: 'error',
59
+ plugins: [{ name: 'stub-api', setup(b) { b.onResolve({ filter: /(^|\/)\.\.?\/api$/ }, () => ({ path: stub })); } }],
60
+ });
61
+
62
+ const css = fs.readFileSync(path.join(WEB, 'src/styles.css'), 'utf8');
63
+ const browser = await chromium.launch(chromiumLaunchOptions());
64
+ try {
65
+ const page = await browser.newPage({ viewport: { width: 390, height: 900 } });
66
+ await page.setContent(`<style>${css}</style><div id="root"></div>`);
67
+ await page.addScriptTag({ path: bundle });
68
+ await page.waitForSelector('.cron-table tbody tr');
69
+
70
+ assert.equal(await page.getByRole('button', { name: 'Codex' }).isDisabled(), true,
71
+ 'unavailable CLI remains visible but cannot be selected');
72
+ assert.equal(await page.locator('.cron-clis button').count(), 2,
73
+ 'the picker is the CLI catalog minus shell/files/trace/remote');
74
+ for (const label of ['Job name', 'Agent name', 'Prompt']) {
75
+ assert.ok(await page.getByLabel(label, { exact: true }).count(), `${label} has an accessible label`);
76
+ }
77
+
78
+ const stateStyle = await page.locator('.cron-state').evaluate((element) => {
79
+ const style = getComputedStyle(element);
80
+ return { background: style.backgroundColor, radius: style.borderRadius, color: style.color };
81
+ });
82
+ assert.equal(stateStyle.background, 'rgba(0, 0, 0, 0)', 'state is text, not a badge fill');
83
+ assert.equal(stateStyle.radius, '0px', 'state is text, not a rounded pill');
84
+
85
+ const overflow = await page.evaluate(() => {
86
+ const main = document.querySelector('.settings-main');
87
+ const table = document.querySelector('.cron-table-wrap');
88
+ return {
89
+ page: main.scrollWidth - main.clientWidth,
90
+ table: table.scrollWidth - table.clientWidth,
91
+ stacked: document.querySelector('#cron-job-name').getBoundingClientRect().top
92
+ >= document.querySelector('label[for="cron-job-name"]').getBoundingClientRect().bottom,
93
+ };
94
+ });
95
+ assert.equal(overflow.page, 0, 'wide job table does not make the phone page scroll sideways');
96
+ assert.ok(overflow.table > 0, 'the table owns its horizontal overflow');
97
+ assert.equal(overflow.stacked, true, 'phone form control sits below its label');
98
+
99
+ const firstRow = page.locator('.cron-table tbody tr').filter({ hasText: 'morning check' });
100
+ const compactRow = await firstRow.evaluate((row) => {
101
+ const cells = [...row.querySelectorAll('td')];
102
+ const buttons = [...row.querySelectorAll('button')];
103
+ return {
104
+ height: row.getBoundingClientRect().height,
105
+ noWrap: cells.every((cell) => getComputedStyle(cell).whiteSpace === 'nowrap'),
106
+ typeText: cells[2].textContent.trim(),
107
+ interval: cells[3].textContent.trim(),
108
+ last: cells[6].textContent.trim(),
109
+ buttonsFit: buttons.every((button) => button.scrollWidth <= button.clientWidth),
110
+ buttonBorders: buttons.map((button) => getComputedStyle(button).borderTopStyle),
111
+ };
112
+ });
113
+ assert.ok(compactRow.height < 40, `job row stays on one line at phone width (${compactRow.height}px)`);
114
+ assert.equal(compactRow.noWrap, true, 'every retained column is pinned to one line');
115
+ assert.equal(compactRow.typeText, '', 'type column is icon-only');
116
+ assert.equal(compactRow.interval, 'every day 09:00', 'interval omits timezone and restart detail');
117
+ assert.match(compactRow.last, /^ok · 19 Aug 09:00$/, 'last run keeps the complete dense date');
118
+ assert.equal(compactRow.buttonsFit, true, 'action labels are not clipped');
119
+ assert.deepEqual(compactRow.buttonBorders, ['solid', 'solid', 'solid'], 'actions use button controls, not text links');
120
+
121
+ await page.setViewportSize({ width: 1200, height: 900 });
122
+ const contentFit = await firstRow.evaluate((row) => {
123
+ const cells = [...row.querySelectorAll('td')];
124
+ return cells.slice(0, 2).map((cell) => {
125
+ const range = document.createRange();
126
+ range.selectNodeContents(cell);
127
+ const textWidth = range.getBoundingClientRect().width;
128
+ const style = getComputedStyle(cell);
129
+ return cell.getBoundingClientRect().width - textWidth
130
+ - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
131
+ });
132
+ });
133
+ assert.equal(contentFit.every((slack) => Math.abs(slack) < 1), true,
134
+ `wide Job and Agent columns hug their content (${contentFit.join(', ')}px surplus)`);
135
+
136
+ const widths = [];
137
+ for (const width of [1200, 980, 760, 390]) {
138
+ await page.setViewportSize({ width, height: 900 });
139
+ widths.push(await firstRow.evaluate((row) => {
140
+ const wrap = row.closest('.cron-table-wrap');
141
+ const cells = [...row.querySelectorAll('td')];
142
+ const buttons = [...row.querySelectorAll('button')];
143
+ return {
144
+ width: window.innerWidth,
145
+ overflow: wrap.scrollWidth - wrap.clientWidth,
146
+ clipped: cells.map((cell) => cell.scrollWidth > cell.clientWidth),
147
+ ellipsis: cells.slice(0, 8).some((cell) => getComputedStyle(cell).textOverflow === 'ellipsis'),
148
+ filler: cells[8].getBoundingClientRect().width,
149
+ actionFits: buttons.every((button) => button.scrollWidth <= button.clientWidth)
150
+ && cells[7].scrollWidth <= cells[7].clientWidth,
151
+ };
152
+ }));
153
+ }
154
+ assert.equal(widths[0].overflow, 0, 'the wide table needs no scrollbar');
155
+ assert.equal(widths[0].filler > 0, true, 'wide surplus lands in the empty column after Actions');
156
+ for (const layout of widths) {
157
+ assert.deepEqual(layout.clipped.slice(0, 8), [false, false, false, false, false, false, false, false],
158
+ `${layout.width}px truncates no data column`);
159
+ assert.equal(layout.ellipsis, false, `${layout.width}px applies no ellipsis treatment`);
160
+ assert.equal(layout.actionFits, true, `${layout.width}px keeps every action usable`);
161
+ }
162
+ assert.equal(widths.at(-1).overflow > 0, true, 'the genuinely narrow table scrolls rather than truncating');
163
+ const synchronizedScroll = await firstRow.evaluate((row) => {
164
+ const wrap = row.closest('.cron-table-wrap');
165
+ const header = row.closest('table').querySelector('th');
166
+ const cell = row.querySelector('td');
167
+ const before = { header: header.getBoundingClientRect().left, cell: cell.getBoundingClientRect().left };
168
+ wrap.scrollLeft = wrap.scrollWidth;
169
+ const after = { header: header.getBoundingClientRect().left, cell: cell.getBoundingClientRect().left };
170
+ return {
171
+ moved: before.header - after.header,
172
+ aligned: Math.abs((before.header - before.cell) - (after.header - after.cell)),
173
+ scrollLeft: wrap.scrollLeft,
174
+ };
175
+ });
176
+ assert.equal(synchronizedScroll.scrollLeft > 0, true, 'phone table can scroll to the Actions column');
177
+ assert.equal(synchronizedScroll.moved > 0, true, 'the header moves with horizontal scrolling');
178
+ assert.equal(synchronizedScroll.aligned < 0.5, true, 'header and body columns stay aligned while scrolling');
179
+
180
+ await page.getByLabel('Job name', { exact: true }).fill('weekday digest');
181
+ await page.getByLabel('Agent name', { exact: true }).fill('digest-agent');
182
+ await page.getByLabel('Prompt', { exact: true }).fill('Summarize yesterday.');
183
+ await page.getByRole('button', { name: 'Weekdays' }).click();
184
+ await page.locator('input[type="time"]').fill('14:25');
185
+ await page.getByLabel('timezone').fill('America/New_York');
186
+ await page.getByRole('button', { name: 'No', exact: true }).click();
187
+ await page.getByRole('button', { name: 'Create job' }).click();
188
+ await page.waitForFunction(() => window.__cronCalls.some((call) => call[0] === 'create'));
189
+ const created = await page.evaluate(() => window.__cronCalls.find((call) => call[0] === 'create')[1]);
190
+ assert.deepEqual(created, {
191
+ name: 'weekday digest', agent: { name: 'digest-agent', cli: 'claude' }, prompt: 'Summarize yesterday.',
192
+ schedule: { cron: '25 14 * * 1-5', tz: 'America/New_York' }, runOnRestart: false,
193
+ });
194
+
195
+ const row = page.locator('.cron-table tbody tr').filter({ hasText: 'morning check' });
196
+ await row.click();
197
+ await page.getByRole('button', { name: 'Update job' }).waitFor();
198
+ assert.equal(await page.getByLabel('Job name', { exact: true }).inputValue(), 'morning check');
199
+ assert.equal(await page.getByLabel('Agent name', { exact: true }).inputValue(), 'triage');
200
+ assert.equal(await page.getByLabel('Prompt', { exact: true }).inputValue(), 'Check.');
201
+ assert.equal(await page.locator('input[type="time"]').inputValue(), '09:00');
202
+ assert.equal(await page.getByLabel('timezone').inputValue(), 'Europe/Zurich');
203
+ assert.equal(await page.getByRole('button', { name: 'Every day' }).getAttribute('aria-pressed'), 'true');
204
+ assert.equal(await page.getByRole('button', { name: 'Yes', exact: true }).getAttribute('aria-pressed'), 'true');
205
+ assert.equal(await page.getByRole('button', { name: 'Codex' }).getAttribute('aria-pressed'), 'true');
206
+ assert.equal(await page.getByRole('button', { name: 'Update job' }).isDisabled(), false,
207
+ 'an unavailable saved CLI is preserved without blocking edits to the other fields');
208
+
209
+ await page.getByLabel('Prompt', { exact: true }).fill('Check and summarize.');
210
+ await page.getByRole('button', { name: 'Weekly' }).click();
211
+ await page.locator('input[type="time"]').fill('10:15');
212
+ await page.locator('.cron-schedule-fields select').selectOption('4');
213
+ await page.getByLabel('timezone').fill('Asia/Tokyo');
214
+ await page.getByRole('button', { name: 'No', exact: true }).click();
215
+ await page.getByRole('button', { name: 'Update job' }).click();
216
+ await page.waitForFunction(() => window.__cronCalls.some((call) => call[0] === 'update' && call[2].prompt));
217
+ const edited = await page.evaluate(() => window.__cronCalls.find((call) => call[0] === 'update' && call[2].prompt));
218
+ assert.deepEqual(edited, ['update', 'cron_one', {
219
+ name: 'morning check', agent: { name: 'triage', cli: 'codex' }, prompt: 'Check and summarize.',
220
+ schedule: { cron: '15 10 * * 4', tz: 'Asia/Tokyo' }, runOnRestart: false,
221
+ }], 'row selection round-trips every persisted field through PUT');
222
+ assert.equal(await page.getByRole('button', { name: 'Create job' }).isVisible(), true, 'successful update returns the form to create mode');
223
+
224
+ await row.getByRole('button', { name: 'Run now' }).click();
225
+ assert.equal(await page.getByRole('button', { name: 'Create job' }).isVisible(), true, 'an action click does not also select the row');
226
+ await row.getByRole('button', { name: 'Stop' }).click();
227
+ await page.waitForFunction(() => window.__cronCalls.some((call) => call[0] === 'update' && call[2].state));
228
+ const actions = await page.evaluate(() => window.__cronCalls.map((call) => call[0]));
229
+ assert.ok(actions.includes('run') && actions.includes('update'), 'Run now and Stop are separate wired actions');
230
+ await row.getByRole('button', { name: 'Delete' }).click();
231
+ await page.waitForFunction(() => window.__cronCalls.some((call) => call[0] === 'delete'));
232
+ assert.ok((await page.evaluate(() => window.__cronCalls)).some((call) => call[0] === 'delete' && call[1] === 'cron_one'));
233
+
234
+ await page.close();
235
+ } finally {
236
+ await browser.close();
237
+ fs.rmSync(tmp, { recursive: true, force: true });
238
+ }
239
+
240
+ console.log('cron settings: form, catalog, actions, state treatment, and phone overflow agree');