privateer-agent 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +135 -443
  2. package/bin/privateer-tui +86 -0
  3. package/bin/privateer.mjs +17 -6
  4. package/bin/pv +28 -0
  5. package/package.json +26 -23
  6. package/src/auth/privateer.ts +57 -0
  7. package/src/boot.ts +43 -0
  8. package/src/bridge/engineAdapter.ts +182 -0
  9. package/src/cli/chat.ts +243 -0
  10. package/src/config/paths.ts +24 -46
  11. package/src/config/permissionMode.ts +5 -0
  12. package/src/crypto/outboxSeal.ts +61 -0
  13. package/src/daemon/index.ts +204 -121
  14. package/src/daemon/ipc.ts +1 -1
  15. package/src/engine/errors.ts +85 -45
  16. package/src/engine/router.ts +11 -165
  17. package/src/ext/permissionGate.ts +216 -0
  18. package/src/main.ts +32 -0
  19. package/src/permissions/classify.ts +172 -0
  20. package/src/permissions/gate.ts +11 -14
  21. package/src/permissions/mode.ts +6 -2
  22. package/src/permissions/{uiGate.ts → modeGate.ts} +18 -3
  23. package/src/providers/account.ts +170 -0
  24. package/src/providers/catalog.ts +73 -61
  25. package/src/providers/genModelsJson.ts +97 -0
  26. package/src/remote/relayClient.ts +15 -0
  27. package/src/remote/remoteBridge.ts +152 -0
  28. package/src/routines/delivery.ts +118 -9
  29. package/src/routines/schema.ts +24 -5
  30. package/src/routines/store.ts +44 -1
  31. package/src/routines/toolSelect.ts +3 -1
  32. package/src/session.ts +81 -273
  33. package/src/tools/routine.ts +120 -101
  34. package/src/tools/saveAttachment.ts +39 -42
  35. package/src/tools/sendFile.ts +75 -0
  36. package/src/util/attachmentStore.ts +18 -35
  37. package/src/util/redact.ts +33 -3
  38. package/LICENSE +0 -21
  39. package/src/agents/loader.ts +0 -49
  40. package/src/commands/custom.ts +0 -123
  41. package/src/commands/registry.ts +0 -618
  42. package/src/components/AgentGroupView.tsx +0 -104
  43. package/src/components/App.tsx +0 -1626
  44. package/src/components/ApprovalPrompt.tsx +0 -49
  45. package/src/components/Banner.tsx +0 -78
  46. package/src/components/Markdown.tsx +0 -183
  47. package/src/components/ModeHint.tsx +0 -40
  48. package/src/components/ModelPicker.tsx +0 -302
  49. package/src/components/Onboarding.tsx +0 -203
  50. package/src/components/OptionPicker.tsx +0 -134
  51. package/src/components/PlanConfirm.tsx +0 -37
  52. package/src/components/PrivateerLogin.tsx +0 -109
  53. package/src/components/PromptInput.tsx +0 -602
  54. package/src/components/RewindPicker.tsx +0 -69
  55. package/src/components/Root.tsx +0 -116
  56. package/src/components/SessionPicker.tsx +0 -64
  57. package/src/components/StatusBar.tsx +0 -131
  58. package/src/components/TodoPanel.tsx +0 -36
  59. package/src/components/ToolCallView.tsx +0 -113
  60. package/src/components/Transcript.tsx +0 -210
  61. package/src/components/figures.ts +0 -14
  62. package/src/components/promptModel.ts +0 -73
  63. package/src/components/spinnerVerbs.ts +0 -46
  64. package/src/components/theme.ts +0 -57
  65. package/src/components/types.ts +0 -34
  66. package/src/components/useTeeShield.ts +0 -104
  67. package/src/components/useTerminalWidth.ts +0 -24
  68. package/src/components/useZdrShield.ts +0 -126
  69. package/src/config/load.ts +0 -115
  70. package/src/config/schema.ts +0 -94
  71. package/src/context/outputStyles.ts +0 -42
  72. package/src/context/projectInfo.ts +0 -59
  73. package/src/context/systemPrompt.ts +0 -176
  74. package/src/engine/QueryEngine.ts +0 -399
  75. package/src/hooks/engine.ts +0 -155
  76. package/src/main.tsx +0 -209
  77. package/src/mcp/client.ts +0 -251
  78. package/src/mcp/oauth.ts +0 -245
  79. package/src/memory/auto.ts +0 -146
  80. package/src/memory/checkpoints.ts +0 -227
  81. package/src/memory/store.ts +0 -127
  82. package/src/providers/attestation.ts +0 -149
  83. package/src/providers/capabilities.ts +0 -104
  84. package/src/providers/models.ts +0 -183
  85. package/src/providers/registry.ts +0 -71
  86. package/src/providers/resolve.ts +0 -78
  87. package/src/skills/installer.ts +0 -222
  88. package/src/skills/loader.ts +0 -88
  89. package/src/tools/askUser.ts +0 -92
  90. package/src/tools/bash.ts +0 -98
  91. package/src/tools/context.ts +0 -128
  92. package/src/tools/edit.ts +0 -67
  93. package/src/tools/exec.ts +0 -60
  94. package/src/tools/glob.ts +0 -39
  95. package/src/tools/grep.ts +0 -86
  96. package/src/tools/index.ts +0 -83
  97. package/src/tools/memory.ts +0 -53
  98. package/src/tools/processRegistry.ts +0 -77
  99. package/src/tools/read.ts +0 -42
  100. package/src/tools/sendFileToClient.ts +0 -55
  101. package/src/tools/skill.ts +0 -44
  102. package/src/tools/task.ts +0 -52
  103. package/src/tools/todo.ts +0 -36
  104. package/src/tools/todoStore.ts +0 -31
  105. package/src/tools/walk.ts +0 -44
  106. package/src/tools/web.ts +0 -145
  107. package/src/tools/worktree.ts +0 -145
  108. package/src/tools/write.ts +0 -40
  109. package/src/util/images.ts +0 -378
  110. package/src/util/limit.ts +0 -32
  111. package/src/version.ts +0 -13
