privateer-agent 0.1.0 → 0.2.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 (44) hide show
  1. package/README.md +86 -33
  2. package/package.json +1 -1
  3. package/src/auth/privateer.ts +71 -1
  4. package/src/commands/custom.ts +52 -4
  5. package/src/commands/registry.ts +124 -5
  6. package/src/components/App.tsx +268 -18
  7. package/src/components/ApprovalPrompt.tsx +15 -4
  8. package/src/components/Banner.tsx +21 -1
  9. package/src/components/ModelPicker.tsx +45 -12
  10. package/src/components/OptionPicker.tsx +134 -0
  11. package/src/components/Root.tsx +30 -9
  12. package/src/components/StatusBar.tsx +11 -1
  13. package/src/components/ToolCallView.tsx +4 -0
  14. package/src/components/Transcript.tsx +14 -7
  15. package/src/components/figures.ts +1 -0
  16. package/src/components/theme.ts +2 -0
  17. package/src/config/paths.ts +2 -0
  18. package/src/context/systemPrompt.ts +9 -0
  19. package/src/daemon/index.ts +322 -0
  20. package/src/daemon/ipc.ts +127 -0
  21. package/src/engine/errors.ts +10 -0
  22. package/src/main.tsx +43 -1
  23. package/src/mcp/client.ts +16 -1
  24. package/src/permissions/gate.ts +5 -0
  25. package/src/permissions/mode.ts +4 -0
  26. package/src/permissions/uiGate.ts +4 -3
  27. package/src/remote/relayClient.ts +161 -6
  28. package/src/routines/cron.ts +109 -0
  29. package/src/routines/delivery.ts +75 -0
  30. package/src/routines/schema.ts +65 -0
  31. package/src/routines/store.ts +205 -0
  32. package/src/routines/toolSelect.ts +48 -0
  33. package/src/routines/trigger.ts +41 -0
  34. package/src/session.ts +37 -12
  35. package/src/skills/installer.ts +222 -0
  36. package/src/skills/loader.ts +88 -0
  37. package/src/tools/askUser.ts +92 -0
  38. package/src/tools/context.ts +14 -0
  39. package/src/tools/index.ts +14 -0
  40. package/src/tools/routine.ts +110 -0
  41. package/src/tools/sendFileToClient.ts +55 -0
  42. package/src/tools/skill.ts +44 -0
  43. package/src/tools/worktree.ts +145 -0
  44. package/src/util/images.ts +35 -0
@@ -54,15 +54,33 @@ export interface RelayCallbacks {
54
54
  onPrompt: (text: string) => void;
55
55
  // The app asked to interrupt the in-flight turn.
56
56
  onInterrupt: () => void;
57
+ // The app asked to turn remote access OFF entirely (the in-app "End remote
58
+ // access" action). The owner should disable /remote-access — i.e. stop this
59
+ // client and not reconnect. Optional: the routines daemon handles it too, but
60
+ // callbacks that predate it keep compiling.
61
+ onTerminate?: () => void;
57
62
  // The app answered a relayed approval request.
58
63
  onApprovalResponse: (id: string, decision: "allow" | "deny") => void;
59
64
  // A controller attached — push a transcript snapshot so it can catch up.
60
65
  onControllerAttached: () => void;
66
+ // A file finished transferring from the app (reassembled from chunks). Held to
67
+ // ride along with the next remote prompt.
68
+ onAttachment: (file: { name: string; mediaType: string; base64: string }) => void;
61
69
  // Surface a one-line status/notice in the TUI.
62
70
  onStatus?: (text: string) => void;
71
+ // The relay socket closed (controller no longer reachable until reconnect).
72
+ onDisconnected?: () => void;
63
73
  }
64
74
 
65
75
  const RECONNECT_MS = 3000;
