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.
- package/README.md +143 -398
- package/bin/privateer-tui +86 -0
- package/bin/privateer.mjs +17 -6
- package/bin/pv +28 -0
- package/package.json +26 -23
- package/src/auth/privateer.ts +128 -1
- package/src/boot.ts +43 -0
- package/src/bridge/engineAdapter.ts +182 -0
- package/src/cli/chat.ts +243 -0
- package/src/config/paths.ts +24 -44
- package/src/config/permissionMode.ts +5 -0
- package/src/crypto/outboxSeal.ts +61 -0
- package/src/daemon/index.ts +405 -0
- package/src/daemon/ipc.ts +127 -0
- package/src/engine/errors.ts +95 -45
- package/src/engine/router.ts +11 -165
- package/src/ext/permissionGate.ts +216 -0
- package/src/main.ts +32 -0
- package/src/permissions/classify.ts +172 -0
- package/src/permissions/gate.ts +13 -11
- package/src/permissions/mode.ts +9 -1
- package/src/permissions/{uiGate.ts → modeGate.ts} +22 -6
- package/src/providers/account.ts +170 -0
- package/src/providers/catalog.ts +73 -61
- package/src/providers/genModelsJson.ts +97 -0
- package/src/remote/relayClient.ts +91 -4
- package/src/remote/remoteBridge.ts +152 -0
- package/src/routines/cron.ts +109 -0
- package/src/routines/delivery.ts +184 -0
- package/src/routines/schema.ts +84 -0
- package/src/routines/store.ts +248 -0
- package/src/routines/toolSelect.ts +50 -0
- package/src/routines/trigger.ts +41 -0
- package/src/session.ts +84 -251
- package/src/tools/routine.ts +129 -0
- package/src/tools/saveAttachment.ts +39 -42
- package/src/tools/sendFile.ts +75 -0
- package/src/util/attachmentStore.ts +18 -35
- package/src/util/redact.ts +33 -3
- package/LICENSE +0 -21
- package/src/agents/loader.ts +0 -49
- package/src/commands/custom.ts +0 -75
- package/src/commands/registry.ts +0 -499
- package/src/components/AgentGroupView.tsx +0 -104
- package/src/components/App.tsx +0 -1420
- package/src/components/ApprovalPrompt.tsx +0 -38
- package/src/components/Banner.tsx +0 -76
- package/src/components/Markdown.tsx +0 -183
- package/src/components/ModeHint.tsx +0 -40
- package/src/components/ModelPicker.tsx +0 -269
- package/src/components/Onboarding.tsx +0 -203
- package/src/components/PlanConfirm.tsx +0 -37
- package/src/components/PrivateerLogin.tsx +0 -109
- package/src/components/PromptInput.tsx +0 -602
- package/src/components/RewindPicker.tsx +0 -69
- package/src/components/Root.tsx +0 -95
- package/src/components/SessionPicker.tsx +0 -64
- package/src/components/StatusBar.tsx +0 -131
- package/src/components/TodoPanel.tsx +0 -36
- package/src/components/ToolCallView.tsx +0 -109
- package/src/components/Transcript.tsx +0 -203
- package/src/components/figures.ts +0 -14
- package/src/components/promptModel.ts +0 -73
- package/src/components/spinnerVerbs.ts +0 -46
- package/src/components/theme.ts +0 -55
- package/src/components/types.ts +0 -34
- package/src/components/useTeeShield.ts +0 -104
- package/src/components/useTerminalWidth.ts +0 -24
- package/src/components/useZdrShield.ts +0 -126
- package/src/config/load.ts +0 -115
- package/src/config/schema.ts +0 -94
- package/src/context/outputStyles.ts +0 -42
- package/src/context/projectInfo.ts +0 -59
- package/src/context/systemPrompt.ts +0 -167
- package/src/engine/QueryEngine.ts +0 -399
- package/src/hooks/engine.ts +0 -155
- package/src/main.tsx +0 -167
- package/src/mcp/client.ts +0 -236
- package/src/mcp/oauth.ts +0 -245
- package/src/memory/auto.ts +0 -146
- package/src/memory/checkpoints.ts +0 -227
- package/src/memory/store.ts +0 -127
- package/src/providers/attestation.ts +0 -149
- package/src/providers/capabilities.ts +0 -104
- package/src/providers/models.ts +0 -183
- package/src/providers/registry.ts +0 -71
- package/src/providers/resolve.ts +0 -78
- package/src/tools/bash.ts +0 -98
- package/src/tools/context.ts +0 -114
- package/src/tools/edit.ts +0 -67
- package/src/tools/exec.ts +0 -60
- package/src/tools/glob.ts +0 -39
- package/src/tools/grep.ts +0 -86
- package/src/tools/index.ts +0 -69
- package/src/tools/memory.ts +0 -53
- package/src/tools/processRegistry.ts +0 -77
- package/src/tools/read.ts +0 -42
- package/src/tools/task.ts +0 -52
- package/src/tools/todo.ts +0 -36
- package/src/tools/todoStore.ts +0 -31
- package/src/tools/walk.ts +0 -44
- package/src/tools/web.ts +0 -145
- package/src/tools/write.ts +0 -40
- package/src/util/images.ts +0 -356
- package/src/util/limit.ts +0 -32
- 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
|
+
}
|