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.
- package/README.md +135 -443
- package/bin/privateer-tui +86 -0
- package/bin/privateer.mjs +17 -6
- package/bin/pv +28 -0
- package/package.json +26 -23
- package/src/auth/privateer.ts +57 -0
- package/src/boot.ts +43 -0
- package/src/bridge/engineAdapter.ts +182 -0
- package/src/cli/chat.ts +243 -0
- package/src/config/paths.ts +24 -46
- package/src/config/permissionMode.ts +5 -0
- package/src/crypto/outboxSeal.ts +61 -0
- package/src/daemon/index.ts +204 -121
- package/src/daemon/ipc.ts +1 -1
- package/src/engine/errors.ts +85 -45
- package/src/engine/router.ts +11 -165
- package/src/ext/permissionGate.ts +216 -0
- package/src/main.ts +32 -0
- package/src/permissions/classify.ts +172 -0
- package/src/permissions/gate.ts +11 -14
- package/src/permissions/mode.ts +6 -2
- package/src/permissions/{uiGate.ts → modeGate.ts} +18 -3
- package/src/providers/account.ts +170 -0
- package/src/providers/catalog.ts +73 -61
- package/src/providers/genModelsJson.ts +97 -0
- package/src/remote/relayClient.ts +15 -0
- package/src/remote/remoteBridge.ts +152 -0
- package/src/routines/delivery.ts +118 -9
- package/src/routines/schema.ts +24 -5
- package/src/routines/store.ts +44 -1
- package/src/routines/toolSelect.ts +3 -1
- package/src/session.ts +81 -273
- package/src/tools/routine.ts +120 -101
- package/src/tools/saveAttachment.ts +39 -42
- package/src/tools/sendFile.ts +75 -0
- package/src/util/attachmentStore.ts +18 -35
- package/src/util/redact.ts +33 -3
- package/LICENSE +0 -21
- package/src/agents/loader.ts +0 -49
- package/src/commands/custom.ts +0 -123
- package/src/commands/registry.ts +0 -618
- package/src/components/AgentGroupView.tsx +0 -104
- package/src/components/App.tsx +0 -1626
- package/src/components/ApprovalPrompt.tsx +0 -49
- package/src/components/Banner.tsx +0 -78
- package/src/components/Markdown.tsx +0 -183
- package/src/components/ModeHint.tsx +0 -40
- package/src/components/ModelPicker.tsx +0 -302
- package/src/components/Onboarding.tsx +0 -203
- package/src/components/OptionPicker.tsx +0 -134
- package/src/components/PlanConfirm.tsx +0 -37
- package/src/components/PrivateerLogin.tsx +0 -109
- package/src/components/PromptInput.tsx +0 -602
- package/src/components/RewindPicker.tsx +0 -69
- package/src/components/Root.tsx +0 -116
- package/src/components/SessionPicker.tsx +0 -64
- package/src/components/StatusBar.tsx +0 -131
- package/src/components/TodoPanel.tsx +0 -36
- package/src/components/ToolCallView.tsx +0 -113
- package/src/components/Transcript.tsx +0 -210
- package/src/components/figures.ts +0 -14
- package/src/components/promptModel.ts +0 -73
- package/src/components/spinnerVerbs.ts +0 -46
- package/src/components/theme.ts +0 -57
- package/src/components/types.ts +0 -34
- package/src/components/useTeeShield.ts +0 -104
- package/src/components/useTerminalWidth.ts +0 -24
- package/src/components/useZdrShield.ts +0 -126
- package/src/config/load.ts +0 -115
- package/src/config/schema.ts +0 -94
- package/src/context/outputStyles.ts +0 -42
- package/src/context/projectInfo.ts +0 -59
- package/src/context/systemPrompt.ts +0 -176
- package/src/engine/QueryEngine.ts +0 -399
- package/src/hooks/engine.ts +0 -155
- package/src/main.tsx +0 -209
- package/src/mcp/client.ts +0 -251
- package/src/mcp/oauth.ts +0 -245
- package/src/memory/auto.ts +0 -146
- package/src/memory/checkpoints.ts +0 -227
- package/src/memory/store.ts +0 -127
- package/src/providers/attestation.ts +0 -149
- package/src/providers/capabilities.ts +0 -104
- package/src/providers/models.ts +0 -183
- package/src/providers/registry.ts +0 -71
- package/src/providers/resolve.ts +0 -78
- package/src/skills/installer.ts +0 -222
- package/src/skills/loader.ts +0 -88
- package/src/tools/askUser.ts +0 -92
- package/src/tools/bash.ts +0 -98
- package/src/tools/context.ts +0 -128
- package/src/tools/edit.ts +0 -67
- package/src/tools/exec.ts +0 -60
- package/src/tools/glob.ts +0 -39
- package/src/tools/grep.ts +0 -86
- package/src/tools/index.ts +0 -83
- package/src/tools/memory.ts +0 -53
- package/src/tools/processRegistry.ts +0 -77
- package/src/tools/read.ts +0 -42
- package/src/tools/sendFileToClient.ts +0 -55
- package/src/tools/skill.ts +0 -44
- package/src/tools/task.ts +0 -52
- package/src/tools/todo.ts +0 -36
- package/src/tools/todoStore.ts +0 -31
- package/src/tools/walk.ts +0 -44
- package/src/tools/web.ts +0 -145
- package/src/tools/worktree.ts +0 -145
- package/src/tools/write.ts +0 -40
- package/src/util/images.ts +0 -378
- package/src/util/limit.ts +0 -32
- package/src/version.ts +0 -13
package/src/daemon/index.ts
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import type { Server } from "node:net";
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
|
|
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
|
|
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
|
|
25
|
-
// a routine firing with nobody watching can't mutate the
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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;
|
|
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.
|
|
162
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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, {
|
|
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();
|
|
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/
|
|
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
|
package/src/engine/errors.ts
CHANGED
|
@@ -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
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
|
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
|
|
120
|
-
const facts = extract(inner);
|
|
133
|
+
const facts = extract(err);
|
|
121
134
|
const status = facts.statusCode;
|
|
122
|
-
const text = facts.providerMessage ?? rawMessage(
|
|
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 (
|
|
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:
|
|
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
|
});
|