privateer-agent 0.9.1 → 0.9.2
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.
|
@@ -26,6 +26,7 @@ import { AttachmentStore, type StoredAttachment } from "../src/util/attachmentSt
|
|
|
26
26
|
import { makeExtensionsControl } from "../src/remote/extensionsControl.ts";
|
|
27
27
|
import { makeSkillsControl } from "../src/remote/skillsControl.ts";
|
|
28
28
|
import { agentDir } from "../src/config/paths.ts";
|
|
29
|
+
import { inHarborDaemon } from "../src/config/harborDaemon.ts";
|
|
29
30
|
import { agentVersion } from "../src/config/version.ts";
|
|
30
31
|
import { SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
31
32
|
import { matchesKey } from "@earendil-works/pi-tui";
|
|
@@ -446,8 +447,18 @@ export default function privateerControl(pi: any): void {
|
|
|
446
447
|
// File transfer both ways: send_file_to_client (CLI→app, via the bridge's relay) and
|
|
447
448
|
// save_attachment (app→CLI, from the AttachmentStore inbound files land in). Both
|
|
448
449
|
// live here because they share the RemoteBridge / its attachment stream.
|
|
449
|
-
|
|
450
|
-
|
|
450
|
+
//
|
|
451
|
+
// NOT inside the harbor daemon. This extension is auto-discovered from the shared
|
|
452
|
+
// ~/.privateer/agent/extensions into every session the daemon runs, but `bridge` only
|
|
453
|
+
// ever gets a relay from THIS file's /remote-access command — which the daemon never
|
|
454
|
+
// runs. Registering there would shadow (Pi: first registration per name wins, and
|
|
455
|
+
// discovered extensions load before inline factories) the session-scoped pair a live
|
|
456
|
+
// task spawn registers against its own connected relay, so send_file_to_client would
|
|
457
|
+
// always answer "remote access is off" while the app was attached and driving.
|
|
458
|
+
if (!inHarborDaemon()) {
|
|
459
|
+
pi.registerTool?.(makeSendFileTool(bridge));
|
|
460
|
+
pi.registerTool?.(makeSaveAttachmentTool(attachments));
|
|
461
|
+
}
|
|
451
462
|
|
|
452
463
|
// Subagents (and print/rpc) run as headless child `pi` processes with no UI. There
|
|
453
464
|
// no one can approve, so a "default" gate would fail-closed on every tool and the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "Privateer — a provider-agnostic, safe-by-default terminal coding agent with TEE/Tinfoil attestation, rebuilt on the Pi toolkit. Bring your own model across 20 providers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// "Am I running inside the harbor daemon?" — a process-level marker, set once by
|
|
2
|
+
// Harbor.start() and read by code that must behave differently there.
|
|
3
|
+
//
|
|
4
|
+
// The harbor daemon shares ~/.privateer/agent with the interactive TUI, so Pi
|
|
5
|
+
// auto-discovers the shipped TUI extensions (extensions/*.ts shims installed by
|
|
6
|
+
// bin/privateer-launch.mjs) into every session the daemon creates. Most of that is
|
|
7
|
+
// harmless, but the gate extension also registers the relay file tools against ITS
|
|
8
|
+
// module-level bridge — the one only `/remote-access` ever attaches a relay to. In the
|
|
9
|
+
// daemon that bridge is permanently unattached, and because Pi resolves duplicate tool
|
|
10
|
+
// names first-registration-wins (discovered extensions load before inline factories), it
|
|
11
|
+
// would shadow the session-scoped pair a live task registers against its own live relay.
|
|
12
|
+
// So the gate stands its file tools down here and each daemon session registers its own
|
|
13
|
+
// (see src/tools/relayFileTools.ts, src/remote/liveTaskSession.ts).
|
|
14
|
+
//
|
|
15
|
+
// An env var rather than a module singleton on purpose: discovered extensions are loaded
|
|
16
|
+
// through jiti with its module cache off, so they may hold a SEPARATE copy of our modules.
|
|
17
|
+
// process.env is the one piece of state both copies are guaranteed to share.
|
|
18
|
+
//
|
|
19
|
+
// IMPORT-SAFETY: no Pi imports, no node builtins — safe to load from anywhere.
|
|
20
|
+
|
|
21
|
+
const ENV = "PRIVATEER_HARBOR_DAEMON";
|
|
22
|
+
|
|
23
|
+
/** Called once by the daemon as it starts. Idempotent. */
|
|
24
|
+
export function markHarborDaemon(): void {
|
|
25
|
+
process.env[ENV] = "1";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** True inside the harbor daemon process (routines, workflows, tasks, live spawns). */
|
|
29
|
+
export function inHarborDaemon(): boolean {
|
|
30
|
+
return process.env[ENV] === "1";
|
|
31
|
+
}
|
package/src/harbor/index.ts
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
loadPendingCloud,
|
|
49
49
|
savePendingCloud,
|
|
50
50
|
type PendingCloud,
|
|
51
|
+
type OutboxKind,
|
|
51
52
|
routineRelayId,
|
|
52
53
|
} from "../routines/store.ts";
|
|
53
54
|
import type { Routine } from "../routines/schema.ts";
|
|
@@ -59,6 +60,7 @@ import { sealJson, decodeAccountPublicKey } from "../crypto/outboxSeal.ts";
|
|
|
59
60
|
import { redactText, collectSecrets } from "../util/redact.ts";
|
|
60
61
|
import { startIpcServer, sendToHarbor, describeRelay, formatDuration, HarborAlreadyRunningError, type IpcRequest, type IpcResponse, type RelayStatus } from "./ipc.ts";
|
|
61
62
|
import { isHosted, publishRelayPub, webEnabled } from "../config/hosted.ts";
|
|
63
|
+
import { markHarborDaemon } from "../config/harborDaemon.ts";
|
|
62
64
|
import { makeWebTools, WEB_TOOL_NAMES } from "../tools/web.ts";
|
|
63
65
|
|
|
64
66
|
// The safe, read-only toolset for unattended runs — Pi builtins with no
|
|
@@ -282,6 +284,11 @@ export class Harbor {
|
|
|
282
284
|
};
|
|
283
285
|
|
|
284
286
|
async start(): Promise<void> {
|
|
287
|
+
// Mark the process before anything can create a session: the shipped TUI extensions
|
|
288
|
+
// are auto-discovered from the shared agent dir into every session we run, and the
|
|
289
|
+
// gate one has to stand its relay file tools down here so a live task's own pair
|
|
290
|
+
// (bound to that task's live relay) isn't shadowed. See config/harborDaemon.ts.
|
|
291
|
+
markHarborDaemon();
|
|
285
292
|
// Single-instance lock FIRST, before any other side effect: binding the IPC
|
|
286
293
|
// socket is the machine's mutex. If a live harbor already holds it this throws
|
|
287
294
|
// HarborAlreadyRunningError — two harbors under one ~/.privateer share a single
|
|
@@ -596,7 +603,7 @@ export class Harbor {
|
|
|
596
603
|
return this.originCache;
|
|
597
604
|
}
|
|
598
605
|
|
|
599
|
-
private async postOutbox(name: string, at: string, status: "ok" | "error", content: string, kind:
|
|
606
|
+
private async postOutbox(name: string, at: string, status: "ok" | "error", content: string, kind: OutboxKind = "routine"): Promise<boolean> {
|
|
600
607
|
const pub = await this.ensureOutboxPub();
|
|
601
608
|
if (!pub) return false;
|
|
602
609
|
const body = content.length > MAX_CLOUD_PLAINTEXT ? content.slice(0, MAX_CLOUD_PLAINTEXT) + "\n…truncated" : content;
|
|
@@ -1047,6 +1054,19 @@ export class Harbor {
|
|
|
1047
1054
|
parseSpec,
|
|
1048
1055
|
log,
|
|
1049
1056
|
onClosed: (id) => this.liveTasks.delete(id),
|
|
1057
|
+
// A live spawn's feed lives only in the attached app; when the session ends
|
|
1058
|
+
// (closed, reaped, or timed out) its answer would otherwise be gone. Seal the
|
|
1059
|
+
// closing one to the outbox like a submitted task's, so the app's inbox holds
|
|
1060
|
+
// every agent result, not just the unattended ones. Same queue-on-failure path.
|
|
1061
|
+
onResult: ({ title, status, content }) => {
|
|
1062
|
+
void (async () => {
|
|
1063
|
+
const at = new Date().toISOString();
|
|
1064
|
+
const body = redactText(content, collectSecrets(loadHarborConfig().providers));
|
|
1065
|
+
if (!(await this.postOutbox(title, at, status, body, "task"))) {
|
|
1066
|
+
addPendingCloud({ routine: title, at, status, content: body, kind: "task" });
|
|
1067
|
+
}
|
|
1068
|
+
})();
|
|
1069
|
+
},
|
|
1050
1070
|
});
|
|
1051
1071
|
this.liveTasks.set(handle.termId, handle);
|
|
1052
1072
|
this.relay?.sendTaskSpawned(handle.termId, handle.label);
|
|
@@ -1111,8 +1131,8 @@ export class Harbor {
|
|
|
1111
1131
|
const content = formatWorkflowResult(wf.workflow.name, result);
|
|
1112
1132
|
const at = new Date().toISOString();
|
|
1113
1133
|
// Durable delivery: seal to the outbox (queue on failure to re-seal later).
|
|
1114
|
-
if (!(await this.postOutbox(wf.workflow.name, at, status, content, "
|
|
1115
|
-
addPendingCloud({ routine: wf.workflow.name, at, status, content, kind: "
|
|
1134
|
+
if (!(await this.postOutbox(wf.workflow.name, at, status, content, "workflow"))) {
|
|
1135
|
+
addPendingCloud({ routine: wf.workflow.name, at, status, content, kind: "workflow" });
|
|
1116
1136
|
}
|
|
1117
1137
|
if (this.controllerAttached) this.relay?.sendWorkflowResult(wf.workflow.name, content);
|
|
1118
1138
|
log(` workflow "${wf.workflow.name}" ${result.status}${result.reason ? `: ${result.reason}` : ""}`);
|
|
@@ -28,6 +28,8 @@ import {
|
|
|
28
28
|
} from "../providers/account.ts";
|
|
29
29
|
import { RelayClient, type TaskSpec } from "./relayClient.ts";
|
|
30
30
|
import { RemoteBridge } from "./remoteBridge.ts";
|
|
31
|
+
import { makeRelayFileTools } from "../tools/relayFileTools.ts";
|
|
32
|
+
import { AttachmentStore, type StoredAttachment } from "../util/attachmentStore.ts";
|
|
31
33
|
import { spawnAccountCredentials, revokeAccountSession, hasCredentials } from "../auth/privateer.ts";
|
|
32
34
|
|
|
33
35
|
export interface LiveTaskHandle {
|
|
@@ -41,6 +43,11 @@ export interface LiveTaskDeps {
|
|
|
41
43
|
parseSpec: (spec: string) => { provider: string; modelId: string };
|
|
42
44
|
log: (msg: string) => void;
|
|
43
45
|
onClosed: (termId: string) => void;
|
|
46
|
+
// Deliver the session's closing answer durably (the harbor seals it to the account
|
|
47
|
+
// outbox, so it lands in the app's inbox). A live spawn's feed is otherwise purely
|
|
48
|
+
// ephemeral: close the screen, reap the session, and everything it said is gone —
|
|
49
|
+
// unlike a submitted task or a routine, whose results are always sealed.
|
|
50
|
+
onResult?: (result: { title: string; status: "ok" | "error"; content: string }) => void;
|
|
44
51
|
}
|
|
45
52
|
|
|
46
53
|
// How long to keep a spawned session alive with NO controller ever attaching, and the
|
|
@@ -72,12 +79,42 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
72
79
|
let attachTimer: ReturnType<typeof setTimeout> | undefined;
|
|
73
80
|
let lifeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
74
81
|
|
|
82
|
+
// Files the app sends down mid-session, keyed by the "#n" ref save_attachment writes
|
|
83
|
+
// back out. `sinceLastPrompt` is drained into the next prompt so the model is told what
|
|
84
|
+
// it just received — same contract as the TUI's (extensions/privateer-gate.ts).
|
|
85
|
+
const attachments = new AttachmentStore();
|
|
86
|
+
let sinceLastPrompt: StoredAttachment[] = [];
|
|
87
|
+
|
|
88
|
+
// The assistant text of the most recent turn, accumulated from the event stream and
|
|
89
|
+
// reset at the start of each one, so what we keep is the session's CLOSING answer
|
|
90
|
+
// rather than a transcript. Bounded — the outbox truncates anyway, and a long
|
|
91
|
+
// session's scrollback is not what makes a useful inbox entry.
|
|
92
|
+
const MAX_RESULT_CHARS = 8000;
|
|
93
|
+
let lastAnswer = "";
|
|
94
|
+
let turnErrored = false;
|
|
95
|
+
|
|
75
96
|
const stop = async (): Promise<void> => {
|
|
76
97
|
if (stopped) return;
|
|
77
98
|
stopped = true;
|
|
99
|
+
// Hand the closing answer over BEFORE tearing anything down. Best-effort by
|
|
100
|
+
// design: no answer (nothing ever ran, or the model only used tools) means
|
|
101
|
+
// nothing to deliver, and a delivery failure must never block teardown.
|
|
102
|
+
const answer = lastAnswer.trim();
|
|
103
|
+
if (answer && deps.onResult) {
|
|
104
|
+
try {
|
|
105
|
+
deps.onResult({
|
|
106
|
+
title: title || "Spawned agent",
|
|
107
|
+
status: turnErrored ? "error" : "ok",
|
|
108
|
+
content: answer.slice(0, MAX_RESULT_CHARS),
|
|
109
|
+
});
|
|
110
|
+
} catch (e) {
|
|
111
|
+
deps.log(`live task ${termId} result delivery failed: ${(e as Error).message}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
78
114
|
if (attachTimer) clearTimeout(attachTimer);
|
|
79
115
|
if (lifeTimer) clearTimeout(lifeTimer);
|
|
80
116
|
try { relay?.stop(); } catch { /* already stopped */ }
|
|
117
|
+
attachments.cleanup(); // drop the scratch dir holding inbound file bytes
|
|
81
118
|
// Revoke ONLY this session's account inference session so it doesn't linger in the
|
|
82
119
|
// app's Linked Devices; the harbor's own child session stays alive. Best-effort.
|
|
83
120
|
if (spawnedAccount) {
|
|
@@ -93,9 +130,20 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
93
130
|
const runTurn = async (text: string): Promise<void> => {
|
|
94
131
|
if (turnActive || stopped) return;
|
|
95
132
|
turnActive = true;
|
|
133
|
+
lastAnswer = ""; // keep the CLOSING answer, not the whole session
|
|
134
|
+
turnErrored = false;
|
|
96
135
|
try {
|
|
97
|
-
|
|
136
|
+
// Fold any files the app sent since the last prompt into a reference note, so the
|
|
137
|
+
// model knows they exist and can save_attachment them to disk.
|
|
138
|
+
const atts = sinceLastPrompt;
|
|
139
|
+
sinceLastPrompt = [];
|
|
140
|
+
const note = atts.length
|
|
141
|
+
? `\n\n[Files attached from the app: ${atts.map((a) => `#${a.n} ${a.name} (${a.mediaType})`).join(", ")}. ` +
|
|
142
|
+
`Use the save_attachment tool with the ref number to write one to disk.]`
|
|
143
|
+
: "";
|
|
144
|
+
await session.prompt(text + note);
|
|
98
145
|
} catch (e) {
|
|
146
|
+
turnErrored = true;
|
|
99
147
|
deps.log(`live task ${termId} turn error: ${(e as Error).message}`);
|
|
100
148
|
} finally {
|
|
101
149
|
turnActive = false;
|
|
@@ -122,6 +170,7 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
122
170
|
bridge.callbacks.onPrompt(spec.prompt);
|
|
123
171
|
}
|
|
124
172
|
},
|
|
173
|
+
onAttachment: (file) => sinceLastPrompt.push(attachments.register(file)),
|
|
125
174
|
onTerminate: () => void stop(),
|
|
126
175
|
onStatus: (t) => deps.log(`live task ${termId}: ${t}`),
|
|
127
176
|
});
|
|
@@ -157,6 +206,11 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
157
206
|
privateerVerifiedTee: (m) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
|
|
158
207
|
}),
|
|
159
208
|
makeAccountProvider(),
|
|
209
|
+
// send_file_to_client / save_attachment bound to THIS session's bridge — the one
|
|
210
|
+
// whose relay the app is attached to. The shipped gate extension is discovered
|
|
211
|
+
// into this session too but stands its own pair down inside the daemon, so these
|
|
212
|
+
// are the ones the model gets (see tools/relayFileTools.ts).
|
|
213
|
+
makeRelayFileTools(bridge, attachments),
|
|
160
214
|
] as any,
|
|
161
215
|
},
|
|
162
216
|
});
|
|
@@ -219,7 +273,12 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
219
273
|
|
|
220
274
|
const adapter = createEngineEventAdapter();
|
|
221
275
|
session.subscribe((ev: any) => {
|
|
222
|
-
for (const ee of adapter.toEngineEvents(ev))
|
|
276
|
+
for (const ee of adapter.toEngineEvents(ev)) {
|
|
277
|
+
// Assistant prose only — reasoning, tool calls and results are deliberately not
|
|
278
|
+
// kept: the inbox entry should read like the agent's answer, not a trace.
|
|
279
|
+
if (ee.type === "text" && lastAnswer.length < MAX_RESULT_CHARS) lastAnswer += ee.text;
|
|
280
|
+
bridge.forwardEvent(ee);
|
|
281
|
+
}
|
|
223
282
|
});
|
|
224
283
|
|
|
225
284
|
relay = new RelayClient(bridge.callbacks, { termId, label });
|
package/src/routines/store.ts
CHANGED
|
@@ -204,6 +204,11 @@ export function drainPendingRelay(): PendingRelay[] {
|
|
|
204
204
|
return queue;
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
// What produced a result delivered to the account outbox. Travels inside the sealed
|
|
208
|
+
// envelope, so the app can label and filter its inbox without the server learning
|
|
209
|
+
// anything. Kept here (not in the harbor) because the pending-cloud queue persists it.
|
|
210
|
+
export type OutboxKind = "routine" | "task" | "workflow";
|
|
211
|
+
|
|
207
212
|
// A `cloud`-delivery result that couldn't be sealed+posted to the account outbox
|
|
208
213
|
// yet (offline, server down, or the app hasn't published its outbox key). Held on
|
|
209
214
|
// disk until a later flush succeeds. Unlike PendingRelay this carries `status`, so
|
|
@@ -214,9 +219,10 @@ export interface PendingCloud {
|
|
|
214
219
|
status: "ok" | "error";
|
|
215
220
|
content: string;
|
|
216
221
|
// What produced this — a scheduled routine (default, for back-compat with items
|
|
217
|
-
// written before ad-hoc tasks existed)
|
|
218
|
-
// so the flush re-seals with the right `kind` and the app
|
|
219
|
-
|
|
222
|
+
// written before ad-hoc tasks existed), an app-submitted one-shot task, or a
|
|
223
|
+
// workflow run. Preserved so the flush re-seals with the right `kind` and the app
|
|
224
|
+
// labels it correctly in the inbox.
|
|
225
|
+
kind?: OutboxKind;
|
|
220
226
|
}
|
|
221
227
|
|
|
222
228
|
function pendingCloudPath(): string {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The two relay file tools as a Pi extension factory bound to ONE specific bridge.
|
|
2
|
+
//
|
|
3
|
+
// `send_file_to_client` / `save_attachment` are normally registered by the shipped TUI
|
|
4
|
+
// extension (extensions/privateer-gate.ts), against that extension's module-level
|
|
5
|
+
// RemoteBridge — the one `/remote-access` attaches a relay to. That is right for the TUI
|
|
6
|
+
// and wrong everywhere else: the extension is AUTO-DISCOVERED from ~/.privateer/agent/
|
|
7
|
+
// extensions into every session that shares the agent dir, including the sessions the
|
|
8
|
+
// harbor daemon stands up (live task spawns), which own their OWN bridge + relay. Pi
|
|
9
|
+
// resolves duplicate tool names first-registration-wins and loads discovered extensions
|
|
10
|
+
// before inline factories, so the discovered pair would shadow a session's own and answer
|
|
11
|
+
// "remote access is off" while the session's relay is connected and driving.
|
|
12
|
+
//
|
|
13
|
+
// Hence this factory: a session that has its own bridge registers the pair here, and the
|
|
14
|
+
// gate extension stands down inside the daemon (PRIVATEER_HARBOR_DAEMON).
|
|
15
|
+
import { makeSendFileTool, type SendFileBridge } from "./sendFile.ts";
|
|
16
|
+
import { makeSaveAttachmentTool } from "./saveAttachment.ts";
|
|
17
|
+
import type { AttachmentStore } from "../util/attachmentStore.ts";
|
|
18
|
+
|
|
19
|
+
export function makeRelayFileTools(bridge: SendFileBridge, attachments: AttachmentStore) {
|
|
20
|
+
return function relayFileTools(pi: any): void {
|
|
21
|
+
pi.registerTool?.(makeSendFileTool(bridge));
|
|
22
|
+
pi.registerTool?.(makeSaveAttachmentTool(attachments));
|
|
23
|
+
};
|
|
24
|
+
}
|