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,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
- private readonly termId = randomUUID();
130
- private readonly label = terminalLabel();
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(private readonly cb: RelayCallbacks) {}
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
+ }