privateer-agent 0.12.23 → 0.12.25
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.12.
|
|
3
|
+
"version": "0.12.25",
|
|
4
4
|
"description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
type EngineEvent,
|
|
21
21
|
type UsageTotals,
|
|
22
22
|
} from "../engine/events.ts";
|
|
23
|
+
import { describeErrorText } from "../engine/errors.ts";
|
|
24
|
+
import { redactText } from "../util/redact.ts";
|
|
23
25
|
|
|
24
26
|
// Minimal structural view of the Pi session events we read. Kept loose (not the
|
|
25
27
|
// full Pi union) so this module type-checks without pinning to Pi's internals;
|
|
@@ -108,16 +110,41 @@ export function createEngineEventAdapter() {
|
|
|
108
110
|
];
|
|
109
111
|
|
|
110
112
|
case "turn_end": {
|
|
111
|
-
const
|
|
113
|
+
const msg = ev.message as any;
|
|
114
|
+
const turn = normUsage(msg?.usage);
|
|
112
115
|
sessionTotal = addUsage(sessionTotal, turn);
|
|
113
116
|
const finishReason =
|
|
114
|
-
(ev.finishReason as string) ??
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
117
|
+
(ev.finishReason as string) ?? (msg?.stopReason as string) ?? "stop";
|
|
118
|
+
const out: EngineEvent[] = [{ type: "usage", usage: sessionTotal, turn }];
|
|
119
|
+
// A FAILED turn ends here, not by throwing — and this is the only place the
|
|
120
|
+
// app can learn it failed.
|
|
121
|
+
//
|
|
122
|
+
// Pi reports a dead model call as an assistant message with
|
|
123
|
+
// `stopReason: "error"` and an `errorMessage`; `prompt()` then resolves
|
|
124
|
+
// NORMALLY, so the desktop's runTurn catch never fires. Our own pi patch
|
|
125
|
+
// widened that path deliberately (a hard 4xx, and a 429 that outlived the
|
|
126
|
+
// retry budget, both end the turn instead of re-entering the agent loop),
|
|
127
|
+
// which is right for the CLI — its TUI reads the assistant message and
|
|
128
|
+
// prints describeErrorText. The app reads EngineEvents, and this adapter
|
|
129
|
+
// used to drop `errorMessage` on the floor: the turn arrived as a bare
|
|
130
|
+
// `finish`, which RemoteDriveContext closes as a green ✓ "done". A signed-in
|
|
131
|
+
// user whose first turn 401'd or hit their cap saw a tick and no words.
|
|
132
|
+
//
|
|
133
|
+
// So say it. Same wording the terminal gets, ahead of the finish so the feed
|
|
134
|
+
// reads in order, and redacted either way because an unrecognised body goes
|
|
135
|
+
// out verbatim.
|
|
136
|
+
if (msg?.stopReason === "error") {
|
|
137
|
+
const raw = typeof msg.errorMessage === "string" ? msg.errorMessage : "";
|
|
138
|
+
const described = describeErrorText(raw);
|
|
139
|
+
out.push({
|
|
140
|
+
type: "error",
|
|
141
|
+
error: described?.message ?? redactText(raw || "The model call failed."),
|
|
142
|
+
...(described?.hint ? { hint: described.hint } : {}),
|
|
143
|
+
...(described?.retryable != null ? { retryable: described.retryable } : {}),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
out.push({ type: "finish", usage: sessionTotal, finishReason });
|
|
147
|
+
return out;
|
|
121
148
|
}
|
|
122
149
|
|
|
123
150
|
// Compaction: Pi collapses history to free context. TODO(verify) field
|
|
@@ -146,8 +173,14 @@ export function createEngineEventAdapter() {
|
|
|
146
173
|
},
|
|
147
174
|
];
|
|
148
175
|
|
|
149
|
-
// Terminal error surfaced at the end of an agent run.
|
|
150
|
-
//
|
|
176
|
+
// Terminal error surfaced at the end of an agent run.
|
|
177
|
+
//
|
|
178
|
+
// ⚠️ Pi 0.84's `agent_end` is `{ messages, willRetry }` — there is NO `error`
|
|
179
|
+
// field, so this branch has been returning [] for every run since the 0.84
|
|
180
|
+
// refactor. It is kept (harmless, and other Pi versions have carried one)
|
|
181
|
+
// but it is NOT the error path: a failed model call arrives on `turn_end`
|
|
182
|
+
// above, which is where the real mapping lives. Do not "restore" error
|
|
183
|
+
// reporting here and delete it there.
|
|
151
184
|
case "agent_end": {
|
|
152
185
|
const err = (ev as any).error;
|
|
153
186
|
if (!err) return [];
|
|
@@ -254,8 +254,9 @@ export interface RelayCallbacks {
|
|
|
254
254
|
// STRICT mode (it executes a graph — non-idempotent, like task_spawn).
|
|
255
255
|
onWorkflowsRun?: (idOrName: string, sig?: string, ts?: number) => void;
|
|
256
256
|
// A file finished transferring from the app (reassembled from chunks). Held to
|
|
257
|
-
// ride along with the next remote prompt.
|
|
258
|
-
|
|
257
|
+
// ride along with the next remote prompt. `path` instead of `base64` is the desktop
|
|
258
|
+
// loopback's shape only — see RemoteAttachment; nothing on this WebSocket sets it.
|
|
259
|
+
onAttachment: (file: { name: string; mediaType: string; base64?: string; path?: string }) => void;
|
|
259
260
|
// Surface a one-line status/notice in the TUI.
|
|
260
261
|
onStatus?: (text: string) => void;
|
|
261
262
|
// The relay socket closed (controller no longer reachable until reconnect).
|
|
@@ -112,7 +112,12 @@ export interface InputRequest {
|
|
|
112
112
|
export interface RemoteAttachment {
|
|
113
113
|
name: string;
|
|
114
114
|
mediaType: string;
|
|
115
|
-
base64:
|
|
115
|
+
// Exactly one of these. `base64` is the cloud relay's shape: the app is on another
|
|
116
|
+
// machine, so the bytes are chunked across. `path` is the desktop's: app and agent
|
|
117
|
+
// share a disk (IpcRelay), so the file is named rather than moved — which is why a
|
|
118
|
+
// desktop attachment has no size cap. Both are consumed by AttachmentStore.register.
|
|
119
|
+
base64?: string;
|
|
120
|
+
path?: string;
|
|
116
121
|
}
|
|
117
122
|
|
|
118
123
|
export interface RemoteBridgeConfig {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// extension gates the tool_call, classified as a write against the destination path).
|
|
5
5
|
|
|
6
6
|
import { Type } from "typebox";
|
|
7
|
-
import {
|
|
7
|
+
import { mkdirSync, copyFileSync, statSync } from "node:fs";
|
|
8
8
|
import { dirname, isAbsolute, resolve } from "node:path";
|
|
9
9
|
import type { AttachmentStore } from "../util/attachmentStore.ts";
|
|
10
10
|
|
|
@@ -42,9 +42,13 @@ export function makeSaveAttachmentTool(store: AttachmentStore) {
|
|
|
42
42
|
const cwd = ctx?.cwd ?? process.cwd();
|
|
43
43
|
const abs = isAbsolute(params.path) ? params.path : resolve(cwd, params.path);
|
|
44
44
|
mkdirSync(dirname(abs), { recursive: true });
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
// copy rather than read-then-write: a desktop attachment is the user's own file
|
|
46
|
+
// at its own path (AttachmentStore adopts it instead of staging a copy), and
|
|
47
|
+
// those have no size cap — reading a 4 GB video into the heap to write it back
|
|
48
|
+
// out would be the one place the uncapped path could still fall over.
|
|
49
|
+
copyFileSync(entry.path, abs);
|
|
50
|
+
const size = statSync(abs).size;
|
|
51
|
+
return text(`Saved attachment #${params.ref} (${entry.name}, ${entry.mediaType}) to ${params.path} (${size} bytes).`);
|
|
48
52
|
},
|
|
49
53
|
};
|
|
50
54
|
}
|
|
@@ -6,12 +6,19 @@ import { join, extname } from "node:path";
|
|
|
6
6
|
// scratch dir keyed by the "#n" reference the model sees, so the save_attachment tool
|
|
7
7
|
// can write one back out to a real path on demand. Ported/adapted from tree-cli
|
|
8
8
|
// (which persisted paste/drop bytes); here the source is inbound relay attachments.
|
|
9
|
+
//
|
|
10
|
+
// A DESKTOP attachment arrives as a path instead of bytes — the app and this agent
|
|
11
|
+
// share a disk, so there is nothing to transfer and nothing to stage. Such an entry
|
|
12
|
+
// simply points at the file where it already lives; `owned` is what tells the two
|
|
13
|
+
// apart, so cleanup() removes only what we wrote. That is also why the desktop
|
|
14
|
+
// composer has no attachment size cap: a 4 GB video costs one string here.
|
|
9
15
|
|
|
10
16
|
export interface StoredAttachment {
|
|
11
17
|
n: number;
|
|
12
|
-
path: string; // absolute
|
|
18
|
+
path: string; // absolute path holding the bytes — our scratch file, or the user's own file
|
|
13
19
|
mediaType: string;
|
|
14
20
|
name: string; // original filename from the app
|
|
21
|
+
owned: boolean; // did WE write this file? false for a desktop path hand-off
|
|
15
22
|
}
|
|
16
23
|
|
|
17
24
|
export class AttachmentStore {
|
|
@@ -26,13 +33,24 @@ export class AttachmentStore {
|
|
|
26
33
|
return this.dir;
|
|
27
34
|
}
|
|
28
35
|
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
|
|
36
|
+
// Register an inbound attachment under the next ref number and return the stored
|
|
37
|
+
// record. Bytes are staged into the scratch dir; a `path` (desktop) is adopted
|
|
38
|
+
// as-is — copying a file the agent can already open would be pure waste, and for
|
|
39
|
+
// the large files this path exists to carry, waste measured in gigabytes.
|
|
40
|
+
register(file: { name: string; mediaType: string; base64?: string; path?: string }): StoredAttachment {
|
|
32
41
|
const n = this.nextN++;
|
|
42
|
+
if (file.path) {
|
|
43
|
+
const stored: StoredAttachment = {
|
|
44
|
+
n, path: file.path, mediaType: file.mediaType, name: file.name, owned: false,
|
|
45
|
+
};
|
|
46
|
+
this.byN.set(n, stored);
|
|
47
|
+
return stored;
|
|
48
|
+
}
|
|
33
49
|
const path = join(this.ensureDir(), `att-${n}${extname(file.name) || ""}`);
|
|
34
|
-
writeFileSync(path, Buffer.from(file.base64, "base64"));
|
|
35
|
-
const stored: StoredAttachment = {
|
|
50
|
+
writeFileSync(path, Buffer.from(file.base64 ?? "", "base64"));
|
|
51
|
+
const stored: StoredAttachment = {
|
|
52
|
+
n, path, mediaType: file.mediaType, name: file.name, owned: true,
|
|
53
|
+
};
|
|
36
54
|
this.byN.set(n, stored);
|
|
37
55
|
return stored;
|
|
38
56
|
}
|