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
- pi.registerTool?.(makeSendFileTool(bridge));
450
- pi.registerTool?.(makeSaveAttachmentTool(attachments));
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.1",
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
+ }
@@ -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: "routine" | "task" = "routine"): Promise<boolean> {
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, "task"))) {
1115
- addPendingCloud({ routine: wf.workflow.name, at, status, content, kind: "task" });
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
- await session.prompt(text);
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)) bridge.forwardEvent(ee);
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 });
@@ -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) or an app-submitted one-shot task. Preserved
218
- // so the flush re-seals with the right `kind` and the app labels it correctly.
219
- kind?: "routine" | "task";
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
+ }