@terminus-ai/cli 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1055 -0
  3. package/bin/agent-discovery.mjs +71 -0
  4. package/bin/agent-icon.mjs +77 -0
  5. package/bin/agent-models.mjs +77 -0
  6. package/bin/agent-type.mjs +51 -0
  7. package/bin/agentdev.mjs +657 -0
  8. package/bin/app-route-script.mjs +59 -0
  9. package/bin/app-runtime-contract.mjs +2 -0
  10. package/bin/appdev-remote.mjs +346 -0
  11. package/bin/appdev.mjs +4446 -0
  12. package/bin/apps.mjs +5512 -0
  13. package/bin/capability-calls.mjs +437 -0
  14. package/bin/capsule-data.mjs +260 -0
  15. package/bin/client.mjs +189 -0
  16. package/bin/commands.mjs +1194 -0
  17. package/bin/dev-capsules.mjs +1599 -0
  18. package/bin/dev-contract.mjs +262 -0
  19. package/bin/dev-data.mjs +287 -0
  20. package/bin/dev-members.mjs +18 -0
  21. package/bin/dev-net.mjs +316 -0
  22. package/bin/dev-notification-popup.mjs +628 -0
  23. package/bin/dev-ports.mjs +567 -0
  24. package/bin/dev-server-binding.mjs +35 -0
  25. package/bin/dev-server-ops.mjs +1086 -0
  26. package/bin/dev-ui/IoskeleyMono-400.woff2 +0 -0
  27. package/bin/dev-ui/IoskeleyMono-600.woff2 +0 -0
  28. package/bin/dev-ui/OFL.txt +92 -0
  29. package/bin/dev-ui/agent-robot.webp +0 -0
  30. package/bin/dev-ui/app.js +5217 -0
  31. package/bin/dev-ui/highlight.js +195 -0
  32. package/bin/dev-ui/index.html +34 -0
  33. package/bin/dev-ui/style.css +3640 -0
  34. package/bin/devlint.mjs +112 -0
  35. package/bin/devserver.mjs +2127 -0
  36. package/bin/devtriggers.mjs +367 -0
  37. package/bin/endpoints.mjs +156 -0
  38. package/bin/errors.mjs +61 -0
  39. package/bin/files.mjs +169 -0
  40. package/bin/horizontal-capabilities/v1/contract.json +280 -0
  41. package/bin/http.mjs +500 -0
  42. package/bin/lint-manifests/justbash-commands.json +88 -0
  43. package/bin/lint-manifests/python-stdlib.json +295 -0
  44. package/bin/login-page.mjs +488 -0
  45. package/bin/schedules.mjs +664 -0
  46. package/bin/server-sandbox.mjs +204 -0
  47. package/bin/servicedev.mjs +425 -0
  48. package/bin/sync.mjs +357 -0
  49. package/bin/terminus.js +3666 -0
  50. package/bin/toolchain.mjs +125 -0
  51. package/bin/vendor/app-runtime-v1/app-host.json +124 -0
  52. package/bin/vendor/app-runtime-v1/capability-calls.json +412 -0
  53. package/bin/vendor/app-runtime-v1/doors.json +2867 -0
  54. package/bin/vendor/appd/node-harness.mjs +209 -0
  55. package/bin/vendor/appd/python-harness.py +12 -0
  56. package/bin/vendor/appd/server-protocol.json +84 -0
  57. package/bin/vendor/where.mjs +541 -0
  58. package/bin/versioning.mjs +72 -0
  59. package/bin/write-rules.mjs +398 -0
  60. package/package.json +41 -0
