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,134 @@
1
+ import React, { useState } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import type { UserQuestion, UserAnswer } from "../tools/askUser.ts";
4
+ import { theme } from "./theme.ts";
5
+
6
+ // Interactive picker shown when the agent calls `ask_user` to choose between
7
+ // competing approaches. The turn is blocked on the human (like the approval prompt).
8
+ // Keys: ↑/↓ move, 1–N jump, Enter confirm, e (or the last row) write a custom answer,
9
+ // Esc dismiss. In multiSelect mode, Space/number toggles and Enter confirms the set.
10
+ export function OptionPicker({
11
+ question,
12
+ onRespond,
13
+ }: {
14
+ question: UserQuestion;
15
+ onRespond: (answer: UserAnswer) => void;
16
+ }) {
17
+ const { options, multiSelect } = question;
18
+ const otherIndex = options.length; // the trailing "write your own" row
19
+ const total = options.length + 1;
20
+ const [cursor, setCursor] = useState(0);
21
+ const [checked, setChecked] = useState<Set<number>>(new Set());
22
+ const [mode, setMode] = useState<"list" | "custom">("list");
23
+ const [draft, setDraft] = useState("");
24
+
25
+ function toggle(i: number) {
26
+ setChecked((prev) => {
27
+ const next = new Set(prev);
28
+ if (next.has(i)) next.delete(i);
29
+ else next.add(i);
30
+ return next;
31
+ });
32
+ }
33
+
34
+ function confirm() {
35
+ if (cursor === otherIndex) {
36
+ setMode("custom");
37
+ return;
38
+ }
39
+ if (multiSelect) {
40
+ const picks = checked.size > 0 ? [...checked] : [cursor];
41
+ onRespond({ kind: "selected", indices: picks.sort((a, b) => a - b) });
42
+ } else {
43
+ onRespond({ kind: "selected", indices: [cursor] });
44
+ }
45
+ }
46
+
47
+ useInput((input, key) => {
48
+ if (mode === "custom") {
49
+ if (key.escape) {
50
+ setMode("list");
51
+ setDraft("");
52
+ } else if (key.return) {
53
+ const text = draft.trim();
54
+ if (text) onRespond({ kind: "custom", text });
55
+ } else if (key.backspace || key.delete) {
56
+ setDraft((d) => d.slice(0, -1));
57
+ } else if (input && !key.ctrl && !key.meta) {
58
+ setDraft((d) => d + input);
59
+ }
60
+ return;
61
+ }
62
+
63
+ if (key.upArrow) setCursor((c) => (c - 1 + total) % total);
64
+ else if (key.downArrow) setCursor((c) => (c + 1) % total);
65
+ else if (key.escape) onRespond({ kind: "dismissed" });
66
+ else if (input === "e") setMode("custom");
67
+ else if (input >= "1" && input <= "9") {
68
+ const i = Number(input) - 1;
69
+ if (i < options.length) {
70
+ if (multiSelect) {
71
+ setCursor(i);
72
+ toggle(i);
73
+ } else {
74
+ onRespond({ kind: "selected", indices: [i] });
75
+ }
76
+ }
77
+ } else if (input === " " && multiSelect && cursor < options.length) {
78
+ toggle(cursor);
79
+ } else if (key.return) {
80
+ confirm();
81
+ }
82
+ });
83
+
84
+ if (mode === "custom") {
85
+ return (
86
+ <Box flexDirection="column" marginTop={1} borderStyle="round" borderColor={theme.accent} paddingX={1}>
87
+ <Text bold color={theme.accent}>
88
+ {question.question}
89
+ </Text>
90
+ <Text>
91
+ <Text color={theme.dim}>your answer: </Text>
92
+ {draft}
93
+ <Text color={theme.accent}>▏</Text>
94
+ </Text>
95
+ <Text dimColor>enter submit · esc back to options</Text>
96
+ </Box>
97
+ );
98
+ }
99
+
100
+ return (
101
+ <Box flexDirection="column" marginTop={1} borderStyle="round" borderColor={theme.accent} paddingX={1}>
102
+ <Text bold color={theme.accent}>
103
+ {question.question}
104
+ </Text>
105
+ {options.map((o, i) => {
106
+ const active = i === cursor;
107
+ const box = multiSelect ? (checked.has(i) ? "[x] " : "[ ] ") : "";
108
+ return (
109
+ <Box key={i} flexDirection="column" marginTop={1}>
110
+ <Text color={active ? theme.accent : undefined}>
111
+ <Text color={active ? theme.accent : theme.dim}>{active ? "❯ " : " "}</Text>
112
+ <Text color={theme.dim}>{i + 1}. </Text>
113
+ {box}
114
+ <Text bold={active}>{o.label}</Text>
115
+ </Text>
116
+ {o.description ? <Text dimColor>{" " + o.description}</Text> : null}
117
+ </Box>
118
+ );
119
+ })}
120
+ <Box marginTop={1}>
121
+ <Text color={cursor === otherIndex ? theme.accent : undefined}>
122
+ <Text color={cursor === otherIndex ? theme.accent : theme.dim}>{cursor === otherIndex ? "❯ " : " "}</Text>
123
+ <Text color={theme.dim}>e. </Text>
124
+ <Text bold={cursor === otherIndex}>Something else — write your own answer</Text>
125
+ </Text>
126
+ </Box>
127
+ <Text dimColor>
128
+ {multiSelect
129
+ ? "↑/↓ move · space toggle · enter confirm · esc skip"
130
+ : "↑/↓ move · 1–" + options.length + " pick · enter confirm · esc skip"}
131
+ </Text>
132
+ </Box>
133
+ );
134
+ }
@@ -1,4 +1,5 @@
1
1
  import React, { useState } from "react";
