privateer-agent 0.1.1 → 0.3.0

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 (106) hide show
  1. package/README.md +143 -398
  2. package/bin/privateer-tui +86 -0
  3. package/bin/privateer.mjs +17 -6
  4. package/bin/pv +28 -0
  5. package/package.json +26 -23
  6. package/src/auth/privateer.ts +128 -1
  7. package/src/boot.ts +43 -0
  8. package/src/bridge/engineAdapter.ts +182 -0
  9. package/src/cli/chat.ts +243 -0
  10. package/src/config/paths.ts +24 -44
  11. package/src/config/permissionMode.ts +5 -0
  12. package/src/crypto/outboxSeal.ts +61 -0
  13. package/src/daemon/index.ts +405 -0
  14. package/src/daemon/ipc.ts +127 -0
  15. package/src/engine/errors.ts +95 -45
  16. package/src/engine/router.ts +11 -165
  17. package/src/ext/permissionGate.ts +216 -0
  18. package/src/main.ts +32 -0
  19. package/src/permissions/classify.ts +172 -0
  20. package/src/permissions/gate.ts +13 -11
  21. package/src/permissions/mode.ts +9 -1
  22. package/src/permissions/{uiGate.ts → modeGate.ts} +22 -6
  23. package/src/providers/account.ts +170 -0
  24. package/src/providers/catalog.ts +73 -61
  25. package/src/providers/genModelsJson.ts +97 -0
  26. package/src/remote/relayClient.ts +91 -4
  27. package/src/remote/remoteBridge.ts +152 -0
  28. package/src/routines/cron.ts +109 -0
  29. package/src/routines/delivery.ts +184 -0
  30. package/src/routines/schema.ts +84 -0
  31. package/src/routines/store.ts +248 -0
  32. package/src/routines/toolSelect.ts +50 -0
  33. package/src/routines/trigger.ts +41 -0
  34. package/src/session.ts +84 -251
  35. package/src/tools/routine.ts +129 -0
  36. package/src/tools/saveAttachment.ts +39 -42
  37. package/src/tools/sendFile.ts +75 -0
  38. package/src/util/attachmentStore.ts +18 -35
  39. package/src/util/redact.ts +33 -3
  40. package/LICENSE +0 -21
  41. package/src/agents/loader.ts +0 -49
  42. package/src/commands/custom.ts +0 -75
  43. package/src/commands/registry.ts +0 -499
  44. package/src/components/AgentGroupView.tsx +0 -104
  45. package/src/components/App.tsx +0 -1420
  46. package/src/components/ApprovalPrompt.tsx +0 -38
  47. package/src/components/Banner.tsx +0 -76
  48. package/src/components/Markdown.tsx +0 -183
  49. package/src/components/ModeHint.tsx +0 -40
  50. package/src/components/ModelPicker.tsx +0 -269
  51. package/src/components/Onboarding.tsx +0 -203
  52. package/src/components/PlanConfirm.tsx +0 -37
  53. package/src/components/PrivateerLogin.tsx +0 -109
  54. package/src/components/PromptInput.tsx +0 -602
  55. package/src/components/RewindPicker.tsx +0 -69
  56. package/src/components/Root.tsx +0 -95
  57. package/src/components/SessionPicker.tsx +0 -64
  58. package/src/components/StatusBar.tsx +0 -131
  59. package/src/components/TodoPanel.tsx +0 -36
  60. package/src/components/ToolCallView.tsx +0 -109
  61. package/src/components/Transcript.tsx +0 -203
  62. package/src/components/figures.ts +0 -14
  63. package/src/components/promptModel.ts +0 -73
  64. package/src/components/spinnerVerbs.ts +0 -46
  65. package/src/components/theme.ts +0 -55
  66. package/src/components/types.ts +0 -34
  67. package/src/components/useTeeShield.ts +0 -104
  68. package/src/components/useTerminalWidth.ts +0 -24
  69. package/src/components/useZdrShield.ts +0 -126
  70. package/src/config/load.ts +0 -115
  71. package/src/config/schema.ts +0 -94
  72. package/src/context/outputStyles.ts +0 -42
  73. package/src/context/projectInfo.ts +0 -59
  74. package/src/context/systemPrompt.ts +0 -167
  75. package/src/engine/QueryEngine.ts +0 -399
  76. package/src/hooks/engine.ts +0 -155
  77. package/src/main.tsx +0 -167
  78. package/src/mcp/client.ts +0 -236
  79. package/src/mcp/oauth.ts +0 -245
  80. package/src/memory/auto.ts +0 -146
  81. package/src/memory/checkpoints.ts +0 -227
  82. package/src/memory/store.ts +0 -127
  83. package/src/providers/attestation.ts +0 -149
  84. package/src/providers/capabilities.ts +0 -104
  85. package/src/providers/models.ts +0 -183
  86. package/src/providers/registry.ts +0 -71
  87. package/src/providers/resolve.ts +0 -78
  88. package/src/tools/bash.ts +0 -98
  89. package/src/tools/context.ts +0 -114
  90. package/src/tools/edit.ts +0 -67
  91. package/src/tools/exec.ts +0 -60
  92. package/src/tools/glob.ts +0 -39
  93. package/src/tools/grep.ts +0 -86
  94. package/src/tools/index.ts +0 -69
  95. package/src/tools/memory.ts +0 -53
  96. package/src/tools/processRegistry.ts +0 -77
  97. package/src/tools/read.ts +0 -42
  98. package/src/tools/task.ts +0 -52
  99. package/src/tools/todo.ts +0 -36
  100. package/src/tools/todoStore.ts +0 -31
  101. package/src/tools/walk.ts +0 -44
  102. package/src/tools/web.ts +0 -145
  103. package/src/tools/write.ts +0 -40
  104. package/src/util/images.ts +0 -356
  105. package/src/util/limit.ts +0 -32
  106. package/src/version.ts +0 -13
