privateer-agent 0.1.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 (106) hide show
  1. package/README.md +143 -398
  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 +128 -1
  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 -44
  11. package/src/config/permissionMode.ts +5 -0
  12. package/src/crypto/outboxSeal.ts +61 -0
  13. package/src/daemon/index.ts +405 -0
  14. package/src/daemon/ipc.ts +127 -0
  15. package/src/engine/errors.ts +95 -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 +13 -11
  21. package/src/permissions/mode.ts +9 -1
  22. package/src/permissions/{uiGate.ts → modeGate.ts} +22 -6
  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 +91 -4
  27. package/src/remote/remoteBridge.ts +152 -0
  28. package/src/routines/cron.ts +109 -0
  29. package/src/routines/delivery.ts +184 -0
  30. package/src/routines/schema.ts +84 -0
  31. package/src/routines/store.ts +248 -0
  32. package/src/routines/toolSelect.ts +50 -0
  33. package/src/routines/trigger.ts +41 -0
  34. package/src/session.ts +84 -251
  35. package/src/tools/routine.ts +129 -0
  36. package/src/tools/saveAttachment.ts +39 -42
  37. package/src/tools/sendFile.ts +75 -0
  38. package/src/util/attachmentStore.ts +18 -35
  39. package/src/util/redact.ts +33 -3
  40. package/LICENSE +0 -21
  41. package/src/agents/loader.ts +0 -49
  42. package/src/commands/custom.ts +0 -75
  43. package/src/commands/registry.ts +0 -499
  44. package/src/components/AgentGroupView.tsx +0 -104
  45. package/src/components/App.tsx +0 -1420
  46. package/src/components/ApprovalPrompt.tsx +0 -38
  47. package/src/components/Banner.tsx +0 -76
  48. package/src/components/Markdown.tsx +0 -183
  49. package/src/components/ModeHint.tsx +0 -40
  50. package/src/components/ModelPicker.tsx +0 -269
  51. package/src/components/Onboarding.tsx +0 -203
  52. package/src/components/PlanConfirm.tsx +0 -37
  53. package/src/components/PrivateerLogin.tsx +0 -109
  54. package/src/components/PromptInput.tsx +0 -602
  55. package/src/components/RewindPicker.tsx +0 -69
  56. package/src/components/Root.tsx +0 -95
  57. package/src/components/SessionPicker.tsx +0 -64
  58. package/src/components/StatusBar.tsx +0 -131
  59. package/src/components/TodoPanel.tsx +0 -36
  60. package/src/components/ToolCallView.tsx +0 -109
  61. package/src/components/Transcript.tsx +0 -203
  62. package/src/components/figures.ts +0 -14
  63. package/src/components/promptModel.ts +0 -73
  64. package/src/components/spinnerVerbs.ts +0 -46
  65. package/src/components/theme.ts +0 -55
  66. package/src/components/types.ts +0 -34
  67. package/src/components/useTeeShield.ts +0 -104
  68. package/src/components/useTerminalWidth.ts +0 -24
  69. package/src/components/useZdrShield.ts +0 -126
  70. package/src/config/load.ts +0 -115
  71. package/src/config/schema.ts +0 -94
  72. package/src/context/outputStyles.ts +0 -42
  73. package/src/context/projectInfo.ts +0 -59
  74. package/src/context/systemPrompt.ts +0 -167
  75. package/src/engine/QueryEngine.ts +0 -399
  76. package/src/hooks/engine.ts +0 -155
  77. package/src/main.tsx +0 -167
  78. package/src/mcp/client.ts +0 -236
  79. package/src/mcp/oauth.ts +0 -245
  80. package/src/memory/auto.ts +0 -146
  81. package/src/memory/checkpoints.ts +0 -227
  82. package/src/memory/store.ts +0 -127
  83. package/src/providers/attestation.ts +0 -149
  84. package/src/providers/capabilities.ts +0 -104
  85. package/src/providers/models.ts +0 -183
  86. package/src/providers/registry.ts +0 -71
  87. package/src/providers/resolve.ts +0 -78
  88. package/src/tools/bash.ts +0 -98
  89. package/src/tools/context.ts +0 -114
  90. package/src/tools/edit.ts +0 -67
  91. package/src/tools/exec.ts +0 -60
  92. package/src/tools/glob.ts +0 -39
  93. package/src/tools/grep.ts +0 -86
  94. package/src/tools/index.ts +0 -69
  95. package/src/tools/memory.ts +0 -53
  96. package/src/tools/processRegistry.ts +0 -77
  97. package/src/tools/read.ts +0 -42
  98. package/src/tools/task.ts +0 -52
  99. package/src/tools/todo.ts +0 -36
  100. package/src/tools/todoStore.ts +0 -31
  101. package/src/tools/walk.ts +0 -44
  102. package/src/tools/web.ts +0 -145
  103. package/src/tools/write.ts +0 -40
  104. package/src/util/images.ts +0 -356
  105. package/src/util/limit.ts +0 -32
  106. package/src/version.ts +0 -13
