privateer-agent 0.9.0 → 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
@@ -5,15 +5,25 @@
5
5
  // over-warning), and a zdr account model as zdr-policy. Replaces loading pi-privacy's
6
6
  // default entry directly.
7
7
  //
8
- // It also WIDENS the tinfoil provider's model list. pi-privacy registers `tinfoil` with
9
- // a single seed model, so any other Tinfoil model notably our default `tinfoil/glm-5-2`
10
- // — resolves as a "custom model id" with a startup warning and never shows in the picker.
11
- // We re-register tinfoil with its current chat catalog AFTER pi-privacy runs (a second
12
- // registerProvider call replaces the provider's model list; pi-privacy registers
13
- // synchronously, so ours lands second and wins). This is purely a display/resolution
14
- // list posture and attestation are dispatcher-bound and unaffected by the model set.
8
+ // It also REPAIRS two provider registrations pi-privacy makes from its own catalog, each
9
+ // of which replaces (not merges) whatever model list that provider already had:
10
+ //
11
+ // - `tinfoil` gets a single seed model, so any other Tinfoil model — notably our
12
+ // default `tinfoil/glm-5-2` resolves as a "custom model id" with a startup warning
13
+ // and never shows in the picker. We re-register it with the current chat catalog.
14
+ // - `privateer` gets pi-privacy's PUBLIC developer-key channel (api.privateer.pro/v1 +
15
+ // `${PRIVATEER_API_KEY}`, one seed model), which clobbers the ACCOUNT channel our own
16
+ // privateer-account extension registers. That is our default model's provider, so the
17
+ // same "not found for provider privateer" warning followed — and worse, the model Pi
18
+ // synthesized pointed at the public endpoint instead of `/api/agent/v1`. We re-assert
19
+ // the account registration (see registerAccountModels).
20
+ //
21
+ // Both repairs run AFTER pi-privacy inside this same extension, so ours land second and
22
+ // win regardless of the order pi discovers extensions in. This is purely a
23
+ // display/resolution + routing list — posture and attestation are dispatcher-bound and
24
+ // unaffected by the model set.
15
25
  import { makePiPrivacyExtension } from "pi-privacy";
16
- import { accountPosture } from "../src/providers/account.ts";
26
+ import { accountPosture, registerAccountModels } from "../src/providers/account.ts";
17
27
 
18
28
  // Tinfoil's live chat models (inference.tinfoil.sh/v1/models), glm-5-2 first — the
19
29
  // launcher's default. Non-chat endpoints (embeddings, tts, whisper, websearch,
@@ -82,4 +92,6 @@ export default function privateerPrivacy(pi: any): void {
82
92
  authHeader: true,
83
93
  models: TINFOIL_MODELS.map(tinfoilModel),
84
94
  });
95
+ // Put the ACCOUNT channel back over pi-privacy's public developer-key `privateer`.
96
+ registerAccountModels(pi);
85
97
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.9.0",
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";
@@ -57,8 +58,9 @@ import { resolveMcpSelection, readMcpInventory, type ResolvedMcpTools } from "..
57
58
  import { deliver, type RelayPusher, type CloudPusher } from "../routines/delivery.ts";
58
59
  import { sealJson, decodeAccountPublicKey } from "../crypto/outboxSeal.ts";
59
60
  import { redactText, collectSecrets } from "../util/redact.ts";
60
- import { startIpcServer, HarborAlreadyRunningError, type IpcRequest, type IpcResponse } from "./ipc.ts";
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;
@@ -807,6 +814,16 @@ export class Harbor {
807
814
  // Privateer's TEE-channel models (near/tinfoil/phala) as "◆ Verifiable TEE"
808
815
  // when logged in; ZDR-channel models stay at their honest floor. The live
809
816
  // verdict still comes from accountPosture on select — this only lifts the label.
817
+ //
818
+ // ORDER MATTERS: pi-privacy's own catalog registers a `privateer`
819
+ // provider (its PUBLIC developer-key channel, one seed model), and Pi's
820
+ // registerProvider REPLACES a provider's models and request config. It
821
+ // must stay ABOVE makeAccountProvider() so the ACCOUNT channel lands last
822
+ // — otherwise the default model stops resolving and requests go to
823
+ // api.privateer.pro/v1 instead of /api/agent/v1. (The TUI hits this
824
+ // through extension discovery, where the order isn't ours to choose;
825
+ // extensions/privateer-privacy.ts re-asserts the account registration
826
+ // there. See registerAccountModels.)
810
827
  makePiPrivacyExtension({
811
828
  privateerVerifiedTee: (m) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
812
829
  }),
@@ -1037,6 +1054,19 @@ export class Harbor {
1037
1054
  parseSpec,
1038
1055
  log,
1039
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
+ },
1040
1070
  });
