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.
- package/README.md +143 -398
- 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 +128 -1
- 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 -44
- package/src/config/permissionMode.ts +5 -0
- package/src/crypto/outboxSeal.ts +61 -0
- package/src/daemon/index.ts +405 -0
- package/src/daemon/ipc.ts +127 -0
- package/src/engine/errors.ts +95 -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 +13 -11
- package/src/permissions/mode.ts +9 -1
- package/src/permissions/{uiGate.ts → modeGate.ts} +22 -6
- 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 +91 -4
- package/src/remote/remoteBridge.ts +152 -0
- package/src/routines/cron.ts +109 -0
- package/src/routines/delivery.ts +184 -0
- package/src/routines/schema.ts +84 -0
- package/src/routines/store.ts +248 -0
- package/src/routines/toolSelect.ts +50 -0
- package/src/routines/trigger.ts +41 -0
- package/src/session.ts +84 -251
- package/src/tools/routine.ts +129 -0
- 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 -75
- package/src/commands/registry.ts +0 -499
- package/src/components/AgentGroupView.tsx +0 -104
- package/src/components/App.tsx +0 -1420
- package/src/components/ApprovalPrompt.tsx +0 -38
- package/src/components/Banner.tsx +0 -76
- package/src/components/Markdown.tsx +0 -183
- package/src/components/ModeHint.tsx +0 -40
- package/src/components/ModelPicker.tsx +0 -269
- package/src/components/Onboarding.tsx +0 -203
- 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 -95
- 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 -109
- package/src/components/Transcript.tsx +0 -203
- 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 -55
- 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 -167
- package/src/engine/QueryEngine.ts +0 -399
- package/src/hooks/engine.ts +0 -155
- package/src/main.tsx +0 -167
- package/src/mcp/client.ts +0 -236
- 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/tools/bash.ts +0 -98
- package/src/tools/context.ts +0 -114
- 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 -69
- package/src/tools/memory.ts +0 -53
- package/src/tools/processRegistry.ts +0 -77
- package/src/tools/read.ts +0 -42
- 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/write.ts +0 -40
- package/src/util/images.ts +0 -356
- package/src/util/limit.ts +0 -32
- package/src/version.ts +0 -13
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { agentDir } from "../config/paths.ts";
|
|
4
|
+
import { providersToGenerate, type ProviderEntry } from "./catalog.ts";
|
|
5
|
+
|
|
6
|
+
// Emit models.json entries for the config-only providers Pi doesn't ship and
|
|
7
|
+
// pi-privacy doesn't register (today: just `qwen`). Built-ins come from pi-ai's
|
|
8
|
+
// static catalogs; privacy providers from the pi-privacy extension; the privateer
|
|
9
|
+
// account channel from code. See catalog.ts for the full accounting.
|
|
10
|
+
//
|
|
11
|
+
// Model metadata defaults follow the migration plan Appendix A.4 (used until a live
|
|
12
|
+
// listing refines them): contextWindow 128000, maxTokens 16384, zero cost, text-only.
|
|
13
|
+
|
|
14
|
+
export interface ModelsJsonModel {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
reasoning: boolean;
|
|
18
|
+
input: ("text" | "image")[];
|
|
19
|
+
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
|
20
|
+
contextWindow: number;
|
|
21
|
+
maxTokens: number;
|
|
22
|
+
compat?: Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ModelsJsonProvider {
|
|
26
|
+
name: string;
|
|
27
|
+
baseUrl: string;
|
|
28
|
+
api: string;
|
|
29
|
+
apiKey?: string;
|
|
30
|
+
authHeader?: boolean;
|
|
31
|
+
compat?: Record<string, unknown>;
|
|
32
|
+
models: ModelsJsonModel[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ModelsJson {
|
|
36
|
+
providers: Record<string, ModelsJsonProvider>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function seedModel(id: string, compat?: Record<string, unknown>): ModelsJsonModel {
|
|
40
|
+
return {
|
|
41
|
+
id,
|
|
42
|
+
name: id,
|
|
43
|
+
reasoning: false,
|
|
44
|
+
input: ["text"],
|
|
45
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
46
|
+
contextWindow: 128000,
|
|
47
|
+
maxTokens: 16384,
|
|
48
|
+
...(compat ? { compat } : {}),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function providerConfig(p: ProviderEntry): ModelsJsonProvider {
|
|
53
|
+
return {
|
|
54
|
+
name: p.id,
|
|
55
|
+
baseUrl: p.baseUrl!,
|
|
56
|
+
api: p.api ?? "openai-completions",
|
|
57
|
+
// Env-template key (Pi resolves ${...}; auth.json takes precedence when present).
|
|
58
|
+
// Never a literal key — and never emitted for the account/venice providers.
|
|
59
|
+
...(p.keyEnv ? { apiKey: p.keyEnv, authHeader: true } : {}),
|
|
60
|
+
...(p.compat ? { compat: p.compat } : {}),
|
|
61
|
+
models: (p.seedModels ?? []).map((id) => seedModel(id, p.compat)),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Build the models.json object for all `generate`-sourced providers.
|
|
66
|
+
export function generateModelsJson(providers: ProviderEntry[] = providersToGenerate()): ModelsJson {
|
|
67
|
+
const out: ModelsJson = { providers: {} };
|
|
68
|
+
for (const p of providers) {
|
|
69
|
+
if (p.source !== "generate" || !p.baseUrl) continue;
|
|
70
|
+
out.providers[p.id] = providerConfig(p);
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Merge generated providers into an existing models.json WITHOUT clobbering
|
|
76
|
+
// user-added or extension-registered providers. Generated ids are refreshed; every
|
|
77
|
+
// other provider in the file is preserved. Writes to $PRIVATEER_HOME/agent/models.json
|
|
78
|
+
// by default. Returns the merged object.
|
|
79
|
+
export function writeModelsJson(
|
|
80
|
+
generated: ModelsJson = generateModelsJson(),
|
|
81
|
+
path: string = join(agentDir(), "models.json"),
|
|
82
|
+
): ModelsJson {
|
|
83
|
+
let existing: ModelsJson = { providers: {} };
|
|
84
|
+
if (existsSync(path)) {
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
87
|
+
if (parsed && typeof parsed === "object" && parsed.providers) existing = parsed;
|
|
88
|
+
} catch {
|
|
89
|
+
// Unreadable/corrupt file → treat as empty rather than throwing; we only add
|
|
90
|
+
// our generated providers and never drop the user's.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const merged: ModelsJson = { providers: { ...existing.providers, ...generated.providers } };
|
|
94
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
95
|
+
writeFileSync(path, JSON.stringify(merged, null, 2) + "\n", "utf8");
|
|
96
|
+
return merged;
|
|
97
|
+
}
|
|
@@ -54,8 +54,17 @@ 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;
|
|
64
|
+
// The app toggled no-quarter (unattended) mode: remote turns auto-approve like
|
|
65
|
+
// bypass mode instead of relaying every action, so the agent runs to completion.
|
|
66
|
+
// Dangerous/destructive actions still relay — they sit above bypass locally too.
|
|
67
|
+
onNoQuarter?: (on: boolean) => void;
|
|
59
68
|
// A controller attached — push a transcript snapshot so it can catch up.
|
|
60
69
|
onControllerAttached: () => void;
|
|
61
70
|
// A file finished transferring from the app (reassembled from chunks). Held to
|
|
@@ -63,6 +72,8 @@ export interface RelayCallbacks {
|
|
|
63
72
|
onAttachment: (file: { name: string; mediaType: string; base64: string }) => void;
|
|
64
73
|
// Surface a one-line status/notice in the TUI.
|
|
65
74
|
onStatus?: (text: string) => void;
|
|
75
|
+
// The relay socket closed (controller no longer reachable until reconnect).
|
|
76
|
+
onDisconnected?: () => void;
|
|
66
77
|
}
|
|
67
78
|
|
|
68
79
|
const RECONNECT_MS = 3000;
|
|
@@ -71,6 +82,9 @@ const RECONNECT_MS = 3000;
|
|
|
71
82
|
// memory with a lying `size` or a flood of concurrent transfers.
|
|
72
83
|
const MAX_ATTACH_BYTES = 10 * 1024 * 1024; // 10 MB per file
|
|
73
84
|
const MAX_INFLIGHT_ATTACH = 8; // simultaneous transfers
|
|
85
|
+
// Base64 chars per file_chunk frame for agent→app sends (~135 KB decoded), kept
|
|
86
|
+
// under the relay's 256 KB per-frame cap. Mirrors the app's CHUNK_CHARS.
|
|
87
|
+
const FILE_CHUNK_CHARS = 180_000;
|
|
74
88
|
// Coalesce streaming deltas so we don't emit one WS frame per token.
|
|
75
89
|
const TEXT_FLUSH_MS = 60;
|
|
76
90
|
|
|
@@ -125,9 +139,12 @@ export class RelayClient {
|
|
|
125
139
|
private bufKind: "text" | "reasoning" | null = null;
|
|
126
140
|
private buf = "";
|
|
127
141
|
private flushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
128
|
-
// Stable for this process so reconnects keep the same terminal identity.
|
|
129
|
-
|
|
130
|
-
|
|
142
|
+
// Stable for this process so reconnects keep the same terminal identity. Callers
|
|
143
|
+
// may pass a persisted id/label (e.g. the routines daemon, so it shows up as one
|
|
144
|
+
// recognizable "Privateer Routines" terminal across restarts instead of a fresh
|
|
145
|
+
// random one each time).
|
|
146
|
+
private readonly termId: string;
|
|
147
|
+
private readonly label: string;
|
|
131
148
|
// In-progress file transfers from the app, keyed by the controller's attachment
|
|
132
149
|
// id. Reassembled from attach_begin/chunk/end frames, then handed to onAttachment.
|
|
133
150
|
private readonly incoming = new Map<
|
|
@@ -135,7 +152,13 @@ export class RelayClient {
|
|
|
135
152
|
{ name: string; mediaType: string; chunks: string[]; received: number }
|
|
136
153
|
>();
|
|
137
154
|
|
|
138
|
-
constructor(
|
|
155
|
+
constructor(
|
|
156
|
+
private readonly cb: RelayCallbacks,
|
|
157
|
+
opts?: { termId?: string; label?: string },
|
|
158
|
+
) {
|
|
159
|
+
this.termId = opts?.termId ?? randomUUID();
|
|
160
|
+
this.label = opts?.label ?? terminalLabel();
|
|
161
|
+
}
|
|
139
162
|
|
|
140
163
|
async start(): Promise<void> {
|
|
141
164
|
this.closed = false;
|
|
@@ -182,6 +205,7 @@ export class RelayClient {
|
|
|
182
205
|
ws.on("message", (data) => this.handle(data));
|
|
183
206
|
ws.on("close", () => {
|
|
184
207
|
if (this.ws === ws) this.ws = null;
|
|
208
|
+
this.cb.onDisconnected?.();
|
|
185
209
|
if (!this.closed) {
|
|
186
210
|
this.cb.onStatus?.(
|
|
187
211
|
opened
|
|
@@ -230,6 +254,7 @@ export class RelayClient {
|
|
|
230
254
|
size?: number;
|
|
231
255
|
seq?: number;
|
|
232
256
|
data?: string;
|
|
257
|
+
on?: boolean;
|
|
233
258
|
};
|
|
234
259
|
try {
|
|
235
260
|
frame = JSON.parse(data.toString());
|
|
@@ -247,9 +272,15 @@ export class RelayClient {
|
|
|
247
272
|
case "interrupt":
|
|
248
273
|
this.cb.onInterrupt();
|
|
249
274
|
break;
|
|
275
|
+
case "terminate":
|
|
276
|
+
this.cb.onTerminate?.();
|
|
277
|
+
break;
|
|
250
278
|
case "approval_response":
|
|
251
279
|
if (frame.id) this.cb.onApprovalResponse(frame.id, frame.decision === "deny" ? "deny" : "allow");
|
|
252
280
|
break;
|
|
281
|
+
case "no_quarter":
|
|
282
|
+
this.cb.onNoQuarter?.(frame.on === true);
|
|
283
|
+
break;
|
|
253
284
|
case "controller_attached":
|
|
254
285
|
this.cb.onControllerAttached();
|
|
255
286
|
break;
|
|
@@ -320,6 +351,23 @@ export class RelayClient {
|
|
|
320
351
|
|
|
321
352
|
// ── agent → controller ──────────────────────────────────────────────────────
|
|
322
353
|
|
|
354
|
+
// Is the relay socket currently open? (Not the same as "a controller is
|
|
355
|
+
// attached" — the server forwards to a controller only when one is present.)
|
|
356
|
+
isConnected(): boolean {
|
|
357
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Push a finished routine result to any attached controller as a text event, so
|
|
361
|
+
// it renders in the app's live feed. Returns whether the socket was open to send
|
|
362
|
+
// on; a durable channel (file/notice) still backs this up, since we can't know
|
|
363
|
+
// for certain a controller was attached.
|
|
364
|
+
sendRoutineResult(name: string, content: string): boolean {
|
|
365
|
+
if (!this.isConnected()) return false;
|
|
366
|
+
this.flushDeltas();
|
|
367
|
+
this.rawSend({ type: "event", event: { type: "text", text: safe(`⏺ Routine "${name}"\n\n${content}`, 8000) } });
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
|
|
323
371
|
sendEvent(ev: EngineEvent): void {
|
|
324
372
|
if (ev.type === "text") return this.bufferDelta("text", ev.text);
|
|
325
373
|
if (ev.type === "reasoning") return this.bufferDelta("reasoning", ev.text);
|
|
@@ -335,6 +383,45 @@ export class RelayClient {
|
|
|
335
383
|
this.rawSend({ type: "snapshot", entries: trimmed });
|
|
336
384
|
}
|
|
337
385
|
|
|
386
|
+
// ── agent → app file transfer (chunked) ─────────────────────────────────────
|
|
387
|
+
// Reverse of the attach_* path: file_begin → file_chunk* → file_end, each frame
|
|
388
|
+
// under the relay's 256 KB cap. Fire-and-forget past the socket — the server
|
|
389
|
+
// drops frames when no controller is attached and there is no ack, so "ok" means
|
|
390
|
+
// "handed to an open socket", not "the app received it". The payload is base64
|
|
391
|
+
// binary, so no redactSecrets (it would corrupt the bytes) — the caller decides
|
|
392
|
+
// what's safe to send.
|
|
393
|
+
async sendFile(file: {
|
|
394
|
+
name: string;
|
|
395
|
+
mediaType: string;
|
|
396
|
+
base64: string;
|
|
397
|
+
size: number;
|
|
398
|
+
}): Promise<{ ok: boolean; reason?: string }> {
|
|
399
|
+
if (!this.isConnected()) return { ok: false, reason: "relay socket not connected" };
|
|
400
|
+
if (file.size > MAX_ATTACH_BYTES) {
|
|
401
|
+
return { ok: false, reason: `exceeds the ${Math.round(MAX_ATTACH_BYTES / (1024 * 1024))} MB relay limit` };
|
|
402
|
+
}
|
|
403
|
+
this.flushDeltas(); // land the file in order relative to buffered text
|
|
404
|
+
const id = randomUUID();
|
|
405
|
+
this.rawSend({ type: "file_begin", id, name: file.name, mediaType: file.mediaType, size: file.size });
|
|
406
|
+
for (let off = 0, seq = 0; off < file.base64.length; off += FILE_CHUNK_CHARS, seq++) {
|
|
407
|
+
if (!this.isConnected()) return { ok: false, reason: "connection lost mid-transfer" };
|
|
408
|
+
this.rawSend({ type: "file_chunk", id, seq, data: file.base64.slice(off, off + FILE_CHUNK_CHARS) });
|
|
409
|
+
// Yield between frames of a multi-chunk file so a big send doesn't starve
|
|
410
|
+
// the event loop (ws buffers internally; no drain dance needed at ≤10 MB).
|
|
411
|
+
if (file.base64.length > FILE_CHUNK_CHARS) await new Promise((r) => setImmediate(r));
|
|
412
|
+
}
|
|
413
|
+
if (!this.isConnected()) return { ok: false, reason: "connection lost mid-transfer" };
|
|
414
|
+
this.rawSend({ type: "file_end", id });
|
|
415
|
+
return { ok: true };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Echo the no-quarter (unattended auto-approve) state up to the controller so
|
|
419
|
+
// its toggle reflects what this terminal is actually doing. Sent on every
|
|
420
|
+
// change (ack) and on controller attach (a re-attaching app resyncs).
|
|
421
|
+
sendNoQuarter(on: boolean): void {
|
|
422
|
+
this.rawSend({ type: "no_quarter", on });
|
|
423
|
+
}
|
|
424
|
+
|
|
338
425
|
requestApproval(id: string, req: PermissionRequest): void {
|
|
339
426
|
this.rawSend({
|
|
340
427
|
type: "approval_request",
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// The wiring between the relay and the rest of the agent — NEW code for the Pi
|
|
2
|
+
// rewrite. It connects three things that were built to meet here:
|
|
3
|
+
// - the Phase-1 adapter's EngineEvents → up to the app (relay.sendEvent)
|
|
4
|
+
// - the Phase-2 gate's remote branch → each tool relayed to the app for
|
|
5
|
+
// allow/deny via `remoteAsk` + `getRemote` (the slots makePermissionGate stubbed)
|
|
6
|
+
// - the app's prompts/interrupts → down into the turn loop (cfg callbacks)
|
|
7
|
+
//
|
|
8
|
+
// The RelayClient itself is KEEP (verbatim 0.2); this bridge is the only new part,
|
|
9
|
+
// so it's what the tests exercise (against a fake relay). Fail-closed throughout:
|
|
10
|
+
// no controller, a disconnect, or an aborted turn all resolve a pending approval
|
|
11
|
+
// to "deny".
|
|
12
|
+
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import type { EngineEvent } from "../engine/events.ts";
|
|
15
|
+
import type { PermissionRequest } from "../permissions/gate.ts";
|
|
16
|
+
import type { AskOutcome } from "../permissions/modeGate.ts";
|
|
17
|
+
import type { RelayCallbacks } from "./relayClient.ts";
|
|
18
|
+
|
|
19
|
+
// The outbound surface the bridge needs; RelayClient implements all of it.
|
|
20
|
+
export interface RelayLike {
|
|
21
|
+
requestApproval(id: string, req: PermissionRequest): void;
|
|
22
|
+
sendEvent(ev: EngineEvent): void;
|
|
23
|
+
isConnected(): boolean;
|
|
24
|
+
sendNoQuarter(on: boolean): void;
|
|
25
|
+
sendFile(file: { name: string; mediaType: string; base64: string; size: number }): Promise<{ ok: boolean; reason?: string }>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RemoteAttachment {
|
|
29
|
+
name: string;
|
|
30
|
+
mediaType: string;
|
|
31
|
+
base64: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RemoteBridgeConfig {
|
|
35
|
+
// A prompt arrived from the app — drive the turn loop (tagged remote). Any files
|
|
36
|
+
// the app sent ahead of the prompt ride along.
|
|
37
|
+
onPrompt: (text: string, attachments: RemoteAttachment[]) => void;
|
|
38
|
+
onInterrupt?: () => void;
|
|
39
|
+
onTerminate?: () => void;
|
|
40
|
+
// A controller (re)attached — the owner should push a transcript snapshot.
|
|
41
|
+
onControllerAttached?: () => void;
|
|
42
|
+
onStatus?: (text: string) => void;
|
|
43
|
+
// A file finished transferring down from the app. The owner registers it (e.g. into
|
|
44
|
+
// an AttachmentStore) so the save_attachment tool can persist it.
|
|
45
|
+
onAttachment?: (file: RemoteAttachment) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class RemoteBridge {
|
|
49
|
+
private relay?: RelayLike;
|
|
50
|
+
private remote = false;
|
|
51
|
+
private noQuarter = false;
|
|
52
|
+
private readonly pending = new Map<string, (d: AskOutcome) => void>();
|
|
53
|
+
private pendingAttachments: RemoteAttachment[] = [];
|
|
54
|
+
|
|
55
|
+
constructor(private readonly cfg: RemoteBridgeConfig) {}
|
|
56
|
+
|
|
57
|
+
// Wire the outbound relay once it's constructed (RelayClient needs `callbacks`
|
|
58
|
+
// at construction, so the relay is attached right after).
|
|
59
|
+
attachRelay(relay: RelayLike): void {
|
|
60
|
+
this.relay = relay;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Hand this to `new RelayClient(bridge.callbacks)`. Typed Required so every hook
|
|
64
|
+
// (including the ones RelayCallbacks marks optional) is defined — the bridge wires
|
|
65
|
+
// them all.
|
|
66
|
+
readonly callbacks: Required<RelayCallbacks> = {
|
|
67
|
+
onPrompt: (text) => {
|
|
68
|
+
this.remote = true; // a remote turn is now in flight → gate relays each action
|
|
69
|
+
const attachments = this.pendingAttachments;
|
|
70
|
+
this.pendingAttachments = [];
|
|
71
|
+
this.cfg.onPrompt(text, attachments);
|
|
72
|
+
},
|
|
73
|
+
onInterrupt: () => this.cfg.onInterrupt?.(),
|
|
74
|
+
onTerminate: () => this.cfg.onTerminate?.(),
|
|
75
|
+
onApprovalResponse: (id, decision) => {
|
|
76
|
+
const resolve = this.pending.get(id);
|
|
77
|
+
if (resolve) resolve(decision);
|
|
78
|
+
},
|
|
79
|
+
onNoQuarter: (on) => {
|
|
80
|
+
this.noQuarter = on;
|
|
81
|
+
this.relay?.sendNoQuarter(on); // echo the ack back so the app's toggle syncs
|
|
82
|
+
},
|
|
83
|
+
onControllerAttached: () => this.cfg.onControllerAttached?.(),
|
|
84
|
+
onAttachment: (file) => {
|
|
85
|
+
this.pendingAttachments.push(file);
|
|
86
|
+
this.cfg.onAttachment?.(file);
|
|
87
|
+
},
|
|
88
|
+
onStatus: (text) => this.cfg.onStatus?.(text),
|
|
89
|
+
onDisconnected: () => {
|
|
90
|
+
this.remote = false;
|
|
91
|
+
// Any approval waiting on a now-gone controller fails closed.
|
|
92
|
+
this.rejectAllPending();
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// ── gate hooks (passed into the GateController) ─────────────────────────────
|
|
97
|
+
|
|
98
|
+
getRemote = (): boolean => this.remote;
|
|
99
|
+
getNoQuarter = (): boolean => this.noQuarter;
|
|
100
|
+
|
|
101
|
+
// The gate's remote approver: relay the request to the app and await its
|
|
102
|
+
// allow/deny. Fail closed if no controller, on abort, or on disconnect. (The gate
|
|
103
|
+
// also wraps this in its own timeout, so a silent app can't wedge the turn.)
|
|
104
|
+
remoteAsk = (req: PermissionRequest, signal?: AbortSignal): Promise<AskOutcome> => {
|
|
105
|
+
if (!this.relay || !this.relay.isConnected()) return Promise.resolve("deny");
|
|
106
|
+
const id = randomUUID();
|
|
107
|
+
return new Promise<AskOutcome>((resolve) => {
|
|
108
|
+
const onAbort = () => settle("deny");
|
|
109
|
+
const settle = (d: AskOutcome) => {
|
|
110
|
+
this.pending.delete(id);
|
|
111
|
+
signal?.removeEventListener("abort", onAbort);
|
|
112
|
+
resolve(d);
|
|
113
|
+
};
|
|
114
|
+
this.pending.set(id, settle);
|
|
115
|
+
if (signal) {
|
|
116
|
+
if (signal.aborted) return onAbort();
|
|
117
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
118
|
+
}
|
|
119
|
+
this.relay!.requestApproval(id, req);
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// ── turn lifecycle + event forwarding ───────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
// Mark the end of a turn so the next (possibly local) turn isn't treated as
|
|
126
|
+
// remote. Call after each driven turn completes.
|
|
127
|
+
settleTurn(): void {
|
|
128
|
+
this.remote = false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Forward an EngineEvent up to the app. Safe to call for every event of every
|
|
132
|
+
// turn (local included) — the relay only sends when a socket is open.
|
|
133
|
+
forwardEvent(ev: EngineEvent): void {
|
|
134
|
+
this.relay?.sendEvent(ev);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Is a controller actually reachable right now? (Relay socket open.)
|
|
138
|
+
isConnected(): boolean {
|
|
139
|
+
return !!this.relay?.isConnected();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Stream a file up to the connected app (the send_file_to_client tool).
|
|
143
|
+
async sendFile(file: { name: string; mediaType: string; base64: string; size: number }): Promise<{ ok: boolean; reason?: string }> {
|
|
144
|
+
if (!this.relay) return { ok: false, reason: "remote access is not enabled" };
|
|
145
|
+
return this.relay.sendFile(file);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private rejectAllPending(): void {
|
|
149
|
+
for (const resolve of this.pending.values()) resolve("deny");
|
|
150
|
+
this.pending.clear();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -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
|
+
}
|