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.
- package/README.md +86 -33
- package/package.json +1 -1
- package/src/auth/privateer.ts +71 -1
- package/src/commands/custom.ts +52 -4
- package/src/commands/registry.ts +124 -5
- package/src/components/App.tsx +268 -18
- package/src/components/ApprovalPrompt.tsx +15 -4
- package/src/components/Banner.tsx +21 -1
- package/src/components/ModelPicker.tsx +45 -12
- package/src/components/OptionPicker.tsx +134 -0
- package/src/components/Root.tsx +30 -9
- package/src/components/StatusBar.tsx +11 -1
- package/src/components/ToolCallView.tsx +4 -0
- package/src/components/Transcript.tsx +14 -7
- package/src/components/figures.ts +1 -0
- package/src/components/theme.ts +2 -0
- package/src/config/paths.ts +2 -0
- package/src/context/systemPrompt.ts +9 -0
- package/src/daemon/index.ts +322 -0
- package/src/daemon/ipc.ts +127 -0
- package/src/engine/errors.ts +10 -0
- package/src/main.tsx +43 -1
- package/src/mcp/client.ts +16 -1
- package/src/permissions/gate.ts +5 -0
- package/src/permissions/mode.ts +4 -0
- package/src/permissions/uiGate.ts +4 -3
- package/src/remote/relayClient.ts +161 -6
- package/src/routines/cron.ts +109 -0
- package/src/routines/delivery.ts +75 -0
- package/src/routines/schema.ts +65 -0
- package/src/routines/store.ts +205 -0
- package/src/routines/toolSelect.ts +48 -0
- package/src/routines/trigger.ts +41 -0
- package/src/session.ts +37 -12
- package/src/skills/installer.ts +222 -0
- package/src/skills/loader.ts +88 -0
- package/src/tools/askUser.ts +92 -0
- package/src/tools/context.ts +14 -0
- package/src/tools/index.ts +14 -0
- package/src/tools/routine.ts +110 -0
- package/src/tools/sendFileToClient.ts +55 -0
- package/src/tools/skill.ts +44 -0
- package/src/tools/worktree.ts +145 -0
- 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
|
-
|
|
122
|
-
|
|
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(
|
|
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: {
|
|
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
|
-
|
|
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
|
+
}
|