@@ -0,0 +1,664 @@
1
+ /**
2
+ * Agent trigger authoring (terminus.json, kind "agent").
3
+ *
4
+ * An agent is its own orchestrator, so its triggers do not need the
5
+ * declarative workflow layer apps use (`workloads.automations` + steps +
6
+ * `$from`): a trigger on an agent means "wake the agent", and the only
7
+ * things worth authoring are when it wakes, what the wake says, and whether
8
+ * the run's result reaches the user as a notification. Agents therefore
9
+ * declare four top-level prompt-form sections —
10
+ *
11
+ * "schedules": [{ "every": "day", "at": "08:30", "timezone": "…",
12
+ * "prompt": "…" }]
13
+ * "webhooks": [{ "name": "github", "prompt": "…" }]
14
+ * "watches": [{ "url": "https://…", "pattern": "…", "prompt": "…" }]
15
+ * "events": [{ "collection": "notes", "prompt": "…" }]
16
+ *
17
+ * — and this module desugars them into the platform's canonical `workloads`
18
+ * wire shape (the runtime is untouched; scheduling, fatigue and
19
+ * park-on-turn all keep working). `name` is optional while a section has one
20
+ * entry (the key is then "default") and required to disambiguate several.
21
+ * `prompt` is optional everywhere: an agent's AGENT.md already says what it
22
+ * does, so an unprompted wake just tells the agent why it woke.
23
+ *
24
+ * The desugared schedule automations are shared and parametrized through
25
+ * `input.prompt` (`scheduled-run`) rather than
26
+ * inlined per schedule — that is what lets the platform mint NEW schedule
27
+ * rows at runtime (a user asking the agent to change its own cadence)
28
+ * without touching the immutable release.
29
+ */
30
+
31
+ import { CliError } from "./client.mjs";
32
+
33
+ export const AGENT_TRIGGER_SECTIONS = ["schedules", "webhooks", "watches", "events"];
34
+
35
+ /** Shared automation name the schedule desugar emits. */
36
+ export const SCHEDULED_RUN = "scheduled-run";
37
+
38
+ /** Mirror of the backend's notification-preview derivation: the reply's
39
+ * first non-empty line, unwrapped of leading markdown furniture and capped —
40
+ * the short text a standing run's notification card shows. */
41
+ export function notificationSummary(text) {
42
+ const line = String(text ?? "")
43
+ .split("\n")
44
+ .map((candidate) => candidate.replace(/^[#>*\-\s]+/, "").trim())
45
+ .find(Boolean) ?? "";
46
+ const collapsed = line.replace(/\s+/g, " ");
47
+ return collapsed.length > 140 ? `${collapsed.slice(0, 139).trimEnd()}…` : collapsed;
48
+ }
49
+
50
+ /** Timezone sentinel: each installer's own timezone, resolved per
51
+ * installation by the platform (and locally to this machine's zone). */
52
+ export const USER_TIMEZONE = "user";
53
+
54
+ /** Shared automation name the watch desugar emits. */
55
+ export const WATCHED_CHANGE = "watched-change";
56
+
57
+ /** A watch's T0 firing condition: exactly one numeric threshold the
58
+ * platform's poller evaluates with no model spend — `changed_by_pct` (moved
59
+ * that far from the value last fired about), or `below`/`above` (crossing
60
+ * the level). Mirrors the backend's validate_watch_condition. */
61
+ function validateWatchCondition(condition, key) {
62
+ if (!plainObject(condition)) {
63
+ throw new CliError(`watch '${key}' condition must be an object`);
64
+ }
65
+ const entries = Object.entries(condition);
66
+ if (entries.length !== 1) {
67
+ throw new CliError(
68
+ `watch '${key}' condition takes exactly one of changed_by_pct, below, above`,
69
+ );
70
+ }
71
+ const [field, value] = entries[0];
72
+ if (!["changed_by_pct", "below", "above"].includes(field)) {
73
+ throw new CliError(
74
+ `unknown watch condition field '${field}' (use changed_by_pct, below, or above)`,
75
+ );
76
+ }
77
+ if (typeof value !== "number" || !Number.isFinite(value)
78
+ || (field === "changed_by_pct" && value <= 0)) {
79
+ throw new CliError(
80
+ field === "changed_by_pct"
81
+ ? `watch '${key}' condition.changed_by_pct must be a positive number`
82
+ : `watch '${key}' condition.${field} must be a finite number`,
83
+ );
84
+ }
85
+ }
86
+
87
+ const MAX_TRIGGER_KEY = 48;
88
+ const MAX_PROMPT = 4000;
89
+ const WEEKDAY_INDEX = {
90
+ sunday: 0, monday: 1, tuesday: 2, wednesday: 3,
91
+ thursday: 4, friday: 5, saturday: 6,
92
+ };
93
+ const WEEKDAY_LABELS = [
94
+ "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday",
95
+ ];
96
+
97
+ function plainObject(value) {
98
+ return value !== null && typeof value === "object" && !Array.isArray(value);
99
+ }
100
+
101
+ /** Minute, hour, day of month, month, weekday (Sunday is 0). */
102
+ const CRON_FIELD_RANGES = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 6]];
103
+
104
+ /** How far ahead the platform looks for a schedule's next run: 527,040
105
+ * minutes (366 days) past the first candidate minute. A schedule with no run
106
+ * inside that window gets none. */
107
+ const CRON_LOOKAHEAD_MINUTES = 527_040;
108
+
109
+ /** The platform's five-field cron grammar (mirrors the backend's
110
+ * `valid_cron_expression`): each field is `*`, `* / N`, or one number. Answers
111
+ * each field's allowed values in ascending order — `* / N` allows the
112
+ * multiples of N, as the backend's `field_matches` reads it — or null when the
113
+ * expression is outside the grammar. */
114
+ function cronFieldValues(expression) {
115
+ if (typeof expression !== "string" || expression.length > 100) return null;
116
+ const fields = expression.trim().split(/\s+/);
117
+ if (fields.length !== 5) return null;
118
+ const allowed = [];
119
+ for (const [index, field] of fields.entries()) {
120
+ const [minimum, maximum] = CRON_FIELD_RANGES[index];
121
+ const range = Array.from({ length: maximum - minimum + 1 }, (_, offset) => minimum + offset);
122
+ if (field === "*") {
123
+ allowed.push(range);
124
+ } else if (/^\*\/\d+$/.test(field)) {
125
+ const step = Number(field.slice(2));
126
+ if (step < 1 || step > maximum) return null;
127
+ allowed.push(range.filter((value) => value % step === 0));
128
+ } else if (/^\d+$/.test(field)) {
129
+ const value = Number(field);
130
+ if (value < minimum || value > maximum) return null;
131
+ allowed.push([value]);
132
+ } else {
133
+ return null;
134
+ }
135
+ }
136
+ return allowed;
137
+ }
138
+
139
+ /** Whether `expression` is in the platform's five-field cron grammar. */
140
+ export function validUtcCron(expression) {
141
+ return cronFieldValues(expression) !== null;
142
+ }
143
+
144
+ /**
145
+ * The platform's next run for a UTC cron after `after`, as an ISO string, or
146
+ * null when it has none inside the platform's window — `0 0 30 2 *` never
147
+ * runs. The same answer as the backend's minute-by-minute search
148
+ * (`next_cron_after_in_timezone`, in UTC): from the next whole minute, every
149
+ * field matching, the day of month AND the weekday. Found field by field
150
+ * instead — a day at a time, then that day's first allowed hour and minute —
151
+ * so a schedule that never runs costs a year of days, not of minutes.
152
+ */
153
+ export function nextCronAfter(expression, after = new Date()) {
154
+ const allowed = cronFieldValues(expression);
155
+ if (!allowed) return null;
156
+ const [minutes, hours, days, months, weekdays] = allowed;
157
+ const first = new Date(after);
158
+ first.setUTCSeconds(0, 0);
159
+ first.setUTCMinutes(first.getUTCMinutes() + 1);
160
+ const last = first.getTime() + CRON_LOOKAHEAD_MINUTES * 60_000;
161
+ const day = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth(), first.getUTCDate()));
162
+ let [fromHour, fromMinute] = [first.getUTCHours(), first.getUTCMinutes()];
163
+ while (day.getTime() <= last) {
164
+ if (months.includes(day.getUTCMonth() + 1)
165
+ && days.includes(day.getUTCDate())
166
+ && weekdays.includes(day.getUTCDay())) {
167
+ for (const hour of hours) {
168
+ if (hour < fromHour) continue;
169
+ const minute = minutes.find((candidate) => hour > fromHour || candidate >= fromMinute);
170
+ if (minute === undefined) continue;
171
+ const run = day.getTime() + (hour * 60 + minute) * 60_000;
172
+ return run <= last ? new Date(run).toISOString() : null;
173
+ }
174
+ }
175
+ day.setUTCDate(day.getUTCDate() + 1);
176
+ [fromHour, fromMinute] = [0, 0];
177
+ }
178
+ return null;
179
+ }
180
+
181
+ export function validTimeZone(timezone) {
182
+ if (typeof timezone !== "string" || !timezone || timezone.length > 100) return false;
183
+ try {
184
+ new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format(new Date(0));
185
+ return true;
186
+ } catch {
187
+ return false;
188
+ }
189
+ }
190
+
191
+ function validKey(value) {
192
+ return typeof value === "string"
193
+ && value.length >= 1
194
+ && value.length <= MAX_TRIGGER_KEY
195
+ && /^[a-z0-9][a-z0-9_-]*$/.test(value);
196
+ }
197
+
198
+ function validPrompt(value) {
199
+ return typeof value === "string"
200
+ && value.trim().length >= 1
201
+ && value.length <= MAX_PROMPT
202
+ // Printable plus ordinary whitespace; other control characters are
203
+ // never part of an authored prompt.
204
+ && !/[\p{Cc}]/u.test(value.replaceAll(/[\n\r\t]/g, ""));
205
+ }
206
+
207
+ const parseAt = (value) => {
208
+ const match = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(String(value ?? ""));
209
+ return match ? { hour: Number(match[1]), minute: Number(match[2]) } : null;
210
+ };
211
+
212
+ /**
213
+ * The structured cadence: exactly one of
214
+ * every: "day" | weekday | "month" | "<N>m" | "<N>h" | "hour"
215
+ * (with `at` for the daily shapes and `on_day` for monthly), or a raw
216
+ * five-field `cron` escape hatch. Returns { cron, human } or throws.
217
+ */
218
+ export function cadenceToCron(entry, label) {
219
+ const { every, at, on_day: onDay, cron } = entry;
220
+ if (cron !== undefined) {
221
+ if (every !== undefined || at !== undefined || onDay !== undefined) {
222
+ throw new CliError(`${label} declares both 'cron' and a structured cadence — pick one`);
223
+ }
224
+ if (!validUtcCron(cron)) {
225
+ throw new CliError(
226
+ `${label} has an invalid cron — five fields, each *, */N, or one number`,
227
+ );
228
+ }
229
+ return { cron: cron.trim(), human: `cron ${cron.trim()}` };
230
+ }
231
+ if (typeof every !== "string" || !every) {
232
+ throw new CliError(`${label} needs a cadence: 'every' ("day", a weekday, "month", "30m", "2h") or 'cron'`);
233
+ }
234
+ const time = at !== undefined ? parseAt(at) : null;
235
+ if (at !== undefined && !time) {
236
+ throw new CliError(`${label} 'at' must be a 24h time like "08:30"`);
237
+ }
238
+ const twelve = (h, m) =>
239
+ `${h % 12 === 0 ? 12 : h % 12}:${String(m).padStart(2, "0")} ${h < 12 ? "AM" : "PM"}`;
240
+ const needsAt = () => {
241
+ if (!time) throw new CliError(`${label} needs 'at' ("HH:MM") with every: "${every}"`);
242
+ return time;
243
+ };
244
+ const noAt = () => {
245
+ if (at !== undefined) {
246
+ throw new CliError(`${label} cannot take 'at' with every: "${every}" — the interval is the time`);
247
+ }
248
+ };
249
+ const noDay = () => {
250
+ if (onDay !== undefined) {
251
+ throw new CliError(`${label} only takes 'on_day' with every: "month"`);
252
+ }
253
+ };
254
+ if (every === "day") {
255
+ noDay();
256
+ const { hour, minute } = needsAt();
257
+ return { cron: `${minute} ${hour} * * *`, human: `every day at ${twelve(hour, minute)}` };
258
+ }
259
+ if (every in WEEKDAY_INDEX) {
260
+ noDay();
261
+ const { hour, minute } = needsAt();
262
+ const dow = WEEKDAY_INDEX[every];
263
+ return {
264
+ cron: `${minute} ${hour} * * ${dow}`,
265
+ human: `every ${WEEKDAY_LABELS[dow]} at ${twelve(hour, minute)}`,
266
+ };
267
+ }
268
+ if (every === "month") {
269
+ const { hour, minute } = needsAt();
270
+ const day = onDay ?? 1;
271
+ if (!Number.isInteger(day) || day < 1 || day > 31) {
272
+ throw new CliError(`${label} 'on_day' must be 1-31`);
273
+ }
274
+ const tens = day % 100;
275
+ const suffix = tens >= 11 && tens <= 13 ? "th" : { 1: "st", 2: "nd", 3: "rd" }[day % 10] ?? "th";
276
+ return {
277
+ cron: `${minute} ${hour} ${day} * *`,
278
+ human: `on the ${day}${suffix} of each month at ${twelve(hour, minute)}`,
279
+ };
280
+ }
281
+ const interval = /^(\d{1,2})(m|h)$/.exec(every === "hour" ? "1h" : every);
282
+ if (interval) {
283
+ noDay();
284
+ noAt();
285
+ const n = Number(interval[1]);
286
+ if (interval[2] === "m") {
287
+ if (n < 1 || n > 59) throw new CliError(`${label} minute intervals are 1m-59m`);
288
+ return { cron: `*/${n} * * * *`, human: n === 1 ? "every minute" : `every ${n} minutes` };
289
+ }
290
+ if (n < 1 || n > 23) throw new CliError(`${label} hour intervals are 1h-23h`);
291
+ return { cron: `0 */${n} * * *`, human: n === 1 ? "every hour" : `every ${n} hours` };
292
+ }
293
+ throw new CliError(
294
+ `${label} 'every' must be "day", a weekday, "month", or an interval like "30m" or "2h"`,
295
+ );
296
+ }
297
+
298
+ /** The inverse, for sugar-up (clone/pull of a published agent): the cadence
299
+ * fields a cron round-trips to, or `{cron}` when it is outside the everyday
300
+ * shapes. */
301
+ export function cronToCadence(cron) {
302
+ const fields = String(cron ?? "").trim().split(/\s+/);
303
+ if (fields.length !== 5) return { cron };
304
+ const [minute, hour, dom, month, dow] = fields;
305
+ const num = (field) => (/^\d+$/.test(field) ? Number(field) : null);
306
+ const step = (field) => (/^\*\/\d+$/.exec(field) ? Number(field.slice(2)) : null);
307
+ const at = num(minute) != null && num(hour) != null
308
+ ? `${String(num(hour)).padStart(2, "0")}:${String(num(minute)).padStart(2, "0")}`
309
+ : null;
310
+ if (month !== "*") return { cron };
311
+ if (step(minute) != null && hour === "*" && dom === "*" && dow === "*") {
312
+ return { every: `${step(minute)}m` };
313
+ }
314
+ if (num(minute) === 0 && step(hour) != null && dom === "*" && dow === "*") {
315
+ return { every: `${step(hour)}h` };
316
+ }
317
+ if (at == null) return { cron };
318
+ if (dom === "*" && dow === "*") return { every: "day", at };
319
+ if (dom === "*" && num(dow) != null) {
320
+ return { every: Object.keys(WEEKDAY_INDEX)[Object.values(WEEKDAY_INDEX).indexOf(num(dow))], at };
321
+ }
322
+ if (num(dom) != null && dow === "*") return { every: "month", on_day: num(dom), at };
323
+ return { cron };
324
+ }
325
+
326
+ /** What an unprompted wake says. The agent's own AGENT.md carries the WHAT;
327
+ * the default prompt only carries the WHY-woken. */
328
+ export function defaultTriggerPrompt(kind, entry) {
329
+ const named = entry.key && entry.key !== "default" ? ` '${entry.key}'` : "";
330
+ switch (kind) {
331
+ case "schedule": {
332
+ const zone = entry.timezone === USER_TIMEZONE
333
+ ? ", the user's own timezone"
334
+ : entry.timezone && entry.timezone !== "UTC" ? `, ${entry.timezone}` : "";
335
+ return `This is your scheduled run${named} (${entry.human}${zone}). `
336
+ + "Do the work your instructions define for this schedule. "
337
+ + "Start your reply with one short line that reads well as a notification "
338
+ + "preview; if there is nothing worth reporting, reply with nothing.";
339
+ }
340
+ case "webhook":
341
+ return `Your webhook${named} received a delivery. Handle it the way your instructions describe. Start your reply with one short line that reads well as a notification preview.`;
342
+ case "watch":
343
+ return `A page you watch changed: ${entry.url}. Review the change below and act on it the way your instructions describe. Start your reply with one short line that reads well as a notification preview.`;
344
+ case "event":
345
+ return `Records changed in '${entry.collection}'. Review the changes below and act on them the way your instructions describe. Start your reply with one short line that reads well as a notification preview.`;
346
+ default:
347
+ return "You were woken by one of your triggers.";
348
+ }
349
+ }
350
+
351
+ function normalizeSection(manifest, section, label) {
352
+ const raw = manifest[section];
353
+ if (raw === undefined) return [];
354
+ if (!Array.isArray(raw)) throw new CliError(`${label} must be an array`);
355
+ const entries = [];
356
+ const keys = new Set();
357
+ for (const value of raw) {
358
+ if (!plainObject(value)) throw new CliError(`${label} entries must be objects`);
359
+ entries.push({ ...value });
360
+ }
361
+ for (const entry of entries) {
362
+ if (entry.name === undefined) {
363
+ if (entries.length > 1) {
364
+ throw new CliError(`${label} entries need unique names once there is more than one`);
365
+ }
366
+ entry.key = "default";
367
+ } else {
368
+ if (!validKey(entry.name)) {
369
+ throw new CliError(
370
+ `${label} names are 1-${MAX_TRIGGER_KEY} lowercase characters (a-z, 0-9, -, _)`,
371
+ );
372
+ }
373
+ entry.key = entry.name;
374
+ }
375
+ if (keys.has(entry.key)) throw new CliError(`${label} contains duplicate '${entry.key}'`);
376
+ keys.add(entry.key);
377
+ if (entry.prompt !== undefined && !validPrompt(entry.prompt)) {
378
+ throw new CliError(`${label} '${entry.key}' prompt must be 1-${MAX_PROMPT} printable characters`);
379
+ }
380
+ if (entry.enabled !== undefined && typeof entry.enabled !== "boolean") {
381
+ throw new CliError(`${label} '${entry.key}' enabled must be boolean`);
382
+ }
383
+ if (entry.description !== undefined
384
+ && (typeof entry.description !== "string" || entry.description.length > 300)) {
385
+ throw new CliError(`${label} descriptions must be at most 300 characters`);
386
+ }
387
+ }
388
+ return entries;
389
+ }
390
+
391
+ const allowedFields = (label, entry, allowed) => {
392
+ const field = Object.keys(entry).find(
393
+ (candidate) => candidate !== "key" && !allowed.includes(candidate),
394
+ );
395
+ if (field) throw new CliError(`unknown ${label} field '${field}'`);
396
+ };
397
+
398
+ /**
399
+ * Parse and validate an agent manifest's top-level trigger sections into
400
+ * normalized entries plus the canonical `workloads` wire shape. Pure —
401
+ * touches nothing on `manifest`.
402
+ */
403
+ export function parseAgentTriggers(manifest, { agentName } = {}) {
404
+ const schedules = normalizeSection(manifest, "schedules", "schedules");
405
+ const webhooks = normalizeSection(manifest, "webhooks", "webhooks");
406
+ const watches = normalizeSection(manifest, "watches", "watches");
407
+ const events = normalizeSection(manifest, "events", "events");
408
+
409
+ for (const entry of schedules) {
410
+ allowedFields("schedules", entry, [
411
+ "name", "every", "at", "on_day", "cron", "timezone", "prompt", "enabled",
412
+ ]);
413
+ const { cron, human } = cadenceToCron(entry, `schedule '${entry.key}'`);
414
+ entry.cron = cron;
415
+ entry.human = human;
416
+ // "user" means each installer's own timezone — the platform resolves it
417
+ // per installation, so "every Monday morning" is THEIR Monday morning.
418
+ if (entry.timezone !== undefined
419
+ && entry.timezone !== USER_TIMEZONE
420
+ && !validTimeZone(entry.timezone)) {
421
+ throw new CliError(
422
+ `schedule '${entry.key}' timezone must be an IANA name or "user" (each user's own timezone)`,
423
+ );
424
+ }
425
+ }
426
+ for (const entry of webhooks) {
427
+ allowedFields("webhooks", entry, [
428
+ "name", "description", "prompt", "coalesce_seconds", "enabled",
429
+ ]);
430
+ }
431
+ for (const entry of watches) {
432
+ allowedFields("watches", entry, [
433
+ "name", "description", "url", "pattern", "interval_minutes", "prompt", "condition", "enabled",
434
+ ]);
435
+ entry.interval_minutes = entry.interval_minutes ?? 60;
436
+ if (entry.condition !== undefined) validateWatchCondition(entry.condition, entry.key);
437
+ }
438
+ for (const entry of events) {
439
+ allowedFields("events", entry, [
440
+ "name", "description", "collection", "prompt", "coalesce_seconds", "enabled",
441
+ ]);
442
+ }
443
+
444
+ // Every standing run notifies — there is no toggle. The notification is a
445
+ // compact card, not a transcript: its body is the run's short summary (the
446
+ // platform derives it from the reply's first line), and a run that
447
+ // produced nothing sends nothing. Zero-trigger agents only compile the
448
+ // notify machinery when they can mint runs later (the schedules grant).
449
+ const hasTriggers = Boolean(
450
+ schedules.length || webhooks.length || watches.length || events.length,
451
+ );
452
+ const grantedTool = (id) => (manifest.tools ?? []).some(
453
+ (tool) => (typeof tool === "string" ? tool : tool?.id) === id,
454
+ );
455
+ const schedulesGranted = grantedTool("schedules.manage");
456
+ const watchesGranted = grantedTool("watches.manage");
457
+ const needsNotifications = hasTriggers
458
+ || schedulesGranted || watchesGranted
459
+ || manifest.shell?.notifications === true;
460
+
461
+ const title = String(agentName ?? "Agent").slice(0, 160);
462
+ const notifyStep = {
463
+ action: "notification.create",
464
+ params: {
465
+ title,
466
+ body: { $from: "steps.0.result.summary" },
467
+ skip_if_empty: true,
468
+ },
469
+ };
470
+ const automations = [{
471
+ name: SCHEDULED_RUN,
472
+ steps: [
473
+ { action: "agent.run", params: { prompt: { $from: "input.prompt" } } },
474
+ ...(needsNotifications ? [structuredClone(notifyStep)] : []),
475
+ ],
476
+ }];
477
+ // Watches share ONE parametrized automation the way schedules do — the
478
+ // seam that lets the platform mint watch rows at runtime. The firing's
479
+ // payload is { prompt, event }: the wake text from the row, the diff
480
+ // fenced as untrusted data.
481
+ if (watches.length || watchesGranted) {
482
+ automations.push({
483
+ name: WATCHED_CHANGE,
484
+ steps: [
485
+ {
486
+ action: "agent.run",
487
+ params: { prompt: { $from: "input.prompt" }, context: { $from: "input.event" } },
488
+ },
489
+ ...(needsNotifications ? [structuredClone(notifyStep)] : []),
490
+ ],
491
+ });
492
+ }
493
+ const eventAutomation = (kind, entry) => {
494
+ const name = `on-${kind}-${entry.key}`;
495
+ automations.push({
496
+ name,
497
+ steps: [
498
+ {
499
+ action: "agent.run",
500
+ params: {
501
+ prompt: entry.prompt ?? defaultTriggerPrompt(kind, entry),
502
+ context: { $from: "input" },
503
+ },
504
+ },
505
+ structuredClone(notifyStep),
506
+ ],
507
+ });
508
+ return name;
509
+ };
510
+
511
+ const workloads = { automations };
512
+ if (schedules.length) {
513
+ workloads.schedules = schedules.map((entry) => ({
514
+ name: entry.key,
515
+ cron: entry.cron,
516
+ ...(entry.timezone !== undefined ? { timezone: entry.timezone } : {}),
517
+ automation: SCHEDULED_RUN,
518
+ input: { prompt: entry.prompt ?? defaultTriggerPrompt("schedule", entry) },
519
+ ...(entry.enabled === false ? { enabled: false } : {}),
520
+ }));
521
+ }
522
+ if (webhooks.length) {
523
+ workloads.webhooks = webhooks.map((entry) => ({
524
+ name: entry.key,
525
+ ...(entry.description !== undefined ? { description: entry.description } : {}),
526
+ automation: eventAutomation("webhook", entry),
527
+ ...(entry.coalesce_seconds !== undefined ? { coalesce_seconds: entry.coalesce_seconds } : {}),
528
+ ...(entry.enabled === false ? { enabled: false } : {}),
529
+ }));
530
+ }
531
+ if (watches.length) {
532
+ workloads.watches = watches.map((entry) => ({
533
+ name: entry.key,
534
+ ...(entry.description !== undefined ? { description: entry.description } : {}),
535
+ url: entry.url,
536
+ ...(entry.pattern !== undefined ? { pattern: entry.pattern } : {}),
537
+ interval_minutes: entry.interval_minutes,
538
+ automation: WATCHED_CHANGE,
539
+ input: { prompt: entry.prompt ?? defaultTriggerPrompt("watch", entry) },
540
+ ...(entry.condition !== undefined ? { condition: entry.condition } : {}),
541
+ ...(entry.enabled === false ? { enabled: false } : {}),
542
+ }));
543
+ }
544
+ if (events.length) {
545
+ workloads.events = events.map((entry) => ({
546
+ name: entry.key,
547
+ ...(entry.description !== undefined ? { description: entry.description } : {}),
548
+ collection: entry.collection,
549
+ automation: eventAutomation("event", entry),
550
+ ...(entry.coalesce_seconds !== undefined ? { coalesce_seconds: entry.coalesce_seconds } : {}),
551
+ ...(entry.enabled === false ? { enabled: false } : {}),
552
+ }));
553
+ }
554
+
555
+ return { schedules, webhooks, watches, events, workloads, needsNotifications };
556
+ }
557
+
558
+ /**
559
+ * The reverse of the desugar, for clone/pull: reconstruct the authored
560
+ * top-level sections from a wire `workloads` this module emitted. Returns
561
+ * null for a wire it did not emit rather than guess — the caller keeps that
562
+ * `workloads` as it is, and validation refuses it.
563
+ */
564
+ export function sugarAgentTriggers(workloads, { agentName, shell, tools } = {}) {
565
+ if (!plainObject(workloads)) return null;
566
+ const automations = new Map(
567
+ (workloads.automations ?? []).map((automation) => [automation?.name, automation]),
568
+ );
569
+ const scheduleEntries = [];
570
+ for (const wire of workloads.schedules ?? []) {
571
+ if (wire?.automation !== SCHEDULED_RUN) return null;
572
+ const prompt = wire?.input?.prompt;
573
+ if (typeof prompt !== "string") return null;
574
+ const cadence = cronToCadence(wire.cron);
575
+ const entry = {
576
+ ...(wire.name !== "default" || (workloads.schedules ?? []).length > 1
577
+ ? { name: wire.name } : {}),
578
+ ...cadence,
579
+ ...(wire.timezone !== undefined ? { timezone: wire.timezone } : {}),
580
+ };
581
+ const bare = {
582
+ key: wire.name,
583
+ human: cadenceToCron({ ...cadence }, `schedule '${wire.name}'`).human,
584
+ timezone: wire.timezone,
585
+ };
586
+ if (prompt !== defaultTriggerPrompt("schedule", bare)) entry.prompt = prompt;
587
+ if (wire.enabled === false) entry.enabled = false;
588
+ scheduleEntries.push(entry);
589
+ }
590
+ const familyEntries = (kind, wires, fields) => {
591
+ const entries = [];
592
+ for (const wire of wires ?? []) {
593
+ const automation = automations.get(`on-${kind}-${wire?.name}`);
594
+ const run = automation?.steps?.[0];
595
+ if (run?.action !== "agent.run" || typeof run?.params?.prompt !== "string") return null;
596
+ const entry = {
597
+ ...(wire.name !== "default" || wires.length > 1 ? { name: wire.name } : {}),
598
+ };
599
+ for (const field of fields) {
600
+ if (wire[field] !== undefined) entry[field] = wire[field];
601
+ }
602
+ const bare = { key: wire.name, url: wire.url, collection: wire.collection };
603
+ if (run.params.prompt !== defaultTriggerPrompt(kind, bare)) entry.prompt = run.params.prompt;
604
+ if (wire.enabled === false) entry.enabled = false;
605
+ entries.push(entry);
606
+ }
607
+ return entries;
608
+ };
609
+ const webhooks = familyEntries("webhook", workloads.webhooks, ["description", "coalesce_seconds"]);
610
+ const events = familyEntries("event", workloads.events, ["description", "collection", "coalesce_seconds"]);
611
+ const watches = [];
612
+ for (const wire of workloads.watches ?? []) {
613
+ if (wire?.automation !== WATCHED_CHANGE) return null;
614
+ const prompt = wire?.input?.prompt;
615
+ if (typeof prompt !== "string") return null;
616
+ const entry = {
617
+ ...(wire.name !== "default" || (workloads.watches ?? []).length > 1
618
+ ? { name: wire.name } : {}),
619
+ };
620
+ for (const field of ["description", "url", "pattern", "interval_minutes", "condition"]) {
621
+ if (wire[field] !== undefined) entry[field] = wire[field];
622
+ }
623
+ const bare = { key: wire.name, url: wire.url };
624
+ if (prompt !== defaultTriggerPrompt("watch", bare)) entry.prompt = prompt;
625
+ if (wire.enabled === false) entry.enabled = false;
626
+ watches.push(entry);
627
+ }
628
+ if (!webhooks || !events) return null;
629
+ // Round-trip check: re-desugaring the reconstruction must reproduce the
630
+ // wire shape, or the wire carries something the sugar cannot say. The
631
+ // tools list rides along because the notify machinery also compiles for a
632
+ // zero-trigger agent holding the schedules grant.
633
+ const document = {
634
+ ...(scheduleEntries.length ? { schedules: scheduleEntries } : {}),
635
+ ...(webhooks.length ? { webhooks } : {}),
636
+ ...(watches.length ? { watches } : {}),
637
+ ...(events.length ? { events } : {}),
638
+ };
639
+ try {
640
+ const roundTrip = parseAgentTriggers(
641
+ {
642
+ ...document,
643
+ ...(plainObject(shell) ? { shell } : {}),
644
+ ...(Array.isArray(tools) ? { tools } : {}),
645
+ },
646
+ { agentName },
647
+ ).workloads;
648
+ if (JSON.stringify(normalizeForCompare(roundTrip))
649
+ !== JSON.stringify(normalizeForCompare(workloads))) return null;
650
+ } catch {
651
+ return null;
652
+ }
653
+ return document;
654
+ }
655
+
656
+ function normalizeForCompare(value) {
657
+ if (Array.isArray(value)) return value.map(normalizeForCompare);
658
+ if (value && typeof value === "object") {
659
+ return Object.fromEntries(
660
+ Object.keys(value).sort().map((key) => [key, normalizeForCompare(value[key])]),
661
+ );
662
+ }
663
+ return value;
664
+ }