privateer-agent 0.1.0 → 0.1.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Privateer — a provider-agnostic terminal coding agent. Bring your own model (OpenRouter, Anthropic, OpenAI, Ollama, NEAR AI).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,7 +25,7 @@ import { loadMcpServers, connectMcpServers, type McpConnection } from "../mcp/cl
25
25
  import { hasStoredAuth, clearStoredAuth } from "../mcp/oauth.ts";
26
26
  import { TodoPanel } from "./TodoPanel.tsx";
27
27
  import { exec } from "../tools/exec.ts";
28
- import { resolveAttachments, chipFor } from "../util/images.ts";
28
+ import { resolveAttachments, chipFor, mediaModality } from "../util/images.ts";
29
29
  import type { Attachment } from "../util/images.ts";
30
30
  import { AttachmentStore } from "../util/attachmentStore.ts";
31
31
  import type { Entry, ToolEntry, Row } from "./types.ts";
@@ -203,6 +203,9 @@ export function App({
203
203
  // rewrote to chips in the buffer text. Each turn claims the ones whose chip
204
204
  // survives into its submitted text, so the base64 still rides along.
205
205
  const pendingImagesRef = useRef<Attachment[]>([]);
206
+ // Files received from the app over the relay, awaiting the next remote prompt to
207
+ // ride along with (mirrors how drag/paste stages into pendingImagesRef).
208
+ const pendingRemoteAttachmentsRef = useRef<{ name: string; mediaType: string; base64: string }[]>([]);
206
209
  // Session-lifetime checkpoint store (survives model/style switches) for /rewind,
207
210
  // plus a live mirror of the committed transcript length for checkpointing. Bound to
208
211
  // the session's on-disk checkpoint dir so /rewind survives a restart-and-resume; a
@@ -457,13 +460,53 @@ export function App({
457
460
  // remote prompt queues/dispatches against the current `busy`, never a stale one.
458
461
  handleInputRef.current = (value, opts) => handleInput(value, opts);
459
462
 
463
+ // Fold any files the app sent over the relay into a remote prompt. Binary kinds
464
+ // (image/pdf/…) become "[Kind #n]" chips backed by staged bytes that runTurn's
465
+ // liveAttachments filter then claims; text-like kinds are decoded and inlined as a
466
+ // fenced block. Returns the augmented prompt, or null when there's nothing to run
467
+ // (no text and no files). Mirrors the drag/paste staging into pendingImagesRef.
468
+ function consumeRemoteAttachments(text: string): string | null {
469
+ const files = pendingRemoteAttachmentsRef.current;
470
+ pendingRemoteAttachmentsRef.current = [];
471
+ if (files.length === 0) return text.trim() ? text : null;
472
+
473
+ const chips: string[] = [];
474
+ const inlined: string[] = [];
475
+ const maxInline = config.router?.inlineTextMaxBytes ?? 65_536;
476
+ for (const f of files) {
477
+ const modality = mediaModality(f.mediaType);
478
+ if (modality) {
479
+ const n = (imageSeqRef.current += 1);
480
+ const att: Attachment = { data: f.base64, mediaType: f.mediaType, modality, path: f.name, n };
481
+ pendingImagesRef.current.push(att);
482
+ chips.push(chipFor(att));
483
+ } else {
484
+ let body = "";
485
+ try { body = Buffer.from(f.base64, "base64").toString("utf8"); } catch { body = ""; }
486
+ if (body.length > maxInline) body = body.slice(0, maxInline) + `\n… (truncated, ${body.length - maxInline} more chars)`;
487
+ inlined.push(`\n\n${f.name}:\n\`\`\`\n${body}\n\`\`\``);
488
+ }
489
+ }
490
+ const head = text.trim();
491
+ const chipLine = chips.length ? (head ? " " : "") + chips.join(" ") : "";
492
+ const composed = `${head}${chipLine}${inlined.join("")}`.trim();
493
+ return composed.length ? composed : null;
494
+ }
495
+
460
496
  // Open/close the relay when /remote-access is toggled. The client is owned here
461
497
  // (mirrors mcpRef) and torn down on disable/unmount. On teardown we resolve any
462
498
  // parked approvals to "deny" so a dropped controller can't wedge a turn.
463
499
  useEffect(() => {
464
500
  if (!remoteEnabled) return;
465
501
  const client = new RelayClient({
466
- onPrompt: (text) => handleInputRef.current(text, { remote: true }),
502
+ onPrompt: (text) => {
503
+ const merged = consumeRemoteAttachments(text);
504
+ if (merged) handleInputRef.current(merged, { remote: true });
505
+ },
506
+ onAttachment: (file) => {
507
+ pendingRemoteAttachmentsRef.current.push(file);
508
+ append({ kind: "notice", text: `📎 received ${file.name} from app` });
509
+ },
467
510
  onInterrupt: () => abortRef.current?.abort(),
468
511
  onApprovalResponse: (id, decision) => {
469
512
  const entry = pendingApprovalsRef.current.get(id);
@@ -1313,6 +1356,7 @@ export function App({
1313
1356
  custom={statusText || undefined}
1314
1357
  zdr={zdr}
1315
1358
  tee={tee}
1359
+ remote={remoteEnabled}
1316
1360
  />
1317
1361
 
1318
1362
  {picking ? (
@@ -4,6 +4,7 @@ import { Box, Text } from "ink";
4
4
  import { VERSION } from "../version.ts";
5
5
  import { theme } from "./theme.ts";
6
6
  import { WELCOME } from "./figures.ts";
7
+ import { currentUser } from "../auth/privateer.ts";
7
8
 
8
9
  // Collapse the user's home directory to ~ for a compact path display.
9
10
  function shortenPath(cwd: string): string {
@@ -13,6 +14,17 @@ function shortenPath(cwd: string): string {
13
14
  : cwd;
14
15
  }
15
16
 
17
+ // The Privateer account this terminal is signed into: email accounts show the
18
+ // email; wallet accounts (no email) show the first few characters of the Solana
19
+ // public key. Returns null when running unauthenticated (BYO key, no account).
20
+ function accountLabel(): string | null {
21
+ const user = currentUser();
22
+ if (!user) return null;
23
+ if (user.email) return user.email;
24
+ if (user.solanaPublicKey) return user.solanaPublicKey.slice(0, 6) + "…";
25
+ return null;
26
+ }
27
+
16
28
  // Anchor motif rendered in ASCII — the Privateer mark (ring, stock, shank, flukes).
17
29
  const ANCHOR = [
18
30
  " .-. ",
@@ -24,6 +36,7 @@ const ANCHOR = [
24
36
  ];
25
37
 
26
38
  export function Banner({ model }: { model: string }) {
39
+ const account = accountLabel();
27
40
  return (
28
41
  <Box flexDirection="column">
29
42
  <Box
@@ -45,6 +58,11 @@ export function Banner({ model }: { model: string }) {
45
58
  {WELCOME} PRIVATEER
46
59
  </Text>
47
60
  <Text color={theme.dim}>bring your own model · v{VERSION}</Text>
61
+ {account && (
62
+ <Text color={theme.dim}>
63
+ connected as <Text color={theme.accent}>{account}</Text>
64
+ </Text>
65
+ )}
48
66
  <Text> </Text>
49
67
  <Text>
50
68
  model <Text color={theme.accent}>{model}</Text>
@@ -2,7 +2,7 @@ import React from "react";
2
2
  import { Box, Text } from "ink";
3
3
  import { basename } from "node:path";
4
4
  import { theme, POSTURE_COLOR } from "./theme.ts";
5
- import { SHIELD } from "./figures.ts";
5
+ import { SHIELD, DOT } from "./figures.ts";
6
6
  import { useTerminalWidth } from "./useTerminalWidth.ts";
7
7
  import { type UsageTotals } from "../engine/events.ts";
8
8
  import type { ZdrState } from "./useZdrShield.ts";
@@ -30,6 +30,14 @@ function TeeBadge({ tee }: { tee?: TeeState }) {
30
30
  return <Text color={theme.dim}>{`${SHIELD} TEE? · `}</Text>;
31
31
  }
32
32
 
33
+ // Remote-access badge: a green "● remote" segment shown while /remote-access is on,
34
+ // signalling the Privateer app can drive this terminal (send prompts / approve its
35
+ // tool calls). Nothing at all when remote access is off.
36
+ function RemoteBadge({ remote }: { remote?: boolean }) {
37
+ if (!remote) return null;
38
+ return <Text color={theme.success}>{`${DOT} remote · `}</Text>;
39
+ }
40
+
33
41
  // Compact token count: 100, 1k, 1m, 1b — one decimal place above 1k, trimmed of
34
42
  // trailing ".0", so 1500 → "1.5k" and 2000 → "2k".
35
43
  export function formatTokens(n: number): string {
@@ -86,6 +94,7 @@ export function StatusBar(props: {
86
94
  custom?: string; // settings-driven status line; overrides the default when set
87
95
  zdr?: ZdrState; // OpenRouter ZDR posture for the selected model (default line only)
88
96
  tee?: TeeState; // NEAR AI TEE attestation posture for the selected model (default line only)
97
+ remote?: boolean; // /remote-access is on — the app can drive this terminal (default line only)
89
98
  }) {
90
99
  // Stay clear of the right edge (parent paddingX={1} plus a 2-col safety gap) so
91
100
  // the line never reaches the final column and the terminal never reflows it.
@@ -109,6 +118,7 @@ export function StatusBar(props: {
109
118
  <Text wrap="truncate-end">
110
119
  <ZdrBadge zdr={props.zdr} />
111
120
  <TeeBadge tee={props.tee} />
121
+ <RemoteBadge remote={props.remote} />
112
122
  <Text color={theme.accent}>⚓ privateer</Text>
113
123
  {diag ? <Text color={theme.dim}>{` [${diag}]`}</Text> : null}
114
124
  <Text color={theme.dim}> (shift+tab to cycle)</Text>
@@ -10,4 +10,5 @@ export const POINTER = "❯"; // selection pointer in menus and the prompt caret
10
10
  export const FAST_FORWARD = "⏵⏵"; // marks an "on" permission mode below the prompt
11
11
  export const PAUSE = "⏸"; // marks plan mode (paused execution) below the prompt
12
12
  export const SHIELD = "⛉"; // U+26C9 — OpenRouter ZDR posture marker in the status bar
13
+ export const DOT = "●"; // U+25CF — "live" marker; the remote-access badge in the status bar
13
14
  export const DOWN = "↓"; // U+2193 — output tokens streaming down from the model (spinner)
@@ -58,11 +58,19 @@ export interface RelayCallbacks {
58
58
  onApprovalResponse: (id: string, decision: "allow" | "deny") => void;
59
59
  // A controller attached — push a transcript snapshot so it can catch up.
60
60
  onControllerAttached: () => void;
61
+ // A file finished transferring from the app (reassembled from chunks). Held to
62
+ // ride along with the next remote prompt.
63
+ onAttachment: (file: { name: string; mediaType: string; base64: string }) => void;
61
64
  // Surface a one-line status/notice in the TUI.
62
65
  onStatus?: (text: string) => void;
63
66
  }
64
67
 
65
68
  const RECONNECT_MS = 3000;
69
+ // File-transfer ceilings for app→CLI attachments. The app enforces its own caps
70
+ // before sending; these are a defensive backstop so a controller can't exhaust
71
+ // memory with a lying `size` or a flood of concurrent transfers.
72
+ const MAX_ATTACH_BYTES = 10 * 1024 * 1024; // 10 MB per file
73
+ const MAX_INFLIGHT_ATTACH = 8; // simultaneous transfers
66
74
  // Coalesce streaming deltas so we don't emit one WS frame per token.
67
75
  const TEXT_FLUSH_MS = 60;
68
76
 
@@ -120,6 +128,12 @@ export class RelayClient {
120
128
  // Stable for this process so reconnects keep the same terminal identity.
121
129
  private readonly termId = randomUUID();
122
130
  private readonly label = terminalLabel();
131
+ // In-progress file transfers from the app, keyed by the controller's attachment
132
+ // id. Reassembled from attach_begin/chunk/end frames, then handed to onAttachment.
133
+ private readonly incoming = new Map<
134
+ string,
135
+ { name: string; mediaType: string; chunks: string[]; received: number }
136
+ >();
123
137
 
124
138
  constructor(private readonly cb: RelayCallbacks) {}
125
139
 
@@ -134,6 +148,7 @@ export class RelayClient {
134
148
  if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = undefined; }
135
149
  this.bufKind = null;
136
150
  this.buf = "";
151
+ this.incoming.clear();
137
152
  try { this.ws?.close(); } catch (_) { /* ignore */ }
138
153
  this.ws = null;
139
154
  }
@@ -205,7 +220,17 @@ export class RelayClient {
205
220
  }
206
221
 
207
222
  private handle(data: WebSocket.RawData): void {
208
- let frame: { type?: string; text?: string; id?: string; decision?: string };
223
+ let frame: {
224
+ type?: string;
225
+ text?: string;
226
+ id?: string;
227
+ decision?: string;
228
+ name?: string;
229
+ mediaType?: string;
230
+ size?: number;
231
+ seq?: number;
232
+ data?: string;
233
+ };
209
234
  try {
210
235
  frame = JSON.parse(data.toString());
211
236
  } catch (_) {
@@ -214,7 +239,10 @@ export class RelayClient {
214
239
  this.debug(`recv ${frame.type}`);
215
240
  switch (frame.type) {
216
241
  case "prompt":
217
- if (typeof frame.text === "string" && frame.text.trim()) this.cb.onPrompt(frame.text);
242
+ // Forward even an empty/whitespace prompt: a file-only send carries no text,
243
+ // and the app folds any pending attachments in on the prompt frame. App.tsx
244
+ // no-ops a blank prompt that has no attachments, so this stays safe.
245
+ if (typeof frame.text === "string") this.cb.onPrompt(frame.text);
218
246
  break;
219
247
  case "interrupt":
220
248
  this.cb.onInterrupt();
@@ -225,7 +253,62 @@ export class RelayClient {
225
253
  case "controller_attached":
226
254
  this.cb.onControllerAttached();
227
255
  break;
256
+ case "attach_begin":
257
+ this.beginAttachment(frame);
258
+ break;
259
+ case "attach_chunk":
260
+ this.appendAttachmentChunk(frame);
261
+ break;
262
+ case "attach_end":
263
+ this.endAttachment(frame);
264
+ break;
265
+ }
266
+ }
267
+
268
+ // ── app → agent file transfer (chunked) ─────────────────────────────────────
269
+ // Files are streamed as attach_begin → attach_chunk* → attach_end so each WS
270
+ // frame stays under the relay's 256 KB cap. We reassemble here and hand the
271
+ // completed file up via onAttachment; App.tsx folds it into the next prompt.
272
+
273
+ private beginAttachment(frame: { id?: string; name?: string; mediaType?: string; size?: number }): void {
274
+ const { id } = frame;
275
+ if (!id || typeof frame.name !== "string" || typeof frame.mediaType !== "string") return;
276
+ if (this.incoming.size >= MAX_INFLIGHT_ATTACH) {
277
+ this.cb.onStatus?.(`Dropped attachment "${frame.name}" — too many transfers in flight.`);
278
+ return;
228
279
  }
280
+ if (typeof frame.size === "number" && frame.size > MAX_ATTACH_BYTES) {
281
+ this.cb.onStatus?.(`Dropped attachment "${frame.name}" — exceeds ${Math.round(MAX_ATTACH_BYTES / (1024 * 1024))} MB.`);
282
+ return;
283
+ }
284
+ this.incoming.set(id, { name: frame.name, mediaType: frame.mediaType, chunks: [], received: 0 });
285
+ }
286
+
287
+ private appendAttachmentChunk(frame: { id?: string; data?: string }): void {
288
+ const { id } = frame;
289
+ if (!id || typeof frame.data !== "string") return;
290
+ const entry = this.incoming.get(id);
291
+ if (!entry) return; // begin was dropped or never seen
292
+ entry.received += frame.data.length;
293
+ // base64 inflates by ~4/3, so received*0.75 ≈ decoded bytes. Bound it in case
294
+ // `size` was absent or lied at begin time.
295
+ if (entry.received * 0.75 > MAX_ATTACH_BYTES + 64 * 1024) {
296
+ this.incoming.delete(id);
297
+ this.cb.onStatus?.(`Dropped attachment "${entry.name}" — stream exceeded size limit.`);
298
+ return;
299
+ }
300
+ entry.chunks.push(frame.data);
301
+ }
302
+
303
+ private endAttachment(frame: { id?: string }): void {
304
+ const { id } = frame;
305
+ if (!id) return;
306
+ const entry = this.incoming.get(id);
307
+ if (!entry) return;
308
+ this.incoming.delete(id);
309
+ const base64 = entry.chunks.join("");
310
+ if (!base64) return;
311
+ this.cb.onAttachment({ name: entry.name, mediaType: entry.mediaType, base64 });
229
312
  }
230
313
 
231
314
  private rawSend(frame: unknown): void {
@@ -26,6 +26,19 @@ const MEDIA_TYPES: Record<string, { mediaType: string; modality: Modality }> = {
26
26
  ".mkv": { mediaType: "video/x-matroska", modality: "video" },
27
27
  };
28
28
 
29
+ // Classify a media type into one of our binary modalities, or null when it's a
30
+ // text-like file that should be inlined as plain text rather than attached. Used by
31
+ // the relay path (App.tsx) to decide what to do with a file received from the app:
32
+ // a null result means "decode and inline the text"; otherwise it's a binary
33
+ // attachment the model reads directly.
34
+ export function mediaModality(mediaType: string): Modality | null {
35
+ if (mediaType.startsWith("image/")) return "image";
36
+ if (mediaType === "application/pdf") return "document";
37
+ if (mediaType.startsWith("audio/")) return "audio";
38
+ if (mediaType.startsWith("video/")) return "video";
39
+ return null;
40
+ }
41
+
29
42
  // Magic-byte checks per media type, used to reject placeholder/corrupt files at capture
30
43
  // time. The motivating case: macOS delivers a drag from a screenshot thumbnail as a
31
44
  // *file promise*, so the terminal's …/T/drop-XXXXXX/ file can be a 4-byte stub holding