@@ -0,0 +1,405 @@
1
+ import type { Server } from "node:net";
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";
15
+ import { RelayClient } from "../remote/relayClient.ts";
16
+ import { hasCredentials, revokeChildSession, apiRequest, spawnAccountCredentials } from "../auth/privateer.ts";
17
+ import {
18
+ loadRoutines,
19
+ upsertRoutine,
20
+ findRoutine,
21
+ removeRoutine,
22
+ addPendingRelay,
23
+ drainPendingRelay,
24
+ addPendingCloud,
25
+ loadPendingCloud,
26
+ savePendingCloud,
27
+ type PendingCloud,
28
+ routineRelayId,
29
+ } from "../routines/store.ts";
30
+ import type { Routine } from "../routines/schema.ts";
31
+ import { triggerError, computeNextRun, advanceAfterRun } from "../routines/trigger.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";
36
+ import { startIpcServer, type IpcRequest, type IpcResponse } from "./ipc.ts";
37
+
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"];
44
+
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
+ }
78
+
79
+ function log(msg: string): void {
80
+ process.stdout.write(`[${new Date().toISOString()}] ${msg}\n`);
81
+ }
82
+
83
+ function formatResult(routine: Routine, body: string, status: "ok" | "error", error?: string): string {
84
+ const when = new Date().toISOString();
85
+ const head = `# ${routine.name}\n\n_${when} · ${status}${routine.model ? ` · ${routine.model}` : ""}_\n\n`;
86
+ if (status === "error") return `${head}**Run failed:** ${error ?? "unknown error"}\n\n${body}`.trimEnd() + "\n";
87
+ return `${head}${body.trim() || "(no output)"}\n`;
88
+ }
89
+
90
+ export class Daemon {
91
+ private server?: Server;
92
+ private timer?: ReturnType<typeof setInterval>;
93
+ private readonly startedAt = Date.now();
94
+ private readonly running = new Set<string>();
95
+ private relay?: RelayClient;
96
+ private controllerAttached = false;
97
+ private relayTerminated = false;
98
+
99
+ private readonly pushRelay: RelayPusher = (routine, content) => {
100
+ if (this.controllerAttached && this.relay?.sendRoutineResult(routine.name, content)) return "live";
101
+ addPendingRelay({ routine: routine.name, at: new Date().toISOString(), content });
102
+ return "queued";
103
+ };
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
+
114
+ start(): void {
115
+ this.primeSchedule();
116
+ this.timer = setInterval(() => void this.tick(), TICK_MS);
117
+ this.server = startIpcServer((req) => this.handleIpc(req));
118
+ this.syncRelay();
119
+ void this.flushPendingCloud();
120
+ const count = loadRoutines().filter((r) => r.enabled).length;
121
+ log(`daemon started (pid ${process.pid}); ${count} enabled routine(s). Tick every ${TICK_MS / 1000}s.`);
122
+ void this.tick();
123
+ }
124
+
125
+ stop(): void {
126
+ if (this.timer) clearInterval(this.timer);
127
+ this.server?.close();
128
+ this.relay?.stop();
129
+ }
130
+
131
+ private syncRelay(): void {
132
+ if (this.relay || this.relayTerminated) return;
133
+ if (!hasCredentials()) return;
134
+ const wantsRelay = loadRoutines().some((r) => r.enabled && r.delivery.includes("relay"));
135
+ if (!wantsRelay) return;
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
+ },
154
+ },
155
+ { termId: routineRelayId(), label: "Privateer Routines" },
156
+ );
157
+ void this.relay.start();
158
+ log("relay connection starting (routine has relay delivery + account signed in)");
159
+ }
160
+
161
+ private onControllerAttached(): void {
162
+ this.controllerAttached = true;
163
+ this.relay?.sendSnapshot([{ kind: "notice", text: "Privateer routines — results will appear here as they run." }]);
164
+ const pending = drainPendingRelay();
165
+ if (pending.length === 0) return;
166
+ log(`controller attached — flushing ${pending.length} pending routine result(s)`);
167
+ for (const p of pending) this.relay?.sendRoutineResult(p.routine, p.content);
168
+ }
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
+
218
+ private primeSchedule(): void {
219
+ for (const r of loadRoutines()) {
220
+ if (!r.enabled) continue;
221
+ if (r.nextRun && !Number.isNaN(Date.parse(r.nextRun))) continue;
222
+ const nr = computeNextRun(r);
223
+ if (nr) this.persistRun(r.id, { nextRun: nr.toISOString() });
224
+ }
225
+ }
226
+
227
+ private async tick(): Promise<void> {
228
+ void this.flushPendingCloud();
229
+ const now = Date.now();
230
+ for (const r of loadRoutines()) {
231
+ if (!r.enabled || this.running.has(r.id)) continue;
232
+ if (triggerError(r)) continue;
233
+ if (!r.nextRun) {
234
+ const nr = computeNextRun(r);
235
+ this.persistRun(r.id, { nextRun: nr?.toISOString() });
236
+ continue;
237
+ }
238
+ if (Date.parse(r.nextRun) <= now) {
239
+ await this.runRoutine(r);
240
+ }
241
+ }
242
+ }
243
+
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.
247
+ async runRoutine(routine: Routine): Promise<IpcResponse> {
248
+ if (this.running.has(routine.id)) return { ok: false, message: "already running" };
249
+ this.running.add(routine.id);
250
+ log(`running routine "${routine.name}"`);
251
+
252
+ const config = loadDaemonConfig();
253
+ const modelSpec = routine.model ?? config.defaultModel;
254
+ const split = splitRoutineTools(routine.tools);
255
+ const allowedTools = split.builtin.length > 0 ? split.builtin : SAFE_TOOLS;
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");
258
+ }
259
+
260
+ let out = "";
261
+ let status: "ok" | "error" = "ok";
262
+ let error: string | undefined;
263
+ try {
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: [],
271
+ cwd: routine.cwd,
272
+ confineToCwd: true,
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
+ },
283
+ });
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}`);
292
+ }
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
+ }
318
+ } catch (err) {
319
+ status = "error";
320
+ error = err instanceof Error ? err.message : String(err);
321
+ }
322
+
323
+ const content = formatResult(routine, out, status, error);
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
+ });
330
+ log(` "${routine.name}" ${status}; delivered via ${report.delivered.join(", ") || "(none)"}`);
331
+
332
+ this.persistRun(routine.id, {
333
+ lastRun: new Date().toISOString(),
334
+ lastStatus: status,
335
+ lastError: error,
336
+ ...advanceAfterRun(routine),
337
+ });
338
+ this.running.delete(routine.id);
339
+ return { ok: status === "ok", message: report.delivered.join(", ") || undefined };
340
+ }
341
+
342
+ private persistRun(id: string, patch: Partial<Routine>): void {
343
+ const current = findRoutine(loadRoutines(), id);
344
+ if (!current) return;
345
+ upsertRoutine({ ...current, ...patch });
346
+ }
347
+
348
+ private async handleIpc(req: IpcRequest): Promise<IpcResponse> {
349
+ switch (req.cmd) {
350
+ case "status":
351
+ return { ok: true, pid: process.pid, uptimeSec: Math.round((Date.now() - this.startedAt) / 1000), routines: loadRoutines() };
352
+ case "list":
353
+ return { ok: true, routines: loadRoutines() };
354
+ case "add": {
355
+ const err = triggerError(req.routine);
356
+ if (err) return { ok: false, message: `invalid trigger: ${err}` };
357
+ const nr = computeNextRun(req.routine);
358
+ upsertRoutine({ ...req.routine, nextRun: nr?.toISOString() });
359
+ this.syncRelay();
360
+ return { ok: true, message: `routine "${req.routine.name}" saved`, routines: loadRoutines() };
361
+ }
362
+ case "remove": {
363
+ const removed = removeRoutine(req.idOrName);
364
+ return removed
365
+ ? { ok: true, message: `removed "${removed.name}"`, routines: loadRoutines() }
366
+ : { ok: false, message: `no routine "${req.idOrName}"` };
367
+ }
368
+ case "pause":
369
+ case "resume": {
370
+ const r = findRoutine(loadRoutines(), req.idOrName);
371
+ if (!r) return { ok: false, message: `no routine "${req.idOrName}"` };
372
+ const enabled = req.cmd === "resume";
373
+ const nr = enabled ? computeNextRun(r)?.toISOString() : undefined;
374
+ upsertRoutine({ ...r, enabled, nextRun: nr });
375
+ if (enabled) this.syncRelay();
376
+ return { ok: true, message: `${enabled ? "resumed" : "paused"} "${r.name}"`, routines: loadRoutines() };
377
+ }
378
+ case "run-now": {
379
+ const r = findRoutine(loadRoutines(), req.idOrName);
380
+ if (!r) return { ok: false, message: `no routine "${req.idOrName}"` };
381
+ void this.runRoutine(r);
382
+ return { ok: true, message: `running "${r.name}" now` };
383
+ }
384
+ case "reload":
385
+ this.primeSchedule();
386
+ this.syncRelay();
387
+ return { ok: true, message: "schedule reloaded", routines: loadRoutines() };
388
+ default:
389
+ return { ok: false, message: "unknown command" };
390
+ }
391
+ }
392
+ }
393
+
394
+ // Entry point for `privateer daemon`. Caller must have imported ./boot.ts first.
395
+ export function runDaemon(): void {
396
+ const daemon = new Daemon();
397
+ daemon.start();
398
+ const shutdown = () => {
399
+ log("shutting down");
400
+ daemon.stop();
401
+ void revokeChildSession().finally(() => process.exit(0));
402
+ };
403
+ process.on("SIGINT", shutdown);
404
+ process.on("SIGTERM", shutdown);
405
+ }
@@ -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/paths.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
+ }