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.
package/src/cli/chat.ts CHANGED
@@ -44,8 +44,10 @@ async function main() {
44
44
  const {
45
45
  accountPosture,
46
46
  rememberAccountCredential,
47
+ persistAccountCredential,
47
48
  dropPersistedAccountCredential,
48
49
  } = await import("../providers/account.ts");
50
+ const { modelRegistryOf } = await import("../providers/piAuthStore.ts");
49
51
  const { agentVersion } = await import("../config/version.ts");
50
52
  const { pickerCatalog, hiddenAccountNotice, hiddenAccountTitleSuffix } = await import("../providers/modelCatalog.ts");
51
53
  const { resolveDefaultModel, resolveSignedInModel, savedPiDefaultSpec } = await import("../providers/defaultModel.ts");
@@ -448,7 +450,7 @@ async function main() {
448
450
  try { await priv.revokeLocalSessions(); } catch { /* best effort — server TTL is the fallback */ }
449
451
  // Ownership-checked: auth.json is machine-global, so removing an entry another
450
452
  // running terminal minted would strand it (see providers/account.ts).
451
- try { dropPersistedAccountCredential({ modelRegistry: { authStorage: services.authStorage } }); } catch { /* nothing persisted */ }
453
+ try { await dropPersistedAccountCredential(); } catch { /* nothing persisted */ }
452
454
  }
453
455
  const onSignal = (): void => { void cleanup().finally(() => process.exit(0)); };
454
456
  process.once("SIGINT", onSignal);
@@ -459,14 +461,14 @@ async function main() {
459
461
  if (provider === "privateer") {
460
462
  try {
461
463
  const creds = await priv.acquireAccountCredential();
462
- (services.authStorage as any).set("privateer", { type: "oauth", ...creds });
464
+ await persistAccountCredential(creds);
463
465
  rememberAccountCredential(creds); // claim it, so cleanup drops OUR entry and only ours
464
466
  } catch (e) {
465
467
  console.log(`${RED}Account channel unavailable: ${(e as Error).message}${RESET}`);
466
468
  }
467
469
  }
468
470
 
469
- const model = (services.modelRegistry as any).find(provider, modelId);
471
+ const model = (modelRegistryOf(services) as any).find(provider, modelId);
470
472
  if (!model) {
471
473
  console.log(`${RED}Model ${provider}/${modelId} not found.${RESET} Set PRIVATEER_MODEL=provider/id and check the key is in .env.`);
472
474
  rl.close();
@@ -633,7 +635,7 @@ async function main() {
633
635
  // is filtered out until the credential is armed).
634
636
  const SIGN_IN_HINT = "Run /login.";
635
637
  async function modelCatalog() {
636
- return pickerCatalog(services.modelRegistry as any);
638
+ return pickerCatalog(modelRegistryOf(services) as any);
637
639
  }
638
640
 
639
641
  // Switch the live session's model in place (history preserved — see
@@ -644,7 +646,7 @@ async function main() {
644
646
  const at = sp.indexOf("/");
645
647
  if (at < 0) { const m = "Usage: /model provider/id"; console.log(`${RED}${m}${RESET}`); if (remote) relay?.sendNotice(m); return; }
646
648
  const p = sp.slice(0, at), id = sp.slice(at + 1);
647
- const model = (session.modelRegistry as any).find?.(p, id) ?? (services.modelRegistry as any).find?.(p, id);
649
+ const model = (session.modelRegistry as any).find?.(p, id) ?? (modelRegistryOf(services) as any).find?.(p, id);
648
650
  if (!model) { const m = `Model ${sp} not found — try /models.`; console.log(`${RED}${m}${RESET}`); if (remote) relay?.sendNotice(m); return; }
649
651
  try {
650
652
  await session.setModel(model);
@@ -62,6 +62,14 @@ export interface MoatOptions {
62
62
  * pair is what the model gets (see tools/relayFileTools.ts).
63
63
  */
64
64
  relayFiles?: { bridge: SendFileBridge; attachments: AttachmentStore };
65
+ /**
66
+ * THIS run's inbox-attachment staging area (routines/resultMedia.ts). Passed only by
67
+ * a path whose result reaches the app's Inbox — a scheduled routine, a submitted
68
+ * task — and never shared between runs, so yesterday's staged file can't ride along
69
+ * with today's. Absent ⇒ attach_to_result doesn't exist for the session, which is
70
+ * the point: a run whose result goes to a webhook or a file has nothing to attach to.
71
+ */
72
+ resultMedia?: import("../routines/resultMedia.ts").ResultMedia;
65
73
  }
66
74
 
67
75
  /**
@@ -206,6 +214,11 @@ export async function buildMoat(opts: MoatOptions): Promise<ExtensionFactory[]>
206
214
  factories.push(makeRelayFileTools(opts.relayFiles.bridge, opts.relayFiles.attachments));
207
215
  }
208
216
 
217
+ if (opts.resultMedia) {
218
+ const { makeAttachResultTools } = await import("../tools/attachResult.ts");
219
+ factories.push(makeAttachResultTools(opts.resultMedia));
220
+ }
221
+
209
222
  if (caps.web && webEnabled()) {
210
223
  const { makeWebTools } = await import("../tools/web.ts");
211
224
  factories.push(makeWebTools());
@@ -17,8 +17,11 @@ import { type GateController } from "../ext/permissionGate.ts";
17
17
  import { moatResourceOptions } from "../config/moat.ts";
18
18
  import {
19
19
  rememberAccountCredential,
20
+ persistAccountCredential,
20
21
  dropPersistedAccountCredential,
21
22
  } from "../providers/account.ts";
23
+ // Pi-free static graph by design — see the header of piAuthStore.ts.
24
+ import { modelRegistryOf } from "../providers/piAuthStore.ts";
22
25
  import { resolveDefaultModel } from "../providers/defaultModel.ts";
23
26
  import { RelayClient, type TaskSpec } from "../remote/relayClient.ts";
24
27
  import { createLiveTaskSession, type LiveTaskHandle } from "../remote/liveTaskSession.ts";
@@ -54,6 +57,9 @@ import { triggerError, computeNextRun, advanceAfterRun } from "../routines/trigg
54
57
  import { splitRoutineTools } from "../routines/toolSelect.ts";
55
58
  import { resolveMcpSelection, readMcpInventory, type ResolvedMcpTools } from "../mcp/toolNames.ts";
56
59
  import { deliver, type RelayPusher, type CloudPusher } from "../routines/delivery.ts";
60
+ import { ResultMedia, type StagedMedia } from "../routines/resultMedia.ts";
61
+ import { withBrief } from "../routines/resultBrief.ts";
62
+ import { ATTACH_RESULT_TOOL } from "../tools/attachResult.ts";
57
63
  import { postOutbox as sealToOutbox } from "../outbox/cloudOutbox.ts";
58
64
  import { redactText, collectSecrets } from "../util/redact.ts";
59
65
  import { startIpcServer, sendToHarbor, describeRelay, formatDuration, HarborAlreadyRunningError, type IpcRequest, type IpcResponse, type RelayStatus } from "./ipc.ts";
@@ -284,10 +290,14 @@ export class Harbor {
284
290
  return "queued";
285
291
  };
286
292
 
287
- private readonly pushCloud: CloudPusher = async (routine, content, status) => {
293
+ private readonly pushCloud: CloudPusher = async (routine, content, status, media = []) => {
288
294
  const at = new Date().toISOString();
289
- if (await this.postOutbox(routine.name, at, status, content)) return "sent";
290
- addPendingCloud({ routine: routine.name, at, status, content });
295
+ if (await this.postOutbox(routine.name, at, status, content, "routine", media)) return "sent";
296
+ // The queued copy keeps the attachment RECORDS, not the bytes: the files are
297
+ // still on this disk, so a flush hours later re-reads them (and says so in the
298
+ // body for any that have since gone). Buffering megabytes into a JSON queue
299
+ // would be the same content twice, on a box that already has it.
300
+ addPendingCloud({ routine: routine.name, at, status, content, ...(media.length ? { media } : {}) });
291
301
  return "queued";
292
302
  };
293
303
 
@@ -577,8 +587,15 @@ export class Harbor {
577
587
  // finished turn, and two copies of an E2EE wire format is one too many. Same
578
588
  // caches, same fail-closed key verification; this is just the harbor's caller.
579
589
 
580
- private async postOutbox(name: string, at: string, status: "ok" | "error", content: string, kind: OutboxKind = "routine"): Promise<boolean> {
581
- return sealToOutbox(name, at, status, content, kind);
590
+ private async postOutbox(
591
+ name: string,
592
+ at: string,
593
+ status: "ok" | "error",
594
+ content: string,
595
+ kind: OutboxKind = "routine",
596
+ media: StagedMedia[] = [],
597
+ ): Promise<boolean> {
598
+ return sealToOutbox(name, at, status, content, kind, media);
582
599
  }
583
600
 
584
601
  private async flushPendingCloud(): Promise<void> {
@@ -587,7 +604,10 @@ export class Harbor {
587
604
  if (queue.length === 0) return;
588
605
  const remaining: PendingCloud[] = [];
589
606
  for (const p of queue) {
590
- if (remaining.length === 0 && (await this.postOutbox(p.routine, p.at, p.status, p.content, p.kind ?? "routine"))) continue;
607
+ if (
608
+ remaining.length === 0 &&
609
+ (await this.postOutbox(p.routine, p.at, p.status, p.content, p.kind ?? "routine", p.media ?? []))
610
+ ) continue;
591
611
  remaining.push(p);
592
612
  }
593
613
  if (remaining.length !== queue.length) {
@@ -705,15 +725,25 @@ export class Harbor {
705
725
  log(" note: email delivery is not wired yet (Phase 5) — skipping it");
706
726
  }
707
727
 
728
+ // A routine delivering to the app's Inbox can answer with more than prose: an
729
+ // artifact fence the app turns into a Cargo card, and files staged through
730
+ // attach_to_result. Both are wired here rather than in the session builder,
731
+ // because both only make sense when there is an Inbox at the other end — a
732
+ // file- or webhook-only routine gets neither the tool nor the brief.
733
+ const toInbox = routine.delivery.includes("cloud");
734
+ const media = toInbox ? new ResultMedia() : undefined;
735
+
708
736
  const { out, status, error, notes } = await this.runSession({
709
- prompt: routine.prompt,
737
+ prompt: withBrief(routine.prompt, { inbox: toInbox, canAttach: !!media }),
710
738
  cwd: routine.cwd,
711
739
  model: modelSpec,
712
740
  tools: routine.tools ?? [],
741
+ media,
713
742
  });
714
743
 
715
744
  const content = formatResult(routine, out, status, error, notes);
716
745
  const report = await deliver(routine, content, status, {
746
+ media: media?.list() ?? [],
717
747
  pushRelay: this.pushRelay,
718
748
  pushCloud: this.pushCloud,
719
749
  webhooks: config.webhooks,
@@ -741,7 +771,7 @@ export class Harbor {
741
771
  // activation, so the build is serialized (serializeBuild) and the previous value is
742
772
  // restored. "__none__" is its sentinel for "register none": an unattended run with
743
773
  // no connector selectors gets no direct tools, whatever a shared mcp.json says.
744
- private buildSessionServices(cwd: string, directTools: string[]): Promise<any> {
774
+ private buildSessionServices(cwd: string, directTools: string[], media?: ResultMedia): Promise<any> {
745
775
  return serializeBuild(async () => {
746
776
  const gate: GateController = {
747
777
  getMode: () => "bypass",
@@ -759,7 +789,7 @@ export class Harbor {
759
789
  // any moat shim an older release left in the shared agent dir (releases up to 0.11
760
790
  // installed one per extension; the launcher sweeps them, but a daemon can outlive
761
791
  // the launch that would have). See config/moat.ts.
762
- const moat = await moatResourceOptions({ kind: "harbor-session", gate });
792
+ const moat = await moatResourceOptions({ kind: "harbor-session", gate, resultMedia: media });
763
793
  // MCP_DIRECT_TOOLS is read by the adapter when its factory RUNS, inside
764
794
  // createAgentSessionServices — so it has to wrap the session creation, not the
765
795
  // moatResourceOptions() call that imported it.
@@ -849,7 +879,7 @@ export class Harbor {
849
879
  rmSync(join(agentDir(), "mcp-cache.json"), { force: true });
850
880
  const services = await this.buildSessionServices(cwd, []);
851
881
  const { provider, modelId } = parseSpec(modelSpec);
852
- const model = (services.modelRegistry as any).find(provider, modelId);
882
+ const model = (modelRegistryOf(services) as any).find(provider, modelId);
853
883
  if (!model) return;
854
884
  const { session } = await createAgentSessionFromServices({
855
885
  services,
@@ -878,22 +908,33 @@ export class Harbor {
878
908
  // shell command still fail-closes headlessly (localAsk denies) — plus per-run account
879
909
  // credentials that are revoked in the finally so they never linger as an orphaned
880
910
  // "device" in the app's Linked Devices.
881
- private async runSession(spec: { prompt: string; cwd: string; model: string; tools: string[] }): Promise<{ out: string; status: "ok" | "error"; error?: string; notes: string[] }> {
911
+ private async runSession(spec: {
912
+ prompt: string;
913
+ cwd: string;
914
+ model: string;
915
+ tools: string[];
916
+ media?: ResultMedia;
917
+ }): Promise<{ out: string; status: "ok" | "error"; error?: string; notes: string[] }> {
882
918
  let out = "";
883
919
  let status: "ok" | "error" = "ok";
884
920
  let error: string | undefined;
885
- let servicesRef: { authStorage?: { remove?: (p: string) => void; get?: (p: string) => unknown } } | null = null;
886
921
  let spawnedAccount = false;
887
- const { tools: allowedTools, directToolsEnv, notes } = await this.resolveRunTools(spec.tools, spec.cwd, spec.model);
922
+ const resolved = await this.resolveRunTools(spec.tools, spec.cwd, spec.model);
923
+ // attach_to_result rides with the staging area rather than the allow-list: it is
924
+ // how the answer is DELIVERED, not a capability the run gains. A routine that
925
+ // named an explicit tool list still gets it, because the alternative is a run
926
+ // that can generate a still and then has no way to hand it over — and the file
927
+ // is going to the user's own mailbox, which `delivery: cloud` already chose.
928
+ const allowedTools = spec.media ? [...resolved.tools, ATTACH_RESULT_TOOL] : resolved.tools;
929
+ const { directToolsEnv, notes } = resolved;
888
930
  try {
889
- const services = await this.buildSessionServices(spec.cwd, directToolsEnv);
890
- servicesRef = services as any;
931
+ const services = await this.buildSessionServices(spec.cwd, directToolsEnv, spec.media);
891
932
 
892
933
  const { provider, modelId } = parseSpec(spec.model);
893
934
  if (provider === "privateer") {
894
935
  try {
895
936
  const creds = await acquireAccountCredential();
896
- (services.authStorage as any).set("privateer", { type: "oauth", ...creds });
937
+ await persistAccountCredential(creds);
897
938
  rememberAccountCredential(creds); // claim it, so the teardown drops OUR entry only
898
939
  spawnedAccount = true;
899
940
  } catch (e) {
@@ -901,7 +942,7 @@ export class Harbor {
901
942
  }
902
943
  }
903
944
 
904
- const model = (services.modelRegistry as any).find(provider, modelId);
945
+ const model = (modelRegistryOf(services) as any).find(provider, modelId);
905
946
  if (!model) {
906
947
  status = "error";
907
948
  error = `model ${provider}/${modelId} not found`;
@@ -935,7 +976,7 @@ export class Harbor {
935
976
  try { await revokeAccountSession(); } catch { /* best effort — server TTL is the fallback */ }
936
977
  // Ownership-checked: an interactive terminal on this machine shares auth.json,
937
978
  // and its entry must survive a harbor run's teardown (see providers/account.ts).
938
- try { dropPersistedAccountCredential({ modelRegistry: { authStorage: servicesRef?.authStorage } }); } catch { /* nothing persisted */ }
979
+ try { await dropPersistedAccountCredential(); } catch { /* nothing persisted */ }
939
980
  }
940
981
  }
941
982
  return { out, status, error, notes };
@@ -959,14 +1000,24 @@ export class Harbor {
959
1000
  }
960
1001
  this.running.add(key);
961
1002
  log(`running task "${title}"`);
1003
+ // A submitted task ALWAYS answers into the Inbox, so it always gets the brief and
1004
+ // the attachment tool — the app is the only place its result is ever read.
1005
+ const media = new ResultMedia();
962
1006
  try {
963
- const { out, status, error, notes } = await this.runSession({ prompt: spec.prompt, cwd, model: modelSpec, tools: spec.tools ?? [] });
1007
+ const { out, status, error, notes } = await this.runSession({
1008
+ prompt: withBrief(spec.prompt, { inbox: true, canAttach: true }),
1009
+ cwd,
1010
+ model: modelSpec,
1011
+ tools: spec.tools ?? [],
1012
+ media,
1013
+ });
964
1014
  const content = redactText(formatTaskResult(title, out, status, error, modelSpec, notes), collectSecrets(config.providers));
965
1015
  const at = new Date().toISOString();
1016
+ const staged = media.list();
966
1017
  // Durable delivery: seal to the outbox. If we can't seal yet (no verified pubkey /
967
1018
  // offline), queue it with kind:"task" so the flush re-seals it correctly later.
968
- const sealed = await this.postOutbox(title, at, status, content, "task");
969
- if (!sealed) addPendingCloud({ routine: title, at, status, content, kind: "task" });
1019
+ const sealed = await this.postOutbox(title, at, status, content, "task", staged);
1020
+ if (!sealed) addPendingCloud({ routine: title, at, status, content, kind: "task", ...(staged.length ? { media: staged } : {}) });
970
1021
  // Live mirror if a controller is attached (the outbox copy is the source of truth).
971
1022
  if (this.controllerAttached) this.relay?.sendTaskResult(title, content);
972
1023
  log(` task "${title}" ${status}; ${sealed ? "sealed to outbox" : "queued for outbox"}`);
package/src/main.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // boot's side effects are guaranteed to have run before any Pi module loads.
4
4
  //
5
5
  // Phase 1 skeleton: this prints the resolved boot state so `npm start` proves
6
- // the boot chain end-to-end (env pinned, dispatcher installed) without a TUI.
6
+ // the boot chain in full (env pinned, dispatcher installed) without a TUI.
7
7
  // Phases 4/6 replace the body with the harbor/relay wiring and the pi-tui app.
8
8
 
9
9
  import "./boot.ts";
@@ -63,7 +63,7 @@ export function signEvent(u: UnsignedEvent, secretKeyHex: string, auxRand?: Uint
63
63
  }
64
64
 
65
65
  /**
66
- * Verify an event end to end, fail-closed on anything malformed.
66
+ * Verify an event in full, fail-closed on anything malformed.
67
67
  *
68
68
  * BOTH checks matter and the order is deliberate: recompute the id first, so a
69
69
  * tampered `content` or `tags` is caught even when the signature over the ORIGINAL
@@ -15,14 +15,23 @@
15
15
  // pin, no signature, or a bad signature ⇒ we don't seal at all, and the caller falls
16
16
  // back to its own durable channel (a queue, a file, a notice).
17
17
  //
18
+ // ATTACHMENTS. A result may also carry media (routines/resultMedia.ts). The message
19
+ // envelope stays small — it is capped by the server at 128KB of base64 — so only a
20
+ // thumbnail-sized file rides inside it; anything larger is sealed on its own and
21
+ // PUT to /api/outbox/blob, with the envelope carrying just its id and metadata. Both
22
+ // halves are sealed to the same account key, so the server holds ciphertext either
23
+ // way, and the app collects the blobs on the same sync that opens the message.
24
+ //
18
25
  // Module-level caches (pubkey, machine origin) — one per process, exactly like the
19
26
  // per-instance caches this replaced.
20
27
 
28
+ import { readFileSync, existsSync, statSync } from "node:fs";
21
29
  import { apiRequest, defaultDeviceLabel } from "../auth/privateer.ts";
22
30
  import { loadAccountSignKey } from "../crypto/accountTrust.ts";
23
31
  import { verifyOutboxKey } from "../crypto/accountVerify.ts";
24
- import { sealJson, decodeAccountPublicKey } from "../crypto/outboxSeal.ts";
32
+ import { sealJson, seal, decodeAccountPublicKey } from "../crypto/outboxSeal.ts";
25
33
  import { routineRelayId, type OutboxKind } from "../routines/store.ts";
34
+ import { MAX_ATTACHMENT_BYTES, type MediaClass, type StagedMedia } from "../routines/resultMedia.ts";
26
35
 
27
36
  export type { OutboxKind };
28
37
 
@@ -31,6 +40,34 @@ export type { OutboxKind };
31
40
  // the mailbox's purpose — summaries and answers, not transcripts.
32
41
  export const MAX_CLOUD_PLAINTEXT = 45_000;
33
42
 
43
+ // Total plaintext an envelope may reach once attachment metadata (and any inline
44
+ // bytes) are folded in. 128KB of base64 is 98,304 bytes of plaintext; this leaves
45
+ // headroom for the sealed-box overhead and JSON escaping.
46
+ const MAX_ENVELOPE_PLAINTEXT = 90_000;
47
+
48
+ /** Largest file carried INSIDE the envelope rather than as its own blob. */
49
+ export const MAX_INLINE_MEDIA_BYTES = 24 * 1024;
50
+ /** Total base64 an envelope will spend on inline media, whatever the file count. */
51
+ const INLINE_TOTAL_B64_BUDGET = 32 * 1024;
52
+
53
+ /**
54
+ * One attachment as the app sees it: metadata always, then EITHER `b64` (small
55
+ * enough to inline) or `blobId` (fetch + open separately). Never both, never
56
+ * neither — an attachment we couldn't deliver is dropped from the list and named
57
+ * in the body instead, because a card that can never load is worse than a sentence
58
+ * saying the file stayed on the machine.
59
+ */
60
+ export interface OutboxMedia {
61
+ id: string;
62
+ name: string;
63
+ mediaType: string;
64
+ cls: MediaClass;
65
+ size: number;
66
+ caption?: string;
67
+ b64?: string;
68
+ blobId?: string;
69
+ }
70
+
34
71
  let outboxPub: Uint8Array | undefined;
35
72
  let originCache: { id: string; label: string } | undefined;
36
73
 
@@ -66,10 +103,119 @@ export function machineOrigin(): { id: string; label: string } {
66
103
  return originCache;
67
104
  }
68
105
 
106
+ /** Seal one attachment's bytes and store them. Returns the blob id, or undefined. */
107
+ async function uploadBlob(pub: Uint8Array, bytes: Uint8Array): Promise<string | undefined> {
108
+ try {
109
+ const res = await apiRequest("/api/outbox/blob", {
110
+ method: "POST",
111
+ headers: { "content-type": "application/json" },
112
+ body: JSON.stringify({ sealed: seal(pub, bytes) }),
113
+ });
114
+ if (!res.ok) return undefined;
115
+ const data = (await res.json()) as { id?: string };
116
+ return typeof data.id === "string" ? data.id : undefined;
117
+ } catch {
118
+ return undefined;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Delete blobs we uploaded for a message that then failed to post. Best-effort: the
124
+ * server's TTL would collect them anyway, but a queued result re-uploads on its next
125
+ * attempt, and leaving the first copy behind spends the account's blob quota on bytes
126
+ * nothing will ever reference.
127
+ */
128
+ async function dropBlobs(ids: string[]): Promise<void> {
129
+ if (ids.length === 0) return;
130
+ try {
131
+ await apiRequest("/api/outbox/blob/ack", {
132
+ method: "POST",
133
+ headers: { "content-type": "application/json" },
134
+ body: JSON.stringify({ ids }),
135
+ });
136
+ } catch {
137
+ /* TTL is the backstop */
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Turn staged files into envelope entries, uploading what doesn't fit inline.
143
+ *
144
+ * `budget` is the base64 room left in the envelope after the body. Files are taken
145
+ * in order — the model attached them in the order it wanted them shown — so a small
146
+ * still after a large one still gets its inline slot.
147
+ */
148
+ async function packMedia(
149
+ pub: Uint8Array,
150
+ staged: StagedMedia[],
151
+ budget: number,
152
+ ): Promise<{ media: OutboxMedia[]; blobIds: string[]; undelivered: string[] }> {
153
+ const media: OutboxMedia[] = [];
154
+ const blobIds: string[] = [];
155
+ const undelivered: string[] = [];
156
+ let inlineLeft = Math.max(0, Math.min(budget, INLINE_TOTAL_B64_BUDGET));
157
+
158
+ for (const item of staged) {
159
+ // Re-check at delivery: the file was measured when the model attached it, and a
160
+ // run can be long. A file that has since been deleted, replaced with a directory
161
+ // or grown past the cap is named in the body rather than silently dropped.
162
+ let bytes: Uint8Array;
163
+ try {
164
+ if (!existsSync(item.path)) throw new Error("missing");
165
+ const stat = statSync(item.path);
166
+ if (!stat.isFile() || stat.size === 0 || stat.size > MAX_ATTACHMENT_BYTES) throw new Error("unusable");
167
+ bytes = new Uint8Array(readFileSync(item.path));
168
+ } catch {
169
+ undelivered.push(item.name);
170
+ continue;
171
+ }
172
+
173
+ const meta: OutboxMedia = {
174
+ id: item.id,
175
+ name: item.name,
176
+ mediaType: item.mediaType,
177
+ cls: item.cls,
178
+ size: bytes.length,
179
+ ...(item.caption ? { caption: item.caption } : {}),
180
+ };
181
+
182
+ if (bytes.length <= MAX_INLINE_MEDIA_BYTES) {
183
+ const b64 = Buffer.from(bytes).toString("base64");
184
+ if (b64.length <= inlineLeft) {
185
+ inlineLeft -= b64.length;
186
+ media.push({ ...meta, b64 });
187
+ continue;
188
+ }
189
+ }
190
+
191
+ const blobId = await uploadBlob(pub, bytes);
192
+ if (!blobId) {
193
+ undelivered.push(item.name);
194
+ continue;
195
+ }
196
+ blobIds.push(blobId);
197
+ media.push({ ...meta, blobId });
198
+ }
199
+
200
+ return { media, blobIds, undelivered };
201
+ }
202
+
203
+ /** A line in the result body for each file that couldn't travel. */
204
+ function undeliveredNote(names: string[]): string {
205
+ if (names.length === 0) return "";
206
+ const label = machineOrigin().label;
207
+ return `\n\n---\n\n> ⚠︎ Couldn't deliver ${names.length === 1 ? "one attachment" : `${names.length} attachments`} (${names.join(", ")}) — ${names.length === 1 ? "it is" : "they are"} still on ${label}.\n`;
208
+ }
209
+
69
210
  /**
70
211
  * Seal one result to the account outbox and POST the ciphertext. Returns whether the
71
212
  * server accepted it; false covers every failure (no verified key, offline, rejected)
72
213
  * and means the caller must fall back to its own durable channel.
214
+ *
215
+ * `staged` is this run's attachments (routines/resultMedia.ts). They are uploaded
216
+ * BEFORE the message, so a message that lands always references bytes that exist;
217
+ * if the message then fails, the blobs are dropped again and the caller's queued
218
+ * retry re-uploads from the same paths on disk.
73
219
  */
74
220
  export async function postOutbox(
75
221
  name: string,
@@ -77,19 +223,49 @@ export async function postOutbox(
77
223
  status: "ok" | "error",
78
224
  content: string,
79
225
  kind: OutboxKind = "routine",
226
+ staged: StagedMedia[] = [],
80
227
  ): Promise<boolean> {
81
228
  const pub = await ensureOutboxPub();
82
229
  if (!pub) return false;
83
- const body = content.length > MAX_CLOUD_PLAINTEXT ? content.slice(0, MAX_CLOUD_PLAINTEXT) + "\n…truncated" : content;
84
- const sealed = sealJson(pub, { v: 1, kind, name, status, at, content: body, origin: machineOrigin() });
230
+ let body = content.length > MAX_CLOUD_PLAINTEXT ? content.slice(0, MAX_CLOUD_PLAINTEXT) + "\n…truncated" : content;
231
+
232
+ const { media, blobIds, undelivered } = staged.length
233
+ ? await packMedia(pub, staged, MAX_ENVELOPE_PLAINTEXT - body.length - 2_000)
234
+ : { media: [] as OutboxMedia[], blobIds: [] as string[], undelivered: [] as string[] };
235
+ body += undeliveredNote(undelivered);
236
+
237
+ // v2 adds `media`. Older apps ignore the field and still render the body, which is
238
+ // why the note above names undelivered files in prose rather than only in metadata.
239
+ const envelope: Record<string, unknown> = {
240
+ v: media.length > 0 ? 2 : 1,
241
+ kind,
242
+ name,
243
+ status,
244
+ at,
245
+ content: body,
246
+ origin: machineOrigin(),
247
+ ...(media.length > 0 ? { media } : {}),
248
+ };
249
+
250
+ let sealed = sealJson(pub, envelope);
251
+ if (sealed.length > 128 * 1024) {
252
+ // The budget arithmetic above should make this unreachable; if it is ever wrong,
253
+ // the ANSWER is what gets clipped, never the attachments — those are the part the
254
+ // user cannot reconstruct from anywhere else.
255
+ envelope.content = body.slice(0, Math.max(0, MAX_CLOUD_PLAINTEXT / 2)) + "\n…truncated";
256
+ sealed = sealJson(pub, envelope);
257
+ }
258
+
85
259
  try {
86
260
  const res = await apiRequest("/api/outbox", {
87
261
  method: "POST",
88
262
  headers: { "content-type": "application/json" },
89
263
  body: JSON.stringify({ sealed }),
90
264
  });
265
+ if (!res.ok) await dropBlobs(blobIds);
91
266
  return res.ok;
92
267
  } catch {
268
+ await dropBlobs(blobIds);
93
269
  return false;
94
270
  }
95
271
  }