@@ -1,11 +1,19 @@
1
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";
2
+ import { readFileSync } from "node:fs";
3
+ // Pi session stack. The daemon MUST be launched after ./boot.ts (env +
4
+ // attestation dispatcher) these are evaluated on import.
5
+ import {
6
+ createAgentSessionServices,
7
+ createAgentSessionFromServices,
8
+ SessionManager,
9
+ } from "@earendil-works/pi-coding-agent";
10
+ import { agentDir, configPath } from "../config/paths.ts";
11
+ import { createEngineEventAdapter } from "../bridge/engineAdapter.ts";
12
+ import { makePermissionGate, type GateController } from "../ext/permissionGate.ts";
13
+ import { makePiPrivacyExtension } from "pi-privacy";
14
+ import { makeAccountProvider } from "../providers/account.ts";
7
15
  import { RelayClient } from "../remote/relayClient.ts";
8
- import { hasCredentials, revokeChildSession } from "../auth/privateer.ts";
16
+ import { hasCredentials, revokeChildSession, apiRequest, spawnAccountCredentials } from "../auth/privateer.ts";
9
17
  import {
10
18
  loadRoutines,
11
19
  upsertRoutine,
@@ -13,25 +21,65 @@ import {
13
21
  removeRoutine,
14
22
  addPendingRelay,
15
23
  drainPendingRelay,
24
+ addPendingCloud,
25
+ loadPendingCloud,
26
+ savePendingCloud,
27
+ type PendingCloud,
16
28
  routineRelayId,
17
29
  } from "../routines/store.ts";
18
30
  import type { Routine } from "../routines/schema.ts";
19
31
  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";
32
+ import { splitRoutineTools } from "../routines/toolSelect.ts";
33
+ import { deliver, type RelayPusher, type CloudPusher } from "../routines/delivery.ts";
34
+ import { sealJson, decodeAccountPublicKey } from "../crypto/outboxSeal.ts";
35
+ import { redactText, collectSecrets } from "../util/redact.ts";
22
36
  import { startIpcServer, type IpcRequest, type IpcResponse } from "./ipc.ts";
23
37
 
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"];
38
+ // The safe, read-only toolset for unattended runs Pi builtins with no
39
+ // write/edit/bash, so a routine firing with nobody watching can't mutate the
40
+ // filesystem or shell out. (0.2's web_fetch/web_search return once the web tools
41
+ // land in Phase 5.) Safety is the tool restriction; the gate auto-approves what's
42
+ // allowed but still fail-closes a dangerous shell command headlessly.
43
+ const SAFE_TOOLS = ["read", "grep", "find", "ls"];
27
44
 
28
45
  const TICK_MS = 60_000; // scan for due routines once a minute
46
+ const MAX_CLOUD_PLAINTEXT = 45_000;
47
+
48
+ interface DaemonConfig {
49
+ defaultModel: string;
50
+ webhooks?: Record<string, { url: string; secret?: string; headers?: Record<string, string> }>;
51
+ providers?: Record<string, { apiKey?: string } | undefined>;
52
+ }
53
+
54
+ // Minimal config read (webhooks + providers-for-redaction + default model). The
55
+ // full config layer is a Phase-7 port; the daemon only needs these fields.
56
+ function loadDaemonConfig(): DaemonConfig {
57
+ try {
58
+ const raw = JSON.parse(readFileSync(configPath(), "utf8"));
59
+ return {
60
+ defaultModel: typeof raw.defaultModel === "string" ? raw.defaultModel : "openrouter/openai/gpt-4o-mini",
61
+ webhooks: raw.webhooks,
62
+ providers: raw.providers,
63
+ };
64
+ } catch {
65
+ return { defaultModel: "openrouter/openai/gpt-4o-mini" };
66
+ }
67
+ }
68
+
69
+ // Split a "provider:model" (0.2) or "provider/model" (new) spec on its first
70
+ // separator. Model ids themselves contain "/", so we key off the first delimiter.
71
+ function parseSpec(spec: string): { provider: string; modelId: string } {
72
+ const i = spec.indexOf(":");
73
+ const j = spec.indexOf("/");
74
+ const sep = i === -1 ? j : j === -1 ? i : Math.min(i, j);
75
+ if (sep <= 0) return { provider: spec, modelId: "" };
76
+ return { provider: spec.slice(0, sep), modelId: spec.slice(sep + 1) };
77
+ }
29
78
 
30
79
  function log(msg: string): void {
31
80
  process.stdout.write(`[${new Date().toISOString()}] ${msg}\n`);
32
81
  }
33
82
 
34
- // Render a run's result as a self-contained markdown document for file/notice output.
35
83
  function formatResult(routine: Routine, body: string, status: "ok" | "error", error?: string): string {
36
84
  const when = new Date().toISOString();
37
85
  const head = `# ${routine.name}\n\n_${when} · ${status}${routine.model ? ` · ${routine.model}` : ""}_\n\n`;
@@ -43,38 +91,34 @@ export class Daemon {
43
91
  private server?: Server;
44
92
  private timer?: ReturnType<typeof setInterval>;
45
93
  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
94
  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
95
  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
96
  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
97
  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.
98
+
63
99
  private readonly pushRelay: RelayPusher = (routine, content) => {
64
100
  if (this.controllerAttached && this.relay?.sendRoutineResult(routine.name, content)) return "live";
65
101
  addPendingRelay({ routine: routine.name, at: new Date().toISOString(), content });
66
102
  return "queued";
67
103
  };
68
104
 
105
+ private outboxPub?: Uint8Array;
106
+
107
+ private readonly pushCloud: CloudPusher = async (routine, content, status) => {
108
+ const at = new Date().toISOString();
109
+ if (await this.postOutbox(routine.name, at, status, content)) return "sent";
110
+ addPendingCloud({ routine: routine.name, at, status, content });
111
+ return "queued";
112
+ };
113
+
69
114
  start(): void {
70
- // Prime nextRun for any routine missing one, then start the loop + IPC server.
71
115
  this.primeSchedule();
72
116
  this.timer = setInterval(() => void this.tick(), TICK_MS);
73
117
  this.server = startIpcServer((req) => this.handleIpc(req));
74
118
  this.syncRelay();
119
+ void this.flushPendingCloud();
75
120
  const count = loadRoutines().filter((r) => r.enabled).length;
76
121
  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
122
  void this.tick();
79
123
  }
80
124
 
@@ -84,46 +128,36 @@ export class Daemon {
84
128
  this.relay?.stop();
85
129
  }
86
130
 
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
131
  private syncRelay(): void {
92
132
  if (this.relay || this.relayTerminated) return;
93
133
  if (!hasCredentials()) return;
94
134
  const wantsRelay = loadRoutines().some((r) => r.enabled && r.delivery.includes("relay"));
95
135
  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;
136
+ this.relay = new RelayClient(
137
+ {
138
+ onPrompt: () => {},
139
+ onInterrupt: () => {},
140
+ onApprovalResponse: () => {},
141
+ onControllerAttached: () => this.onControllerAttached(),
142
+ onAttachment: () => {},
143
+ onTerminate: () => {
144
+ this.relayTerminated = true;
145
+ this.controllerAttached = false;
146
+ this.relay?.stop();
147
+ this.relay = undefined;
148
+ log("relay terminated from the app; staying offline until the daemon restarts");
149
+ },
150
+ onStatus: (text) => log(`relay: ${text}`),
151
+ onDisconnected: () => {
152
+ this.controllerAttached = false;
153
+ },
114
154
  },
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
- });
155
+ { termId: routineRelayId(), label: "Privateer Routines" },
156
+ );
121
157
  void this.relay.start();
122
158
  log("relay connection starting (routine has relay delivery + account signed in)");
123
159
  }
124
160
 
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
161
  private onControllerAttached(): void {
128
162
  this.controllerAttached = true;
129
163
  this.relay?.sendSnapshot([{ kind: "notice", text: "Privateer routines — results will appear here as they run." }]);
@@ -133,6 +167,54 @@ export class Daemon {
133
167
  for (const p of pending) this.relay?.sendRoutineResult(p.routine, p.content);
134
168
  }
135
169
 
170
+ // ── Cloud outbox (sealed store-and-forward) ────────────────────────────────
171
+
172
+ private async ensureOutboxPub(): Promise<Uint8Array | undefined> {
173
+ if (this.outboxPub) return this.outboxPub;
174
+ try {
175
+ const res = await apiRequest("/api/outbox/pubkey");
176
+ if (!res.ok) return undefined;
177
+ const data = (await res.json()) as { outboxPublicKey?: string | null };
178
+ if (!data.outboxPublicKey) return undefined;
179
+ this.outboxPub = decodeAccountPublicKey(data.outboxPublicKey);
180
+ return this.outboxPub;
181
+ } catch {
182
+ return undefined;
183
+ }
184
+ }
185
+
186
+ private async postOutbox(routine: string, at: string, status: "ok" | "error", content: string): Promise<boolean> {
187
+ const pub = await this.ensureOutboxPub();
188
+ if (!pub) return false;
189
+ const body = content.length > MAX_CLOUD_PLAINTEXT ? content.slice(0, MAX_CLOUD_PLAINTEXT) + "\n…truncated" : content;
190
+ const sealed = sealJson(pub, { v: 1, kind: "routine", name: routine, status, at, content: body });
191
+ try {
192
+ const res = await apiRequest("/api/outbox", {
193
+ method: "POST",
194
+ headers: { "content-type": "application/json" },
195
+ body: JSON.stringify({ sealed }),
196
+ });
197
+ return res.ok;
198
+ } catch {
199
+ return false;
200
+ }
201
+ }
202
+
203
+ private async flushPendingCloud(): Promise<void> {
204
+ if (!hasCredentials()) return;
205
+ const queue = loadPendingCloud();
206
+ if (queue.length === 0) return;
207
+ const remaining: PendingCloud[] = [];
208
+ for (const p of queue) {
209
+ if (remaining.length === 0 && (await this.postOutbox(p.routine, p.at, p.status, p.content))) continue;
210
+ remaining.push(p);
211
+ }
212
+ if (remaining.length !== queue.length) {
213
+ savePendingCloud(remaining);
214
+ log(`flushed ${queue.length - remaining.length} pending cloud outbox item(s); ${remaining.length} remaining`);
215
+ }
216
+ }
217
+
136
218
  private primeSchedule(): void {
137
219
  for (const r of loadRoutines()) {
138
220
  if (!r.enabled) continue;
@@ -143,10 +225,11 @@ export class Daemon {
143
225
  }
144
226
 
145
227
  private async tick(): Promise<void> {
228
+ void this.flushPendingCloud();
146
229
  const now = Date.now();
147
230
  for (const r of loadRoutines()) {
148
231
  if (!r.enabled || this.running.has(r.id)) continue;
149
- if (triggerError(r)) continue; // skip malformed triggers
232
+ if (triggerError(r)) continue;
150
233
  if (!r.nextRun) {
151
234
  const nr = computeNextRun(r);
152
235
  this.persistRun(r.id, { nextRun: nr?.toISOString() });
@@ -158,89 +241,94 @@ export class Daemon {
158
241
  }
159
242
  }
160
243
 
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.
244
+ // Execute a routine to completion and deliver the result. The one rewired seam:
245
+ // drive a headless Pi session (auto-approve gate + restricted tool set) instead of
246
+ // the old engine, collecting the text output.
163
247
  async runRoutine(routine: Routine): Promise<IpcResponse> {
164
248
  if (this.running.has(routine.id)) return { ok: false, message: "already running" };
165
249
  this.running.add(routine.id);
166
250
  log(`running routine "${routine.name}"`);
167
251
 
168
- const config = loadConfig();
252
+ const config = loadDaemonConfig();
169
253
  const modelSpec = routine.model ?? config.defaultModel;
170
254
  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
255
  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
- }
256
+ if (split.mcp.length > 0 || routine.delivery.includes("email")) {
257
+ log(" note: MCP tools + email delivery are not wired yet (Phase 5) — skipping those");
210
258
  }
211
259
 
212
260
  let out = "";
213
261
  let status: "ok" | "error" = "ok";
214
262
  let error: string | undefined;
215
263
  try {
216
- const session = createSession({
217
- config,
218
- modelSpec,
264
+ // Auto-approve (bypass) gate — safety is `tools: allowedTools`; a dangerous
265
+ // shell command still fail-closes headlessly (localAsk denies).
266
+ const gate: GateController = {
267
+ getMode: () => "bypass",
268
+ setMode: () => {},
269
+ allowlist: [],
270
+ allowedOutsideRoots: [],
219
271
  cwd: routine.cwd,
220
- gate: autoApproveGate,
221
272
  confineToCwd: true,
222
- allowedTools,
223
- extraTools,
273
+ async localAsk() {
274
+ return "deny";
275
+ },
276
+ };
277
+ const services = await createAgentSessionServices({
278
+ cwd: routine.cwd,
279
+ agentDir: agentDir(),
280
+ resourceLoaderOptions: {
281
+ extensionFactories: [makePermissionGate(gate), makePiPrivacyExtension(), makeAccountProvider()] as any,
282
+ },
224
283
  });
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;
284
+
285
+ const { provider, modelId } = parseSpec(modelSpec);
286
+ if (provider === "privateer") {
287
+ try {
288
+ const creds = await spawnAccountCredentials();
289
+ (services.authStorage as any).set("privateer", { type: "oauth", ...creds });
290
+ } catch (e) {
291
+ log(` account channel unavailable: ${(e as Error).message}`);
230
292
  }
231
293
  }
294
+
295
+ const model = (services.modelRegistry as any).find(provider, modelId);
296
+ if (!model) {
297
+ status = "error";
298
+ error = `model ${provider}/${modelId} not found`;
299
+ } else {
300
+ const { session } = await createAgentSessionFromServices({
301
+ services,
302
+ sessionManager: SessionManager.inMemory(routine.cwd),
303
+ model,
304
+ tools: allowedTools,
305
+ } as any);
306
+ const adapter = createEngineEventAdapter();
307
+ session.subscribe((ev: any) => {
308
+ for (const ee of adapter.toEngineEvents(ev)) {
309
+ if (ee.type === "text") out += ee.text;
310
+ else if (ee.type === "error") {
311
+ status = "error";
312
+ error = ee.error;
313
+ }
314
+ }
315
+ });
316
+ await session.prompt(routine.prompt);
317
+ }
232
318
  } catch (err) {
233
319
  status = "error";
234
320
  error = err instanceof Error ? err.message : String(err);
235
- } finally {
236
- closeMcp?.();
237
321
  }
238
322
 
239
323
  const content = formatResult(routine, out, status, error);
240
- const report = deliver(routine, content, status, { pushRelay: this.pushRelay });
324
+ const report = await deliver(routine, content, status, {
325
+ pushRelay: this.pushRelay,
326
+ pushCloud: this.pushCloud,
327
+ webhooks: config.webhooks,
328
+ redact: (text) => redactText(text, collectSecrets(config.providers)),
329
+ });
241
330
  log(` "${routine.name}" ${status}; delivered via ${report.delivered.join(", ") || "(none)"}`);
242
331
 
243
- // Recurring routines reschedule; one-offs disable themselves after firing.
244
332
  this.persistRun(routine.id, {
245
333
  lastRun: new Date().toISOString(),
246
334
  lastStatus: status,
@@ -251,8 +339,6 @@ export class Daemon {
251
339
  return { ok: status === "ok", message: report.delivered.join(", ") || undefined };
252
340
  }
253
341
 
254
- // Merge run bookkeeping into the persisted routine, re-reading first so we don't
255
- // clobber concurrent IPC edits (add/pause/remove).
256
342
  private persistRun(id: string, patch: Partial<Routine>): void {
257
343
  const current = findRoutine(loadRoutines(), id);
258
344
  if (!current) return;
@@ -270,7 +356,7 @@ export class Daemon {
270
356
  if (err) return { ok: false, message: `invalid trigger: ${err}` };
271
357
  const nr = computeNextRun(req.routine);
272
358
  upsertRoutine({ ...req.routine, nextRun: nr?.toISOString() });
273
- this.syncRelay(); // connect the relay if this routine introduced relay delivery
359
+ this.syncRelay();
274
360
  return { ok: true, message: `routine "${req.routine.name}" saved`, routines: loadRoutines() };
275
361
  }
276
362
  case "remove": {
@@ -292,7 +378,6 @@ export class Daemon {
292
378
  case "run-now": {
293
379
  const r = findRoutine(loadRoutines(), req.idOrName);
294
380
  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
381
  void this.runRoutine(r);
297
382
  return { ok: true, message: `running "${r.name}" now` };
298
383
  }
@@ -306,15 +391,13 @@ export class Daemon {
306
391
  }
307
392
  }
308
393
 
309
- // Entry point for `privateer daemon`.
394
+ // Entry point for `privateer daemon`. Caller must have imported ./boot.ts first.
310
395
  export function runDaemon(): void {
311
396
  const daemon = new Daemon();
312
397
  daemon.start();
313
398
  const shutdown = () => {
314
399
  log("shutting down");
315
400
  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
401
  void revokeChildSession().finally(() => process.exit(0));
319
402
  };
320
403
  process.on("SIGINT", shutdown);
package/src/daemon/ipc.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createServer, createConnection, type Socket, type Server } from "node:net";
2
2
  import { existsSync, unlinkSync, chmodSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { globalDir } from "../config/load.ts";
4
+ import { globalDir } from "../config/paths.ts";
5
5
  import type { Routine } from "../routines/schema.ts";
6
6
 
7
7
  // The CLI/TUI talks to the resident daemon over a unix domain socket. The protocol
@@ -17,8 +17,11 @@ interface ErrorFacts {
17
17
  statusCode?: number;
18
18
  providerMessage?: string;
19
19
  code?: string; // provider's machine-readable error code, e.g. "DAILY_CAP_HIT"
20
+ errno?: string; // Node socket-level code, e.g. "ECONNREFUSED"
20
21
  model?: string;
21
22
  provider?: string;
23
+ url?: string; // the endpoint the failing request targeted
24
+ message?: string; // deepest non-empty Error message in the chain
22
25
  }
23
26
 
24
27
  const HOST_LABELS: Record<string, string> = {
@@ -26,70 +29,81 @@ const HOST_LABELS: Record<string, string> = {
26
29
  "api.anthropic.com": "Anthropic",
27
30
  "api.openai.com": "OpenAI",
28
31
  "cloud-api.near.ai": "NEAR AI",
32
+ "localhost:11434": "Ollama",
33
+ "127.0.0.1:11434": "Ollama",
29
34
  };
30
35
 
31
- // The AI SDK wraps the real provider error: a retry sequence that exhausts its
32
- // attempts throws AI_RetryError, whose `.lastError` is the APICallError that
33
- // actually carries statusCode / responseBody / requestBodyValues. Without peeling
34
- // that off we'd read undefined for every field and fall back to the wrapper's bare
35
- // "Too Many Requests". Follow `.lastError` (and a `.cause` that looks like a
36
- // richer error) until we reach the one with the useful fields.
37
- function unwrap(err: unknown): unknown {
38
- let cur = err;
39
- for (let i = 0; i < 5; i++) {
40
- if (!cur || typeof cur !== "object") break;
41
- const e = cur as Record<string, unknown>;
42
- const richer = e.statusCode == null && e.responseBody == null;
43
- const inner = e.lastError ?? (richer ? e.cause : undefined);
44
- if (!inner || inner === cur) break;
45
- cur = inner;
46
- }
47
- return cur;
48
- }
36
+ // Socket-level failures Node/undici report via an error `code`. Distinct from the
37
+ // provider's machine code (which comes out of the response body) — these mean the
38
+ // request never got a response at all.
39
+ const NETWORK_ERRNO =
40
+ /^(ECONNREFUSED|ECONNRESET|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|EPIPE|ENETUNREACH|EHOSTUNREACH|UND_ERR_CONNECT_TIMEOUT|UND_ERR_SOCKET)$/;
49
41
 
50
42
  // Pull structured fields off an unknown error without trusting any one shape.
43
+ //
44
+ // The AI SDK nests the useful error: retry exhaustion throws AI_RetryError, whose
45
+ // `.lastError` is the APICallError carrying statusCode / url / responseBody, whose
46
+ // `.cause` on a connection failure is the socket error (often an AggregateError
47
+ // with an EMPTY message but an errno code). No single level has everything, so
48
+ // walk the whole chain and merge: HTTP-ish facts keep the first (shallowest)
49
+ // value found, while `message` keeps the deepest NON-EMPTY one — inner messages
50
+ // are more specific ("Cannot connect to API") than the wrapper's ("Failed after
51
+ // 3 attempts…"), but the socket error at the very bottom may have none at all.
51
52
  function extract(err: unknown): ErrorFacts {
52
- const e = (err ?? {}) as Record<string, unknown>;
53
- const statusCode = typeof e.statusCode === "number" ? e.statusCode : undefined;
53
+ const facts: ErrorFacts = {};
54
54
 
55
55
  // The provider's own message + machine code, preferred over the SDK's wrapper
56
56
  // text. Providers disagree on shape: OpenAI/OpenRouter nest under `error`, while
57
57
  // the Privateer account backend returns a flat `{ message, code }` (e.g. a daily
58
58
  // usage cap). Read both shapes; keep the first message/code we find.
59
- let providerMessage: string | undefined;
60
- let code: string | undefined;
61
59
  const readBody = (body: unknown) => {
62
60
  const b = body as
63
61
  | { error?: { message?: unknown; code?: unknown }; message?: unknown; code?: unknown }
64
62
  | undefined;
65
63
  if (!b || typeof b !== "object") return;
66
64
  const msg = b.error?.message ?? b.message;
67
- if (providerMessage == null && typeof msg === "string") providerMessage = msg;
65
+ if (facts.providerMessage == null && typeof msg === "string") facts.providerMessage = msg;
68
66
  const c = b.error?.code ?? b.code;
69
- if (code == null && typeof c === "string") code = c;
67
+ if (facts.code == null && typeof c === "string") facts.code = c;
70
68
  };
71
- readBody(e.data);
72
- if (typeof e.responseBody === "string") {
73
- try {
74
- readBody(JSON.parse(e.responseBody));
75
- } catch {
76
- /* responseBody wasn't JSON fall back to the wrapper message */
69
+
70
+ let cur: unknown = err;
71
+ for (let i = 0; i < 6 && cur && typeof cur === "object"; i++) {
72
+ const e = cur as Record<string, unknown>;
73
+
74
+ if (facts.statusCode == null && typeof e.statusCode === "number") facts.statusCode = e.statusCode;
75
+ readBody(e.data);
76
+ if (typeof e.responseBody === "string") {
77
+ try {
78
+ readBody(JSON.parse(e.responseBody));
79
+ } catch {
80
+ /* responseBody wasn't JSON — fall back to the wrapper message */
81
+ }
77
82
  }
78
- }
79
83
 
80
- const reqBody = e.requestBodyValues as { model?: unknown } | undefined;
81
- const model = typeof reqBody?.model === "string" ? reqBody.model : undefined;
84
+ const reqBody = e.requestBodyValues as { model?: unknown } | undefined;
85
+ if (facts.model == null && typeof reqBody?.model === "string") facts.model = reqBody.model;
86
+
87
+ if (facts.url == null && typeof e.url === "string" && /^https?:/.test(e.url)) {
88
+ facts.url = e.url;
89
+ try {
90
+ facts.provider = HOST_LABELS[new URL(e.url).host];
91
+ } catch {
92
+ /* not a URL */
93
+ }
94
+ }
82
95
 
83
- let provider: string | undefined;
84
- if (typeof e.url === "string") {
85
- try {
86
- provider = HOST_LABELS[new URL(e.url).host];
87
- } catch {
88
- /* not a URL */
96
+ if (facts.errno == null && typeof e.code === "string" && NETWORK_ERRNO.test(e.code)) {
97
+ facts.errno = e.code;
89
98
  }
99
+ if (typeof e.message === "string" && e.message.trim()) facts.message = e.message;
100
+
101
+ const next = e.lastError ?? e.cause;
102
+ if (!next || next === cur) break;
103
+ cur = next;
90
104
  }
91
105
 
92
- return { statusCode, providerMessage, code, model, provider };
106
+ return facts;
93
107
  }
94
108
 
95
109
  // Machine codes the Privateer backend returns for a hard account cap (daily /
@@ -116,10 +130,9 @@ function rawMessage(err: unknown): string {
116
130
  // Map a provider error to a friendly message + hint. Falls back to the raw
117
131
  // (redacted) message for anything we don't recognize, so nothing is swallowed.
118
132
  export function describeError(err: unknown): DescribedError {
119
- const inner = unwrap(err);
120
- const facts = extract(inner);
133
+ const facts = extract(err);
121
134
  const status = facts.statusCode;
122
- const text = facts.providerMessage ?? rawMessage(inner);
135
+ const text = facts.providerMessage ?? facts.message ?? rawMessage(err);
123
136
  const forModel = facts.model ? ` for ${facts.model}` : "";
124
137
  const forProvider = facts.provider ? ` for ${facts.provider}` : "";
125
138
 
@@ -195,9 +208,36 @@ export function describeError(err: unknown): DescribedError {
195
208
  retryable: true,
196
209
  });
197
210
  }
198
- if (/fetch failed|ENOTFOUND|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|network/i.test(text)) {
211
+ if (
212
+ facts.errno != null ||
213
+ /fetch failed|cannot connect|ENOTFOUND|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|network/i.test(text)
214
+ ) {
215
+ let origin: string | undefined;
216
+ let localhost = false;
217
+ if (facts.url) {
218
+ try {
219
+ const u = new URL(facts.url);
220
+ origin = u.origin;
221
+ localhost = /^(localhost$|127\.|0\.0\.0\.0$|\[::1\]$)/.test(u.hostname);
222
+ } catch {
223
+ /* not a URL */
224
+ }
225
+ }
226
+ // A local inference server that refuses connections isn't a flaky network —
227
+ // it isn't running. Say which one and how to start it; retrying won't help
228
+ // until the user acts, so no `retryable` flag.
229
+ if (localhost) {
230
+ const label = facts.provider ?? "the local server";
231
+ return out({
232
+ message: `Cannot connect to ${label} at ${origin} — nothing is listening there.`,
233
+ hint:
234
+ facts.provider === "Ollama"
235
+ ? "Start Ollama (run `ollama serve`, or open the Ollama app), then try again — or run /model to switch models."
236
+ : "Start the server (or check its base URL), then try again — or run /model to switch models.",
237
+ });
238
+ }
199
239
  return out({
200
- message: "Network error reaching the provider.",
240
+ message: `Network error reaching ${facts.provider ?? origin ?? "the provider"}.`,
201
241
  hint: "Check your connection and try again.",
202
242
  retryable: true,
203
243
  });