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 +1 -1
- package/src/components/App.tsx +46 -2
- package/src/components/Banner.tsx +18 -0
- package/src/components/StatusBar.tsx +11 -1
- package/src/components/figures.ts +1 -0
- package/src/remote/relayClient.ts +85 -2
- package/src/util/images.ts +13 -0
package/package.json
CHANGED
package/src/components/App.tsx
CHANGED
|
@@ -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) =>
|
|
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: {
|
|
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
|
-
|
|
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 {
|
package/src/util/images.ts
CHANGED
|
@@ -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
|