privateer-agent 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +86 -33
  2. package/package.json +1 -1
  3. package/src/auth/privateer.ts +71 -1
  4. package/src/commands/custom.ts +52 -4
  5. package/src/commands/registry.ts +124 -5
  6. package/src/components/App.tsx +222 -16
  7. package/src/components/ApprovalPrompt.tsx +15 -4
  8. package/src/components/Banner.tsx +3 -1
  9. package/src/components/ModelPicker.tsx +45 -12
  10. package/src/components/OptionPicker.tsx +134 -0
  11. package/src/components/Root.tsx +30 -9
  12. package/src/components/ToolCallView.tsx +4 -0
  13. package/src/components/Transcript.tsx +14 -7
  14. package/src/components/theme.ts +2 -0
  15. package/src/config/paths.ts +2 -0
  16. package/src/context/systemPrompt.ts +9 -0
  17. package/src/daemon/index.ts +322 -0
  18. package/src/daemon/ipc.ts +127 -0
  19. package/src/engine/errors.ts +10 -0
  20. package/src/main.tsx +43 -1
  21. package/src/mcp/client.ts +16 -1
  22. package/src/permissions/gate.ts +5 -0
  23. package/src/permissions/mode.ts +4 -0
  24. package/src/permissions/uiGate.ts +4 -3
  25. package/src/remote/relayClient.ts +76 -4
  26. package/src/routines/cron.ts +109 -0
  27. package/src/routines/delivery.ts +75 -0
  28. package/src/routines/schema.ts +65 -0
  29. package/src/routines/store.ts +205 -0
  30. package/src/routines/toolSelect.ts +48 -0
  31. package/src/routines/trigger.ts +41 -0
  32. package/src/session.ts +37 -12
  33. package/src/skills/installer.ts +222 -0
  34. package/src/skills/loader.ts +88 -0
  35. package/src/tools/askUser.ts +92 -0
  36. package/src/tools/context.ts +14 -0
  37. package/src/tools/index.ts +14 -0
  38. package/src/tools/routine.ts +110 -0
  39. package/src/tools/sendFileToClient.ts +55 -0
  40. package/src/tools/skill.ts +44 -0
  41. package/src/tools/worktree.ts +145 -0
  42. package/src/util/images.ts +22 -0