@@ -0,0 +1,184 @@
1
+ import type { Routine } from "./schema.ts";
2
+ import { webhookName } from "./schema.ts";
3
+ import { writeRoutineOutput, addNotice } from "./store.ts";
4
+
5
+ // A relay pusher, injected by the daemon. Given the finished result it either
6
+ // forwards it to an attached controller immediately ("live") or persists it to the
7
+ // pending-relay queue to flush when the app next attaches ("queued"). Either way the
8
+ // result is durably accounted for, so delivery doesn't add a notice backstop for it.
9
+ export type RelayPusher = (routine: Routine, content: string) => "live" | "queued";
10
+
11
+ // A cloud-outbox pusher, injected by the daemon. Seals the result to the account's
12
+ // outbox public key and POSTs the ciphertext to the server (E2EE store-and-forward),
13
+ // or buffers it durably on failure. Returns "sent" when the server accepted it,
14
+ // "queued" when it was persisted for a later flush — either way the result is
15
+ // durably accounted for. `status` is carried so the sealed envelope can render
16
+ // ok/error without re-parsing the markdown body.
17
+ export type CloudPusher = (routine: Routine, content: string, status: "ok" | "error") => Promise<"sent" | "queued">;
18
+
19
+ // A named webhook endpoint from config `webhooks`.
20
+ export interface WebhookTarget {
21
+ url: string;
22
+ format?: "slack" | "discord" | "json";
23
+ }
24
+
25
+ export interface DeliveryContext {
26
+ pushRelay?: RelayPusher;
27
+ // Seals + posts the result to the account's cloud outbox, or buffers it. Absent
28
+ // (e.g. not signed in) → the `cloud` channel falls back to a notice so the result
29
+ // isn't silently lost.
30
+ pushCloud?: CloudPusher;
31
+ // Named endpoints for "webhook:<name>" delivery entries; results POST here.
32
+ webhooks?: Record<string, WebhookTarget>;
33
+ // Scrubs secrets from anything leaving the machine. Webhook bodies always pass
34
+ // through this when provided (the daemon wires in redactText).
35
+ redact?: (text: string) => string;
36
+ // Injectable for tests; defaults to global fetch.
37
+ fetchImpl?: typeof fetch;
38
+ }
39
+
40
+ export interface DeliveryReport {
41
+ // Channels that actually delivered (email is handled inside the agent run, so it
42
+ // never appears here — see the daemon). Failed webhooks show as
43
+ // "webhook:<name>(failed)" and leave a notice so the result isn't silently lost.
44
+ delivered: string[];
45
+ // Absolute path to latest.md when file delivery ran.
46
+ filePath?: string;
47
+ }
48
+
49
+ function previewOf(content: string): string {
50
+ return content.replace(/\s+/g, " ").trim().slice(0, 120) || "(no output)";
51
+ }
52
+
53
+ // Message-size caps per format (Discord rejects >2000 chars of content; Slack
54
+ // truncates around 40k; plain JSON consumers get a generous bound).
55
+ const FORMAT_CAPS = { slack: 39_000, discord: 1_900, json: 100_000 } as const;
56
+
57
+ function truncate(text: string, cap: number): string {
58
+ return text.length <= cap ? text : text.slice(0, cap - 12) + "\n…truncated";
59
+ }
60
+
61
+ // Wrap the result for the target service. All formats carry plain text — no
62
+ // service-specific rich blocks, so a generic receiver can consume `json` too.
63
+ export function webhookBody(
64
+ target: WebhookTarget,
65
+ routine: Pick<Routine, "name">,
66
+ content: string,
67
+ status: "ok" | "error",
68
+ ): string {
69
+ const format = target.format ?? "json";
70
+ const text = truncate(content, FORMAT_CAPS[format]);
71
+ switch (format) {
72
+ case "slack":
73
+ return JSON.stringify({ text: `*${routine.name}* (${status})\n${text}` });
74
+ case "discord":
75
+ return JSON.stringify({ content: `**${routine.name}** (${status})\n${text}` });
76
+ case "json":
77
+ return JSON.stringify({ routine: routine.name, status, at: new Date().toISOString(), content: text });
78
+ }
79
+ }
80
+
81
+ async function postWebhook(
82
+ target: WebhookTarget,
83
+ routine: Routine,
84
+ content: string,
85
+ status: "ok" | "error",
86
+ fetchImpl: typeof fetch,
87
+ ): Promise<void> {
88
+ const res = await fetchImpl(target.url, {
89
+ method: "POST",
90
+ headers: { "content-type": "application/json" },
91
+ body: webhookBody(target, routine, content, status),
92
+ signal: AbortSignal.timeout(15_000),
93
+ });
94
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
95
+ }
96
+
97
+ // Deliver a routine's result to its configured channels. `file` and `notice` are
98
+ // deterministic and on-box. `relay` pushes to an attached controller in real time
99
+ // (best-effort — the socket may be up with no controller attached), so we ALSO keep
100
+ // a durable record when the routine has no other on-box channel, guaranteeing the
101
+ // result is never silently lost. `webhook:<name>` POSTs the (redacted) result to a
102
+ // named endpoint from config; a failed or unconfigured webhook leaves a notice.
103
+ // `email` is intentionally not handled here: it is fulfilled inside the agent turn
104
+ // (the daemon adds the Gmail tool + an instruction to the prompt) so plaintext
105
+ // egress stays an explicit, gated action.
106
+ export async function deliver(
107
+ routine: Routine,
108
+ content: string,
109
+ status: "ok" | "error",
110
+ ctx: DeliveryContext = {},
111
+ ): Promise<DeliveryReport> {
112
+ const delivered: string[] = [];
113
+ const wants = new Set(routine.delivery);
114
+ let filePath: string | undefined;
115
+ let noticed = false;
116
+
117
+ const leaveNotice = () => {
118
+ if (noticed) return;
119
+ addNotice({ routine: routine.name, at: new Date().toISOString(), status, preview: previewOf(content), path: filePath });
120
+ noticed = true;
121
+ };
122
+
123
+ // On-box copy.
124
+ if (wants.has("file")) {
125
+ filePath = writeRoutineOutput(routine.name, content);
126
+ delivered.push("file");
127
+ }
128
+
129
+ // Relay: pushed live to an attached controller, or queued to flush when the app
130
+ // next attaches (the daemon persists that queue, so it's durable either way). Only
131
+ // when no pusher is wired at all do we fall back to a notice so it isn't lost.
132
+ if (wants.has("relay")) {
133
+ const pushed = ctx.pushRelay?.(routine, content);
134
+ if (pushed === "live") delivered.push("relay");
135
+ else if (pushed === "queued") delivered.push("relay(queued)");
136
+ else if (!wants.has("file") && !wants.has("notice")) {
137
+ leaveNotice();
138
+ delivered.push("notice(backstop)");
139
+ }
140
+ }
141
+
142
+ // Cloud outbox: sealed E2EE store-and-forward. "sent" reached the server; "queued"
143
+ // was buffered on disk (offline / server down / app hasn't published its outbox key
144
+ // yet) and flushes later — both are durable. Only when no pusher is wired at all,
145
+ // and nothing else keeps a durable record, do we fall back to a notice.
146
+ if (wants.has("cloud")) {
147
+ const sent = ctx.pushCloud ? await ctx.pushCloud(routine, content, status) : undefined;
148
+ if (sent === "sent") delivered.push("cloud");
149
+ else if (sent === "queued") delivered.push("cloud(queued)");
150
+ else if (!wants.has("file") && !wants.has("notice") && !wants.has("relay")) {
151
+ leaveNotice();
152
+ delivered.push("notice(backstop)");
153
+ }
154
+ }
155
+
156
+ // Webhooks: plaintext leaves the machine, so the body is always redacted when a
157
+ // scrubber is wired. Any failure (unconfigured name, HTTP error, timeout) leaves
158
+ // a notice so the user learns the result existed and the push didn't happen.
159
+ for (const entry of routine.delivery) {
160
+ const name = webhookName(entry);
161
+ if (!name) continue;
162
+ const target = ctx.webhooks?.[name];
163
+ if (!target) {
164
+ leaveNotice();
165
+ delivered.push(`${entry}(unconfigured)`);
166
+ continue;
167
+ }
168
+ try {
169
+ const body = ctx.redact ? ctx.redact(content) : content;
170
+ await postWebhook(target, routine, body, status, ctx.fetchImpl ?? fetch);
171
+ delivered.push(entry);
172
+ } catch {
173
+ leaveNotice();
174
+ delivered.push(`${entry}(failed)`);
175
+ }
176
+ }
177
+
178
+ if (wants.has("notice")) {
179
+ leaveNotice();
180
+ delivered.push("notice");
181
+ }
182
+
183
+ return { delivered, filePath };
184
+ }
@@ -0,0 +1,84 @@
1
+ import { z } from "zod";
2
+
3
+ // Where a routine's result is delivered after it runs. `file`/`relay`/`notice` stay
4
+ // inside the user's trust boundary. `cloud` stores an END-TO-END-ENCRYPTED copy in
5
+ // the account's server outbox for the app to catch up on when it next opens (the
6
+ // server only ever sees ciphertext — it can't read results — but does learn that a
7
+ // result exists, plus its time and rough size). `email` and `webhook:<name>` cross
8
+ // the boundary in plaintext to a third-party service, so they are opt-in and labeled
9
+ // at approval time.
10
+ export const DELIVERY_CHANNELS = ["file", "relay", "notice", "cloud", "email"] as const;
11
+ export type DeliveryChannel = (typeof DELIVERY_CHANNELS)[number];
12
+
13
+ // A webhook entry references a named endpoint from config `webhooks` — the routine
14
+ // itself never carries a URL, so all egress targets stay in one reviewable place.
15
+ const WEBHOOK_ENTRY_RE = /^webhook:[a-zA-Z0-9._-]{1,64}$/;
16
+
17
+ export const DeliveryEntry = z.union([
18
+ z.enum(DELIVERY_CHANNELS),
19
+ z.string().regex(WEBHOOK_ENTRY_RE, "webhook entries look like 'webhook:<config-name>'"),
20
+ ]);
21
+ export type DeliveryEntry = z.infer<typeof DeliveryEntry>;
22
+
23
+ // The webhook name of a delivery entry, or null for the builtin channels.
24
+ export function webhookName(entry: string): string | null {
25
+ return entry.startsWith("webhook:") ? entry.slice("webhook:".length) : null;
26
+ }
27
+
28
+ // A saved, unattended agent task. Persisted in routines.json and executed by the
29
+ // daemon when its trigger comes due. A routine's trigger is EITHER recurring (a
30
+ // cron expression) or one-off (`at`, a specific datetime) — exactly one is set.
31
+ export const Routine = z
32
+ .object({
33
+ // Stable id ("r-" + mint time), used as the key for updates/removal.
34
+ id: z.string(),
35
+ // Human label, unique across routines; used by /routine and as the output dir.
36
+ name: z.string(),
37
+ // Recurring trigger: a standard 5-field cron expression, e.g. "0 8 * * *".
38
+ cron: z.string().optional(),
39
+ // One-off trigger: an ISO-8601 datetime, e.g. "2026-07-02T15:00:00". Fires once,
40
+ // then the routine disables itself.
41
+ at: z.string().optional(),
42
+ // The instruction handed to the agent each time the routine fires.
43
+ prompt: z.string(),
44
+ // Working directory the run executes in (file tools are confined here).
45
+ cwd: z.string(),
46
+ // Optional "provider:model" override; falls back to config.defaultModel.
47
+ model: z.string().optional(),
48
+ // Where to deliver the result. Defaults to on-box file output.
49
+ delivery: z.array(DeliveryEntry).default(["file"]),
50
+ // Optional tool allow-subset. Unset → the safe read/web set (see daemon). Entries
51
+ // may be builtin names ("read") or MCP selectors — "<server>__<tool>" exact or
52
+ // "<server>__*" for a whole server (see routines/toolSelect.ts). Selected MCP
53
+ // tools run unattended under the auto-approve gate, so grant the minimum needed.
54
+ tools: z.array(z.string()).optional(),
55
+ // Paused routines stay in the file but never fire.
56
+ enabled: z.boolean().default(true),
57
+ // Bookkeeping, updated by the daemon after each run.
58
+ lastRun: z.string().optional(),
59
+ lastStatus: z.enum(["ok", "error"]).optional(),
60
+ lastError: z.string().optional(),
61
+ nextRun: z.string().optional(),
62
+ })
63
+ // Exactly one trigger: recurring (cron) or one-off (at).
64
+ .refine((r) => Boolean(r.cron) !== Boolean(r.at), {
65
+ message: "set exactly one of `cron` (recurring) or `at` (one-off)",
66
+ path: ["cron"],
67
+ });
68
+ export type Routine = z.infer<typeof Routine>;
69
+
70
+ // True when the routine repeats (cron) rather than firing once (at).
71
+ export function isRecurring(r: Pick<Routine, "cron" | "at">): boolean {
72
+ return Boolean(r.cron);
73
+ }
74
+
75
+ // The on-disk shape of routines.json.
76
+ export const RoutineFile = z.object({
77
+ routines: z.array(Routine).default([]),
78
+ });
79
+ export type RoutineFile = z.infer<typeof RoutineFile>;
80
+
81
+ // A time-ordered routine id minted once at creation.
82
+ export function newRoutineId(): string {
83
+ return `r-${Date.now()}`;
84
+ }
@@ -0,0 +1,248 @@
1
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { globalDir } from "../config/paths.ts";
5
+ import { Routine, RoutineFile } from "./schema.ts";
6
+
7
+ // routines.json lives alongside config.json in the global dir. It can carry the
8
+ // prompt text and (for email delivery) recipient hints, so it is written owner-only
9
+ // (0600) inside the owner-only global dir, mirroring saveGlobalConfig.
10
+ export function routinesFilePath(): string {
11
+ return join(globalDir(), "routines.json");
12
+ }
13
+
14
+ // Per-routine output directory (dated result files + latest.md).
15
+ export function routineOutputDir(name: string): string {
16
+ return join(globalDir(), "routines", slug(name));
17
+ }
18
+
19
+ function slug(name: string): string {
20
+ return name.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "routine";
21
+ }
22
+
23
+ // A stable relay terminal id for the daemon, persisted so it reappears as the same
24
+ // "Privateer Routines" terminal in the app across restarts (rather than a fresh
25
+ // random terminal each boot). Random on first use so it stays unique per install —
26
+ // the relay routes on this id with no user namespacing, so a shared constant could
27
+ // collide across accounts. Matches the server's isValidTermId (`[A-Za-z0-9_-]{8,64}`).
28
+ export function routineRelayId(): string {
29
+ const path = join(globalDir(), "routines", "relay-id");
30
+ if (existsSync(path)) {
31
+ const existing = readFileSync(path, "utf8").trim();
32
+ if (/^[A-Za-z0-9_-]{8,64}$/.test(existing)) return existing;
33
+ }
34
+ const id = `routines-${randomUUID().replace(/-/g, "")}`;
35
+ const dir = join(globalDir(), "routines");
36
+ mkdirSync(dir, { recursive: true });
37
+ writeFileSync(path, id + "\n", { encoding: "utf8", mode: 0o600 });
38
+ tryChmod(path, 0o600);
39
+ return id;
40
+ }
41
+
42
+ function tryChmod(path: string, mode: number): void {
43
+ try {
44
+ chmodSync(path, mode);
45
+ } catch {
46
+ /* non-POSIX filesystem or insufficient perms — nothing we can do */
47
+ }
48
+ }
49
+
50
+ export function loadRoutines(): Routine[] {
51
+ const path = routinesFilePath();
52
+ if (!existsSync(path)) return [];
53
+ try {
54
+ return RoutineFile.parse(JSON.parse(readFileSync(path, "utf8"))).routines;
55
+ } catch {
56
+ // A corrupt or hand-edited file shouldn't crash the daemon; treat as empty.
57
+ return [];
58
+ }
59
+ }
60
+
61
+ export function saveRoutines(routines: Routine[]): void {
62
+ const dir = globalDir();
63
+ mkdirSync(dir, { recursive: true });
64
+ tryChmod(dir, 0o700);
65
+ const payload: RoutineFile = { routines };
66
+ writeFileSync(routinesFilePath(), JSON.stringify(payload, null, 2) + "\n", {
67
+ encoding: "utf8",
68
+ mode: 0o600,
69
+ });
70
+ tryChmod(routinesFilePath(), 0o600);
71
+ }
72
+
73
+ // Look up by id first, then by (case-insensitive) name for CLI convenience.
74
+ export function findRoutine(routines: Routine[], idOrName: string): Routine | undefined {
75
+ const needle = idOrName.trim().toLowerCase();
76
+ return (
77
+ routines.find((r) => r.id === idOrName) ??
78
+ routines.find((r) => r.name.toLowerCase() === needle)
79
+ );
80
+ }
81
+
82
+ // Insert or replace a routine (matched by id), persisting the whole file.
83
+ export function upsertRoutine(routine: Routine): Routine[] {
84
+ const routines = loadRoutines();
85
+ const i = routines.findIndex((r) => r.id === routine.id);
86
+ if (i >= 0) routines[i] = routine;
87
+ else routines.push(routine);
88
+ saveRoutines(routines);
89
+ return routines;
90
+ }
91
+
92
+ // Remove a routine by id or name. Returns the removed routine, or null if absent.
93
+ export function removeRoutine(idOrName: string): Routine | null {
94
+ const routines = loadRoutines();
95
+ const target = findRoutine(routines, idOrName);
96
+ if (!target) return null;
97
+ saveRoutines(routines.filter((r) => r.id !== target.id));
98
+ return target;
99
+ }
100
+
101
+ // Write a run's result to the routine's output dir: a dated file plus latest.md.
102
+ // Returns the absolute path of latest.md.
103
+ export function writeRoutineOutput(name: string, content: string): string {
104
+ const dir = routineOutputDir(name);
105
+ mkdirSync(dir, { recursive: true });
106
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
107
+ writeFileSync(join(dir, `${stamp}.md`), content, "utf8");
108
+ const latest = join(dir, "latest.md");
109
+ writeFileSync(latest, content, "utf8");
110
+ return latest;
111
+ }
112
+
113
+ // A pending routine result queued for the next interactive session ("notice"
114
+ // delivery). The TUI drains these on startup so results surface even when no
115
+ // terminal was attached at fire time.
116
+ export interface RoutineNotice {
117
+ routine: string;
118
+ at: string; // ISO timestamp
119
+ status: "ok" | "error";
120
+ preview: string; // short single-line summary
121
+ path?: string; // latest.md, when file delivery also ran
122
+ }
123
+
124
+ function noticesPath(): string {
125
+ return join(globalDir(), "routines", "notices.json");
126
+ }
127
+
128
+ export function loadNotices(): RoutineNotice[] {
129
+ const path = noticesPath();
130
+ if (!existsSync(path)) return [];
131
+ try {
132
+ const data = JSON.parse(readFileSync(path, "utf8"));
133
+ return Array.isArray(data) ? (data as RoutineNotice[]) : [];
134
+ } catch {
135
+ return [];
136
+ }
137
+ }
138
+
139
+ export function addNotice(notice: RoutineNotice): void {
140
+ const dir = join(globalDir(), "routines");
141
+ mkdirSync(dir, { recursive: true });
142
+ const notices = loadNotices();
143
+ notices.push(notice);
144
+ // Keep the queue bounded so an offline stretch can't grow it without limit.
145
+ const trimmed = notices.slice(-50);
146
+ writeFileSync(noticesPath(), JSON.stringify(trimmed, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
147
+ tryChmod(noticesPath(), 0o600);
148
+ }
149
+
150
+ // Read and clear the notice queue (called by the TUI on startup).
151
+ export function drainNotices(): RoutineNotice[] {
152
+ const notices = loadNotices();
153
+ if (notices.length === 0) return [];
154
+ try {
155
+ writeFileSync(noticesPath(), "[]\n", { encoding: "utf8", mode: 0o600 });
156
+ } catch {
157
+ /* best-effort clear */
158
+ }
159
+ return notices;
160
+ }
161
+
162
+ // A relay result produced while no controller was attached, held until the app
163
+ // next connects. Persisted (not just in-memory) so it survives a daemon restart.
164
+ export interface PendingRelay {
165
+ routine: string;
166
+ at: string; // ISO timestamp
167
+ content: string;
168
+ }
169
+
170
+ function pendingRelayPath(): string {
171
+ return join(globalDir(), "routines", "pending-relay.json");
172
+ }
173
+
174
+ export function loadPendingRelay(): PendingRelay[] {
175
+ const path = pendingRelayPath();
176
+ if (!existsSync(path)) return [];
177
+ try {
178
+ const data = JSON.parse(readFileSync(path, "utf8"));
179
+ return Array.isArray(data) ? (data as PendingRelay[]) : [];
180
+ } catch {
181
+ return [];
182
+ }
183
+ }
184
+
185
+ export function addPendingRelay(entry: PendingRelay): void {
186
+ const dir = join(globalDir(), "routines");
187
+ mkdirSync(dir, { recursive: true });
188
+ const queue = loadPendingRelay();
189
+ queue.push(entry);
190
+ const trimmed = queue.slice(-50); // bound the backlog
191
+ writeFileSync(pendingRelayPath(), JSON.stringify(trimmed, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
192
+ tryChmod(pendingRelayPath(), 0o600);
193
+ }
194
+
195
+ // Read and clear the pending-relay queue (called when a controller attaches).
196
+ export function drainPendingRelay(): PendingRelay[] {
197
+ const queue = loadPendingRelay();
198
+ if (queue.length === 0) return [];
199
+ try {
200
+ writeFileSync(pendingRelayPath(), "[]\n", { encoding: "utf8", mode: 0o600 });
201
+ } catch {
202
+ /* best-effort clear */
203
+ }
204
+ return queue;
205
+ }
206
+
207
+ // A `cloud`-delivery result that couldn't be sealed+posted to the account outbox
208
+ // yet (offline, server down, or the app hasn't published its outbox key). Held on
209
+ // disk until a later flush succeeds. Unlike PendingRelay this carries `status`, so
210
+ // the sealed envelope the app opens can render ok/error without re-parsing markdown.
211
+ export interface PendingCloud {
212
+ routine: string;
213
+ at: string; // ISO timestamp
214
+ status: "ok" | "error";
215
+ content: string;
216
+ }
217
+
218
+ function pendingCloudPath(): string {
219
+ return join(globalDir(), "routines", "pending-cloud.json");
220
+ }
221
+
222
+ export function loadPendingCloud(): PendingCloud[] {
223
+ const path = pendingCloudPath();
224
+ if (!existsSync(path)) return [];
225
+ try {
226
+ const data = JSON.parse(readFileSync(path, "utf8"));
227
+ return Array.isArray(data) ? (data as PendingCloud[]) : [];
228
+ } catch {
229
+ return [];
230
+ }
231
+ }
232
+
233
+ export function addPendingCloud(entry: PendingCloud): void {
234
+ const dir = join(globalDir(), "routines");
235
+ mkdirSync(dir, { recursive: true });
236
+ const queue = loadPendingCloud();
237
+ queue.push(entry);
238
+ savePendingCloud(queue.slice(-50)); // bound the backlog
239
+ }
240
+
241
+ // Overwrite the queue wholesale — used by the flush to drop the items it managed
242
+ // to post while keeping the ones that still failed (and their order).
243
+ export function savePendingCloud(entries: PendingCloud[]): void {
244
+ const dir = join(globalDir(), "routines");
245
+ mkdirSync(dir, { recursive: true });
246
+ writeFileSync(pendingCloudPath(), JSON.stringify(entries, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
247
+ tryChmod(pendingCloudPath(), 0o600);
248
+ }
@@ -0,0 +1,50 @@
1
+ // A record of tool-name → tool definition. Was `ai`'s ToolSet in 0.2; MCP tool
2
+ // wiring is a Phase-5 concern, so a structural type suffices for the filter below.
3
+ type ToolSet = Record<string, unknown>;
4
+
5
+ // A routine's `tools` field mixes builtin tool names with MCP selectors. MCP tools
6
+ // are namespaced "<server>__<tool>" (see adaptMcpTools), and no builtin name contains
7
+ // "__", so the separator is unambiguous: entries with "__" are MCP selectors — an
8
+ // exact tool name or a per-server wildcard "<server>__*" — everything else is a
9
+ // builtin allowlist entry.
10
+
11
+ export interface RoutineToolSplit {
12
+ // Builtin tool names (read, glob, ...). Empty → caller falls back to the safe set.
13
+ builtin: string[];
14
+ // MCP selectors: "<server>__<tool>" exact, or "<server>__*" for a whole server.
15
+ mcp: string[];
16
+ // Unique server prefixes from `mcp`, i.e. which servers need connecting at all.
17
+ servers: string[];
18
+ }
19
+
20
+ export function splitRoutineTools(tools?: string[]): RoutineToolSplit {
21
+ const builtin: string[] = [];
22
+ const mcp: string[] = [];
23
+ const servers = new Set<string>();
24
+ for (const t of tools ?? []) {
25
+ const sep = t.indexOf("__");
26
+ if (sep > 0) {
27
+ mcp.push(t);
28
+ servers.add(t.slice(0, sep));
29
+ } else {
30
+ builtin.push(t);
31
+ }
32
+ }
33
+ return { builtin, mcp, servers: [...servers] };
34
+ }
35
+
36
+ // Does a namespaced MCP tool name match a selector? Exact match, or "<server>__*"
37
+ // matching any tool on that server.
38
+ export function matchesSelector(name: string, selector: string): boolean {
39
+ if (selector.endsWith("__*")) return name.startsWith(selector.slice(0, -1));
40
+ return name === selector;
41
+ }
42
+
43
+ // Narrow a connected MCP toolset to the selected tools. Least privilege matters here:
44
+ // routine runs use the auto-approve gate, so anything left in this set fires without
45
+ // a human in the loop.
46
+ export function filterMcpTools(tools: ToolSet, selectors: string[]): ToolSet {
47
+ return Object.fromEntries(
48
+ Object.entries(tools).filter(([name]) => selectors.some((s) => matchesSelector(name, s))),
49
+ );
50
+ }
@@ -0,0 +1,41 @@
1
+ import { nextRun as cronNext, cronError } from "./cron.ts";
2
+ import { isRecurring, type Routine } from "./schema.ts";
3
+
4
+ // A routine's trigger: a recurring `cron` expression or a one-off `at` datetime.
5
+ type Trigger = Pick<Routine, "cron" | "at">;
6
+
7
+ // Validate the trigger, returning an error message or null. Enforces "exactly one"
8
+ // and that the chosen form parses.
9
+ export function triggerError(t: Trigger): string | null {
10
+ const hasCron = Boolean(t.cron);
11
+ const hasAt = Boolean(t.at);
12
+ if (hasCron === hasAt) return "set exactly one of `cron` (recurring) or `at` (one-off)";
13
+ if (hasCron) return cronError(t.cron!);
14
+ return Number.isNaN(Date.parse(t.at!)) ? `invalid datetime "${t.at}"` : null;
15
+ }
16
+
17
+ // The fire time to store as `nextRun`. For cron: the next match strictly after
18
+ // `from`. For a one-off: the fixed `at` time as-is (even if already past, so a
19
+ // missed one-off still fires once when the daemon comes back). Null if unparseable.
20
+ export function computeNextRun(t: Trigger, from: Date = new Date()): Date | null {
21
+ if (t.cron) return cronNext(t.cron, from);
22
+ if (t.at) {
23
+ const d = new Date(t.at);
24
+ return Number.isNaN(d.getTime()) ? null : d;
25
+ }
26
+ return null;
27
+ }
28
+
29
+ // After a run, what to persist. Recurring routines reschedule; one-offs disable
30
+ // themselves (they've now fired).
31
+ export function advanceAfterRun(routine: Routine, from: Date = new Date()): Partial<Routine> {
32
+ if (isRecurring(routine)) return { nextRun: computeNextRun(routine, from)?.toISOString() };
33
+ return { enabled: false, nextRun: undefined };
34
+ }
35
+
36
+ // Short human description of when a routine fires, for /routine listings.
37
+ export function describeTrigger(t: Trigger): string {
38
+ if (t.cron) return t.cron;
39
+ if (t.at) return `once @ ${new Date(t.at).toLocaleString()}`;
40
+ return "(no trigger)";
41
+ }