privateer-agent 0.12.45 → 0.12.49

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.
@@ -0,0 +1,146 @@
1
+ // APPROVALS FOR A RUN WITH NO SCREEN, answered from the Privateer app.
2
+ //
3
+ // privateer -p --approve-in-app "make the trailer"
4
+ //
5
+ // A `-p` run's gate has nobody to ask, so it denies. With --approve-in-app it instead
6
+ // brings up the same E2EE relay `/remote-access` uses, waits for the app to attach to
7
+ // this terminal, and puts the question there — "Generate a video (billed…) · about
8
+ // $0.50" — and denies only if nobody answers before the timeout. The relay is started
9
+ // LAZILY, on the first ask: a run that never needs an approval never opens a socket.
10
+ //
11
+ // WHAT THIS IS NOT. The relay reaches an app that is running (foreground, or
12
+ // backgrounded with its socket alive — the app raises its own local notification for an
13
+ // approval then). An app that is fully closed is only reachable by a server-sent push,
14
+ // which does not exist yet (docs/push-on-gate-server.md). So the waiting line says to
15
+ // OPEN the app, rather than promising a notification that may never arrive.
16
+ //
17
+ // POSTURE. Nothing here widens what the gate allows: this is only the asker the gate
18
+ // calls when it has already decided to ask. Fail-closed throughout — not signed in, no
19
+ // relay, timeout, abort: deny. "Allow and remember" from the app counts as allow-once,
20
+ // because a headless run has no later turn for a remembered rule to help and the gate
21
+ // never remembers the always-ask kinds anyway. A PROMPT sent from the app is refused:
22
+ // this terminal takes approvals, not instructions, for the length of the run.
23
+
24
+ import { RemoteBridge, type RelayLike } from "./remoteBridge.ts";
25
+ import type { PermissionRequest } from "../permissions/gate.ts";
26
+ import type { AskOutcome } from "../permissions/modeGate.ts";
27
+ import type { RelayCallbacks } from "./relayClient.ts";
28
+
29
+ /** The relay surface this needs beyond the bridge's: lifecycle and presence. */
30
+ export interface HeadlessRelay extends RelayLike {
31
+ start(): Promise<void>;
32
+ stop(): void;
33
+ hasController(): boolean;
34
+ }
35
+
36
+ export interface HeadlessApprovalOptions {
37
+ /** Per ask: how long to wait for the app to attach AND answer. */
38
+ timeoutMs: number;
39
+ /** Where the waiting / outcome lines go (stderr — stdout may be the answer or JSON). */
40
+ log: (line: string) => void;
41
+ signedIn: () => boolean;
42
+ makeRelay: (callbacks: RelayCallbacks) => HeadlessRelay;
43
+ /** Label for this terminal in the app. */
44
+ label?: string;
45
+ /** Presence poll interval; tests shorten it. */
46
+ pollMs?: number;
47
+ }
48
+
49
+ const mins = (ms: number): string => (ms >= 60_000 ? `${Math.round(ms / 60_000)}m` : `${Math.round(ms / 1000)}s`);
50
+
51
+ export class HeadlessAppApprover {
52
+ private bridge?: RemoteBridge;
53
+ private relay?: HeadlessRelay;
54
+ private starting?: Promise<void>;
55
+ private closed = false;
56
+ // How many times the app has left. A deny that coincides with a departure is the
57
+ // bridge failing the ask closed, not a person pressing Deny — and presence alone
58
+ // can't tell, because the app may already be back by the time we look.
59
+ private departures = 0;
60
+
61
+ constructor(private readonly opts: HeadlessApprovalOptions) {}
62
+
63
+ private ensureRelay(): Promise<void> {
64
+ if (this.starting) return this.starting;
65
+ const bridge = new RemoteBridge({
66
+ onPrompt: () => bridge.sendNotice("This terminal is a headless run waiting for approvals only — it can't take a prompt."),
67
+ onCommand: () => bridge.sendNotice("This terminal is a headless run waiting for approvals only — it can't run commands."),
68
+ onControllerDetached: () => this.departures++,
69
+ });
70
+ this.bridge = bridge;
71
+ this.relay = this.opts.makeRelay(bridge.callbacks);
72
+ bridge.attachRelay(this.relay);
73
+ this.starting = this.relay.start();
74
+ return this.starting;
75
+ }
76
+
77
+ private async waitForController(deadline: number, signal?: AbortSignal): Promise<boolean> {
78
+ const poll = this.opts.pollMs ?? 500;
79
+ while (!this.closed && !signal?.aborted && Date.now() < deadline) {
80
+ if (this.relay?.hasController()) return true;
81
+ await new Promise((r) => setTimeout(r, Math.min(poll, Math.max(0, deadline - Date.now()))));
82
+ }
83
+ return !!this.relay?.hasController() && !signal?.aborted;
84
+ }
85
+
86
+ /** The gate's asker for this run. `priceNote` (e.g. "about $0.50") rides on the prompt. */
87
+ async ask(req: PermissionRequest, signal?: AbortSignal, priceNote?: string): Promise<AskOutcome> {
88
+ const what = `${req.title}${priceNote ? ` · ${priceNote}` : ""}`;
89
+ if (!this.opts.signedIn()) {
90
+ this.opts.log(`⚓ ${what} — denied: --approve-in-app needs this machine signed in to Privateer (run \`privateer\`, then /login).`);
91
+ return "deny";
92
+ }
93
+ const deadline = Date.now() + this.opts.timeoutMs;
94
+ try {
95
+ await this.ensureRelay();
96
+ } catch (e) {
97
+ this.opts.log(`⚓ ${what} — denied: couldn't reach the Privateer relay (${e instanceof Error ? e.message : String(e)}).`);
98
+ return "deny";
99
+ }
100
+ const asked: PermissionRequest = priceNote ? { ...req, detail: `${req.detail} · ${priceNote}` } : req;
101
+
102
+ this.opts.log(
103
+ `⚓ Approval needed: ${what}\n` +
104
+ ` Waiting up to ${mins(this.opts.timeoutMs)} — open the Privateer app and approve it there` +
105
+ (this.opts.label ? ` (terminal "${this.opts.label}")` : "") +
106
+ ".",
107
+ );
108
+
109
+ // Loop, because the app coming and going is normal (a phone locks, a user switches
110
+ // apps): a detach fails the pending ask closed, and the next attach gets it again.
111
+ while (Date.now() < deadline && !signal?.aborted && !this.closed) {
112
+ if (!(await this.waitForController(deadline, signal))) break;
113
+ const controller = new AbortController();
114
+ const onAbort = () => controller.abort();
115
+ signal?.addEventListener("abort", onAbort, { once: true });
116
+ const timer = setTimeout(() => controller.abort(), Math.max(0, deadline - Date.now()));
117
+ let outcome: AskOutcome;
118
+ const departuresBefore = this.departures;
119
+ try {
120
+ outcome = await this.bridge!.remoteAsk(asked, controller.signal);
121
+ } finally {
122
+ clearTimeout(timer);
123
+ signal?.removeEventListener("abort", onAbort);
124
+ }
125
+ if (outcome !== "deny") {
126
+ this.opts.log(`⚓ Approved in the app: ${what}`);
127
+ return "allow";
128
+ }
129
+ // A real "Deny" is the answer. A deny because the app left is not — wait for it
130
+ // to come back and ask again.
131
+ // A dropped socket fails it closed the same way, with no detach to count.
132
+ if (controller.signal.aborted || (this.departures === departuresBefore && this.relay?.isConnected())) break;
133
+ }
134
+ const why = signal?.aborted ? "the run was cancelled" : `no answer from the app in ${mins(this.opts.timeoutMs)}`;
135
+ const deniedInApp = !signal?.aborted && Date.now() < deadline;
136
+ this.opts.log(`⚓ Denied: ${what} — ${deniedInApp ? "denied in the app" : why}.`);
137
+ return "deny";
138
+ }
139
+
140
+ /** Drop the relay. Safe to call twice, and before anything started. */
141
+ close(): void {
142
+ this.closed = true;
143
+ this.relay?.stop();
144
+ this.bridge?.dispose();
145
+ }
146
+ }
@@ -947,9 +947,32 @@ export const generateSfxToolDefinition = {
947
947
 
948
948
  // ── Capabilities ─────────────────────────────────────────────────────────────
949
949
 
950
- interface CapabilitiesResponse {
951
- image?: { model?: string; blockedByZdr?: boolean; maxPerCall?: number };
952
- video?: { model?: string; blockedByZdr?: boolean; durations?: number[] | null; aspectRatios?: string[] | null };
950
+ /** One priced video call shape. `resolution: null` is a call that sends none. */
951
+ export interface VideoPriceRow {
952
+ seconds: number | null;
953
+ resolution: string | null;
954
+ audio: boolean;
955
+ usd: number;
956
+ }
957
+
958
+ export interface CapabilitiesResponse {
959
+ /** `priceUsdEach` is what one image costs the account; null when the server couldn't price it. */
960
+ image?: { model?: string; blockedByZdr?: boolean; maxPerCall?: number; priceUsdEach?: number | null };
961
+ /** `priceUsd`/`priceTable` are the server's RESERVATION figures — worst-case-biased, so
962
+ * a budget built on them can only come in under. Absent on a server older than them. */
963
+ video?: {
964
+ model?: string;
965
+ blockedByZdr?: boolean;
966
+ durations?: number[] | null;
967
+ aspectRatios?: string[] | null;
968
+ priceUsd?: { min?: number; max?: number } | null;
969
+ priceTable?: VideoPriceRow[] | null;
970
+ priceSource?: "table" | "fallback";
971
+ };
972
+ sprites?: {
973
+ available?: boolean;
974
+ directionSets?: { id: string; billedClips?: number; billedTurnStills?: number }[];
975
+ };
953
976
  model3d?: {
954
977
  model?: string;
955
978
  configured?: boolean;
@@ -1066,6 +1089,119 @@ function describeAxis(a: MeshAxis): string {
1066
1089
  return ` ${a.name}: ${(a.values ?? []).join(" | ")} (default ${String(a.default)})${cost}`;
1067
1090
  }
1068
1091
 
1092
+ const usd = (n: number | undefined | null): string => `$${(n ?? 0).toFixed(2)}`;
1093
+
1094
+ /**
1095
+ * The image and video sections of the capability report.
1096
+ *
1097
+ * Exported and pure for the reason describeModel3d is. The prices are the point: an
1098
+ * agent planning a film used to see what a mesh or an effect cost and nothing at all
1099
+ * for a clip — the dearest call it can make — so it could neither weigh the job nor
1100
+ * set a sensible `--max-spend`. A server too old to report a price gets that said
1101
+ * plainly rather than a $0.00 that reads as free.
1102
+ */
1103
+ export function describeImageVideo(image: CapabilitiesResponse["image"], video: CapabilitiesResponse["video"]): string[] {
1104
+ const imagePrice =
1105
+ typeof image?.priceUsdEach === "number" ? `, about ${usd(image.priceUsdEach)} each` : ", price not reported";
1106
+ const lines = [
1107
+ `Image model: ${image?.model ?? "unknown"}${image?.blockedByZdr ? " [BLOCKED by this account's ZDR setting]" : ""}`,
1108
+ ` up to ${image?.maxPerCall ?? 1} image(s) per call${imagePrice}`,
1109
+ `Video model: ${video?.model ?? "unknown"}${video?.blockedByZdr ? " [BLOCKED by this account's ZDR setting]" : ""}`,
1110
+ ` clip lengths: ${video?.durations?.length ? `${video.durations.join(", ")}s` : "model default only"}`,
1111
+ ` aspect ratios: ${video?.aspectRatios?.length ? video.aspectRatios.join(", ") : "model default only"}`,
1112
+ ];
1113
+ const table = video?.priceTable ?? [];
1114
+ if (video?.priceUsd && table.length) {
1115
+ const fallback = video.priceSource === "fallback" ? " (no price row for this model — a conservative estimate)" : "";
1116
+ lines.push(` cost: ${usd(video.priceUsd.min)}-${usd(video.priceUsd.max)} a clip${fallback}, reserved up front and settled at the real cost`);
1117
+ // Per length at the call's default (no resolution sent, no audio): that is the
1118
+ // call an agent makes unless it asks for more, and the line it budgets from.
1119
+ const plain = table.filter((r) => r.resolution === null && !r.audio && r.seconds !== null);
1120
+ if (plain.length) lines.push(` by length (default resolution, no audio): ${plain.map((r) => `${r.seconds}s ${usd(r.usd)}`).join(", ")}`);
1121
+ const resolutions = [...new Set(table.map((r) => r.resolution).filter((r): r is string => !!r))];
1122
+ if (resolutions.length) lines.push(` resolution changes the price: ${resolutions.join(", ")} (pass generate_video's \`resolution\`)`);
1123
+ if (table.some((r) => r.audio)) lines.push(" audio: true costs more on this model");
1124
+ } else {
1125
+ lines.push(" cost: not reported by this server — roughly $0.10-$1 a clip, more for long or high-resolution clips");
1126
+ }
1127
+ return lines;
1128
+ }
1129
+
1130
+ /**
1131
+ * What one call of a billed tool is expected to cost, in USD — or null when it can't
1132
+ * be priced from what the server reports. Pure over a capability report, for the
1133
+ * `--max-spend` budget (permissions/cliSpend.ts): it prices the call the model is
1134
+ * ABOUT to make from that call's own arguments, and it errs high everywhere it has
1135
+ * to choose, because a cap that under-counts is not a cap.
1136
+ */
1137
+ export function quoteMediaCallUsd(tool: string, input: unknown, caps: CapabilitiesResponse): number | null {
1138
+ const args = (input && typeof input === "object" ? input : {}) as Record<string, unknown>;
1139
+ const num = (v: unknown): number | undefined => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
1140
+ const clip = (seconds: number | undefined, resolution: string | undefined, audio: boolean): number | null => {
1141
+ const table = caps.video?.priceTable ?? [];
1142
+ if (!table.length) return null;
1143
+ const exact = table.find(
1144
+ (r) => r.seconds === (seconds ?? r.seconds) && r.resolution === (resolution ?? null) && r.audio === audio,
1145
+ );
1146
+ // An exact row when the call names a legal length; otherwise the dearest row that
1147
+ // still matches what we do know — never a cheaper guess.
1148
+ if (exact && seconds !== undefined) return exact.usd;
1149
+ // `resolution: null` rows are the server's price for a call that sends none, so
1150
+ // that's the set an unspecified resolution is quoted from.
1151
+ const candidates = table.filter((r) => r.resolution === (resolution ?? null) && (audio || !r.audio));
1152
+ const pool = candidates.length ? candidates : table;
1153
+ return Math.max(...pool.map((r) => r.usd));
1154
+ };
1155
+ switch (tool) {
1156
+ case "generate_image": {
1157
+ // The report prices the ACCOUNT's image model; a call naming another one would
1158
+ // be quoted at the wrong model's rate, so it isn't quoted at all.
1159
+ if (typeof args.model === "string" && args.model) return null;
1160
+ const each = caps.image?.priceUsdEach;
1161
+ if (typeof each !== "number") return null;
1162
+ const n = Math.min(Math.max(Math.trunc(num(args.count) ?? 1), 1), caps.image?.maxPerCall ?? 4);
1163
+ return each * n;
1164
+ }
1165
+ case "generate_video":
1166
+ return clip(num(args.seconds), typeof args.resolution === "string" ? args.resolution : undefined, args.audio === true);
1167
+ case "generate_model":
1168
+ return typeof caps.model3d?.priceUsd?.max === "number" ? caps.model3d.priceUsd.max : null;
1169
+ case "generate_sprite": {
1170
+ const set = typeof args.directions === "string" ? args.directions : "one";
1171
+ const row = caps.sprites?.directionSets?.find((d) => d.id === set);
1172
+ // The sprite pipeline picks each clip's length and resolution itself, so every
1173
+ // clip is quoted at the dearest silent row.
1174
+ const silent = (caps.video?.priceTable ?? []).filter((r) => !r.audio);
1175
+ const clipUsd = silent.length ? Math.max(...silent.map((r) => r.usd)) : null;
1176
+ if (!row || clipUsd === null) return null;
1177
+ const stills = row.billedTurnStills ?? 0;
1178
+ const each = caps.image?.priceUsdEach;
1179
+ if (stills > 0 && (typeof each !== "number" || (typeof args.image_model === "string" && args.image_model))) return null;
1180
+ return clipUsd * (row.billedClips ?? 1) + stills * (each ?? 0);
1181
+ }
1182
+ case "generate_sfx":
1183
+ // No server figure; the tool's own documented ceiling for a single effect.
1184
+ return 0.02;
1185
+ default:
1186
+ // Speech and music: the server reports no price, and a guess would be a cap in
1187
+ // name only.
1188
+ return null;
1189
+ }
1190
+ }
1191
+
1192
+ /** Read the capability report (optionally describing a specific video / 3D model). */
1193
+ export async function readMediaCapabilities(
1194
+ query: { videoModel?: string; model?: string } = {},
1195
+ signal?: AbortSignal,
1196
+ ): Promise<{ ok: true; data: CapabilitiesResponse } | { ok: false; message: string }> {
1197
+ const q = new URLSearchParams();
1198
+ if (query.videoModel) q.set("videoModel", query.videoModel);
1199
+ if (query.model) q.set("model", query.model);
1200
+ const qs = q.toString();
1201
+ const r = await callAccount<CapabilitiesResponse>(`/api/agent/media/capabilities${qs ? `?${qs}` : ""}`, { method: "GET", signal });
1202
+ return r.ok ? { ok: true, data: r.data } : { ok: false, message: r.message };
1203
+ }
1204
+
1069
1205
  /**
1070
1206
  * The sound-effect section of the capability report.
1071
1207
  *
@@ -1137,7 +1273,7 @@ export const mediaCapabilitiesToolDefinition = {
1137
1273
  label: "Media Capabilities",
1138
1274
  description:
1139
1275
  "Report what this Privateer account can generate right now: which image and video models it " +
1140
- "resolves to, the clip lengths and aspect ratios that video model accepts, and whether the " +
1276
+ "resolves to, what an image and a clip cost, the clip lengths and aspect ratios that video model accepts, and whether the " +
1141
1277
  "account's privacy settings currently block media generation. Free and instant. Call it before " +
1142
1278
  "planning a multi-clip video so you pick a legal clip length instead of discovering it through a " +
1143
1279
  "rejected — or worse, billed — call.\n" +
@@ -1157,6 +1293,14 @@ export const mediaCapabilitiesToolDefinition = {
1157
1293
  "their legal values and the price — is per-model.",
1158
1294
  }),
1159
1295
  ),
1296
+ videoModel: Type.Optional(
1297
+ Type.String({
1298
+ description:
1299
+ "A video model id to describe instead of the account default (e.g. 'bytedance/seedance-2.0'). " +
1300
+ "Clip lengths, aspect ratios and the price per clip are all per-model — check the one you will " +
1301
+ "pass as generate_video's `model` before you spend on it.",
1302
+ }),
1303
+ ),
1160
1304
  ttsModel: Type.Optional(
1161
1305
  Type.String({
1162
1306
  description:
@@ -1166,9 +1310,10 @@ export const mediaCapabilitiesToolDefinition = {
1166
1310
  }),
1167
1311
  ),
1168
1312
  }),
1169
- async execute(_toolCallId: string, params: { model?: string; ttsModel?: string }, signal?: AbortSignal) {
1313
+ async execute(_toolCallId: string, params: { model?: string; ttsModel?: string; videoModel?: string }, signal?: AbortSignal) {
1170
1314
  const query = new URLSearchParams();
1171
1315
  if (params?.model) query.set("model", params.model);
1316
+ if (params?.videoModel) query.set("videoModel", params.videoModel);
1172
1317
  if (params?.ttsModel) query.set("ttsModel", params.ttsModel);
1173
1318
  const qs = query.toString();
1174
1319
  const r = await callAccount<CapabilitiesResponse>(
@@ -1178,13 +1323,7 @@ export const mediaCapabilitiesToolDefinition = {
1178
1323
  if (!r.ok) return text(`Could not read media capabilities: ${r.message}`);
1179
1324
 
1180
1325
  const { image, video, model3d, sfx, speech, privacy } = r.data;
1181
- const lines = [
1182
- `Image model: ${image?.model ?? "unknown"}${image?.blockedByZdr ? " [BLOCKED by this account's ZDR setting]" : ""}`,
1183
- ` up to ${image?.maxPerCall ?? 1} image(s) per call`,
1184
- `Video model: ${video?.model ?? "unknown"}${video?.blockedByZdr ? " [BLOCKED by this account's ZDR setting]" : ""}`,
1185
- ` clip lengths: ${video?.durations?.length ? `${video.durations.join(", ")}s` : "model default only"}`,
1186
- ` aspect ratios: ${video?.aspectRatios?.length ? video.aspectRatios.join(", ") : "model default only"}`,
1187
- ];
1326
+ const lines = describeImageVideo(image, video);
1188
1327
 
1189
1328
  lines.push(...describeModel3d(model3d));
1190
1329
  lines.push(...describeSfx(sfx));