privateer-agent 0.12.8 → 0.12.11

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.
@@ -28,6 +28,7 @@ import { globalDir } from "../config/paths.ts";
28
28
  import { canOpenBrowser, openInBrowser } from "../util/openBrowser.ts";
29
29
  import { interpretReport, teePosture, tierFromTeePosture, type PrivacyTier } from "pi-privacy";
30
30
  import { ACCOUNT_DEFAULT_MODEL_ID, ACCOUNT_NEAR_MODEL_ID, ensurePiDefaultModel } from "./defaultModel.ts";
31
+ import { piAuthStore } from "./piAuthStore.ts";
31
32
  import {
32
33
  sealedEnabled,
33
34
  sealedProviderFor,
@@ -484,7 +485,7 @@ export const privateerOAuthProvider = {
484
485
  };
485
486
 
486
487
  // Confidential-compute prefixes in the account catalog: every model the server serves
487
- // out of a TEE. `near/` is the one we can attest end to end from here (the server
488
+ // out of a TEE. `near/` is the one we can attest in full from here (the server
488
489
  // proxies a nonce'd quote); `tinfoil/` and `phala/` are equally real enclaves whose
489
490
  // attestation we cannot bind to THIS connection through the proxy — see accountPosture.
490
491
  const TEE_PREFIXES = ["near/", "tinfoil/", "phala/"];
@@ -780,16 +781,11 @@ async function accountCredential(): Promise<AccountCredential> {
780
781
  return slot.inFlight;
781
782
  }
782
783
 
783
- // `ctx` is Pi's ExtensionContext; the auth store hangs off its model registry (the same
784
- // path privateer-brand uses to DROP the credential on sign-out).
784
+ // `ctx` is Pi's ExtensionContext. It used to carry the auth store too
785
+ // (ctx.modelRegistry.authStorage) — pi 0.84 removed it, and the surviving
786
+ // ModelRegistry facade has no credential surface at all, so the store is resolved
787
+ // from piAuthStore() instead and `ctx` is now only ever consulted for UI.
785
788
  type SeedContext = {
786
- modelRegistry?: {
787
- authStorage?: {
788
- set?: (provider: string, cred: unknown) => void;
789
- get?: (provider: string) => unknown;
790
- remove?: (provider: string) => void;
791
- };
792
- };
793
789
  model?: { provider?: string; id?: string };
794
790
  hasUI?: boolean;
795
791
  ui?: { notify?: (message: string, level: string) => void };
@@ -809,10 +805,12 @@ export async function armAccountCredential(
809
805
  opts: { notify?: boolean } = {},
810
806
  ): Promise<boolean> {
811
807
  if (!hasCredentials()) return false;
812
- const store = (ctx as SeedContext)?.modelRegistry?.authStorage;
813
- if (typeof store?.set !== "function") return false;
814
808
  try {
815
- store.set("privateer", { type: "oauth", ...(await accountCredential()) });
809
+ // `modify` is the store's only write path: it hands us the current entry and
810
+ // takes the replacement back. We ignore the current one — a freshly minted
811
+ // child credential always supersedes whatever is on disk.
812
+ const cred = await accountCredential();
813
+ await (await piAuthStore()).modify("privateer", async () => ({ type: "oauth", ...cred }));
816
814
  return true;
817
815
  } catch (e) {
818
816
  // The account channel is NOT armed: a dead machine login (401 → credentials cleared
@@ -831,6 +829,17 @@ export async function armAccountCredential(
831
829
  }
832
830
  }
833
831
 
832
+ // Write an already-acquired account credential into Pi's auth store.
833
+ //
834
+ // The entrypoints that mint their own child credential (chat, acp, harbor, the live
835
+ // task session) used to poke `services.authStorage.set(...)` directly. pi 0.84 removed
836
+ // that property and made the store's only write path an async `modify`, so the poke
837
+ // lives here now — one place that knows the shape, next to the ownership rules that
838
+ // govern the same entry.
839
+ export async function persistAccountCredential(creds: AccountCredential): Promise<void> {
840
+ await (await piAuthStore()).modify("privateer", async () => ({ type: "oauth", ...creds }));
841
+ }
842
+
834
843
  // Drop Pi's PERSISTED account credential (the `privateer` entry in auth.json) — but
835
844
  // ONLY when it's the one this process minted. Every exit path pairs a session revoke
836
845
  // with this drop (the contract in auth/privateer.ts): Pi reuses the persisted
@@ -849,31 +858,32 @@ export async function armAccountCredential(
849
858
  // machine's whole token family — every terminal's session included. Those callers have
850
859
  // also already wiped the local credentials, so the ownership memo is gone by then and an
851
860
  // ownership-checked drop would refuse and strand a dead entry on disk.
852
- export function dropPersistedAccountCredential(ctx: unknown, opts: { force?: boolean } = {}): boolean {
853
- const store = (ctx as SeedContext)?.modelRegistry?.authStorage;
861
+ export async function dropPersistedAccountCredential(
862
+ opts: { force?: boolean } = {},
863
+ ): Promise<boolean> {
864
+ const store = await piAuthStore();
854
865
  const mine = ownedAccountCredential()?.access;
855
866
  // Every caller revokes this process's session immediately before calling us, so drop
856
867
  // the memo whatever happens to the entry on disk — otherwise a later arm() in this
857
868
  // process (a harbor run's session_start, say) could re-persist the revoked token.
858
869
  armSlot().cred = undefined;
859
- if (typeof store?.remove !== "function") return false;
860
870
  try {
861
- if (opts.force) {
862
- /* whole family revoked — the entry is dead for every terminal, drop it */
863
- } else if (typeof store.get === "function") {
864
- const persisted = store.get("privateer") as { access?: unknown } | undefined;
871
+ if (!opts.force) {
872
+ // Not force: read the entry back and only drop it if it is ours. This read is
873
+ // the whole safety property — it is what sees the entry a CONCURRENT terminal
874
+ // wrote, and auth.json is re-read from disk when its revision changes, so this
875
+ // observes other processes rather than a stale in-process copy.
876
+ const persisted = (await store.read("privateer")) as { access?: unknown } | undefined;
865
877
  if (!persisted) return false; // nothing persisted — nothing to drop
866
878
  // Some other terminal's session. Leave it: it's the key that terminal is using.
867
879
  if (typeof persisted.access === "string" && persisted.access !== mine) return false;
868
- } else if (!mine) {
869
- // Older Pi with no readable auth store AND we never minted anything — we have no
870
- // basis to claim the entry, so leave it alone rather than break another terminal.
871
- return false;
872
880
  }
873
- store.remove("privateer");
881
+ // force: the whole token family is revoked, so the entry is dead for every
882
+ // terminal and ownership is irrelevant.
883
+ await store.delete("privateer");
874
884
  return true;
875
885
  } catch {
876
- return false; // older Pi shape / unwritable store — the server TTL is the fallback
886
+ return false; // unwritable store — the server TTL is the fallback
877
887
  }
878
888
  }
879
889
 
@@ -889,12 +899,10 @@ export function dropPersistedAccountCredential(ctx: unknown, opts: { force?: boo
889
899
  // refresh path (refreshToken), and pre-empting it would mint a second session row.
890
900
  export async function ensureAccountArmed(ctx: unknown): Promise<void> {
891
901
  if (!hasCredentials()) return;
892
- const store = (ctx as SeedContext)?.modelRegistry?.authStorage;
893
- if (typeof store?.get !== "function") return; // can't tell — leave it to session_start
894
902
  try {
895
- if (store.get("privateer")) return;
903
+ if (await (await piAuthStore()).read("privateer")) return;
896
904
  } catch {
897
- return;
905
+ return; // can't tell — leave it to the next session_start
898
906
  }
899
907
  await armAccountCredential(ctx, { notify: false });
900
908
  }
@@ -71,7 +71,7 @@ export const ACCOUNT_DEFAULT_MODEL_ID = TINFOIL_MODEL_ID;
71
71
  export const ACCOUNT_DEFAULT_SPEC = `privateer/${ACCOUNT_DEFAULT_MODEL_ID}`;
72
72
 
73
73
  // The account channel's NEAR confidential-compute model — no longer the default, but
74
- // still the one account model we can attest end-to-end through the server proxy, so
74
+ // still the one account model we can attest in full through the server proxy, so
75
75
  // it stays first in the seed catalog after the default. See providers/account.ts.
76
76
  export const ACCOUNT_NEAR_MODEL_ID = "near/zai-org/GLM-5.1-FP8";
77
77
 
@@ -0,0 +1,129 @@
1
+ // Access to Pi's credential store and model registry after the 0.84 refactor.
2
+ //
3
+ // pi-coding-agent 0.84 folded credential storage and the model registry into a single
4
+ // `ModelRuntime` and removed both from `AgentSessionServices`. Two things that used to
5
+ // hang off `services` no longer do:
6
+ //
7
+ // services.authStorage -> gone; the runtime owns a private CredentialStore
8
+ // services.modelRegistry -> gone; superseded by services.modelRuntime
9
+ //
10
+ // This module is the single place that knows about that change, so the account channel
11
+ // and the entrypoints don't each grow their own workaround.
12
+ //
13
+ // ⚠️ NOTHING HERE MAY STATICALLY IMPORT PI. This module is pulled in by
14
+ // providers/account.ts, which is itself statically imported by extensions and by the
15
+ // harbor — and whose static graph is deliberately Pi-free, because Pi must not load
16
+ // before boot.ts has installed the env and the dispatcher (the same contract session.ts
17
+ // documents). So the one place a Pi class is genuinely needed loads it dynamically, and
18
+ // everything else is duck-typed off objects Pi already handed us.
19
+
20
+ import { join } from "node:path";
21
+
22
+ import { agentDir as defaultAgentDir } from "../config/paths.ts";
23
+
24
+ /** The agent dir Pi is actually using — boot pins PI_CODING_AGENT_DIR to ours. */
25
+ function resolveAgentDir(agentDir?: string): string {
26
+ return agentDir ?? process.env.PI_CODING_AGENT_DIR ?? defaultAgentDir();
27
+ }
28
+
29
+ /** The subset of Pi's CredentialStore the account channel uses. All async in 0.84. */
30
+ export type PiCredentialStore = {
31
+ read(providerId: string): Promise<unknown>;
32
+ modify(providerId: string, fn: (current: unknown) => Promise<unknown>): Promise<unknown>;
33
+ delete(providerId: string): Promise<void>;
34
+ };
35
+
36
+ let cached: { path: string; store: Promise<PiCredentialStore> } | undefined;
37
+ let override: Promise<PiCredentialStore> | undefined;
38
+
39
+ /**
40
+ * Pi's credential store for auth.json — the same file, format and class the ModelRuntime
41
+ * uses. `AuthStorage` IS that class: model-runtime.js defaults to
42
+ * `DefaultAuthStorage.create(authPath)`, where DefaultAuthStorage is this exact export,
43
+ * and it already implements the public CredentialStore interface. 0.84 merely stopped
44
+ * re-exporting it, so our pi patch puts the export back — which keeps us on Pi's own
45
+ * store and file format instead of reimplementing either.
46
+ *
47
+ * Why ModelRuntime isn't enough: it exposes login/logout/listCredentials but no way to
48
+ * WRITE an OAuth credential, and pi-ai exports only InMemoryCredentialStore. The account
49
+ * channel must write one, because Pi persists an OAuth credential only for a login IT
50
+ * drove — never for our device-code flow.
51
+ *
52
+ * Holding a second instance over the same path is safe and is not a shortcut: auth.json
53
+ * is machine-global and shared by every terminal on the box, so the store is already
54
+ * built for concurrent access (per-path shared read state, a file-revision check on
55
+ * read, serialized `modify` writes). A second instance in this process is just one more
56
+ * reader/writer, exactly like another terminal — which is the case the ownership check
57
+ * in account.ts exists to handle.
58
+ */
59
+ export function piAuthStore(agentDir?: string): Promise<PiCredentialStore> {
60
+ if (override) return override;
61
+ const path = join(resolveAgentDir(agentDir), "auth.json");
62
+ if (cached?.path !== path) {
63
+ cached = {
64
+ path,
65
+ store: import("@earendil-works/pi-coding-agent").then((pi) => {
66
+ // `AuthStorage` reaches the index through OUR pi patch (0.84 stopped exporting
67
+ // it), and patches are best-effort — `bin/apply-patches.mjs` degrades to stock
68
+ // pi when it cannot write to node_modules, the `sudo npm i -g` case. This is
69
+ // the one place left that genuinely needs the patched export: only pi's own
70
+ // class knows auth.json's locking, revision checks and file format, and pi's
71
+ // package exports map blocks the deep path it still lives at. So say what
72
+ // happened. Unpatched, the alternative is `undefined.create is not a function`
73
+ // at whatever moment the account channel first writes a credential.
74
+ const ctor = (pi as { AuthStorage?: { create(p: string): PiCredentialStore } }).AuthStorage;
75
+ if (!ctor) {
76
+ throw new Error(
77
+ "pi's AuthStorage export is missing — the privateer patch for " +
78
+ "@earendil-works/pi-coding-agent has not been applied to this install. " +
79
+ "Run `privateer` once with write access to node_modules (a root-owned " +
80
+ "global install needs sudo), or reinstall with a release bundle.",
81
+ );
82
+ }
83
+ return ctor.create(path);
84
+ }),
85
+ };
86
+ }
87
+ return cached.store;
88
+ }
89
+
90
+ /**
91
+ * Substitute the store. Tests only.
92
+ *
93
+ * The account channel's ownership rules used to be testable by handing the functions a
94
+ * ctx with a fake store on it; the store is resolved internally now, so this is the seam
95
+ * that replaces it. Pass undefined to restore the real one.
96
+ */
97
+ export function setPiAuthStoreForTests(store: PiCredentialStore | undefined): void {
98
+ override = store ? Promise.resolve(store) : undefined;
99
+ cached = undefined;
100
+ }
101
+
102
+ /** The registry-shaped reads our callers make. Matches the old ModelRegistry facade. */
103
+ export type RegistryLike = {
104
+ find(provider: string, modelId: string): unknown;
105
+ getAll(): readonly unknown[];
106
+ getAvailable(): Promise<readonly unknown[]>;
107
+ };
108
+
109
+ /**
110
+ * A registry-shaped view of a session's services.
111
+ *
112
+ * 0.84 kept `ModelRegistry` as a synchronous facade over `ModelRuntime` (that is how
113
+ * extensions still see it) but stopped putting one on `services`. We deliberately do NOT
114
+ * construct one: importing the class would drag Pi into this module's static graph. The
115
+ * runtime is a live object Pi already gave us, so three delegating methods reproduce
116
+ * exactly the surface our callers used — `find` is `getModel` renamed.
117
+ */
118
+ export function modelRegistryOf(services: { modelRuntime?: unknown } | null | undefined): RegistryLike {
119
+ const runtime = services?.modelRuntime as {
120
+ getModel(p: string, id: string): unknown;
121
+ getModels(): readonly unknown[];
122
+ getAvailable(): Promise<readonly unknown[]>;
123
+ } | undefined;
124
+ return {
125
+ find: (provider, modelId) => runtime?.getModel(provider, modelId),
126
+ getAll: () => runtime?.getModels() ?? [],
127
+ getAvailable: async () => (await runtime?.getAvailable()) ?? [],
128
+ };
129
+ }
@@ -41,7 +41,7 @@ import { iterateSSE } from "./phala/sse.ts";
41
41
  export type SealedProvider = "tinfoil" | "phala";
42
42
 
43
43
  // Sealed mode is ON by default as of 2026-07-31, when the live checklist in
44
- // docs/tee-verified-tinfoil-ehbp.md passed end to end against the deployed relay:
44
+ // docs/tee-verified-tinfoil-ehbp.md passed in full against the deployed relay:
45
45
  // both enclaves attest client-side (Tinfoil HPKE-key match; Phala report binding +
46
46
  // TDX quote), a sealed turn round-trips and streams incrementally, a bogus enclave is
47
47
  // refused rather than silently greened, and the server bills the turn.
@@ -20,7 +20,9 @@ import { agentVersion } from "../config/version.ts";
20
20
  import { createEngineEventAdapter } from "../bridge/engineAdapter.ts";
21
21
  import { isRemoteUnsafeTool, type GateController } from "../ext/permissionGate.ts";
22
22
  import { moatResourceOptions } from "../config/moat.ts";
23
- import { rememberAccountCredential, dropPersistedAccountCredential } from "../providers/account.ts";
23
+ import { rememberAccountCredential, persistAccountCredential, dropPersistedAccountCredential } from "../providers/account.ts";
24
+ // Pi-free static graph by design — see the header of piAuthStore.ts.
25
+ import { modelRegistryOf } from "../providers/piAuthStore.ts";
24
26
  import { RelayClient, type TaskSpec } from "./relayClient.ts";
25
27
  import { RemoteBridge } from "./remoteBridge.ts";
26
28
  import { AttachmentStore, type StoredAttachment } from "../util/attachmentStore.ts";
@@ -70,7 +72,6 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
70
72
  let initialPromptSent = false;
71
73
  let stopped = false;
72
74
  let spawnedAccount = false;
73
- let servicesRef: { authStorage?: { remove?: (p: string) => void; get?: (p: string) => unknown } } | null = null;
74
75
 
75
76
  let attachTimer: ReturnType<typeof setTimeout> | undefined;
76
77
  let lifeTimer: ReturnType<typeof setTimeout> | undefined;
@@ -117,7 +118,7 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
117
118
  try { await revokeAccountSession(); } catch { /* server TTL is the fallback */ }
118
119
  // Ownership-checked: auth.json is shared machine-wide, so a live task's teardown
119
120
  // must not delete an interactive terminal's entry (see providers/account.ts).
120
- try { dropPersistedAccountCredential({ modelRegistry: { authStorage: servicesRef?.authStorage } }); } catch { /* nothing persisted */ }
121
+ try { await dropPersistedAccountCredential(); } catch { /* nothing persisted */ }
121
122
  }
122
123
  deps.onClosed(termId);
123
124
  deps.log(`live task ${termId} closed`);
@@ -218,13 +219,12 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
218
219
  })) as any),
219
220
  },
220
221
  });