1041
1071
  this.liveTasks.set(handle.termId, handle);
1042
1072
  this.relay?.sendTaskSpawned(handle.termId, handle.label);
@@ -1101,8 +1131,8 @@ export class Harbor {
1101
1131
  const content = formatWorkflowResult(wf.workflow.name, result);
1102
1132
  const at = new Date().toISOString();
1103
1133
  // Durable delivery: seal to the outbox (queue on failure to re-seal later).
1104
- if (!(await this.postOutbox(wf.workflow.name, at, status, content, "task"))) {
1105
- 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" });
1106
1136
  }
1107
1137
  if (this.controllerAttached) this.relay?.sendWorkflowResult(wf.workflow.name, content);
1108
1138
  log(` workflow "${wf.workflow.name}" ${result.status}${result.reason ? `: ${result.reason}` : ""}`);
@@ -1192,6 +1222,31 @@ export class Harbor {
1192
1222
  return { text: notes.length > 0 ? `${out}${formatNotes(notes)}` : out, output, status, error };
1193
1223
  }
1194
1224
 
1225
+ // What the app can actually see. A harbor is only drivable while its relay socket
1226
+ // is up, and every way that can fail — signed out, turned off from the app, refused
1227
+ // by the plan's agent cap, a socket that died without closing — used to be visible
1228
+ // ONLY as a line in a log file nobody reads. Reported alongside pid/uptime so
1229
+ // `privateer harbor status` can never again say "running" about a harbor the app
1230
+ // shows as offline.
1231
+ private relayStatus(): RelayStatus {
1232
+ const termId = routineRelayId();
1233
+ if (this.relayTerminated) {
1234
+ return { termId, connected: false, detail: "remote access was turned off from the app — restart the harbor to re-enable it" };
1235
+ }
1236
+ if (!this.relay) {
1237
+ return {
1238
+ termId,
1239
+ connected: false,
1240
+ detail: hasCredentials()
1241
+ ? "relay not started"
1242
+ : "no account signed in on this machine — run `privateer` and /login, then restart the harbor",
1243
+ };
1244
+ }
1245
+ const conn = this.relay.connectionStatus();
1246
+ if (!conn.connected) return { termId, connected: false, detail: "connecting…" };
1247
+ return { termId, connected: true, upSec: conn.upSec, quietSec: conn.quietSec };
1248
+ }
1249
+
1195
1250
  private persistRun(id: string, patch: Partial<Routine>): void {
1196
1251
  const current = findRoutine(loadRoutines(), id);
1197
1252
  if (!current) return;
@@ -1201,7 +1256,13 @@ export class Harbor {
1201
1256
  private async handleIpc(req: IpcRequest): Promise<IpcResponse> {
1202
1257
  switch (req.cmd) {
1203
1258
  case "status":
1204
- return { ok: true, pid: process.pid, uptimeSec: Math.round((Date.now() - this.startedAt) / 1000), routines: loadRoutines() };
1259
+ return {
1260
+ ok: true,
1261
+ pid: process.pid,
1262
+ uptimeSec: Math.round((Date.now() - this.startedAt) / 1000),
1263
+ relay: this.relayStatus(),
1264
+ routines: loadRoutines(),
1265
+ };
1205
1266
  case "list":
1206
1267
  return { ok: true, routines: loadRoutines() };
1207
1268
  case "add": {
@@ -1254,11 +1315,29 @@ export function runHarbor(): void {
1254
1315
  };
1255
1316
  process.on("SIGINT", shutdown);
1256
1317
  process.on("SIGTERM", shutdown);
1257
- harbor.start().catch((err) => {
1318
+ harbor.start().catch(async (err) => {
1258
1319
  if (err instanceof HarborAlreadyRunningError) {
1259
1320
  // A resident harbor already owns this machine — leave it in charge. Exit 0 so a
1260
1321
  // manual `privateer harbor run` beside the installed login service isn't an error.
1322
+ //
1323
+ // Say WHICH harbor, and whether it is actually reachable from the app: the
1324
+ // incumbent can be a stale process that still answers IPC while its relay socket
1325
+ // is long dead, and "leaving the existing one in charge" then reads as reassurance
1326
+ // for a harbor the app shows as offline. The incumbent knows — ask it.
1261
1327
  process.stderr.write("A Harbor is already running on this machine — leaving the existing one in charge.\n");
1328
+ try {
1329
+ const status = await sendToHarbor({ cmd: "status" }, 3_000);
1330
+ const up = typeof status.uptimeSec === "number" ? `, up ${formatDuration(status.uptimeSec)}` : "";
1331
+ process.stderr.write(` incumbent: pid ${status.pid ?? "?"}${up}\n`);
1332
+ process.stderr.write(` relay: ${describeRelay(status.relay)}\n`);
1333
+ if (status.relay && !status.relay.connected) {
1334
+ process.stderr.write(" Stop that process (or `privateer harbor uninstall && privateer harbor install`) to hand the machine to a fresh Harbor.\n");
1335
+ }
1336
+ } catch {
1337
+ // It held the lock a moment ago but won't answer now — a wedged process is
1338
+ // worth naming too, since nothing else will start while it holds the socket.
1339
+ process.stderr.write(" incumbent: holds the lock but is not answering IPC — it may be wedged; stop it and start again.\n");
1340
+ }
1262
1341
  process.exit(0);
1263
1342
  }
1264
1343
  process.stderr.write(`Harbor failed to start: ${err instanceof Error ? err.message : String(err)}\n`);
package/src/harbor/ipc.ts CHANGED
@@ -22,6 +22,28 @@ export type IpcRequest =
22
22
  | { cmd: "run-now"; idOrName: string }
23
23
  | { cmd: "reload" };
24
24
 
25
+ /**
26
+ * The harbor's view of its own relay connection, reported by `status`.
27
+ *
28
+ * A harbor answering on this socket is running; that is NOT the same as being
29
+ * reachable from the app, which needs the relay socket up (the server drops a
30
+ * terminal from its presence registry ~60s after it stops hearing from it). The two
31
+ * used to be conflated — "running (answering IPC)" while the app showed the same
32
+ * harbor as offline — so every liveness report carries both now.
33
+ */
34
+ export interface RelayStatus {
35
+ /** The relay terminal id the app looks for ("routines-…"). */
36
+ termId: string;
37
+ /** Socket open right now, i.e. the app can see and drive this harbor. */
38
+ connected: boolean;
39
+ /** Seconds the current connection has been up. */
40
+ upSec?: number;
41
+ /** Seconds since the server last sent anything (frame, ping or pong). */
42
+ quietSec?: number;
43
+ /** Why it isn't connected, when we know: signed out, turned off from the app, … */
44
+ detail?: string;
45
+ }
46
+
25
47
  export interface IpcResponse {
26
48
  ok: boolean;
27
49
  message?: string;
@@ -29,6 +51,8 @@ export interface IpcResponse {
29
51
  // Harbor liveness/uptime for `status`.
30
52
  pid?: number;
31
53
  uptimeSec?: number;
54
+ // Relay reachability for `status` — see RelayStatus.
55
+ relay?: RelayStatus;
32
56
  }
33
57
 
34
58
  export type IpcHandler = (req: IpcRequest) => Promise<IpcResponse> | IpcResponse;
@@ -164,6 +188,32 @@ export class HarborAlreadyRunningError extends Error {
164
188
  }
165
189
  }
166
190
 
191
+ // One-line, human rendering of a status reply's relay block — shared by
192
+ // `privateer harbor status` and the second-instance notice so both tell the same
193
+ // story. `undefined` means the harbor answering us predates this field.
194
+ export function describeRelay(relay?: RelayStatus): string {
195
+ if (!relay) return "unknown (this harbor is an older build)";
196
+ if (!relay.connected) {
197
+ return `NOT connected — the app shows this Harbor as inactive${relay.detail ? ` (${relay.detail})` : ""}`;
198
+ }
199
+ const up = typeof relay.upSec === "number" ? `, up ${formatDuration(relay.upSec)}` : "";
200
+ // A connected socket the server hasn't spoken on in a while is the half-open shape;
201
+ // the watchdog drops it within ~75s, so say so rather than reporting a flat "connected".
202
+ const quiet = typeof relay.quietSec === "number" && relay.quietSec > 40 ? `, quiet for ${relay.quietSec}s — checking` : "";
203
+ return `connected — drivable from the Privateer app (${relay.termId}${up}${quiet})`;
204
+ }
205
+
206
+ export function formatDuration(totalSec: number): string {
207
+ const s = Math.max(0, Math.round(totalSec));
208
+ const d = Math.floor(s / 86400);
209
+ const h = Math.floor((s % 86400) / 3600);
210
+ const m = Math.floor((s % 3600) / 60);
211
+ if (d) return `${d}d ${h}h`;
212
+ if (h) return `${h}h ${m}m`;
213
+ if (m) return `${m}m`;
214
+ return `${s}s`;
215
+ }
216
+
167
217
  // Convenience: is the harbor reachable right now?
168
218
  export async function harborIsRunning(): Promise<boolean> {
169
219
  try {
@@ -12,7 +12,7 @@ import { homedir } from "node:os";
12
12
  import { join, dirname, resolve } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { globalDir } from "../config/paths.ts";
15
- import { harborIsRunning } from "./ipc.ts";
15
+ import { harborIsRunning, sendToHarbor, describeRelay, formatDuration, type IpcResponse } from "./ipc.ts";
16
16
 
17
17
  const LABEL = "pro.privateer.harbor"; // launchd label / reverse-dns id
18
18
  const UNIT = "privateer-harbor.service"; // systemd --user unit name
@@ -62,7 +62,13 @@ function xmlEscape(s: string): string {
62
62
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
63
63
  }
64
64
 
65
- function launchdPlist(): string {
65
+ // KeepAlive is `{ SuccessfulExit: false }`, NOT plain `true` — restart on a crash,
66
+ // but leave a CLEAN exit alone. A bare `true` restarts unconditionally, which turns
67
+ // the two clean-exit paths into loops: the harbor that finds another one already
68
+ // holding the machine lock (exit 0 every ~10s, appending the same line to harbor.log
69
+ // forever — this is what produced a 7 MB log of "already running"), and a deliberate
70
+ // shutdown, which launchd would undo. Matches the systemd unit's Restart=on-failure.
71
+ export function launchdPlist(): string {
66
72
  const args = [nodeBinaryPath(), harborLauncherPath(), "run"];
67
73
  const envVars = forwardedEnv();
68
74
  const argXml = args.map((a) => ` <string>${xmlEscape(a)}</string>`).join("\n");
@@ -83,7 +89,10 @@ ${argXml}
83
89
  ${envVars.PRIVATEER_HOME || envVars.PRIVATEER_SERVER_URL ? ` <key>EnvironmentVariables</key>\n <dict>\n${envXml}\n </dict>\n` : ""} <key>RunAtLoad</key>
84
90
  <true/>
85
91
  <key>KeepAlive</key>
86
- <true/>
92
+ <dict>
93
+ <key>SuccessfulExit</key>
94
+ <false/>
95
+ </dict>
87
96
  <key>StandardOutPath</key>
88
97
  <string>${log}</string>
89
98
  <key>StandardErrorPath</key>
@@ -199,6 +208,24 @@ export interface ServiceInfo {
199
208
  installed: boolean;
200
209
  unitPath: string;
201
210
  logPath: string;
211
+ /** Installed unit predates a fix and should be rewritten — see needsRefresh(). */
212
+ needsRefresh: boolean;
213
+ }
214
+
215
+ // Does the INSTALLED unit need rewriting? Deliberately narrow: a full text compare
216
+ // against what we'd generate today would flag every service installed by a different
217
+ // copy of the CLI (a dev checkout resolves a different launcher path), which is not a
218
+ // problem and not something the user should be nagged about. The one thing worth
219
+ // flagging is the pre-fix launchd `KeepAlive: true`, which restarts the harbor even
220
+ // after a clean exit — the log-spam loop described above launchdPlist().
221
+ export function unitNeedsRefresh(platform: NodeJS.Platform, unitPath: string): boolean {
222
+ if (platform !== "darwin" || !existsSync(unitPath)) return false;
223
+ try {
224
+ const plist = readFileSync(unitPath, "utf8");
225
+ return /<key>KeepAlive<\/key>\s*<true\s*\/>/.test(plist);
226
+ } catch {
227
+ return false;
228
+ }
202
229
  }
203
230
 
204
231
  function unitPathFor(platform: NodeJS.Platform): string {
@@ -216,6 +243,7 @@ export function serviceInfo(): ServiceInfo {
216
243
  installed: !!unitPath && existsSync(unitPath),
217
244
  unitPath,
218
245
  logPath: harborLogPath(),
246
+ needsRefresh: unitNeedsRefresh(platform, unitPath),
219
247
  };
220
248
  }
221
249
 
@@ -236,20 +264,40 @@ export function uninstallService(): ServiceInfo {
236
264
  return serviceInfo();
237
265
  }
238
266
 
239
- // Human-readable status line for `privateer harbor status`: whether the service is
240
- // installed AND whether a harbor is actually answering on the IPC socket right now.
267
+ // Human-readable status for `privateer harbor status`: whether the service is
268
+ // installed, whether a harbor is answering on the IPC socket, and — the part that
269
+ // actually answers "why does the app say inactive?" — whether that harbor is
270
+ // connected to the relay. Answering IPC only proves a local process is alive; the
271
+ // app lists a harbor from the server's presence registry, which a dead relay socket
272
+ // drops within ~60s. Reporting the first as if it implied the second is what made a
273
+ // stale harbor look healthy from the terminal and offline from the phone.
241
274
  export async function statusReport(): Promise<string> {
242
275
  const info = serviceInfo();
243
- const live = await harborIsRunning();
276
+ let status: IpcResponse | null = null;
277
+ try {
278
+ status = await sendToHarbor({ cmd: "status" }, 3_000);
279
+ } catch {
280
+ status = null; // not running, or wedged — harborIsRunning() below tells them apart
281
+ }
282
+ const live = status ? status.ok : await harborIsRunning();
283
+ const up = status && typeof status.uptimeSec === "number" ? `, up ${formatDuration(status.uptimeSec)}` : "";
284
+ const pid = status?.pid ? `pid ${status.pid}${up}` : "answering IPC";
244
285
  const lines = [
245
286
  `platform: ${info.platform}${info.supported ? "" : " (auto-start unsupported — run `privateer harbor` manually)"}`,
246
287
  `service: ${info.installed ? `installed (${info.unitPath})` : "not installed"}`,
247
- `harbor: ${live ? "running (answering IPC)" : "not reachable"}`,
248
- `logs: ${info.logPath}`,
288
+ `harbor: ${live ? `running (${pid})` : "not reachable"}`,
249
289
  ];
290
+ if (live) lines.push(`relay: ${describeRelay(status?.relay)}`);
291
+ lines.push(`logs: ${info.logPath}`);
250
292
  // Surface a stale-unit hint: file present but nothing answering usually means it
251
293
  // failed to boot — the log path above is where to look.
252
294
  if (info.installed && !live) lines.push("hint: service is installed but not answering — check the log for a boot error.");
295
+ if (live && status?.relay && !status.relay.connected) {
296
+ lines.push("hint: Harbor is running but not reachable from the app. Restart it (`privateer harbor uninstall && privateer harbor install`) once the cause above is resolved.");
297
+ }
298
+ if (info.needsRefresh) {
299
+ lines.push("hint: the installed login service restarts Harbor even after a clean exit (older install) — run `privateer harbor install` to refresh it.");
300
+ }
253
301
  return lines.join("\n");
254
302
  }
255
303
 
@@ -429,6 +429,51 @@ export async function accountPosture(modelId: string): Promise<AccountPosture> {
429
429
  }
430
430
  }
431
431
 
432
+ // A model entry, with a per-model baseUrl override once the EHBP shim is listening:
433
+ // `tinfoil/*` then route through the loopback shim (which seals to the blind relay)
434
+ // instead of the cleartext `/api/agent/v1` proxy. Everything else keeps the provider
435
+ // baseUrl. Until the shim is up (or when sealed mode is off) sealed models fall back to
436
+ // the cleartext path — and the badge stays honestly `tee-unverified` (see accountPosture).
437
+ function modelEntry(id: string) {
438
+ const base = seedModel(id);
439
+ const provider = sealedEnabled() ? sealedProviderFor(id) : null;
440
+ const shim = sealedShimBase();
441
+ return provider && shim ? { ...base, baseUrl: `${shim}/${provider}/v1` } : base;
442
+ }
443
+
444
+ // The Pi provider config for the account channel, over a given set of model ids.
445
+ export function accountProviderConfig(ids: string[]): Record<string, unknown> {
446
+ return {
447
+ name: "Privateer account",
448
+ baseUrl: `${serverBaseUrl()}/api/agent/v1`,
449
+ api: "openai-completions",
450
+ oauth: privateerOAuthProvider,
451
+ models: ids.map(modelEntry),
452
+ };
453
+ }
454
+
455
+ // Re-assert the account channel's registration from ANOTHER extension.
456
+ //
457
+ // pi-privacy also ships a `privateer` provider (its PRIVACY_PROVIDERS catalog) — the
458
+ // PUBLIC developer-key channel: baseUrl api.privateer.pro/v1, `${PRIVATEER_API_KEY}`,
459
+ // and a single seed model (near/zai-org/GLM-5.1-FP8). Pi's registerProvider FULLY
460
+ // REPLACES a provider's model list and its request config, so whichever registration
461
+ // lands last wins — and pi extensions are discovered with an unsorted readdirSync, which
462
+ // on a typical box puts privateer-privacy after privateer-account. The account channel's
463
+ // whole catalog was then replaced by that one model, so the default `tinfoil/glm-5-2` no
464
+ // longer resolved ("not found for provider privateer. Using custom model id") and the
465
+ // synthesized model inherited the PUBLIC endpoint instead of `/api/agent/v1`.
466
+ //
467
+ // So privateer-privacy.ts calls this right after pi-privacy runs, exactly as it re-widens
468
+ // `tinfoil`. Idempotent and order-independent: if privacy happens to load first, the
469
+ // account extension's own registration lands afterwards with the same config, and the
470
+ // live-catalog fetch re-registers over both moments later either way.
471
+ export function registerAccountModels(pi: {
472
+ registerProvider?: (name: string, config: unknown) => void;
473
+ }): void {
474
+ pi.registerProvider?.("privateer", accountProviderConfig(seedCatalogIds()));
475
+ }
476
+
432
477
  // Extension factory: registers the account provider so `/login` can offer it.
433
478
  //
434
479
  // We register UNCONDITIONALLY (not only when a machine login already exists). Pi's
@@ -454,31 +499,13 @@ export function makeAccountProvider() {
454
499
  on?: (event: string, handler: (e: unknown, ctx: unknown) => void) => void;
455
500
  }): void => {
456
501
  if (typeof pi.registerProvider !== "function") return;
457
- // A model entry, with a per-model baseUrl override for sealed models once the
458
- // EHBP shim is listening: `tinfoil/*` then route through the loopback shim (which
459
- // seals to the blind relay) instead of the cleartext `/api/agent/v1` proxy.
460
- // Everything else keeps the provider baseUrl below. Until the shim is up (or when
461
- // sealed mode is off) sealed models fall back to the cleartext path — and the
462
- // badge stays honestly `tee-unverified` (see accountPosture).
463
- const modelEntry = (id: string) => {
464
- const base = seedModel(id);
465
- const provider = sealedEnabled() ? sealedProviderFor(id) : null;
466
- const shim = sealedShimBase();
467
- return provider && shim ? { ...base, baseUrl: `${shim}/${provider}/v1` } : base;
468
- };
469
502
  // Seed with the last live catalog when we have one (see seedCatalogIds): this is the
470
503
  // list Pi resolves a saved default / a restored session model against at launch,
471
504
  // before the live re-registration can reach the registry.
472
505
  let lastIds: string[] = seedCatalogIds();
473
506
  const register = (ids: string[]): void => {
474
507
  lastIds = ids;
475
- pi.registerProvider!("privateer", {
476
- name: "Privateer account",
477
- baseUrl: `${serverBaseUrl()}/api/agent/v1`,
478
- api: "openai-completions",
479
- oauth: privateerOAuthProvider,
480
- models: ids.map(modelEntry),
481
- });
508
+ pi.registerProvider!("privateer", accountProviderConfig(ids));
482
509
  };
483
510
  register(lastIds); // immediate: provider exists this tick, with a resolvable catalog
484
511
  // Bring up the sealed shim, then re-register so sealed models pick up their shim
@@ -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 });
@@ -233,6 +233,24 @@ const RECONNECT_MS = 3000;
233
233
  // record TTL so the app's "blocked" row stays warm between attempts rather than
234
234
  // flickering in and out of the plan-limit state.
235
235
  const REFUSED_RECONNECT_MS = 60_000;
236
+ // ── Liveness ────────────────────────────────────────────────────────────────────
237
+ // A TCP socket can die without either side being told: a server instance restarts,
238
+ // a NAT/idle timer drops the flow, a laptop sleeps. The kernel keeps reporting
239
+ // ESTABLISHED, `ws` never fires 'close', and the reconnect path above — which only
240
+ // runs on close/error — never runs. That failure mode is invisible AND permanent:
241
+ // the server prunes the terminal from its presence registry after ~60s, so the app
242
+ // shows the harbor as offline while the harbor's own log says "connected", forever.
243
+ //
244
+ // So don't wait to be told. The server pings every 25s, so an alive socket sees
245
+ // inbound traffic at least that often; we ping on our own timer too (the peer's pong
246
+ // counts as inbound). If nothing arrives for LIVENESS_TIMEOUT_MS — three missed
247
+ // server pings — the socket is dead: terminate it and take the normal reconnect path.
248
+ const HEARTBEAT_MS = 20_000;
249
+ const LIVENESS_TIMEOUT_MS = 75_000;
250
+ // Cap the opening handshake too. Without this a black-holed connect leaves `this.ws`
251
+ // set with no open/close/error ever firing, and connect()'s `if (this.ws) return`
252
+ // guard then blocks every future attempt — the same permanent silence by another route.
253
+ const HANDSHAKE_TIMEOUT_MS = 15_000;
236
254
  // File-transfer ceilings for app→CLI attachments. The app enforces its own caps
237
255
  // before sending; these are a defensive backstop so a controller can't exhaust
238
256
  // memory with a lying `size` or a flood of concurrent transfers.
@@ -291,6 +309,11 @@ export class RelayClient {
291
309
  private closed = false;
292
310
  private connecting = false;
293
311
  private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
312
+ // Liveness watchdog for the open socket (see HEARTBEAT_MS): our own ping timer plus
313
+ // the epoch of the last thing we heard from the server — any frame, ping or pong.
314
+ private heartbeatTimer: ReturnType<typeof setInterval> | undefined;
315
+ private lastInboundAt = 0;
316
+ private connectedAt = 0;
294
317
  // Last refusal reason reported, so a 4xx is logged once instead of on every retry.
295
318
  private refusal: string | null = null;
296
319
  // Ordered delta buffer (text/reasoning) coalesced into one frame per flush.
@@ -377,6 +400,8 @@ export class RelayClient {
377
400
  this.settleFirstConnect(new Error("relay stopped before registering"));
378
401
  if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = undefined; }
379
402
  if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = undefined; }
403
+ this.stopHeartbeat();
404
+ this.connectedAt = 0;
380
405
  this.bufKind = null;
381
406
  this.buf = "";
382
407
  this.incoming.clear();
@@ -417,7 +442,7 @@ export class RelayClient {
417
442
  const wsUrl =
418
443
  serverBaseUrl().replace(/^http/, "ws") + `/relay?ticket=${encodeURIComponent(ticket)}`;
419
444
  this.debug(`connecting → ${wsUrl}`);
420
- const ws = new WebSocket(wsUrl);
445
+ const ws = new WebSocket(wsUrl, { handshakeTimeout: HANDSHAKE_TIMEOUT_MS });
421
446
  this.ws = ws;
422
447
  let opened = false;
423
448
  let lastErr = "";
@@ -425,12 +450,20 @@ export class RelayClient {
425
450
  ws.on("open", () => {
426
451
  opened = true;
427
452
  this.refusal = null; // a later refusal is news again
453
+ this.connectedAt = Date.now();
454
+ this.startHeartbeat(ws);
428
455
  this.settleFirstConnect(); // terminal is live on the relay — awaitRegistered() resolves
429
456
  this.cb.onStatus?.("Remote access connected — drive this terminal from the Privateer app.");
430
457
  });
431
- ws.on("message", (data) => this.handle(data));
458
+ // Anything the server sends counts as proof of life for the watchdog. `ws`
459
+ // answers server pings with a pong for us, and answers our pings with 'pong'.
460
+ ws.on("message", (data) => { this.lastInboundAt = Date.now(); this.handle(data); });
461
+ ws.on("ping", () => { this.lastInboundAt = Date.now(); });
462
+ ws.on("pong", () => { this.lastInboundAt = Date.now(); });
432
463
  ws.on("close", () => {
464
+ this.stopHeartbeat();
433
465
  if (this.ws === ws) this.ws = null;
466
+ this.connectedAt = 0;
434
467
  this.cb.onDisconnected?.();
435
468
  if (!this.closed) {
436
469
  this.cb.onStatus?.(
@@ -484,6 +517,37 @@ export class RelayClient {
484
517
  if (process.env.PRIVATEER_RELAY_DEBUG) this.cb.onStatus?.(`relay: ${msg}`);
485
518
  }
486
519
 
520
+ // Watch one open socket: ping on a timer, and terminate it if the server has gone
521
+ // quiet for longer than any healthy connection ever is (see LIVENESS_TIMEOUT_MS).
522
+ // `terminate()` (not close()) because the point is that the peer may be gone — a
523
+ // close handshake would wait for a reply that never comes. The 'close' it fires
524
+ // takes the ordinary reconnect path, so recovery needs no separate machinery.
525
+ private startHeartbeat(ws: WebSocket): void {
526
+ this.stopHeartbeat();
527
+ this.lastInboundAt = Date.now();
528
+ this.heartbeatTimer = setInterval(() => {
529
+ // A socket we've since replaced or dropped isn't ours to police anymore.
530
+ if (this.ws !== ws) { this.stopHeartbeat(); return; }
531
+ if (ws.readyState !== WebSocket.OPEN) return; // closing — 'close' will clean up
532
+ const quietMs = Date.now() - this.lastInboundAt;
533
+ if (quietMs > LIVENESS_TIMEOUT_MS) {
534
+ this.cb.onStatus?.(
535
+ `Remote access went silent for ${Math.round(quietMs / 1000)}s (the connection died without closing) — dropping it and reconnecting…`,
536
+ );
537
+ this.stopHeartbeat();
538
+ try { ws.terminate(); } catch (_) { /* already gone — 'close' still fires */ }
539
+ return;
540
+ }
541
+ try { ws.ping(); } catch (_) { /* socket dying — the next tick or 'close' handles it */ }
542
+ }, HEARTBEAT_MS);
543
+ // Never hold the process open for a heartbeat alone.
544
+ this.heartbeatTimer.unref?.();
545
+ }
546
+
547
+ private stopHeartbeat(): void {
548
+ if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = undefined; }
549
+ }
550
+
487
551
  private scheduleReconnect(delayMs: number = RECONNECT_MS): void {
488
552
  if (this.closed || this.reconnectTimer) return;
489
553
  this.reconnectTimer = setTimeout(() => {
@@ -746,6 +810,21 @@ export class RelayClient {
746
810
  return this.ws?.readyState === WebSocket.OPEN;
747
811
  }
748
812
 
813
+ // Connection health, for `privateer harbor status` / the IPC status reply. `quietSec`
814
+ // is how long since the server last said anything: a connected socket that has been
815
+ // quiet for longer than the server's 25s ping cadence is the shape of the half-open
816
+ // failure the watchdog exists to catch, so it is worth showing rather than a bare
817
+ // "connected".
818
+ connectionStatus(): { connected: boolean; upSec?: number; quietSec?: number } {
819
+ if (!this.isConnected()) return { connected: false };
820
+ const now = Date.now();
821
+ return {
822
+ connected: true,
823
+ upSec: this.connectedAt ? Math.round((now - this.connectedAt) / 1000) : undefined,
824
+ quietSec: this.lastInboundAt ? Math.round((now - this.lastInboundAt) / 1000) : undefined,
825
+ };
826
+ }
827
+
749
828
  // Push a finished routine result to any attached controller as a text event, so
750
829
  // it renders in the app's live feed. Returns whether the socket was open to send
751
830
  // on; a durable channel (file/notice) still backs this up, since we can't know
@@ -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
+ }