privateer-agent 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) 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 +268 -18
  7. package/src/components/ApprovalPrompt.tsx +15 -4
  8. package/src/components/Banner.tsx +21 -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/StatusBar.tsx +11 -1
  13. package/src/components/ToolCallView.tsx +4 -0
  14. package/src/components/Transcript.tsx +14 -7
  15. package/src/components/figures.ts +1 -0
  16. package/src/components/theme.ts +2 -0
  17. package/src/config/paths.ts +2 -0
  18. package/src/context/systemPrompt.ts +9 -0
  19. package/src/daemon/index.ts +322 -0
  20. package/src/daemon/ipc.ts +127 -0
  21. package/src/engine/errors.ts +10 -0
  22. package/src/main.tsx +43 -1
  23. package/src/mcp/client.ts +16 -1
  24. package/src/permissions/gate.ts +5 -0
  25. package/src/permissions/mode.ts +4 -0
  26. package/src/permissions/uiGate.ts +4 -3
  27. package/src/remote/relayClient.ts +161 -6
  28. package/src/routines/cron.ts +109 -0
  29. package/src/routines/delivery.ts +75 -0
  30. package/src/routines/schema.ts +65 -0
  31. package/src/routines/store.ts +205 -0
  32. package/src/routines/toolSelect.ts +48 -0
  33. package/src/routines/trigger.ts +41 -0
  34. package/src/session.ts +37 -12
  35. package/src/skills/installer.ts +222 -0
  36. package/src/skills/loader.ts +88 -0
  37. package/src/tools/askUser.ts +92 -0
  38. package/src/tools/context.ts +14 -0
  39. package/src/tools/index.ts +14 -0
  40. package/src/tools/routine.ts +110 -0
  41. package/src/tools/sendFileToClient.ts +55 -0
  42. package/src/tools/skill.ts +44 -0
  43. package/src/tools/worktree.ts +145 -0
  44. package/src/util/images.ts +35 -0
@@ -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
+ }
@@ -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";