221
- servicesRef = services as any;
222
222
 
223
223
  const { provider, modelId } = deps.parseSpec(modelSpec);
224
224
  if (provider === "privateer") {
225
225
  try {
226
226
  const creds = await spawnAccountCredentials();
227
- (services.authStorage as any).set("privateer", { type: "oauth", ...creds });
227
+ await persistAccountCredential(creds);
228
228
  rememberAccountCredential(creds); // claim it, so stop() drops OUR entry only
229
229
  spawnedAccount = true;
230
230
  } catch (e) {
@@ -236,7 +236,7 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
236
236
  // just spawned + closes the relay), or a throw would leak an orphaned account "device"
237
237
  // until its token TTL. Everything post-account-spawn runs under one guard.
238
238
  try {
239
- const model = (services.modelRegistry as any).find(provider, modelId);
239
+ const model = (modelRegistryOf(services) as any).find(provider, modelId);
240
240
  if (!model) {
241
241
  throw new Error(`model ${provider}/${modelId} not found`);
242
242
  }
@@ -1,6 +1,7 @@
1
1
  import type { Routine } from "./schema.ts";
2
2
  import { webhookName } from "./schema.ts";
3
3
  import { writeRoutineOutput, addNotice } from "./store.ts";
4
+ import type { StagedMedia } from "./resultMedia.ts";
4
5
 
5
6
  // A relay pusher, injected by the harbor. Given the finished result it either
6
7
  // forwards it to an attached controller immediately ("live") or persists it to the
@@ -14,7 +15,14 @@ export type RelayPusher = (routine: Routine, content: string) => "live" | "queue
14
15
  // "queued" when it was persisted for a later flush — either way the result is
15
16
  // durably accounted for. `status` is carried so the sealed envelope can render
16
17
  // ok/error without re-parsing the markdown body.
17
- export type CloudPusher = (routine: Routine, content: string, status: "ok" | "error") => Promise<"sent" | "queued">;
18
+ // `media` is what the run staged for the Inbox (resultMedia.ts) — sealed and stored
19
+ // alongside the message by the pusher, or named in the body when it can't travel.
20
+ export type CloudPusher = (
21
+ routine: Routine,
22
+ content: string,
23
+ status: "ok" | "error",
24
+ media: StagedMedia[],
25
+ ) => Promise<"sent" | "queued">;
18
26
 
19
27
  // A named webhook endpoint from config `webhooks`.
20
28
  export interface WebhookTarget {
@@ -28,6 +36,11 @@ export interface DeliveryContext {
28
36
  // (e.g. not signed in) → the `cloud` channel falls back to a notice so the result
29
37
  // isn't silently lost.
30
38
  pushCloud?: CloudPusher;
39
+ // Files the run staged for the app's Inbox. Only the `cloud` channel carries them:
40
+ // a webhook receiver has no key to open a sealed blob, and the file channel already
41
+ // has the originals on disk — so for every other channel the body's own prose is
42
+ // what tells the reader an attachment exists.
43
+ media?: StagedMedia[];
31
44
  // Named endpoints for "webhook:<name>" delivery entries; results POST here.
32
45
  webhooks?: Record<string, WebhookTarget>;
33
46
  // Scrubs secrets from anything leaving the machine. Webhook bodies always pass
@@ -144,7 +157,7 @@ export async function deliver(
144
157
  // yet) and flushes later — both are durable. Only when no pusher is wired at all,
145
158
  // and nothing else keeps a durable record, do we fall back to a notice.
146
159
  if (wants.has("cloud")) {
147
- const sent = ctx.pushCloud ? await ctx.pushCloud(routine, content, status) : undefined;
160
+ const sent = ctx.pushCloud ? await ctx.pushCloud(routine, content, status, ctx.media ?? []) : undefined;
148
161
  if (sent === "sent") delivered.push("cloud");
149
162
  else if (sent === "queued") delivered.push("cloud(queued)");
150
163
  else if (!wants.has("file") && !wants.has("notice") && !wants.has("relay")) {
@@ -0,0 +1,60 @@
1
+ // The delivery brief — what an unattended run is told about where its answer lands.
2
+ //
3
+ // A routine's prompt is written by the user ("summarise my open PRs at 7am"). It says
4
+ // what to do and nothing about the surface, so the model wrote for a terminal: paths,
5
+ // scrollback references, "see above", a plan to run something else next. None of that
6
+ // survives the trip. The result is sealed to the account outbox and read hours later
7
+ // in the app's Inbox, by someone who was not there and cannot answer back.
8
+ //
9
+ // So we prepend this. It states the surface (markdown, in an app), what the surface can
10
+ // render beyond prose (ONE fenced artifact, which the app turns into a Cargo card with
11
+ // Preview / Download / Save), and how to hand over media (the attach_to_result tool —
12
+ // only mentioned when it is actually registered, because a brief that advertises a tool
13
+ // the run doesn't have is how a model ends up describing an attachment nobody gets).
14
+ //
15
+ // Deliberately short. It is prepended to every unattended turn, so every line costs
16
+ // tokens on every run; anything that isn't load-bearing for the delivery is the user's
17
+ // prompt's business, not ours.
18
+
19
+ export interface BriefOptions {
20
+ /** The result reaches the app's Inbox (cloud delivery). False → no brief at all. */
21
+ inbox: boolean;
22
+ /** attach_to_result is registered for this run. */
23
+ canAttach: boolean;
24
+ }
25
+
26
+ const HEADER = "[Privateer delivery brief — how this answer will be read]";
27
+
28
+ /**
29
+ * The brief for one run, or "" when it wouldn't help. Callers prepend it with
30
+ * `withBrief` rather than concatenating by hand.
31
+ */
32
+ export function deliveryBrief(opts: BriefOptions): string {
33
+ if (!opts.inbox) return "";
34
+ const lines = [
35
+ HEADER,
36
+ "Nobody is at this terminal. Your final message is delivered to the user's Privateer Inbox in the app and read later, on a phone or in a browser, with no access to this machine.",
37
+ "",
38
+ "- Write the answer so it stands alone. No scrollback, no \"see above\", no next step you meant to run.",
39
+ "- It renders as Markdown: headings, lists, tables, links and code all work.",
40
+ "- A local file path is not something the user can open. Don't offer one as the way to see something.",
41
+ "- If the answer is better as something they can keep, open or share, end your message with ONE fenced artifact and describe it in prose above:",
42
+ " ```html … ``` a complete standalone HTML document — page, slide deck or game. Inline all CSS and JS; it is opened with no network and no sibling files.",
43
+ " ```md kind=pdf … ``` a document (also kind=docx, kind=md).",
44
+ " ```csv kind=sheet … ``` a spreadsheet.",
45
+ " The app turns that fence into an artifact card with Preview, Download and Save — so don't also paste the same content as prose.",
46
+ ];
47
+ if (opts.canAttach) {
48
+ lines.push(
49
+ "- To send an image, video, audio clip or document, call attach_to_result with its path. That is the only way bytes reach the user; base64 pasted into the answer is not rendered.",
50
+ );
51
+ }
52
+ return lines.join("\n");
53
+ }
54
+
55
+ /** Prepend the brief to a user prompt, fenced off so the two don't read as one instruction. */
56
+ export function withBrief(prompt: string, opts: BriefOptions): string {
57
+ const brief = deliveryBrief(opts);
58
+ if (!brief) return prompt;
59
+ return `${brief}\n\n---\n\n${prompt}`;
60
+ }
@@ -0,0 +1,131 @@
1
+ // Media staged for ONE unattended result — the stills, clips and files an agent
2
+ // wants the user to actually see in their Privateer Inbox, rather than describe.
3
+ //
4
+ // WHY THIS EXISTS. A routine can already generate media (src/tools/media.ts) and
5
+ // compose it (videoCompose.ts), but until now the run could only ever say where it
6
+ // put the file. Nobody is at that machine — that is the whole premise of an
7
+ // unattended run — so "saved to ~/.privateer/media/still-3.png" is a result the
8
+ // user cannot open from the surface they were handed. `send_file_to_client` doesn't
9
+ // close that gap either: it needs a controller attached RIGHT NOW, which is exactly
10
+ // what a 7am routine doesn't have.
11
+ //
12
+ // So the run stages files here (the attach_to_result tool — src/tools/attachResult.ts),
13
+ // and delivery seals them to the account outbox alongside the message: small ones
14
+ // inline in the envelope, larger ones as sealed blobs the app collects on the same
15
+ // sync (src/outbox/cloudOutbox.ts). Store-and-forward, E2EE, no controller needed.
16
+ //
17
+ // The ceilings below are the honest part. They are checked at STAGE time, not at
18
+ // delivery time, so the model learns "that clip is too big" while it can still do
19
+ // something about it (re-encode, trim, attach a frame instead) rather than after the
20
+ // turn has ended. A refusal is never traded against something already staged.
21
+
22
+ import { existsSync, statSync } from "node:fs";
23
+ import { basename, isAbsolute, resolve } from "node:path";
24
+ import { randomUUID } from "node:crypto";
25
+ import { mediaTypeForPath } from "../tools/sendFile.ts";
26
+
27
+ /** Most files one result may carry. */
28
+ export const MAX_ATTACHMENTS = 6;
29
+ /** Largest single file, matching the server's per-blob ceiling. */
30
+ export const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024;
31
+ /** Largest total per result — bounds one run's share of the account's mailbox. */
32
+ export const MAX_TOTAL_ATTACHMENT_BYTES = 24 * 1024 * 1024;
33
+
34
+ /** How the app should present an attachment. Derived from the media type. */
35
+ export type MediaClass = "image" | "video" | "audio" | "file";
36
+
37
+ export interface StagedMedia {
38
+ /** Per-result id; ties the envelope's metadata to its bytes. */
39
+ id: string;
40
+ /** Absolute path on THIS machine. Never leaves it — see the note in delivery. */
41
+ path: string;
42
+ name: string;
43
+ mediaType: string;
44
+ cls: MediaClass;
45
+ size: number;
46
+ caption?: string;
47
+ }
48
+
49
+ export function classifyMedia(mediaType: string): MediaClass {
50
+ if (mediaType.startsWith("image/")) return "image";
51
+ if (mediaType.startsWith("video/")) return "video";
52
+ if (mediaType.startsWith("audio/")) return "audio";
53
+ return "file";
54
+ }
55
+
56
+ export type StageResult =
57
+ | { ok: true; item: StagedMedia }
58
+ | { ok: false; reason: string };
59
+
60
+ /**
61
+ * One run's staging area. Created per routine/task run and thrown away with it, so
62
+ * a file staged by yesterday's run can never ride along with today's.
63
+ */
64
+ export class ResultMedia {
65
+ private items: StagedMedia[] = [];
66
+
67
+ /** Bytes staged so far. */
68
+ totalBytes(): number {
69
+ return this.items.reduce((n, m) => n + m.size, 0);
70
+ }
71
+
72
+ list(): StagedMedia[] {
73
+ return [...this.items];
74
+ }
75
+
76
+ clear(): void {
77
+ this.items = [];
78
+ }
79
+
80
+ /**
81
+ * Stage one file. `cwd` resolves a relative path the same way the run's other
82
+ * file tools do. Re-staging the same path REPLACES the earlier entry (a model
83
+ * that regenerates an image and attaches it again means the new one), which also
84
+ * stops a retry loop from eating the count cap.
85
+ */
86
+ stage(path: string, cwd: string, caption?: string): StageResult {
87
+ const abs = isAbsolute(path) ? path : resolve(cwd, path);
88
+ if (!existsSync(abs)) return { ok: false, reason: `File not found: ${path}` };
89
+ const stat = statSync(abs);
90
+ if (stat.isDirectory()) return { ok: false, reason: `${path} is a directory — attach a single file.` };
91
+ if (stat.size === 0) return { ok: false, reason: `${path} is empty — nothing to attach.` };
92
+ if (stat.size > MAX_ATTACHMENT_BYTES) {
93
+ return {
94
+ ok: false,
95
+ reason: `${basename(abs)} is ${mb(stat.size)} MB; the inbox caps one attachment at ${mb(MAX_ATTACHMENT_BYTES)} MB. Re-encode it smaller, trim it, or attach a single frame instead.`,
96
+ };
97
+ }
98
+
99
+ const existing = this.items.findIndex((m) => m.path === abs);
100
+ const others = existing >= 0 ? this.items.filter((_, i) => i !== existing) : this.items;
101
+ if (others.length >= MAX_ATTACHMENTS) {
102
+ return { ok: false, reason: `Already carrying ${MAX_ATTACHMENTS} attachments — that is the limit for one result.` };
103
+ }
104
+ const otherBytes = others.reduce((n, m) => n + m.size, 0);
105
+ if (otherBytes + stat.size > MAX_TOTAL_ATTACHMENT_BYTES) {
106
+ return {
107
+ ok: false,
108
+ reason: `That would put this result over the ${mb(MAX_TOTAL_ATTACHMENT_BYTES)} MB attachment budget (${mb(otherBytes)} MB already staged).`,
109
+ };
110
+ }
111
+
112
+ const mediaType = mediaTypeForPath(abs);
113
+ const item: StagedMedia = {
114
+ id: randomUUID(),
115
+ path: abs,
116
+ name: basename(abs),
117
+ mediaType,
118
+ cls: classifyMedia(mediaType),
119
+ size: stat.size,
120
+ // A caption is shown under the attachment in the app, so it is worth having
121
+ // and worth bounding — this is model-authored text heading for a UI.
122
+ ...(caption?.trim() ? { caption: caption.trim().slice(0, 200) } : {}),
123
+ };
124
+ this.items = [...others, item];
125
+ return { ok: true, item };
126
+ }
127
+ }
128
+
129
+ function mb(bytes: number): string {
130
+ return (bytes / 1048576).toFixed(1).replace(/\.0$/, "");
131
+ }
@@ -2,7 +2,7 @@ import { randomBytes } from "node:crypto";
2
2
  import { z } from "zod";
3
3
 
4
4
  // Where a routine's result is delivered after it runs. `file`/`relay`/`notice` stay
5
- // inside the user's trust boundary. `cloud` stores an END-TO-END-ENCRYPTED copy in
5
+ // inside the user's trust boundary. `cloud` stores an ENCRYPTED copy in
6
6
  // the account's server outbox for the app to catch up on when it next opens (the
7
7
  // server only ever sees ciphertext — it can't read results — but does learn that a
8
8
  // result exists, plus its time and rough size). `email` and `webhook:<name>` cross
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { globalDir } from "../config/paths.ts";
5
5
  import { Routine, RoutineFile } from "./schema.ts";
6
+ import type { StagedMedia } from "./resultMedia.ts";
6
7
 
7
8
  // routines.json lives alongside config.json in the global dir. It can carry the
8
9
  // prompt text and (for email delivery) recipient hints, so it is written owner-only
@@ -223,6 +224,12 @@ export interface PendingCloud {
223
224
  // workflow run. Preserved so the flush re-seals with the right `kind` and the app
224
225
  // labels it correctly in the inbox.
225
226
  kind?: OutboxKind;
227
+ // Attachments the run staged for the Inbox (resultMedia.ts) — RECORDS, not bytes.
228
+ // The files are still on this machine, so a flush hours later re-reads them from
229
+ // these paths; one that has since been deleted is named in the delivered body
230
+ // instead. Copying megabytes into this queue would be the same content twice on
231
+ // the one box that already has it.
232
+ media?: StagedMedia[];
226
233
  }
227
234
 
228
235
  function pendingCloudPath(): string {