privateer-agent 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -33
- package/package.json +1 -1
- package/src/auth/privateer.ts +71 -1
- package/src/commands/custom.ts +52 -4
- package/src/commands/registry.ts +124 -5
- package/src/components/App.tsx +222 -16
- package/src/components/ApprovalPrompt.tsx +15 -4
- package/src/components/Banner.tsx +3 -1
- package/src/components/ModelPicker.tsx +45 -12
- package/src/components/OptionPicker.tsx +134 -0
- package/src/components/Root.tsx +30 -9
- package/src/components/ToolCallView.tsx +4 -0
- package/src/components/Transcript.tsx +14 -7
- package/src/components/theme.ts +2 -0
- package/src/config/paths.ts +2 -0
- package/src/context/systemPrompt.ts +9 -0
- package/src/daemon/index.ts +322 -0
- package/src/daemon/ipc.ts +127 -0
- package/src/engine/errors.ts +10 -0
- package/src/main.tsx +43 -1
- package/src/mcp/client.ts +16 -1
- package/src/permissions/gate.ts +5 -0
- package/src/permissions/mode.ts +4 -0
- package/src/permissions/uiGate.ts +4 -3
- package/src/remote/relayClient.ts +76 -4
- package/src/routines/cron.ts +109 -0
- package/src/routines/delivery.ts +75 -0
- package/src/routines/schema.ts +65 -0
- package/src/routines/store.ts +205 -0
- package/src/routines/toolSelect.ts +48 -0
- package/src/routines/trigger.ts +41 -0
- package/src/session.ts +37 -12
- package/src/skills/installer.ts +222 -0
- package/src/skills/loader.ts +88 -0
- package/src/tools/askUser.ts +92 -0
- package/src/tools/context.ts +14 -0
- package/src/tools/index.ts +14 -0
- package/src/tools/routine.ts +110 -0
- package/src/tools/sendFileToClient.ts +55 -0
- package/src/tools/skill.ts +44 -0
- package/src/tools/worktree.ts +145 -0
- package/src/util/images.ts +22 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { createServer, createConnection, type Socket, type Server } from "node:net";
|
|
2
|
+
import { existsSync, unlinkSync, chmodSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { globalDir } from "../config/load.ts";
|
|
5
|
+
import type { Routine } from "../routines/schema.ts";
|
|
6
|
+
|
|
7
|
+
// The CLI/TUI talks to the resident daemon over a unix domain socket. The protocol
|
|
8
|
+
// is one JSON request per connection, answered with one JSON response, both
|
|
9
|
+
// newline-terminated. Kept tiny and local — nothing crosses the machine boundary.
|
|
10
|
+
|
|
11
|
+
export function daemonSocketPath(): string {
|
|
12
|
+
return join(globalDir(), "daemon.sock");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type IpcRequest =
|
|
16
|
+
| { cmd: "status" }
|
|
17
|
+
| { cmd: "list" }
|
|
18
|
+
| { cmd: "add"; routine: Routine }
|
|
19
|
+
| { cmd: "remove"; idOrName: string }
|
|
20
|
+
| { cmd: "pause"; idOrName: string }
|
|
21
|
+
| { cmd: "resume"; idOrName: string }
|
|
22
|
+
| { cmd: "run-now"; idOrName: string }
|
|
23
|
+
| { cmd: "reload" };
|
|
24
|
+
|
|
25
|
+
export interface IpcResponse {
|
|
26
|
+
ok: boolean;
|
|
27
|
+
message?: string;
|
|
28
|
+
routines?: Routine[];
|
|
29
|
+
// Daemon liveness/uptime for `status`.
|
|
30
|
+
pid?: number;
|
|
31
|
+
uptimeSec?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type IpcHandler = (req: IpcRequest) => Promise<IpcResponse> | IpcResponse;
|
|
35
|
+
|
|
36
|
+
// Start the daemon-side socket server. Returns the Server so the caller can close it.
|
|
37
|
+
export function startIpcServer(handler: IpcHandler): Server {
|
|
38
|
+
const path = daemonSocketPath();
|
|
39
|
+
// A stale socket file from a previous crash would block bind; remove it first.
|
|
40
|
+
if (existsSync(path)) {
|
|
41
|
+
try {
|
|
42
|
+
unlinkSync(path);
|
|
43
|
+
} catch {
|
|
44
|
+
/* ignore — bind will surface a clearer error */
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const server = createServer((sock: Socket) => {
|
|
48
|
+
let buf = "";
|
|
49
|
+
sock.on("data", (chunk) => {
|
|
50
|
+
buf += chunk.toString("utf8");
|
|
51
|
+
const nl = buf.indexOf("\n");
|
|
52
|
+
if (nl < 0) return; // wait for the full line
|
|
53
|
+
const line = buf.slice(0, nl);
|
|
54
|
+
void (async () => {
|
|
55
|
+
let res: IpcResponse;
|
|
56
|
+
try {
|
|
57
|
+
res = await handler(JSON.parse(line) as IpcRequest);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
res = { ok: false, message: err instanceof Error ? err.message : String(err) };
|
|
60
|
+
}
|
|
61
|
+
sock.end(JSON.stringify(res) + "\n");
|
|
62
|
+
})();
|
|
63
|
+
});
|
|
64
|
+
sock.on("error", () => sock.destroy());
|
|
65
|
+
});
|
|
66
|
+
server.listen(path, () => {
|
|
67
|
+
try {
|
|
68
|
+
chmodSync(path, 0o600); // owner-only IPC endpoint
|
|
69
|
+
} catch {
|
|
70
|
+
/* non-POSIX — best effort */
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
return server;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Client side: send one request, resolve with the response. Rejects if the daemon
|
|
77
|
+
// isn't running (no socket / connection refused) so callers can offer to start it.
|
|
78
|
+
export function sendToDaemon(req: IpcRequest, timeoutMs = 5_000): Promise<IpcResponse> {
|
|
79
|
+
const path = daemonSocketPath();
|
|
80
|
+
return new Promise<IpcResponse>((resolve, reject) => {
|
|
81
|
+
if (!existsSync(path)) {
|
|
82
|
+
reject(new DaemonNotRunningError());
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const sock = createConnection(path);
|
|
86
|
+
let buf = "";
|
|
87
|
+
const timer = setTimeout(() => {
|
|
88
|
+
sock.destroy();
|
|
89
|
+
reject(new Error("daemon did not respond in time"));
|
|
90
|
+
}, timeoutMs);
|
|
91
|
+
sock.on("connect", () => sock.end(JSON.stringify(req) + "\n"));
|
|
92
|
+
sock.on("data", (chunk) => {
|
|
93
|
+
buf += chunk.toString("utf8");
|
|
94
|
+
});
|
|
95
|
+
sock.on("end", () => {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
try {
|
|
98
|
+
resolve(JSON.parse(buf.trim()) as IpcResponse);
|
|
99
|
+
} catch {
|
|
100
|
+
reject(new Error("malformed response from daemon"));
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
sock.on("error", (err: NodeJS.ErrnoException) => {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
// ECONNREFUSED means a stale socket file with no listener behind it.
|
|
106
|
+
if (err.code === "ENOENT" || err.code === "ECONNREFUSED") reject(new DaemonNotRunningError());
|
|
107
|
+
else reject(err);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export class DaemonNotRunningError extends Error {
|
|
113
|
+
constructor() {
|
|
114
|
+
super("Privateer daemon is not running. Start it with `privateer daemon`.");
|
|
115
|
+
this.name = "DaemonNotRunningError";
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Convenience: is the daemon reachable right now?
|
|
120
|
+
export async function daemonIsRunning(): Promise<boolean> {
|
|
121
|
+
try {
|
|
122
|
+
const res = await sendToDaemon({ cmd: "status" }, 2_000);
|
|
123
|
+
return res.ok;
|
|
124
|
+
} catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
package/src/engine/errors.ts
CHANGED
|
@@ -153,6 +153,16 @@ export function describeError(err: unknown): DescribedError {
|
|
|
153
153
|
hint: "Upgrade or top up your Privateer account, or run /provider to use your own API key.",
|
|
154
154
|
});
|
|
155
155
|
}
|
|
156
|
+
// Privateer machine-login expiry (thrown by the session spawn after the
|
|
157
|
+
// server rejects the parent refresh token). The stored credentials are
|
|
158
|
+
// already wiped; the only fix is a fresh /login, so say exactly that and
|
|
159
|
+
// never mark it retryable.
|
|
160
|
+
if (/privateer session expired/i.test(text)) {
|
|
161
|
+
return out({
|
|
162
|
+
message: "Your Privateer session expired — this terminal was signed out.",
|
|
163
|
+
hint: "Run /login to sign back in to your Privateer account.",
|
|
164
|
+
});
|
|
165
|
+
}
|
|
156
166
|
if (status === 401 || status === 403) {
|
|
157
167
|
return out({
|
|
158
168
|
message: `Authentication failed${forProvider} (${status}).`,
|
package/src/main.tsx
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { render } from "ink";
|
|
3
3
|
import { Command } from "commander";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { openSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
4
7
|
import { Root } from "./components/Root.tsx";
|
|
5
8
|
import { NAME, VERSION, DESCRIPTION } from "./version.ts";
|
|
6
|
-
import { loadConfig } from "./config/load.ts";
|
|
9
|
+
import { loadConfig, globalDir } from "./config/load.ts";
|
|
7
10
|
import { createSession } from "./session.ts";
|
|
8
11
|
import { loadLatest, loadSession } from "./memory/store.ts";
|
|
9
12
|
import { configuredProviders } from "./providers/resolve.ts";
|
|
10
13
|
import { describeError } from "./engine/errors.ts";
|
|
14
|
+
import { runDaemon } from "./daemon/index.ts";
|
|
15
|
+
import { revokeChildSession } from "./auth/privateer.ts";
|
|
11
16
|
|
|
12
17
|
// Set while Ink owns the screen. A stray unhandled rejection while the TUI is up
|
|
13
18
|
// must NOT reach stdout/stderr — Node's default printer dumps the whole error
|
|
@@ -58,6 +63,16 @@ async function main() {
|
|
|
58
63
|
.option("-r, --resume <id>", "resume a specific session by id (printed on exit)")
|
|
59
64
|
.option("--onboard", "run the provider/key setup flow")
|
|
60
65
|
.action(async (promptParts: string[], options: CliOptions) => {
|
|
66
|
+
// Terminal-window close (SIGHUP) or a kill (SIGTERM) bypasses the normal
|
|
67
|
+
// exit path below — revoke this terminal's Privateer session first so it
|
|
68
|
+
// drops off the app's Linked Devices immediately instead of lingering
|
|
69
|
+
// until server-side expiry. Installing a handler replaces Node's default
|
|
70
|
+
// terminate-on-signal, so exit explicitly with the conventional code.
|
|
71
|
+
const revokeAndExit = (code: number) => () => {
|
|
72
|
+
void revokeChildSession().finally(() => process.exit(code));
|
|
73
|
+
};
|
|
74
|
+
process.on("SIGHUP", revokeAndExit(129));
|
|
75
|
+
process.on("SIGTERM", revokeAndExit(143));
|
|
61
76
|
try {
|
|
62
77
|
if (options.cwd) process.chdir(options.cwd);
|
|
63
78
|
const config = loadConfig();
|
|
@@ -79,6 +94,7 @@ async function main() {
|
|
|
79
94
|
|
|
80
95
|
if (options.print) {
|
|
81
96
|
await runPrint(modelSpec, promptParts.join(" ").trim(), config.confineToCwd);
|
|
97
|
+
await revokeChildSession();
|
|
82
98
|
return;
|
|
83
99
|
}
|
|
84
100
|
|
|
@@ -101,6 +117,11 @@ async function main() {
|
|
|
101
117
|
await waitUntilExit();
|
|
102
118
|
tuiActive = false;
|
|
103
119
|
|
|
120
|
+
// This terminal is done — release its Privateer session so it leaves the
|
|
121
|
+
// app's Linked Devices right away. Started before the resume hint prints
|
|
122
|
+
// and awaited after, so it doesn't delay the output.
|
|
123
|
+
const revoked = revokeChildSession();
|
|
124
|
+
|
|
104
125
|
// On exit, print a hash that resumes this conversation later (à la Claude
|
|
105
126
|
// Code). The latest persisted session carries the id used this run; it only
|
|
106
127
|
// exists once at least one turn has been saved.
|
|
@@ -108,6 +129,7 @@ async function main() {
|
|
|
108
129
|
if (last && last.messages.length > 0) {
|
|
109
130
|
process.stdout.write(`\nResume this session: ${NAME} --resume ${last.id}\n`);
|
|
110
131
|
}
|
|
132
|
+
await revoked;
|
|
111
133
|
} catch (err) {
|
|
112
134
|
// Configuration/resolution errors are expected and user-facing — print them
|
|
113
135
|
// cleanly without a stack trace.
|
|
@@ -116,6 +138,26 @@ async function main() {
|
|
|
116
138
|
}
|
|
117
139
|
});
|
|
118
140
|
|
|
141
|
+
// The scheduler daemon: a resident process that fires routines on their cron
|
|
142
|
+
// schedule. Runs in the foreground by default; --detach forks a background copy.
|
|
143
|
+
program
|
|
144
|
+
.command("daemon")
|
|
145
|
+
.description("run the scheduler that fires saved routines on their cron schedule")
|
|
146
|
+
.option("--detach", "start the daemon in the background and return")
|
|
147
|
+
.action((opts: { detach?: boolean }) => {
|
|
148
|
+
if (opts.detach) {
|
|
149
|
+
const logPath = join(globalDir(), "daemon.log");
|
|
150
|
+
const out = openSync(logPath, "a");
|
|
151
|
+
// Re-invoke this same runtime + script without --detach, fully detached.
|
|
152
|
+
const args = process.argv.slice(1).filter((a) => a !== "--detach");
|
|
153
|
+
const child = spawn(process.argv[0], args, { detached: true, stdio: ["ignore", out, out] });
|
|
154
|
+
child.unref();
|
|
155
|
+
process.stdout.write(`Daemon started in background (pid ${child.pid}).\nLogs: ${logPath}\n`);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
runDaemon();
|
|
159
|
+
});
|
|
160
|
+
|
|
119
161
|
await program.parseAsync(process.argv);
|
|
120
162
|
}
|
|
121
163
|
|
package/src/mcp/client.ts
CHANGED
|
@@ -23,6 +23,10 @@ export interface McpToolDef {
|
|
|
23
23
|
name: string;
|
|
24
24
|
description?: string;
|
|
25
25
|
inputSchema?: Record<string, unknown>;
|
|
26
|
+
// Standard MCP behavioral hints. We use `destructiveHint`/`readOnlyHint` to
|
|
27
|
+
// decide whether a tool may auto-approve: a tool that performs an irreversible
|
|
28
|
+
// external action marks itself destructive so it ALWAYS reaches the human.
|
|
29
|
+
annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean };
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
// Read mcp.json from project then user scope (project overrides). Accepts either a
|
|
@@ -148,7 +152,12 @@ export class McpClient {
|
|
|
148
152
|
async listTools(): Promise<McpToolDef[]> {
|
|
149
153
|
const res = await this.client!.listTools();
|
|
150
154
|
return Array.isArray(res?.tools)
|
|
151
|
-
? res.tools.map((t) => ({
|
|
155
|
+
? res.tools.map((t) => ({
|
|
156
|
+
name: t.name,
|
|
157
|
+
description: t.description,
|
|
158
|
+
inputSchema: t.inputSchema as Record<string, unknown>,
|
|
159
|
+
annotations: t.annotations as McpToolDef["annotations"],
|
|
160
|
+
}))
|
|
152
161
|
: [];
|
|
153
162
|
}
|
|
154
163
|
|
|
@@ -184,6 +193,11 @@ export function adaptMcpTools(
|
|
|
184
193
|
const set: ToolSet = {};
|
|
185
194
|
for (const d of defs) {
|
|
186
195
|
const name = `${server}__${d.name}`;
|
|
196
|
+
// A mutating tool (e.g. send email, delete file) marks itself destructive. We
|
|
197
|
+
// map that to `alwaysAsk`, which the gate never auto-approves — so it always
|
|
198
|
+
// prompts the human (phone on remote turns, terminal otherwise) even under
|
|
199
|
+
// bypass mode or the allowlist. Read-only tools follow the normal policy.
|
|
200
|
+
const destructive = d.annotations?.destructiveHint === true && d.annotations?.readOnlyHint !== true;
|
|
187
201
|
set[name] = tool({
|
|
188
202
|
description: d.description ?? `${d.name} (MCP server: ${server})`,
|
|
189
203
|
inputSchema: jsonSchema((d.inputSchema as any) ?? { type: "object", properties: {} }),
|
|
@@ -193,6 +207,7 @@ export function adaptMcpTools(
|
|
|
193
207
|
kind: "fetch",
|
|
194
208
|
title: `MCP ${server}: ${d.name}`,
|
|
195
209
|
detail: JSON.stringify(args ?? {}).slice(0, 120),
|
|
210
|
+
alwaysAsk: destructive,
|
|
196
211
|
});
|
|
197
212
|
if (decision === "deny") throw new PermissionDeniedError(name);
|
|
198
213
|
return client.callTool(d.name, args);
|
package/src/permissions/gate.ts
CHANGED
|
@@ -14,6 +14,11 @@ export interface PermissionRequest {
|
|
|
14
14
|
title: string; // short action label, e.g. "Run command"
|
|
15
15
|
detail: string; // the command, or file path + change preview
|
|
16
16
|
protected?: boolean; // target is a guarded file: never auto-approve, always prompt
|
|
17
|
+
// Always require a human decision, ABOVE bypass mode and the allowlist (like a
|
|
18
|
+
// dangerous shell command). Set for MCP tools that declare themselves
|
|
19
|
+
// destructive (destructiveHint), so even a "take no prisoners" run can't fire
|
|
20
|
+
// an irreversible external action silently. The decision is never remembered.
|
|
21
|
+
alwaysAsk?: boolean;
|
|
17
22
|
// Target resolves outside the working directory: never auto-approve (unless bypass),
|
|
18
23
|
// always prompt. `path` carries the absolute target so "always" can remember its dir.
|
|
19
24
|
outside?: boolean;
|
package/src/permissions/mode.ts
CHANGED
|
@@ -27,6 +27,10 @@ export function decideAuto(
|
|
|
27
27
|
// Dangerous shell (destructive / secret-exfil) always confirms — this sits
|
|
28
28
|
// above bypass and the allowlist so an injected command can't run silently.
|
|
29
29
|
if (req.kind === "bash" && isDangerousCommand(req.detail, denylist)) return "ask";
|
|
30
|
+
// Explicitly destructive actions (e.g. an MCP tool that declares destructiveHint)
|
|
31
|
+
// always confirm too — also above bypass, so "skip permissions" can't fire them
|
|
32
|
+
// blind.
|
|
33
|
+
if (req.alwaysAsk) return "ask";
|
|
30
34
|
if (mode === "bypass") return "allow";
|
|
31
35
|
// Access outside the working directory always confirms (the user has to explicitly
|
|
32
36
|
// allow leaving cwd), even under acceptEdits or the allowlist.
|
|
@@ -48,9 +48,10 @@ export class ModeGate implements PermissionGate {
|
|
|
48
48
|
|
|
49
49
|
if (auto !== "ask") return auto;
|
|
50
50
|
|
|
51
|
-
// A dangerous command
|
|
52
|
-
//
|
|
53
|
-
|
|
51
|
+
// A dangerous command (or an always-ask destructive action) can be approved
|
|
52
|
+
// once, but is never remembered: adding it to the allowlist or relaxing the
|
|
53
|
+
// mode would let a later variant slip through.
|
|
54
|
+
const dangerous = req.alwaysAsk === true || (req.kind === "bash" && isDangerousCommand(req.detail, denylist));
|
|
54
55
|
|
|
55
56
|
const outcome = await this.deps.ask(req);
|
|
56
57
|
if (outcome === "deny") return "deny";
|
|
@@ -54,6 +54,11 @@ export interface RelayCallbacks {
|
|
|
54
54
|
onPrompt: (text: string) => void;
|
|
55
55
|
// The app asked to interrupt the in-flight turn.
|
|
56
56
|
onInterrupt: () => void;
|
|
57
|
+
// The app asked to turn remote access OFF entirely (the in-app "End remote
|
|
58
|
+
// access" action). The owner should disable /remote-access — i.e. stop this
|
|
59
|
+
// client and not reconnect. Optional: the routines daemon handles it too, but
|
|
60
|
+
// callbacks that predate it keep compiling.
|
|
61
|
+
onTerminate?: () => void;
|
|
57
62
|
// The app answered a relayed approval request.
|
|
58
63
|
onApprovalResponse: (id: string, decision: "allow" | "deny") => void;
|
|
59
64
|
// A controller attached — push a transcript snapshot so it can catch up.
|
|
@@ -63,6 +68,8 @@ export interface RelayCallbacks {
|
|
|
63
68
|
onAttachment: (file: { name: string; mediaType: string; base64: string }) => void;
|
|
64
69
|
// Surface a one-line status/notice in the TUI.
|
|
65
70
|
onStatus?: (text: string) => void;
|
|
71
|
+
// The relay socket closed (controller no longer reachable until reconnect).
|
|
72
|
+
onDisconnected?: () => void;
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
const RECONNECT_MS = 3000;
|
|
@@ -71,6 +78,9 @@ const RECONNECT_MS = 3000;
|
|
|
71
78
|
// memory with a lying `size` or a flood of concurrent transfers.
|
|
72
79
|
const MAX_ATTACH_BYTES = 10 * 1024 * 1024; // 10 MB per file
|
|
73
80
|
const MAX_INFLIGHT_ATTACH = 8; // simultaneous transfers
|
|
81
|
+
// Base64 chars per file_chunk frame for agent→app sends (~135 KB decoded), kept
|
|
82
|
+
// under the relay's 256 KB per-frame cap. Mirrors the app's CHUNK_CHARS.
|
|
83
|
+
const FILE_CHUNK_CHARS = 180_000;
|
|
74
84
|
// Coalesce streaming deltas so we don't emit one WS frame per token.
|
|
75
85
|
const TEXT_FLUSH_MS = 60;
|
|
76
86
|
|
|
@@ -125,9 +135,12 @@ export class RelayClient {
|
|
|
125
135
|
private bufKind: "text" | "reasoning" | null = null;
|
|
126
136
|
private buf = "";
|
|
127
137
|
private flushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
128
|
-
// Stable for this process so reconnects keep the same terminal identity.
|
|
129
|
-
|
|
130
|
-
|
|
138
|
+
// Stable for this process so reconnects keep the same terminal identity. Callers
|
|
139
|
+
// may pass a persisted id/label (e.g. the routines daemon, so it shows up as one
|
|
140
|
+
// recognizable "Privateer Routines" terminal across restarts instead of a fresh
|
|
141
|
+
// random one each time).
|
|
142
|
+
private readonly termId: string;
|
|
143
|
+
private readonly label: string;
|
|
131
144
|
// In-progress file transfers from the app, keyed by the controller's attachment
|
|
132
145
|
// id. Reassembled from attach_begin/chunk/end frames, then handed to onAttachment.
|
|
133
146
|
private readonly incoming = new Map<
|
|
@@ -135,7 +148,13 @@ export class RelayClient {
|
|
|
135
148
|
{ name: string; mediaType: string; chunks: string[]; received: number }
|
|
136
149
|
>();
|
|
137
150
|
|
|
138
|
-
constructor(
|
|
151
|
+
constructor(
|
|
152
|
+
private readonly cb: RelayCallbacks,
|
|
153
|
+
opts?: { termId?: string; label?: string },
|
|
154
|
+
) {
|
|
155
|
+
this.termId = opts?.termId ?? randomUUID();
|
|
156
|
+
this.label = opts?.label ?? terminalLabel();
|
|
157
|
+
}
|
|
139
158
|
|
|
140
159
|
async start(): Promise<void> {
|
|
141
160
|
this.closed = false;
|
|
@@ -182,6 +201,7 @@ export class RelayClient {
|
|
|
182
201
|
ws.on("message", (data) => this.handle(data));
|
|
183
202
|
ws.on("close", () => {
|
|
184
203
|
if (this.ws === ws) this.ws = null;
|
|
204
|
+
this.cb.onDisconnected?.();
|
|
185
205
|
if (!this.closed) {
|
|
186
206
|
this.cb.onStatus?.(
|
|
187
207
|
opened
|
|
@@ -247,6 +267,9 @@ export class RelayClient {
|
|
|
247
267
|
case "interrupt":
|
|
248
268
|
this.cb.onInterrupt();
|
|
249
269
|
break;
|
|
270
|
+
case "terminate":
|
|
271
|
+
this.cb.onTerminate?.();
|
|
272
|
+
break;
|
|
250
273
|
case "approval_response":
|
|
251
274
|
if (frame.id) this.cb.onApprovalResponse(frame.id, frame.decision === "deny" ? "deny" : "allow");
|
|
252
275
|
break;
|
|
@@ -320,6 +343,23 @@ export class RelayClient {
|
|
|
320
343
|
|
|
321
344
|
// ── agent → controller ──────────────────────────────────────────────────────
|
|
322
345
|
|
|
346
|
+
// Is the relay socket currently open? (Not the same as "a controller is
|
|
347
|
+
// attached" — the server forwards to a controller only when one is present.)
|
|
348
|
+
isConnected(): boolean {
|
|
349
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Push a finished routine result to any attached controller as a text event, so
|
|
353
|
+
// it renders in the app's live feed. Returns whether the socket was open to send
|
|
354
|
+
// on; a durable channel (file/notice) still backs this up, since we can't know
|
|
355
|
+
// for certain a controller was attached.
|
|
356
|
+
sendRoutineResult(name: string, content: string): boolean {
|
|
357
|
+
if (!this.isConnected()) return false;
|
|
358
|
+
this.flushDeltas();
|
|
359
|
+
this.rawSend({ type: "event", event: { type: "text", text: safe(`⏺ Routine "${name}"\n\n${content}`, 8000) } });
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
|
|
323
363
|
sendEvent(ev: EngineEvent): void {
|
|
324
364
|
if (ev.type === "text") return this.bufferDelta("text", ev.text);
|
|
325
365
|
if (ev.type === "reasoning") return this.bufferDelta("reasoning", ev.text);
|
|
@@ -335,6 +375,38 @@ export class RelayClient {
|
|
|
335
375
|
this.rawSend({ type: "snapshot", entries: trimmed });
|
|
336
376
|
}
|
|
337
377
|
|
|
378
|
+
// ── agent → app file transfer (chunked) ─────────────────────────────────────
|
|
379
|
+
// Reverse of the attach_* path: file_begin → file_chunk* → file_end, each frame
|
|
380
|
+
// under the relay's 256 KB cap. Fire-and-forget past the socket — the server
|
|
381
|
+
// drops frames when no controller is attached and there is no ack, so "ok" means
|
|
382
|
+
// "handed to an open socket", not "the app received it". The payload is base64
|
|
383
|
+
// binary, so no redactSecrets (it would corrupt the bytes) — the caller decides
|
|
384
|
+
// what's safe to send.
|
|
385
|
+
async sendFile(file: {
|
|
386
|
+
name: string;
|
|
387
|
+
mediaType: string;
|
|
388
|
+
base64: string;
|
|
389
|
+
size: number;
|
|
390
|
+
}): Promise<{ ok: boolean; reason?: string }> {
|
|
391
|
+
if (!this.isConnected()) return { ok: false, reason: "relay socket not connected" };
|
|
392
|
+
if (file.size > MAX_ATTACH_BYTES) {
|
|
393
|
+
return { ok: false, reason: `exceeds the ${Math.round(MAX_ATTACH_BYTES / (1024 * 1024))} MB relay limit` };
|
|
394
|
+
}
|
|
395
|
+
this.flushDeltas(); // land the file in order relative to buffered text
|
|
396
|
+
const id = randomUUID();
|
|
397
|
+
this.rawSend({ type: "file_begin", id, name: file.name, mediaType: file.mediaType, size: file.size });
|
|
398
|
+
for (let off = 0, seq = 0; off < file.base64.length; off += FILE_CHUNK_CHARS, seq++) {
|
|
399
|
+
if (!this.isConnected()) return { ok: false, reason: "connection lost mid-transfer" };
|
|
400
|
+
this.rawSend({ type: "file_chunk", id, seq, data: file.base64.slice(off, off + FILE_CHUNK_CHARS) });
|
|
401
|
+
// Yield between frames of a multi-chunk file so a big send doesn't starve
|
|
402
|
+
// the event loop (ws buffers internally; no drain dance needed at ≤10 MB).
|
|
403
|
+
if (file.base64.length > FILE_CHUNK_CHARS) await new Promise((r) => setImmediate(r));
|
|
404
|
+
}
|
|
405
|
+
if (!this.isConnected()) return { ok: false, reason: "connection lost mid-transfer" };
|
|
406
|
+
this.rawSend({ type: "file_end", id });
|
|
407
|
+
return { ok: true };
|
|
408
|
+
}
|
|
409
|
+
|
|
338
410
|
requestApproval(id: string, req: PermissionRequest): void {
|
|
339
411
|
this.rawSend({
|
|
340
412
|
type: "approval_request",
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// A minimal standard 5-field cron parser: "minute hour day-of-month month day-of-week".
|
|
2
|
+
// Supports `*`, single numbers, comma lists (`1,15`), ranges (`1-5`), and steps
|
|
3
|
+
// (`*/2`, `0-30/10`). Month (1-12) and day-of-week (0-6, Sunday=0) also accept the
|
|
4
|
+
// usual three-letter names (jan…dec, sun…sat). Kept dependency-free on purpose.
|
|
5
|
+
|
|
6
|
+
interface CronFields {
|
|
7
|
+
minute: Set<number>;
|
|
8
|
+
hour: Set<number>;
|
|
9
|
+
dom: Set<number>; // day of month
|
|
10
|
+
month: Set<number>;
|
|
11
|
+
dow: Set<number>; // day of week, 0=Sun
|
|
12
|
+
domRestricted: boolean; // field was not "*"
|
|
13
|
+
dowRestricted: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
|
17
|
+
const DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
|
18
|
+
|
|
19
|
+
function nameToNum(token: string, names: string[]): string {
|
|
20
|
+
const i = names.indexOf(token.toLowerCase());
|
|
21
|
+
return i >= 0 ? String(i + (names === MONTHS ? 1 : 0)) : token;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Expand one field into the set of matching integers, validating against [min,max].
|
|
25
|
+
function parseField(field: string, min: number, max: number, names?: string[]): Set<number> {
|
|
26
|
+
const out = new Set<number>();
|
|
27
|
+
for (const part of field.split(",")) {
|
|
28
|
+
const [rangePart, stepPart] = part.split("/");
|
|
29
|
+
const step = stepPart === undefined ? 1 : Number(stepPart);
|
|
30
|
+
if (!Number.isInteger(step) || step < 1) throw new Error(`invalid step in "${part}"`);
|
|
31
|
+
|
|
32
|
+
let lo: number;
|
|
33
|
+
let hi: number;
|
|
34
|
+
if (rangePart === "*") {
|
|
35
|
+
lo = min;
|
|
36
|
+
hi = max;
|
|
37
|
+
} else {
|
|
38
|
+
const bounds = rangePart.split("-").map((t) => (names ? nameToNum(t, names) : t));
|
|
39
|
+
lo = Number(bounds[0]);
|
|
40
|
+
hi = bounds.length > 1 ? Number(bounds[1]) : lo;
|
|
41
|
+
if (!Number.isInteger(lo) || !Number.isInteger(hi)) throw new Error(`invalid range "${rangePart}"`);
|
|
42
|
+
// Allow Sunday as both 0 and 7 for day-of-week.
|
|
43
|
+
if (max === 6) {
|
|
44
|
+
if (lo === 7) lo = 0;
|
|
45
|
+
if (hi === 7) hi = 0;
|
|
46
|
+
}
|
|
47
|
+
if (lo < min || hi > max || lo > hi) throw new Error(`out-of-range field "${rangePart}"`);
|
|
48
|
+
}
|
|
49
|
+
for (let v = lo; v <= hi; v += step) out.add(v);
|
|
50
|
+
}
|
|
51
|
+
if (out.size === 0) throw new Error(`empty field "${field}"`);
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function parseCron(expr: string): CronFields {
|
|
56
|
+
const parts = expr.trim().split(/\s+/);
|
|
57
|
+
if (parts.length !== 5) {
|
|
58
|
+
throw new Error(`cron expression must have 5 fields (got ${parts.length}): "${expr}"`);
|
|
59
|
+
}
|
|
60
|
+
const [minute, hour, dom, month, dow] = parts;
|
|
61
|
+
return {
|
|
62
|
+
minute: parseField(minute, 0, 59),
|
|
63
|
+
hour: parseField(hour, 0, 23),
|
|
64
|
+
dom: parseField(dom, 1, 31),
|
|
65
|
+
month: parseField(month, 1, 12, MONTHS),
|
|
66
|
+
dow: parseField(dow, 0, 6, DAYS),
|
|
67
|
+
domRestricted: dom !== "*",
|
|
68
|
+
dowRestricted: dow !== "*",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Validate an expression, returning an error message or null if it parses.
|
|
73
|
+
export function cronError(expr: string): string | null {
|
|
74
|
+
try {
|
|
75
|
+
parseCron(expr);
|
|
76
|
+
return null;
|
|
77
|
+
} catch (err) {
|
|
78
|
+
return err instanceof Error ? err.message : String(err);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function matches(f: CronFields, d: Date): boolean {
|
|
83
|
+
if (!f.minute.has(d.getMinutes())) return false;
|
|
84
|
+
if (!f.hour.has(d.getHours())) return false;
|
|
85
|
+
if (!f.month.has(d.getMonth() + 1)) return false;
|
|
86
|
+
// Standard cron: when BOTH day-of-month and day-of-week are restricted, a match
|
|
87
|
+
// on EITHER is sufficient. When only one is restricted, it alone must match.
|
|
88
|
+
const domOk = f.dom.has(d.getDate());
|
|
89
|
+
const dowOk = f.dow.has(d.getDay());
|
|
90
|
+
if (f.domRestricted && f.dowRestricted) return domOk || dowOk;
|
|
91
|
+
if (f.domRestricted) return domOk;
|
|
92
|
+
if (f.dowRestricted) return dowOk;
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The next fire time strictly after `from` (local time), or null if none within a
|
|
97
|
+
// ~4-year horizon (e.g. Feb 30). Seconds/millis are cleared; we scan minute by minute.
|
|
98
|
+
export function nextRun(expr: string, from: Date = new Date()): Date | null {
|
|
99
|
+
const fields = parseCron(expr);
|
|
100
|
+
const d = new Date(from.getTime());
|
|
101
|
+
d.setSeconds(0, 0);
|
|
102
|
+
d.setMinutes(d.getMinutes() + 1); // strictly after `from`
|
|
103
|
+
const limit = 366 * 4 * 24 * 60; // minutes in ~4 years
|
|
104
|
+
for (let i = 0; i < limit; i++) {
|
|
105
|
+
if (matches(fields, d)) return d;
|
|
106
|
+
d.setMinutes(d.getMinutes() + 1);
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Routine } from "./schema.ts";
|
|
2
|
+
import { writeRoutineOutput, addNotice } from "./store.ts";
|
|
3
|
+
|
|
4
|
+
// A relay pusher, injected by the daemon. Given the finished result it either
|
|
5
|
+
// forwards it to an attached controller immediately ("live") or persists it to the
|
|
6
|
+
// pending-relay queue to flush when the app next attaches ("queued"). Either way the
|
|
7
|
+
// result is durably accounted for, so delivery doesn't add a notice backstop for it.
|
|
8
|
+
export type RelayPusher = (routine: Routine, content: string) => "live" | "queued";
|
|
9
|
+
|
|
10
|
+
export interface DeliveryContext {
|
|
11
|
+
pushRelay?: RelayPusher;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface DeliveryReport {
|
|
15
|
+
// Channels that actually delivered (email is handled inside the agent run, so it
|
|
16
|
+
// never appears here — see the daemon).
|
|
17
|
+
delivered: string[];
|
|
18
|
+
// Absolute path to latest.md when file delivery ran.
|
|
19
|
+
filePath?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function previewOf(content: string): string {
|
|
23
|
+
return content.replace(/\s+/g, " ").trim().slice(0, 120) || "(no output)";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Deliver a routine's result to its configured channels. `file` and `notice` are
|
|
27
|
+
// deterministic and on-box. `relay` pushes to an attached controller in real time
|
|
28
|
+
// (best-effort — the socket may be up with no controller attached), so we ALSO keep
|
|
29
|
+
// a durable record when the routine has no other on-box channel, guaranteeing the
|
|
30
|
+
// result is never silently lost. `email` is intentionally not handled here: it is
|
|
31
|
+
// fulfilled inside the agent turn (the daemon adds the Gmail tool + an instruction
|
|
32
|
+
// to the prompt) so plaintext egress stays an explicit, gated action.
|
|
33
|
+
export function deliver(
|
|
34
|
+
routine: Routine,
|
|
35
|
+
content: string,
|
|
36
|
+
status: "ok" | "error",
|
|
37
|
+
ctx: DeliveryContext = {},
|
|
38
|
+
): DeliveryReport {
|
|
39
|
+
const delivered: string[] = [];
|
|
40
|
+
const wants = new Set(routine.delivery);
|
|
41
|
+
let filePath: string | undefined;
|
|
42
|
+
let noticed = false;
|
|
43
|
+
|
|
44
|
+
const leaveNotice = () => {
|
|
45
|
+
if (noticed) return;
|
|
46
|
+
addNotice({ routine: routine.name, at: new Date().toISOString(), status, preview: previewOf(content), path: filePath });
|
|
47
|
+
noticed = true;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// On-box copy.
|
|
51
|
+
if (wants.has("file")) {
|
|
52
|
+
filePath = writeRoutineOutput(routine.name, content);
|
|
53
|
+
delivered.push("file");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Relay: pushed live to an attached controller, or queued to flush when the app
|
|
57
|
+
// next attaches (the daemon persists that queue, so it's durable either way). Only
|
|
58
|
+
// when no pusher is wired at all do we fall back to a notice so it isn't lost.
|
|
59
|
+
if (wants.has("relay")) {
|
|
60
|
+
const status = ctx.pushRelay?.(routine, content);
|
|
61
|
+
if (status === "live") delivered.push("relay");
|
|
62
|
+
else if (status === "queued") delivered.push("relay(queued)");
|
|
63
|
+
else if (!wants.has("file") && !wants.has("notice")) {
|
|
64
|
+
leaveNotice();
|
|
65
|
+
delivered.push("notice(backstop)");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (wants.has("notice")) {
|
|
70
|
+
leaveNotice();
|
|
71
|
+
delivered.push("notice");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { delivered, filePath };
|
|
75
|
+
}
|