@@ -0,0 +1,127 @@
1
+ import { createServer, createConnection, type Socket, type Server } from "node:net";
2
+ import { existsSync, unlinkSync, chmodSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { globalDir } from "../config/load.ts";
5
+ import type { Routine } from "../routines/schema.ts";
6
+
7
+ // The CLI/TUI talks to the resident daemon over a unix domain socket. The protocol
8
+ // is one JSON request per connection, answered with one JSON response, both
9
+ // newline-terminated. Kept tiny and local — nothing crosses the machine boundary.
10
+
11
+ export function daemonSocketPath(): string {
12
+ return join(globalDir(), "daemon.sock");
13
+ }
14
+
15
+ export type IpcRequest =
16
+ | { cmd: "status" }
17
+ | { cmd: "list" }
18
+ | { cmd: "add"; routine: Routine }
19
+ | { cmd: "remove"; idOrName: string }
20
+ | { cmd: "pause"; idOrName: string }
21
+ | { cmd: "resume"; idOrName: string }
22
+ | { cmd: "run-now"; idOrName: string }
23
+ | { cmd: "reload" };
24
+
25
+ export interface IpcResponse {
26
+ ok: boolean;
27
+ message?: string;
28
+ routines?: Routine[];
29
+ // Daemon liveness/uptime for `status`.
30
+ pid?: number;
31
+ uptimeSec?: number;
32
+ }
33
+
34
+ export type IpcHandler = (req: IpcRequest) => Promise<IpcResponse> | IpcResponse;
35
+
36
+ // Start the daemon-side socket server. Returns the Server so the caller can close it.
37
+ export function startIpcServer(handler: IpcHandler): Server {
38
+ const path = daemonSocketPath();
39
+ // A stale socket file from a previous crash would block bind; remove it first.
40
+ if (existsSync(path)) {
41
+ try {
42
+ unlinkSync(path);
43
+ } catch {
44
+ /* ignore — bind will surface a clearer error */
45
+ }
46
+ }
47
+ const server = createServer((sock: Socket) => {
48
+ let buf = "";
49
+ sock.on("data", (chunk) => {
50
+ buf += chunk.toString("utf8");
51
+ const nl = buf.indexOf("\n");
52
+ if (nl < 0) return; // wait for the full line
53
+ const line = buf.slice(0, nl);
54
+ void (async () => {
55
+ let res: IpcResponse;
56
+ try {
57
+ res = await handler(JSON.parse(line) as IpcRequest);
58
+ } catch (err) {
59
+ res = { ok: false, message: err instanceof Error ? err.message : String(err) };
60
+ }
61
+ sock.end(JSON.stringify(res) + "\n");
62
+ })();
63
+ });
64
+ sock.on("error", () => sock.destroy());
65
+ });
66
+ server.listen(path, () => {
67
+ try {
68
+ chmodSync(path, 0o600); // owner-only IPC endpoint
69
+ } catch {
70
+ /* non-POSIX — best effort */
71
+ }
72
+ });
73
+ return server;
74
+ }
75
+
76
+ // Client side: send one request, resolve with the response. Rejects if the daemon
77
+ // isn't running (no socket / connection refused) so callers can offer to start it.
78
+ export function sendToDaemon(req: IpcRequest, timeoutMs = 5_000): Promise<IpcResponse> {
79
+ const path = daemonSocketPath();
80
+ return new Promise<IpcResponse>((resolve, reject) => {
81
+ if (!existsSync(path)) {
82
+ reject(new DaemonNotRunningError());
83
+ return;
84
+ }
85
+ const sock = createConnection(path);
86
+ let buf = "";
87
+ const timer = setTimeout(() => {
88
+ sock.destroy();
89
+ reject(new Error("daemon did not respond in time"));
90
+ }, timeoutMs);
91
+ sock.on("connect", () => sock.end(JSON.stringify(req) + "\n"));
92
+ sock.on("data", (chunk) => {
93
+ buf += chunk.toString("utf8");
94
+ });
95
+ sock.on("end", () => {
96
+ clearTimeout(timer);
97
+ try {
98
+ resolve(JSON.parse(buf.trim()) as IpcResponse);
99
+ } catch {
100
+ reject(new Error("malformed response from daemon"));
101
+ }
102
+ });
103
+ sock.on("error", (err: NodeJS.ErrnoException) => {
104
+ clearTimeout(timer);
105
+ // ECONNREFUSED means a stale socket file with no listener behind it.
106
+ if (err.code === "ENOENT" || err.code === "ECONNREFUSED") reject(new DaemonNotRunningError());
107
+ else reject(err);
108
+ });
109
+ });
110
+ }
111
+
112
+ export class DaemonNotRunningError extends Error {
113
+ constructor() {
114
+ super("Privateer daemon is not running. Start it with `privateer daemon`.");
115
+ this.name = "DaemonNotRunningError";
116
+ }
117
+ }
118
+
119
+ // Convenience: is the daemon reachable right now?
120
+ export async function daemonIsRunning(): Promise<boolean> {
121
+ try {
122
+ const res = await sendToDaemon({ cmd: "status" }, 2_000);
123
+ return res.ok;
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
@@ -153,6 +153,16 @@ export function describeError(err: unknown): DescribedError {
153
153
  hint: "Upgrade or top up your Privateer account, or run /provider to use your own API key.",
154
154
  });
155
155
  }
156
+ // Privateer machine-login expiry (thrown by the session spawn after the
157
+ // server rejects the parent refresh token). The stored credentials are
158
+ // already wiped; the only fix is a fresh /login, so say exactly that and
159
+ // never mark it retryable.
160
+ if (/privateer session expired/i.test(text)) {
161
+ return out({
162
+ message: "Your Privateer session expired — this terminal was signed out.",
163
+ hint: "Run /login to sign back in to your Privateer account.",
164
+ });
165
+ }
156
166
  if (status === 401 || status === 403) {
157
167
  return out({
158
168
  message: `Authentication failed${forProvider} (${status}).`,
package/src/main.tsx CHANGED
@@ -1,13 +1,18 @@
1
1
  import React from "react";
2
2
  import { render } from "ink";
3
3
  import { Command } from "commander";
4
+ import { spawn } from "node:child_process";
5
+ import { openSync } from "node:fs";
6
+ import { join } from "node:path";
4
7
  import { Root } from "./components/Root.tsx";
5
8
  import { NAME, VERSION, DESCRIPTION } from "./version.ts";
6
- import { loadConfig } from "./config/load.ts";
9
+ import { loadConfig, globalDir } from "./config/load.ts";
7
10
  import { createSession } from "./session.ts";
8
11
  import { loadLatest, loadSession } from "./memory/store.ts";
9
12
  import { configuredProviders } from "./providers/resolve.ts";
10
13
  import { describeError } from "./engine/errors.ts";
14
+ import { runDaemon } from "./daemon/index.ts";
15
+ import { revokeChildSession } from "./auth/privateer.ts";
11
16
 
12
17
  // Set while Ink owns the screen. A stray unhandled rejection while the TUI is up
13
18
  // must NOT reach stdout/stderr — Node's default printer dumps the whole error
@@ -58,6 +63,16 @@ async function main() {
58
63
  .option("-r, --resume <id>", "resume a specific session by id (printed on exit)")
59
64
  .option("--onboard", "run the provider/key setup flow")
60
65
  .action(async (promptParts: string[], options: CliOptions) => {
66
+ // Terminal-window close (SIGHUP) or a kill (SIGTERM) bypasses the normal
67
+ // exit path below — revoke this terminal's Privateer session first so it
68
+ // drops off the app's Linked Devices immediately instead of lingering
69
+ // until server-side expiry. Installing a handler replaces Node's default
70
+ // terminate-on-signal, so exit explicitly with the conventional code.
71
+ const revokeAndExit = (code: number) => () => {
72
+ void revokeChildSession().finally(() => process.exit(code));
73
+ };
74
+ process.on("SIGHUP", revokeAndExit(129));
75
+ process.on("SIGTERM", revokeAndExit(143));
61
76
  try {
62
77
  if (options.cwd) process.chdir(options.cwd);
63
78
  const config = loadConfig();
@@ -79,6 +94,7 @@ async function main() {
79
94
 
80
95
  if (options.print) {
81
96
  await runPrint(modelSpec, promptParts.join(" ").trim(), config.confineToCwd);
97
+ await revokeChildSession();
82
98
  return;
83
99
  }
84
100
 
@@ -101,6 +117,11 @@ async function main() {
101
117
  await waitUntilExit();
102
118
  tuiActive = false;
103
119
 
120
+ // This terminal is done — release its Privateer session so it leaves the
121
+ // app's Linked Devices right away. Started before the resume hint prints
122
+ // and awaited after, so it doesn't delay the output.
123
+ const revoked = revokeChildSession();
124
+
104
125
  // On exit, print a hash that resumes this conversation later (à la Claude
105
126
  // Code). The latest persisted session carries the id used this run; it only
106
127
  // exists once at least one turn has been saved.
@@ -108,6 +129,7 @@ async function main() {
108
129
  if (last && last.messages.length > 0) {
109
130
  process.stdout.write(`\nResume this session: ${NAME} --resume ${last.id}\n`);
110
131
  }
132
+ await revoked;
111
133
  } catch (err) {
112
134
  // Configuration/resolution errors are expected and user-facing — print them
113
135
  // cleanly without a stack trace.
@@ -116,6 +138,26 @@ async function main() {
116
138
  }
117
139
  });
118
140
 
141
+ // The scheduler daemon: a resident process that fires routines on their cron
142
+ // schedule. Runs in the foreground by default; --detach forks a background copy.
143
+ program
144
+ .command("daemon")
145
+ .description("run the scheduler that fires saved routines on their cron schedule")
146
+ .option("--detach", "start the daemon in the background and return")
147
+ .action((opts: { detach?: boolean }) => {
148
+ if (opts.detach) {
149
+ const logPath = join(globalDir(), "daemon.log");
150
+ const out = openSync(logPath, "a");
151
+ // Re-invoke this same runtime + script without --detach, fully detached.
152
+ const args = process.argv.slice(1).filter((a) => a !== "--detach");
153
+ const child = spawn(process.argv[0], args, { detached: true, stdio: ["ignore", out, out] });
154
+ child.unref();
155
+ process.stdout.write(`Daemon started in background (pid ${child.pid}).\nLogs: ${logPath}\n`);
156
+ return;
157
+ }
158
+ runDaemon();
159
+ });
160
+
119
161
  await program.parseAsync(process.argv);
120
162
  }
121
163
 
package/src/mcp/client.ts CHANGED
@@ -23,6 +23,10 @@ export interface McpToolDef {
23
23
  name: string;
24
24
  description?: string;
25
25
  inputSchema?: Record<string, unknown>;
26
+ // Standard MCP behavioral hints. We use `destructiveHint`/`readOnlyHint` to
27
+ // decide whether a tool may auto-approve: a tool that performs an irreversible
28
+ // external action marks itself destructive so it ALWAYS reaches the human.
29
+ annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean };
26
30
  }
27
31
 
28
32
  // Read mcp.json from project then user scope (project overrides). Accepts either a
@@ -148,7 +152,12 @@ export class McpClient {
148
152
  async listTools(): Promise<McpToolDef[]> {
149
153
  const res = await this.client!.listTools();
150
154
  return Array.isArray(res?.tools)
151
- ? res.tools.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema as Record<string, unknown> }))
155
+ ? res.tools.map((t) => ({
156
+ name: t.name,
157
+ description: t.description,
158
+ inputSchema: t.inputSchema as Record<string, unknown>,
159
+ annotations: t.annotations as McpToolDef["annotations"],
160
+ }))
152
161
  : [];
153
162
  }
154
163
 
@@ -184,6 +193,11 @@ export function adaptMcpTools(
184
193
  const set: ToolSet = {};
185
194
  for (const d of defs) {
186
195
  const name = `${server}__${d.name}`;
196
+ // A mutating tool (e.g. send email, delete file) marks itself destructive. We
197
+ // map that to `alwaysAsk`, which the gate never auto-approves — so it always
198
+ // prompts the human (phone on remote turns, terminal otherwise) even under
199
+ // bypass mode or the allowlist. Read-only tools follow the normal policy.
200
+ const destructive = d.annotations?.destructiveHint === true && d.annotations?.readOnlyHint !== true;
187
201
  set[name] = tool({
188
202
  description: d.description ?? `${d.name} (MCP server: ${server})`,
189
203
  inputSchema: jsonSchema((d.inputSchema as any) ?? { type: "object", properties: {} }),
@@ -193,6 +207,7 @@ export function adaptMcpTools(
193
207
  kind: "fetch",
194
208
  title: `MCP ${server}: ${d.name}`,
195
209
  detail: JSON.stringify(args ?? {}).slice(0, 120),
210
+ alwaysAsk: destructive,
196
211
  });
197
212
  if (decision === "deny") throw new PermissionDeniedError(name);
198
213
  return client.callTool(d.name, args);
@@ -14,6 +14,11 @@ export interface PermissionRequest {
14
14
  title: string; // short action label, e.g. "Run command"
15
15
  detail: string; // the command, or file path + change preview
16
16
  protected?: boolean; // target is a guarded file: never auto-approve, always prompt
17
+ // Always require a human decision, ABOVE bypass mode and the allowlist (like a
18
+ // dangerous shell command). Set for MCP tools that declare themselves
19
+ // destructive (destructiveHint), so even a "take no prisoners" run can't fire
20
+ // an irreversible external action silently. The decision is never remembered.
21
+ alwaysAsk?: boolean;
17
22
  // Target resolves outside the working directory: never auto-approve (unless bypass),
18
23
  // always prompt. `path` carries the absolute target so "always" can remember its dir.
19
24
  outside?: boolean;
@@ -27,6 +27,10 @@ export function decideAuto(
27
27
  // Dangerous shell (destructive / secret-exfil) always confirms — this sits
28
28
  // above bypass and the allowlist so an injected command can't run silently.
29
29
  if (req.kind === "bash" && isDangerousCommand(req.detail, denylist)) return "ask";
30
+ // Explicitly destructive actions (e.g. an MCP tool that declares destructiveHint)
31
+ // always confirm too — also above bypass, so "skip permissions" can't fire them
32
+ // blind.
33
+ if (req.alwaysAsk) return "ask";
30
34
  if (mode === "bypass") return "allow";
31
35
  // Access outside the working directory always confirms (the user has to explicitly
32
36
  // allow leaving cwd), even under acceptEdits or the allowlist.
@@ -48,9 +48,10 @@ export class ModeGate implements PermissionGate {
48
48
 
49
49
  if (auto !== "ask") return auto;
50
50
 
51
- // A dangerous command can be approved once, but is never remembered: adding
52
- // it to the allowlist would let a later injected variant slip through.
53
- const dangerous = req.kind === "bash" && isDangerousCommand(req.detail, denylist);
51
+ // A dangerous command (or an always-ask destructive action) can be approved
52
+ // once, but is never remembered: adding it to the allowlist or relaxing the
53
+ // mode would let a later variant slip through.
54
+ const dangerous = req.alwaysAsk === true || (req.kind === "bash" && isDangerousCommand(req.detail, denylist));
54
55
 
55
56
  const outcome = await this.deps.ask(req);
56
57
  if (outcome === "deny") return "deny";
@@ -54,6 +54,11 @@ 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.
@@ -63,6 +68,8 @@ export interface RelayCallbacks {
63
68
  onAttachment: (file: { name: string; mediaType: string; base64: string }) => void;
64
69
  // Surface a one-line status/notice in the TUI.
65
70
  onStatus?: (text: string) => void;
71
+ // The relay socket closed (controller no longer reachable until reconnect).
72
+ onDisconnected?: () => void;
66
73
  }
67
74
 
68
75
  const RECONNECT_MS = 3000;
@@ -71,6 +78,9 @@ const RECONNECT_MS = 3000;
71
78
  // memory with a lying `size` or a flood of concurrent transfers.
72
79
  const MAX_ATTACH_BYTES = 10 * 1024 * 1024; // 10 MB per file
73
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;
74
84
  // Coalesce streaming deltas so we don't emit one WS frame per token.
75
85
  const TEXT_FLUSH_MS = 60;
76
86
 
@@ -125,9 +135,12 @@ export class RelayClient {
125
135
  private bufKind: "text" | "reasoning" | null = null;
126
136
  private buf = "";
127
137
  private flushTimer: ReturnType<typeof setTimeout> | undefined;
128
- // Stable for this process so reconnects keep the same terminal identity.
129
- private readonly termId = randomUUID();
130
- private readonly label = terminalLabel();
138
+ // Stable for this process so reconnects keep the same terminal identity. Callers
139
+ // may pass a persisted id/label (e.g. the routines daemon, so it shows up as one
140
+ // recognizable "Privateer Routines" terminal across restarts instead of a fresh
141
+ // random one each time).
142
+ private readonly termId: string;
143
+ private readonly label: string;
131
144
  // In-progress file transfers from the app, keyed by the controller's attachment
132
145
  // id. Reassembled from attach_begin/chunk/end frames, then handed to onAttachment.
133
146
  private readonly incoming = new Map<
@@ -135,7 +148,13 @@ export class RelayClient {
135
148
  { name: string; mediaType: string; chunks: string[]; received: number }
136
149
  >();
137
150
 
138
- constructor(private readonly cb: RelayCallbacks) {}
151
+ constructor(
152
+ private readonly cb: RelayCallbacks,
153
+ opts?: { termId?: string; label?: string },
154
+ ) {
155
+ this.termId = opts?.termId ?? randomUUID();
156
+ this.label = opts?.label ?? terminalLabel();
157
+ }
139
158
 
140
159
  async start(): Promise<void> {
141
160
  this.closed = false;
@@ -182,6 +201,7 @@ export class RelayClient {
182
201
  ws.on("message", (data) => this.handle(data));
183
202
  ws.on("close", () => {
184
203
  if (this.ws === ws) this.ws = null;
204
+ this.cb.onDisconnected?.();
185
205
  if (!this.closed) {
186
206
  this.cb.onStatus?.(
187
207
  opened
@@ -247,6 +267,9 @@ export class RelayClient {
247
267
  case "interrupt":
248
268
  this.cb.onInterrupt();
249
269
  break;
270
+ case "terminate":
271
+ this.cb.onTerminate?.();
272
+ break;
250
273
  case "approval_response":
251
274
  if (frame.id) this.cb.onApprovalResponse(frame.id, frame.decision === "deny" ? "deny" : "allow");
252
275
  break;
@@ -320,6 +343,23 @@ export class RelayClient {
320
343
 
321
344
  // ── agent → controller ──────────────────────────────────────────────────────
322
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
+
323
363
  sendEvent(ev: EngineEvent): void {
324
364
  if (ev.type === "text") return this.bufferDelta("text", ev.text);
325
365
  if (ev.type === "reasoning") return this.bufferDelta("reasoning", ev.text);
@@ -335,6 +375,38 @@ export class RelayClient {
335
375
  this.rawSend({ type: "snapshot", entries: trimmed });
336
376
  }
337
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
+
338
410
  requestApproval(id: string, req: PermissionRequest): void {
339
411
  this.rawSend({
340
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
+ }