76
+ // File-transfer ceilings for app→CLI attachments. The app enforces its own caps
77
+ // before sending; these are a defensive backstop so a controller can't exhaust
78
+ // memory with a lying `size` or a flood of concurrent transfers.
79
+ const MAX_ATTACH_BYTES = 10 * 1024 * 1024; // 10 MB per file
80
+ const MAX_INFLIGHT_ATTACH = 8; // simultaneous transfers
81
+ // Base64 chars per file_chunk frame for agent→app sends (~135 KB decoded), kept
82
+ // under the relay's 256 KB per-frame cap. Mirrors the app's CHUNK_CHARS.
83
+ const FILE_CHUNK_CHARS = 180_000;
66
84
  // Coalesce streaming deltas so we don't emit one WS frame per token.
67
85
  const TEXT_FLUSH_MS = 60;
68
86
 
@@ -117,11 +135,26 @@ export class RelayClient {
117
135
  private bufKind: "text" | "reasoning" | null = null;
118
136
  private buf = "";
119
137
  private flushTimer: ReturnType<typeof setTimeout> | undefined;
120
- // Stable for this process so reconnects keep the same terminal identity.
121
- private readonly termId = randomUUID();
122
- private readonly label = terminalLabel();
138
+ // Stable for this process so reconnects keep the same terminal identity. Callers
139
+ // may pass a persisted id/label (e.g. the routines daemon, so it shows up as one
140
+ // recognizable "Privateer Routines" terminal across restarts instead of a fresh
141
+ // random one each time).
142
+ private readonly termId: string;
143
+ private readonly label: string;
144
+ // In-progress file transfers from the app, keyed by the controller's attachment
145
+ // id. Reassembled from attach_begin/chunk/end frames, then handed to onAttachment.
146
+ private readonly incoming = new Map<
147
+ string,
148
+ { name: string; mediaType: string; chunks: string[]; received: number }
149
+ >();
123
150
 
124
- constructor(private readonly cb: RelayCallbacks) {}
151
+ constructor(
152
+ private readonly cb: RelayCallbacks,
153
+ opts?: { termId?: string; label?: string },
154
+ ) {
155
+ this.termId = opts?.termId ?? randomUUID();
156
+ this.label = opts?.label ?? terminalLabel();
157
+ }
125
158
 
126
159
  async start(): Promise<void> {
127
160
  this.closed = false;
@@ -134,6 +167,7 @@ export class RelayClient {
134
167
  if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = undefined; }
135
168
  this.bufKind = null;
136
169
  this.buf = "";
170
+ this.incoming.clear();
137
171
  try { this.ws?.close(); } catch (_) { /* ignore */ }
138
172
  this.ws = null;
139
173
  }
@@ -167,6 +201,7 @@ export class RelayClient {
167
201
  ws.on("message", (data) => this.handle(data));
168
202
  ws.on("close", () => {
169
203
  if (this.ws === ws) this.ws = null;
204
+ this.cb.onDisconnected?.();
170
205
  if (!this.closed) {
171
206
  this.cb.onStatus?.(
172
207
  opened
@@ -205,7 +240,17 @@ export class RelayClient {
205
240
  }
206
241
 
207
242
  private handle(data: WebSocket.RawData): void {
208
- let frame: { type?: string; text?: string; id?: string; decision?: string };
243
+ let frame: {
244
+ type?: string;
245
+ text?: string;
246
+ id?: string;
247
+ decision?: string;
248
+ name?: string;
249
+ mediaType?: string;
250
+ size?: number;
251
+ seq?: number;
252
+ data?: string;
253
+ };
209
254
  try {
210
255
  frame = JSON.parse(data.toString());
211
256
  } catch (_) {
@@ -214,18 +259,79 @@ export class RelayClient {
214
259
  this.debug(`recv ${frame.type}`);
215
260
  switch (frame.type) {
216
261
  case "prompt":
217
- if (typeof frame.text === "string" && frame.text.trim()) this.cb.onPrompt(frame.text);
262
+ // Forward even an empty/whitespace prompt: a file-only send carries no text,
263
+ // and the app folds any pending attachments in on the prompt frame. App.tsx
264
+ // no-ops a blank prompt that has no attachments, so this stays safe.
265
+ if (typeof frame.text === "string") this.cb.onPrompt(frame.text);
218
266
  break;
219
267
  case "interrupt":
220
268
  this.cb.onInterrupt();
221
269
  break;
270
+ case "terminate":
271
+ this.cb.onTerminate?.();
272
+ break;
222
273
  case "approval_response":
223
274
  if (frame.id) this.cb.onApprovalResponse(frame.id, frame.decision === "deny" ? "deny" : "allow");
224
275
  break;
225
276
  case "controller_attached":
226
277
  this.cb.onControllerAttached();
227
278
  break;
279
+ case "attach_begin":
280
+ this.beginAttachment(frame);
281
+ break;
282
+ case "attach_chunk":
283
+ this.appendAttachmentChunk(frame);
284
+ break;
285
+ case "attach_end":
286
+ this.endAttachment(frame);
287
+ break;
288
+ }
289
+ }
290
+
291
+ // ── app → agent file transfer (chunked) ─────────────────────────────────────
292
+ // Files are streamed as attach_begin → attach_chunk* → attach_end so each WS
293
+ // frame stays under the relay's 256 KB cap. We reassemble here and hand the
294
+ // completed file up via onAttachment; App.tsx folds it into the next prompt.
295
+
296
+ private beginAttachment(frame: { id?: string; name?: string; mediaType?: string; size?: number }): void {
297
+ const { id } = frame;
298
+ if (!id || typeof frame.name !== "string" || typeof frame.mediaType !== "string") return;
299
+ if (this.incoming.size >= MAX_INFLIGHT_ATTACH) {
300
+ this.cb.onStatus?.(`Dropped attachment "${frame.name}" — too many transfers in flight.`);
301
+ return;
302
+ }
303
+ if (typeof frame.size === "number" && frame.size > MAX_ATTACH_BYTES) {
304
+ this.cb.onStatus?.(`Dropped attachment "${frame.name}" — exceeds ${Math.round(MAX_ATTACH_BYTES / (1024 * 1024))} MB.`);
305
+ return;
306
+ }
307
+ this.incoming.set(id, { name: frame.name, mediaType: frame.mediaType, chunks: [], received: 0 });
308
+ }
309
+
310
+ private appendAttachmentChunk(frame: { id?: string; data?: string }): void {
311
+ const { id } = frame;
312
+ if (!id || typeof frame.data !== "string") return;
313
+ const entry = this.incoming.get(id);
314
+ if (!entry) return; // begin was dropped or never seen
315
+ entry.received += frame.data.length;
316
+ // base64 inflates by ~4/3, so received*0.75 ≈ decoded bytes. Bound it in case
317
+ // `size` was absent or lied at begin time.
318
+ if (entry.received * 0.75 > MAX_ATTACH_BYTES + 64 * 1024) {
319
+ this.incoming.delete(id);
320
+ this.cb.onStatus?.(`Dropped attachment "${entry.name}" — stream exceeded size limit.`);
321
+ return;
228
322
  }
323
+ entry.chunks.push(frame.data);
324
+ }
325
+
326
+ private endAttachment(frame: { id?: string }): void {
327
+ const { id } = frame;
328
+ if (!id) return;
329
+ const entry = this.incoming.get(id);
330
+ if (!entry) return;
331
+ this.incoming.delete(id);
332
+ const base64 = entry.chunks.join("");
333
+ if (!base64) return;
334
+ this.cb.onAttachment({ name: entry.name, mediaType: entry.mediaType, base64 });
229
335
  }
230
336
 
231
337
  private rawSend(frame: unknown): void {
@@ -237,6 +343,23 @@ export class RelayClient {
237
343
 
238
344
  // ── agent → controller ──────────────────────────────────────────────────────
239
345
 
346
+ // Is the relay socket currently open? (Not the same as "a controller is
347
+ // attached" — the server forwards to a controller only when one is present.)
348
+ isConnected(): boolean {
349
+ return this.ws?.readyState === WebSocket.OPEN;
350
+ }
351
+
352
+ // Push a finished routine result to any attached controller as a text event, so
353
+ // it renders in the app's live feed. Returns whether the socket was open to send
354
+ // on; a durable channel (file/notice) still backs this up, since we can't know
355
+ // for certain a controller was attached.
356
+ sendRoutineResult(name: string, content: string): boolean {
357
+ if (!this.isConnected()) return false;
358
+ this.flushDeltas();
359
+ this.rawSend({ type: "event", event: { type: "text", text: safe(`⏺ Routine "${name}"\n\n${content}`, 8000) } });
360
+ return true;
361
+ }
362
+
240
363
  sendEvent(ev: EngineEvent): void {
241
364
  if (ev.type === "text") return this.bufferDelta("text", ev.text);
242
365
  if (ev.type === "reasoning") return this.bufferDelta("reasoning", ev.text);
@@ -252,6 +375,38 @@ export class RelayClient {
252
375
  this.rawSend({ type: "snapshot", entries: trimmed });
253
376
  }
254
377
 
378
+ // ── agent → app file transfer (chunked) ─────────────────────────────────────
379
+ // Reverse of the attach_* path: file_begin → file_chunk* → file_end, each frame
380
+ // under the relay's 256 KB cap. Fire-and-forget past the socket — the server
381
+ // drops frames when no controller is attached and there is no ack, so "ok" means
382
+ // "handed to an open socket", not "the app received it". The payload is base64
383
+ // binary, so no redactSecrets (it would corrupt the bytes) — the caller decides
384
+ // what's safe to send.
385
+ async sendFile(file: {
386
+ name: string;
387
+ mediaType: string;
388
+ base64: string;
389
+ size: number;
390
+ }): Promise<{ ok: boolean; reason?: string }> {
391
+ if (!this.isConnected()) return { ok: false, reason: "relay socket not connected" };
392
+ if (file.size > MAX_ATTACH_BYTES) {
393
+ return { ok: false, reason: `exceeds the ${Math.round(MAX_ATTACH_BYTES / (1024 * 1024))} MB relay limit` };
394
+ }
395
+ this.flushDeltas(); // land the file in order relative to buffered text
396
+ const id = randomUUID();
397
+ this.rawSend({ type: "file_begin", id, name: file.name, mediaType: file.mediaType, size: file.size });
398
+ for (let off = 0, seq = 0; off < file.base64.length; off += FILE_CHUNK_CHARS, seq++) {
399
+ if (!this.isConnected()) return { ok: false, reason: "connection lost mid-transfer" };
400
+ this.rawSend({ type: "file_chunk", id, seq, data: file.base64.slice(off, off + FILE_CHUNK_CHARS) });
401
+ // Yield between frames of a multi-chunk file so a big send doesn't starve
402
+ // the event loop (ws buffers internally; no drain dance needed at ≤10 MB).
403
+ if (file.base64.length > FILE_CHUNK_CHARS) await new Promise((r) => setImmediate(r));
404
+ }
405
+ if (!this.isConnected()) return { ok: false, reason: "connection lost mid-transfer" };
406
+ this.rawSend({ type: "file_end", id });
407
+ return { ok: true };
408
+ }
409
+
255
410
  requestApproval(id: string, req: PermissionRequest): void {
256
411
  this.rawSend({
257
412
  type: "approval_request",
@@ -0,0 +1,109 @@
1
+ // A minimal standard 5-field cron parser: "minute hour day-of-month month day-of-week".
2
+ // Supports `*`, single numbers, comma lists (`1,15`), ranges (`1-5`), and steps
3
+ // (`*/2`, `0-30/10`). Month (1-12) and day-of-week (0-6, Sunday=0) also accept the
4
+ // usual three-letter names (jan…dec, sun…sat). Kept dependency-free on purpose.
5
+
6
+ interface CronFields {
7
+ minute: Set<number>;
8
+ hour: Set<number>;
9
+ dom: Set<number>; // day of month
10
+ month: Set<number>;
11
+ dow: Set<number>; // day of week, 0=Sun
12
+ domRestricted: boolean; // field was not "*"
13
+ dowRestricted: boolean;
14
+ }
15
+
16
+ const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
17
+ const DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
18
+
19
+ function nameToNum(token: string, names: string[]): string {
20
+ const i = names.indexOf(token.toLowerCase());
21
+ return i >= 0 ? String(i + (names === MONTHS ? 1 : 0)) : token;
22
+ }
23
+
24
+ // Expand one field into the set of matching integers, validating against [min,max].
25
+ function parseField(field: string, min: number, max: number, names?: string[]): Set<number> {
26
+ const out = new Set<number>();
27
+ for (const part of field.split(",")) {
28
+ const [rangePart, stepPart] = part.split("/");
29
+ const step = stepPart === undefined ? 1 : Number(stepPart);
30
+ if (!Number.isInteger(step) || step < 1) throw new Error(`invalid step in "${part}"`);
31
+
32
+ let lo: number;
33
+ let hi: number;
34
+ if (rangePart === "*") {
35
+ lo = min;
36
+ hi = max;
37
+ } else {
38
+ const bounds = rangePart.split("-").map((t) => (names ? nameToNum(t, names) : t));
39
+ lo = Number(bounds[0]);
40
+ hi = bounds.length > 1 ? Number(bounds[1]) : lo;
41
+ if (!Number.isInteger(lo) || !Number.isInteger(hi)) throw new Error(`invalid range "${rangePart}"`);
42
+ // Allow Sunday as both 0 and 7 for day-of-week.
43
+ if (max === 6) {
44
+ if (lo === 7) lo = 0;
45
+ if (hi === 7) hi = 0;
46
+ }
47
+ if (lo < min || hi > max || lo > hi) throw new Error(`out-of-range field "${rangePart}"`);
48
+ }
49
+ for (let v = lo; v <= hi; v += step) out.add(v);
50
+ }
51
+ if (out.size === 0) throw new Error(`empty field "${field}"`);
52
+ return out;
53
+ }
54
+
55
+ export function parseCron(expr: string): CronFields {
56
+ const parts = expr.trim().split(/\s+/);
57
+ if (parts.length !== 5) {
58
+ throw new Error(`cron expression must have 5 fields (got ${parts.length}): "${expr}"`);
59
+ }
60
+ const [minute, hour, dom, month, dow] = parts;
61
+ return {
62
+ minute: parseField(minute, 0, 59),
63
+ hour: parseField(hour, 0, 23),
64
+ dom: parseField(dom, 1, 31),
65
+ month: parseField(month, 1, 12, MONTHS),
66
+ dow: parseField(dow, 0, 6, DAYS),
67
+ domRestricted: dom !== "*",
68
+ dowRestricted: dow !== "*",
69
+ };
70
+ }
71
+
72
+ // Validate an expression, returning an error message or null if it parses.
73
+ export function cronError(expr: string): string | null {
74
+ try {
75
+ parseCron(expr);
76
+ return null;
77
+ } catch (err) {
78
+ return err instanceof Error ? err.message : String(err);
79
+ }
80
+ }
81
+
82
+ function matches(f: CronFields, d: Date): boolean {
83
+ if (!f.minute.has(d.getMinutes())) return false;
84
+ if (!f.hour.has(d.getHours())) return false;
85
+ if (!f.month.has(d.getMonth() + 1)) return false;
86
+ // Standard cron: when BOTH day-of-month and day-of-week are restricted, a match
87
+ // on EITHER is sufficient. When only one is restricted, it alone must match.
88
+ const domOk = f.dom.has(d.getDate());
89
+ const dowOk = f.dow.has(d.getDay());
90
+ if (f.domRestricted && f.dowRestricted) return domOk || dowOk;
91
+ if (f.domRestricted) return domOk;
92
+ if (f.dowRestricted) return dowOk;
93
+ return true;
94
+ }
95
+
96
+ // The next fire time strictly after `from` (local time), or null if none within a
97
+ // ~4-year horizon (e.g. Feb 30). Seconds/millis are cleared; we scan minute by minute.
98
+ export function nextRun(expr: string, from: Date = new Date()): Date | null {
99
+ const fields = parseCron(expr);
100
+ const d = new Date(from.getTime());
101
+ d.setSeconds(0, 0);
102
+ d.setMinutes(d.getMinutes() + 1); // strictly after `from`
103
+ const limit = 366 * 4 * 24 * 60; // minutes in ~4 years
104
+ for (let i = 0; i < limit; i++) {
105
+ if (matches(fields, d)) return d;
106
+ d.setMinutes(d.getMinutes() + 1);
107
+ }
108
+ return null;
109
+ }
@@ -0,0 +1,75 @@
1
+ import type { Routine } from "./schema.ts";
2
+ import { writeRoutineOutput, addNotice } from "./store.ts";
3
+
4
+ // A relay pusher, injected by the daemon. Given the finished result it either
5
+ // forwards it to an attached controller immediately ("live") or persists it to the
6
+ // pending-relay queue to flush when the app next attaches ("queued"). Either way the
7
+ // result is durably accounted for, so delivery doesn't add a notice backstop for it.
8
+ export type RelayPusher = (routine: Routine, content: string) => "live" | "queued";
9
+
10
+ export interface DeliveryContext {
11
+ pushRelay?: RelayPusher;
12
+ }
13
+
14
+ export interface DeliveryReport {
15
+ // Channels that actually delivered (email is handled inside the agent run, so it
16
+ // never appears here — see the daemon).
17
+ delivered: string[];
18
+ // Absolute path to latest.md when file delivery ran.
19
+ filePath?: string;
20
+ }
21
+
22
+ function previewOf(content: string): string {
23
+ return content.replace(/\s+/g, " ").trim().slice(0, 120) || "(no output)";
24
+ }
25
+
26
+ // Deliver a routine's result to its configured channels. `file` and `notice` are
27
+ // deterministic and on-box. `relay` pushes to an attached controller in real time
28
+ // (best-effort — the socket may be up with no controller attached), so we ALSO keep
29
+ // a durable record when the routine has no other on-box channel, guaranteeing the
30
+ // result is never silently lost. `email` is intentionally not handled here: it is
31
+ // fulfilled inside the agent turn (the daemon adds the Gmail tool + an instruction
32
+ // to the prompt) so plaintext egress stays an explicit, gated action.
33
+ export function deliver(
34
+ routine: Routine,
35
+ content: string,
36
+ status: "ok" | "error",
37
+ ctx: DeliveryContext = {},
38
+ ): DeliveryReport {
39
+ const delivered: string[] = [];
40
+ const wants = new Set(routine.delivery);
41
+ let filePath: string | undefined;
42
+ let noticed = false;
43
+
44
+ const leaveNotice = () => {
45
+ if (noticed) return;
46
+ addNotice({ routine: routine.name, at: new Date().toISOString(), status, preview: previewOf(content), path: filePath });
47
+ noticed = true;
48
+ };
49
+
50
+ // On-box copy.
51
+ if (wants.has("file")) {
52
+ filePath = writeRoutineOutput(routine.name, content);
53
+ delivered.push("file");
54
+ }
55
+
56
+ // Relay: pushed live to an attached controller, or queued to flush when the app
57
+ // next attaches (the daemon persists that queue, so it's durable either way). Only
58
+ // when no pusher is wired at all do we fall back to a notice so it isn't lost.
59
+ if (wants.has("relay")) {
60
+ const status = ctx.pushRelay?.(routine, content);
61
+ if (status === "live") delivered.push("relay");
62
+ else if (status === "queued") delivered.push("relay(queued)");
63
+ else if (!wants.has("file") && !wants.has("notice")) {
64
+ leaveNotice();
65
+ delivered.push("notice(backstop)");
66
+ }
67
+ }
68
+
69
+ if (wants.has("notice")) {
70
+ leaveNotice();
71
+ delivered.push("notice");
72
+ }
73
+
74
+ return { delivered, filePath };
75
+ }
@@ -0,0 +1,65 @@
1
+ import { z } from "zod";
2
+
3
+ // Where a routine's result is delivered after it runs. Everything except `email`
4
+ // stays inside the user's trust boundary; `email` crosses it (hands plaintext to a
5
+ // third-party mail provider via the Gmail MCP tool), so it is opt-in and labeled.
6
+ export const DELIVERY_CHANNELS = ["file", "relay", "notice", "email"] as const;
7
+ export type DeliveryChannel = (typeof DELIVERY_CHANNELS)[number];
8
+
9
+ // A saved, unattended agent task. Persisted in routines.json and executed by the
10
+ // daemon when its trigger comes due. A routine's trigger is EITHER recurring (a
11
+ // cron expression) or one-off (`at`, a specific datetime) — exactly one is set.
12
+ export const Routine = z
13
+ .object({
14
+ // Stable id ("r-" + mint time), used as the key for updates/removal.
15
+ id: z.string(),
16
+ // Human label, unique across routines; used by /routine and as the output dir.
17
+ name: z.string(),
18
+ // Recurring trigger: a standard 5-field cron expression, e.g. "0 8 * * *".
19
+ cron: z.string().optional(),
20
+ // One-off trigger: an ISO-8601 datetime, e.g. "2026-07-02T15:00:00". Fires once,
21
+ // then the routine disables itself.
22
+ at: z.string().optional(),
23
+ // The instruction handed to the agent each time the routine fires.
24
+ prompt: z.string(),
25
+ // Working directory the run executes in (file tools are confined here).
26
+ cwd: z.string(),
27
+ // Optional "provider:model" override; falls back to config.defaultModel.
28
+ model: z.string().optional(),
29
+ // Where to deliver the result. Defaults to on-box file output.
30
+ delivery: z.array(z.enum(DELIVERY_CHANNELS)).default(["file"]),
31
+ // Optional tool allow-subset. Unset → the safe read/web set (see daemon). Entries
32
+ // may be builtin names ("read") or MCP selectors — "<server>__<tool>" exact or
33
+ // "<server>__*" for a whole server (see routines/toolSelect.ts). Selected MCP
34
+ // tools run unattended under the auto-approve gate, so grant the minimum needed.
35
+ tools: z.array(z.string()).optional(),
36
+ // Paused routines stay in the file but never fire.
37
+ enabled: z.boolean().default(true),
38
+ // Bookkeeping, updated by the daemon after each run.
39
+ lastRun: z.string().optional(),
40
+ lastStatus: z.enum(["ok", "error"]).optional(),
41
+ lastError: z.string().optional(),
42
+ nextRun: z.string().optional(),
43
+ })
44
+ // Exactly one trigger: recurring (cron) or one-off (at).
45
+ .refine((r) => Boolean(r.cron) !== Boolean(r.at), {
46
+ message: "set exactly one of `cron` (recurring) or `at` (one-off)",
47
+ path: ["cron"],
48
+ });
49
+ export type Routine = z.infer<typeof Routine>;
50
+
51
+ // True when the routine repeats (cron) rather than firing once (at).
52
+ export function isRecurring(r: Pick<Routine, "cron" | "at">): boolean {
53
+ return Boolean(r.cron);
54
+ }
55
+
56
+ // The on-disk shape of routines.json.
57
+ export const RoutineFile = z.object({
58
+ routines: z.array(Routine).default([]),
59
+ });
60
+ export type RoutineFile = z.infer<typeof RoutineFile>;
61
+
62
+ // A time-ordered routine id minted once at creation.
63
+ export function newRoutineId(): string {
64
+ return `r-${Date.now()}`;
65
+ }