2
+ import { useStdout } from "ink";
2
3
  import { App } from "./App.tsx";
3
4
  import { Onboarding, type OnboardingResult } from "./Onboarding.tsx";
4
5
  import { PrivateerLogin } from "./PrivateerLogin.tsx";
@@ -30,6 +31,17 @@ export function Root({
30
31
  const [modelSpec, setModelSpec] = useState(initialModel);
31
32
  const [onboarding, setOnboarding] = useState(startInOnboarding);
32
33
  const [loggingIn, setLoggingIn] = useState(false);
34
+ const { stdout } = useStdout();
35
+
36
+ // The App's banner/transcript lives in Ink's <Static> region, which stays in the
37
+ // terminal scrollback even after the component unmounts. Swapping screens (login,
38
+ // onboarding) and back therefore stacks a second banner under the stale one. Wipe
39
+ // screen + scrollback around every top-level swap so each view starts on a clean
40
+ // page — same trick App uses when the terminal is resized.
41
+ function swapScreen(update: () => void) {
42
+ stdout?.write("\x1b[2J\x1b[3J\x1b[H"); // clear screen + scrollback, home cursor
43
+ update();
44
+ }
33
45
 
34
46
  // Providers that already have credentials — pre-checked when re-running onboarding.
35
47
  const configured = configuredProviders(config)
@@ -47,9 +59,11 @@ export function Root({
47
59
  } catch {
48
60
  /* non-fatal: keys just won't persist to disk this run */
49
61
  }
50
- setConfig(next);
51
- setModelSpec(result.defaultModel);
52
- setOnboarding(false);
62
+ swapScreen(() => {
63
+ setConfig(next);
64
+ setModelSpec(result.defaultModel);
65
+ setOnboarding(false);
66
+ });
53
67
  }
54
68
 
55
69
  // After a successful account login, switch to the Privateer-billed model so the
@@ -68,13 +82,20 @@ export function Root({
68
82
  } catch {
69
83
  /* non-fatal: model choice just won't persist this run */
70
84
  }
71
- setConfig(next);
72
- setModelSpec(spec);
73
- setLoggingIn(false);
85
+ swapScreen(() => {
86
+ setConfig(next);
87
+ setModelSpec(spec);
88
+ setLoggingIn(false);
89
+ });
74
90
  }
75
91
 
76
92
  if (loggingIn) {
77
- return <PrivateerLogin onComplete={finishLogin} onCancel={() => setLoggingIn(false)} />;
93
+ return (
94
+ <PrivateerLogin
95
+ onComplete={finishLogin}
96
+ onCancel={() => swapScreen(() => setLoggingIn(false))}
97
+ />
98
+ );
78
99
  }
79
100
 
80
101
  if (onboarding) {
@@ -88,8 +109,8 @@ export function Root({
88
109
  config={config}
89
110
  cwd={cwd}
90
111
  resume={resume}
91
- onLogin={() => setOnboarding(true)}
92
- onPrivateerLogin={() => setLoggingIn(true)}
112
+ onLogin={() => swapScreen(() => setOnboarding(true))}
113
+ onPrivateerLogin={() => swapScreen(() => setLoggingIn(true))}
93
114
  />
94
115
  );
95
116
  }
@@ -21,6 +21,10 @@ function summarizeInput(name: string, input: unknown): string {
21
21
  return String(o.pattern ?? "") + (o.glob ? ` (${o.glob})` : "");
22
22
  case "task":
23
23
  return String(o.description ?? o.subagent_type ?? "");
24
+ case "ask_user":
25
+ return String(o.question ?? "");
26
+ case "worktree":
27
+ return String(o.action ?? "") + (o.name ? ` ${o.name}` : "");
24
28
  default:
25
29
  try {
26
30
  return JSON.stringify(o);
@@ -102,20 +102,26 @@ export function EntryView({
102
102
  // Split off a trailing `recap:` line so it can render dimmed below the
103
103
  // response body. The model is asked to end each turn with one such line.
104
104
  const { body, recap } = splitRecap(entry.text);
105
+ const hasBody = body.trim().length > 0;
106
+ // Nothing to paint: a whitespace-only entry (possible in transcripts
107
+ // persisted before the turn loop filtered them) would render a bare ⏺.
108
+ if (!hasBody && !recap) return null;
105
109
  // ⏺ bullet in its own column so wrapped lines align under the text.
106
110
  return (
107
111
  <Box marginTop={1} flexDirection="column">
108
- <Box>
109
- <Text color={theme.accent}>{BULLET} </Text>
110
- <Box width={bodyWidth}>
111
- <Markdown text={body} />
112
+ {hasBody && (
113
+ <Box>
114
+ <Text color={theme.accent}>{BULLET} </Text>
115
+ <Box width={bodyWidth}>
116
+ <Markdown text={body} />
117
+ </Box>
112
118
  </Box>
113
- </Box>
119
+ )}
114
120
  {recap && (
115
- <Box marginTop={1}>
121
+ <Box marginTop={hasBody ? 1 : 0}>
116
122
  <Text color={theme.dim}>{" "}</Text>
117
123
  <Box width={bodyWidth}>
118
- <Text color="white">
124
+ <Text color={theme.dim} dimColor italic>
119
125
  {recap}
120
126
  </Text>
121
127
  </Box>
@@ -127,6 +133,7 @@ export function EntryView({
127
133
  case "thinking": {
128
134
  // The model's reasoning, rendered dimmed under a thinking mark. When
129
135
  // collapsed (Ctrl+O), show just a one-line summary instead of the full text.
136
+ if (entry.text.trim() === "") return null;
130
137
  if (collapsed) {
131
138
  const lineCount = entry.text.trim() === "" ? 0 : entry.text.trim().split("\n").length;
132
139
  return (
@@ -50,6 +50,8 @@ const TOOL_DISPLAY: Record<string, string> = {
50
50
  task: "Task",
51
51
  web_fetch: "WebFetch",
52
52
  web_search: "WebSearch",
53
+ ask_user: "AskUser",
54
+ worktree: "Worktree",
53
55
  };
54
56
 
55
57
  export const toolDisplayName = (name: string): string => TOOL_DISPLAY[name] ?? name;
@@ -35,6 +35,7 @@ export interface ScopePaths {
35
35
  settingsLocal: string; // settings.local.json — gitignored local overrides
36
36
  commands: string; // commands/ — custom slash commands (M2)
37
37
  agents: string; // agents/ — custom subagents (M4)
38
+ skills: string; // skills/ — agent skills, one SKILL.md directory each
38
39
  outputStyles: string; // output-styles/ — persona prompts (M2)
39
40
  mcp: string; // mcp.json — MCP server declarations (M4)
40
41
  }
@@ -47,6 +48,7 @@ function scopePaths(dir: string): ScopePaths {
47
48
  settingsLocal: join(dir, "settings.local.json"),
48
49
  commands: join(dir, "commands"),
49
50
  agents: join(dir, "agents"),
51
+ skills: join(dir, "skills"),
50
52
  outputStyles: join(dir, "output-styles"),
51
53
  mcp: join(dir, "mcp.json"),
52
54
  };
@@ -51,6 +51,15 @@ before doing it.
51
51
  work is attributed to Privateer as a co-author:
52
52
  Co-Authored-By: Privateer <291203302+privateer-first-mate@users.noreply.github.com>
53
53
  - Use 'web_fetch' to read a known URL when the user provides one or you need current docs.
54
+ - When the implementation direction is genuinely ambiguous and the choice materially shapes the \
55
+ work — a structural/architectural fork, which library or data model to adopt, the scope of a \
56
+ change — don't silently guess. Lay out the realistic approaches and call 'ask_user' to let the \
57
+ user pick (2–4 options, most-recommended first, each with its trade-offs). Reserve it for \
58
+ consequential forks: for small, reversible choices, just make the call and proceed. After the \
59
+ user chooses, build that direction without re-litigating it.
60
+ - To try a risky or exploratory approach without disturbing the main tree, use 'worktree' to spin \
61
+ up an isolated branch + working copy, do the work there, and let the user compare its diff before \
62
+ keeping or discarding it.
54
63
  - Mutating actions (write/edit/bash) may require user approval; that's expected — proceed and let \
55
64
  the gate handle it.`;
56
65
 
@@ -0,0 +1,322 @@
1
+ import type { Server } from "node:net";
2
+ import type { ToolSet } from "ai";
3
+ import { loadConfig } from "../config/load.ts";
4
+ import { createSession } from "../session.ts";
5
+ import { autoApproveGate } from "../permissions/gate.ts";
6
+ import { loadMcpServers, connectMcpServers } from "../mcp/client.ts";
7
+ import { RelayClient } from "../remote/relayClient.ts";
8
+ import { hasCredentials, revokeChildSession } from "../auth/privateer.ts";
9
+ import {
10
+ loadRoutines,
11
+ upsertRoutine,
12
+ findRoutine,
13
+ removeRoutine,
14
+ addPendingRelay,
15
+ drainPendingRelay,
16
+ routineRelayId,
17
+ } from "../routines/store.ts";
18
+ import type { Routine } from "../routines/schema.ts";
19
+ import { triggerError, computeNextRun, advanceAfterRun } from "../routines/trigger.ts";
20
+ import { splitRoutineTools, filterMcpTools } from "../routines/toolSelect.ts";
21
+ import { deliver, type RelayPusher } from "../routines/delivery.ts";
22
+ import { startIpcServer, type IpcRequest, type IpcResponse } from "./ipc.ts";
23
+
24
+ // The safe, read-only-plus-web toolset for unattended runs. No write/edit/bash, so
25
+ // a routine firing with nobody watching can't mutate the filesystem or shell out.
26
+ const SAFE_TOOLS = ["read", "glob", "grep", "web_fetch", "web_search"];
27
+
28
+ const TICK_MS = 60_000; // scan for due routines once a minute
29
+
30
+ function log(msg: string): void {
31
+ process.stdout.write(`[${new Date().toISOString()}] ${msg}\n`);
32
+ }
33
+
34
+ // Render a run's result as a self-contained markdown document for file/notice output.
35
+ function formatResult(routine: Routine, body: string, status: "ok" | "error", error?: string): string {
36
+ const when = new Date().toISOString();
37
+ const head = `# ${routine.name}\n\n_${when} · ${status}${routine.model ? ` · ${routine.model}` : ""}_\n\n`;
38
+ if (status === "error") return `${head}**Run failed:** ${error ?? "unknown error"}\n\n${body}`.trimEnd() + "\n";
39
+ return `${head}${body.trim() || "(no output)"}\n`;
40
+ }
41
+
42
+ export class Daemon {
43
+ private server?: Server;
44
+ private timer?: ReturnType<typeof setInterval>;
45
+ private readonly startedAt = Date.now();
46
+ // Set of routine ids currently executing, so a slow run can't be re-entered by
47
+ // the next tick.
48
+ private readonly running = new Set<string>();
49
+ // Outbound relay connection to the Privateer server, opened lazily when a signed-in
50
+ // user has a routine that delivers over `relay`. Pushes results to an attached
51
+ // controller (e.g. the mobile app) in real time.
52
+ private relay?: RelayClient;
53
+ // Best-effort "is a controller attached right now?" — set on controller_attached,
54
+ // cleared when our socket drops. Used to decide push-live vs queue-for-later.
55
+ private controllerAttached = false;
56
+ // The app sent `terminate` (End remote access): keep the relay down until the
57
+ // daemon restarts, even if routine edits re-run syncRelay. Results still queue
58
+ // durably and deliver over the other channels meanwhile.
59
+ private relayTerminated = false;
60
+ // Push a result live if a controller is attached; otherwise persist it to the
61
+ // pending queue so it flushes the moment the app next attaches. Either path is
62
+ // durable, so delivery treats both as handled.
63
+ private readonly pushRelay: RelayPusher = (routine, content) => {
64
+ if (this.controllerAttached && this.relay?.sendRoutineResult(routine.name, content)) return "live";
65
+ addPendingRelay({ routine: routine.name, at: new Date().toISOString(), content });
66
+ return "queued";
67
+ };
68
+
69
+ start(): void {
70
+ // Prime nextRun for any routine missing one, then start the loop + IPC server.
71
+ this.primeSchedule();
72
+ this.timer = setInterval(() => void this.tick(), TICK_MS);
73
+ this.server = startIpcServer((req) => this.handleIpc(req));
74
+ this.syncRelay();
75
+ const count = loadRoutines().filter((r) => r.enabled).length;
76
+ log(`daemon started (pid ${process.pid}); ${count} enabled routine(s). Tick every ${TICK_MS / 1000}s.`);
77
+ // Fire an immediate scan so a just-due routine doesn't wait a full minute.
78
+ void this.tick();
79
+ }
80
+
81
+ stop(): void {
82
+ if (this.timer) clearInterval(this.timer);
83
+ this.server?.close();
84
+ this.relay?.stop();
85
+ }
86
+
87
+ // Open the relay connection when it's both wanted (a signed-in account + at least
88
+ // one enabled routine delivering over `relay`) and not already up. Started ahead
89
+ // of fire time so it's connected when a routine actually pushes. We never tear it
90
+ // down once up — an idle authenticated socket is cheap and reconnects itself.
91
+ private syncRelay(): void {
92
+ if (this.relay || this.relayTerminated) return;
93
+ if (!hasCredentials()) return;
94
+ const wantsRelay = loadRoutines().some((r) => r.enabled && r.delivery.includes("relay"));
95
+ if (!wantsRelay) return;
96
+ this.relay = new RelayClient({
97
+ // The daemon publishes results but is not a drivable terminal: ignore any
98
+ // prompts/approvals a controller might send.
99
+ onPrompt: () => {},
100
+ onInterrupt: () => {},
101
+ onApprovalResponse: () => {},
102
+ onControllerAttached: () => this.onControllerAttached(),
103
+ onAttachment: () => {},
104
+ onTerminate: () => {
105
+ this.relayTerminated = true;
106
+ this.controllerAttached = false;
107
+ this.relay?.stop();
108
+ this.relay = undefined;
109
+ log("relay terminated from the app; staying offline until the daemon restarts");
110
+ },
111
+ onStatus: (text) => log(`relay: ${text}`),
112
+ onDisconnected: () => {
113
+ this.controllerAttached = false;
114
+ },
115
+ }, {
116
+ // Stable identity so the daemon shows up as one recognizable terminal in the
117
+ // app across restarts, instead of a fresh random "terminal-xxxx" each boot.
118
+ termId: routineRelayId(),
119
+ label: "Privateer Routines",
120
+ });
121
+ void this.relay.start();
122
+ log("relay connection starting (routine has relay delivery + account signed in)");
123
+ }
124
+
125
+ // The app attached: greet it, then flush any routine results that finished while it
126
+ // was closed so it catches up immediately (in fire order).
127
+ private onControllerAttached(): void {
128
+ this.controllerAttached = true;
129
+ this.relay?.sendSnapshot([{ kind: "notice", text: "Privateer routines — results will appear here as they run." }]);
130
+ const pending = drainPendingRelay();
131
+ if (pending.length === 0) return;
132
+ log(`controller attached — flushing ${pending.length} pending routine result(s)`);
133
+ for (const p of pending) this.relay?.sendRoutineResult(p.routine, p.content);
134
+ }
135
+
136
+ private primeSchedule(): void {
137
+ for (const r of loadRoutines()) {
138
+ if (!r.enabled) continue;
139
+ if (r.nextRun && !Number.isNaN(Date.parse(r.nextRun))) continue;
140
+ const nr = computeNextRun(r);
141
+ if (nr) this.persistRun(r.id, { nextRun: nr.toISOString() });
142
+ }
143
+ }
144
+
145
+ private async tick(): Promise<void> {
146
+ const now = Date.now();
147
+ for (const r of loadRoutines()) {
148
+ if (!r.enabled || this.running.has(r.id)) continue;
149
+ if (triggerError(r)) continue; // skip malformed triggers
150
+ if (!r.nextRun) {
151
+ const nr = computeNextRun(r);
152
+ this.persistRun(r.id, { nextRun: nr?.toISOString() });
153
+ continue;
154
+ }
155
+ if (Date.parse(r.nextRun) <= now) {
156
+ await this.runRoutine(r);
157
+ }
158
+ }
159
+ }
160
+
161
+ // Execute a routine to completion and deliver the result. Advances nextRun past
162
+ // now afterwards (skipping any backlog) so at most one run fires per tick.
163
+ async runRoutine(routine: Routine): Promise<IpcResponse> {
164
+ if (this.running.has(routine.id)) return { ok: false, message: "already running" };
165
+ this.running.add(routine.id);
166
+ log(`running routine "${routine.name}"`);
167
+
168
+ const config = loadConfig();
169
+ const modelSpec = routine.model ?? config.defaultModel;
170
+ const split = splitRoutineTools(routine.tools);
171
+ // If the routine names no builtin tools, it still gets the safe read/web set —
172
+ // a routine that only lists MCP selectors shouldn't lose the ability to read.
173
+ const allowedTools = split.builtin.length > 0 ? split.builtin : SAFE_TOOLS;
174
+ const wantsEmail = routine.delivery.includes("email");
175
+
176
+ // MCP tools are fulfilled inside the agent turn. Two grants exist: explicit
177
+ // "<server>__<tool>" selectors in routine.tools (least privilege: only the named
178
+ // servers are launched and only the selected tools exposed), and the legacy email
179
+ // delivery, which exposes every configured server so the mail tool is reachable.
180
+ // Either way, egress stays an explicit tool action rather than a side channel.
181
+ let extraTools: ToolSet | undefined;
182
+ let closeMcp: (() => void) | undefined;
183
+ let prompt = routine.prompt;
184
+ if (split.mcp.length > 0 || wantsEmail) {
185
+ try {
186
+ const all = loadMcpServers(routine.cwd);
187
+ for (const s of split.servers) {
188
+ if (!all[s]) log(` mcp: server "${s}" not configured in mcp.json — skipping`);
189
+ }
190
+ const servers = wantsEmail
191
+ ? all
192
+ : Object.fromEntries(Object.entries(all).filter(([name]) => split.servers.includes(name)));
193
+ const conn = await connectMcpServers(servers, routine.cwd, autoApproveGate);
194
+ const selected = filterMcpTools(conn.tools, split.mcp);
195
+ // Email needs every server's tools (the mail tool isn't in the selectors);
196
+ // otherwise expose only what the routine was granted.
197
+ extraTools = wantsEmail ? conn.tools : selected;
198
+ closeMcp = () => conn.clients.forEach((c) => c.close());
199
+ if (split.mcp.length > 0 && Object.keys(selected).length === 0) {
200
+ log(` mcp: selectors matched no tools (${split.mcp.join(", ")})`);
201
+ }
202
+ if (wantsEmail) {
203
+ prompt +=
204
+ "\n\nWhen finished, email the result to the account owner using the available mail tool " +
205
+ "(e.g. a Gmail create/send tool). Keep the subject short and put the summary in the body.";
206
+ }
207
+ } catch (err) {
208
+ log(` mcp setup failed: ${err instanceof Error ? err.message : String(err)}`);
209
+ }
210
+ }
211
+
212
+ let out = "";
213
+ let status: "ok" | "error" = "ok";
214
+ let error: string | undefined;
215
+ try {
216
+ const session = createSession({
217
+ config,
218
+ modelSpec,
219
+ cwd: routine.cwd,
220
+ gate: autoApproveGate,
221
+ confineToCwd: true,
222
+ allowedTools,
223
+ extraTools,
224
+ });
225
+ for await (const ev of session.engine.send(prompt)) {
226
+ if (ev.type === "text") out += ev.text;
227
+ else if (ev.type === "error") {
228
+ status = "error";
229
+ error = ev.error;
230
+ }
231
+ }
232
+ } catch (err) {
233
+ status = "error";
234
+ error = err instanceof Error ? err.message : String(err);
235
+ } finally {
236
+ closeMcp?.();
237
+ }
238
+
239
+ const content = formatResult(routine, out, status, error);
240
+ const report = deliver(routine, content, status, { pushRelay: this.pushRelay });
241
+ log(` "${routine.name}" ${status}; delivered via ${report.delivered.join(", ") || "(none)"}`);
242
+
243
+ // Recurring routines reschedule; one-offs disable themselves after firing.
244
+ this.persistRun(routine.id, {
245
+ lastRun: new Date().toISOString(),
246
+ lastStatus: status,
247
+ lastError: error,
248
+ ...advanceAfterRun(routine),
249
+ });
250
+ this.running.delete(routine.id);
251
+ return { ok: status === "ok", message: report.delivered.join(", ") || undefined };
252
+ }
253
+
254
+ // Merge run bookkeeping into the persisted routine, re-reading first so we don't
255
+ // clobber concurrent IPC edits (add/pause/remove).
256
+ private persistRun(id: string, patch: Partial<Routine>): void {
257
+ const current = findRoutine(loadRoutines(), id);
258
+ if (!current) return;
259
+ upsertRoutine({ ...current, ...patch });
260
+ }
261
+
262
+ private async handleIpc(req: IpcRequest): Promise<IpcResponse> {
263
+ switch (req.cmd) {
264
+ case "status":
265
+ return { ok: true, pid: process.pid, uptimeSec: Math.round((Date.now() - this.startedAt) / 1000), routines: loadRoutines() };
266
+ case "list":
267
+ return { ok: true, routines: loadRoutines() };
268
+ case "add": {
269
+ const err = triggerError(req.routine);
270
+ if (err) return { ok: false, message: `invalid trigger: ${err}` };
271
+ const nr = computeNextRun(req.routine);
272
+ upsertRoutine({ ...req.routine, nextRun: nr?.toISOString() });
273
+ this.syncRelay(); // connect the relay if this routine introduced relay delivery
274
+ return { ok: true, message: `routine "${req.routine.name}" saved`, routines: loadRoutines() };
275
+ }
276
+ case "remove": {
277
+ const removed = removeRoutine(req.idOrName);
278
+ return removed
279
+ ? { ok: true, message: `removed "${removed.name}"`, routines: loadRoutines() }
280
+ : { ok: false, message: `no routine "${req.idOrName}"` };
281
+ }
282
+ case "pause":
283
+ case "resume": {
284
+ const r = findRoutine(loadRoutines(), req.idOrName);
285
+ if (!r) return { ok: false, message: `no routine "${req.idOrName}"` };
286
+ const enabled = req.cmd === "resume";
287
+ const nr = enabled ? computeNextRun(r)?.toISOString() : undefined;
288
+ upsertRoutine({ ...r, enabled, nextRun: nr });
289
+ if (enabled) this.syncRelay();
290
+ return { ok: true, message: `${enabled ? "resumed" : "paused"} "${r.name}"`, routines: loadRoutines() };
291
+ }
292
+ case "run-now": {
293
+ const r = findRoutine(loadRoutines(), req.idOrName);
294
+ if (!r) return { ok: false, message: `no routine "${req.idOrName}"` };
295
+ // Fire in the background so the IPC caller isn't blocked on a long run.
296
+ void this.runRoutine(r);
297
+ return { ok: true, message: `running "${r.name}" now` };
298
+ }
299
+ case "reload":
300
+ this.primeSchedule();
301
+ this.syncRelay();
302
+ return { ok: true, message: "schedule reloaded", routines: loadRoutines() };
303
+ default:
304
+ return { ok: false, message: "unknown command" };
305
+ }
306
+ }
307
+ }
308
+
309
+ // Entry point for `privateer daemon`.
310
+ export function runDaemon(): void {
311
+ const daemon = new Daemon();
312
+ daemon.start();
313
+ const shutdown = () => {
314
+ log("shutting down");
315
+ daemon.stop();
316
+ // Release this process's Privateer session so the daemon drops off the
317
+ // app's Linked Devices immediately (best effort, then exit regardless).
318
+ void revokeChildSession().finally(() => process.exit(0));
319
+ };
320
+ process.on("SIGINT", shutdown);
321
+ process.on("SIGTERM", shutdown);
322
+ }