privateer-agent 0.10.0 → 0.12.0
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/README.md +13 -0
- package/bin/privateer-launch.mjs +144 -66
- package/bin/privateer-subagent.mjs +35 -22
- package/extensions/privateer-brand.ts +14 -1
- package/extensions/privateer-gate.ts +20 -28
- package/extensions/privateer-media.ts +20 -0
- package/extensions/privateer-privacy.ts +3 -3
- package/package.json +17 -7
- package/patches/@earendil-works+pi-coding-agent+0.80.3.patch +876 -5
- package/src/acp/run.ts +16 -19
- package/src/channels/run.ts +18 -27
- package/src/cli/chat.ts +70 -25
- package/src/config/hosted.ts +34 -0
- package/src/config/moat.ts +236 -0
- package/src/config/moatManifest.json +26 -0
- package/src/config/moatManifest.ts +71 -0
- package/src/harbor/index.ts +49 -113
- package/src/outbox/cloudOutbox.ts +95 -0
- package/src/permissions/classify.ts +122 -0
- package/src/providers/account.ts +141 -19
- package/src/providers/defaultModel.ts +66 -11
- package/src/providers/phala/aci-verifier/VENDORED.md +16 -0
- package/src/providers/phala/reportBinding.ts +193 -0
- package/src/providers/phalaSeal.ts +7 -3
- package/src/providers/sealedShim.ts +35 -6
- package/src/remote/extensionsControl.ts +12 -22
- package/src/remote/liveTaskSession.ts +15 -24
- package/src/remote/relayClient.ts +47 -7
- package/src/remote/remoteBridge.ts +65 -0
- package/src/tools/media.ts +554 -0
- package/src/tools/relayFileTools.ts +10 -11
- package/src/tools/videoCompose.ts +579 -0
- package/src/tools/web.ts +12 -8
- package/src/config/harborDaemon.ts +0 -31
- package/src/config/inlineMoat.ts +0 -67
|
@@ -40,13 +40,22 @@ import { iterateSSE } from "./phala/sse.ts";
|
|
|
40
40
|
// (unsealed) path, not sealed. See docs/tee-verified-tinfoil-ehbp.md §12.
|
|
41
41
|
export type SealedProvider = "tinfoil" | "phala";
|
|
42
42
|
|
|
43
|
-
// Sealed mode is
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
43
|
+
// Sealed mode is ON by default as of 2026-07-31, when the live checklist in
|
|
44
|
+
// docs/tee-verified-tinfoil-ehbp.md passed end to end against the deployed relay:
|
|
45
|
+
// both enclaves attest client-side (Tinfoil HPKE-key match; Phala report binding +
|
|
46
|
+
// TDX quote), a sealed turn round-trips and streams incrementally, a bogus enclave is
|
|
47
|
+
// refused rather than silently greened, and the server bills the turn.
|
|
48
|
+
//
|
|
49
|
+
// What it buys: the prompt is sealed to the enclave the client itself attested, so the
|
|
50
|
+
// badge is a quote WE checked rather than the account's word — green instead of
|
|
51
|
+
// "Trusted Execution (unconfirmed)".
|
|
52
|
+
//
|
|
53
|
+
// PRIVATEER_SEALED=0 (or =false) drops back to the cleartext `/api/agent/v1` path and
|
|
54
|
+
// the honest yellow badge. Note that is a real downgrade for `phala/*`, which is
|
|
55
|
+
// sealed-only and simply disappears from the catalog (see isServableAccountModel).
|
|
47
56
|
export function sealedEnabled(): boolean {
|
|
48
57
|
const v = process.env.PRIVATEER_SEALED;
|
|
49
|
-
return v === "
|
|
58
|
+
return !(v === "0" || v === "false");
|
|
50
59
|
}
|
|
51
60
|
|
|
52
61
|
// The sealed provider a model id routes through, or null if it isn't a sealed
|
|
@@ -151,7 +160,12 @@ export function buildForward(
|
|
|
151
160
|
// Not JSON — forward unchanged (X-Sealed-Model stays "unknown"; relay logs it).
|
|
152
161
|
}
|
|
153
162
|
const headers: Record<string, string> = {
|
|
154
|
-
|
|
163
|
+
// MUST carry the charset. EHBP seals the body but headers travel in cleartext
|
|
164
|
+
// (tinfoil/dist/encrypted-body-fetch.js: "EHBP only seals the body"), so this
|
|
165
|
+
// Content-Type is what the enclave's router actually validates — and it rejects a
|
|
166
|
+
// bare `application/json` with "Unsupported Media Type: Only 'application/json' is
|
|
167
|
+
// allowed" (verified live 2026-07-31: bare → 400, any `charset=` variant → 200).
|
|
168
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
155
169
|
"X-Sealed-Model": sealedModel,
|
|
156
170
|
};
|
|
157
171
|
if (authHeader) headers.Authorization = authHeader;
|
|
@@ -165,6 +179,7 @@ const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
|
|
|
165
179
|
|
|
166
180
|
let shimBase: string | null = null;
|
|
167
181
|
let shimStarting: Promise<string> | null = null;
|
|
182
|
+
let shimServer: http.Server | null = null;
|
|
168
183
|
|
|
169
184
|
// The shim's base URL once listening, else null. account.ts reads this to decide
|
|
170
185
|
// whether a sealed model can point its baseUrl at the shim yet.
|
|
@@ -179,6 +194,19 @@ export function ensureSealedShim(): Promise<string> {
|
|
|
179
194
|
return shimStarting;
|
|
180
195
|
}
|
|
181
196
|
|
|
197
|
+
// Close the shim and forget it, so a later ensureSealedShim() starts a fresh one.
|
|
198
|
+
// The listener is `unref`'d and never blocks exit, so this is not needed for
|
|
199
|
+
// shutdown — it exists so a caller that stops sealing (or a test) can drop the
|
|
200
|
+
// socket deterministically rather than leaving a port open for the process lifetime.
|
|
201
|
+
export function stopSealedShim(): Promise<void> {
|
|
202
|
+
const server = shimServer;
|
|
203
|
+
shimServer = null;
|
|
204
|
+
shimBase = null;
|
|
205
|
+
shimStarting = null;
|
|
206
|
+
if (!server) return Promise.resolve();
|
|
207
|
+
return new Promise((resolve) => server.close(() => resolve()));
|
|
208
|
+
}
|
|
209
|
+
|
|
182
210
|
function startShim(): Promise<string> {
|
|
183
211
|
return new Promise((resolve, reject) => {
|
|
184
212
|
const server = http.createServer((req, res) => {
|
|
@@ -192,6 +220,7 @@ function startShim(): Promise<string> {
|
|
|
192
220
|
server.listen(0, "127.0.0.1", () => {
|
|
193
221
|
const addr = server.address();
|
|
194
222
|
if (addr && typeof addr === "object") {
|
|
223
|
+
shimServer = server;
|
|
195
224
|
shimBase = `http://127.0.0.1:${addr.port}`;
|
|
196
225
|
resolve(shimBase);
|
|
197
226
|
} else {
|
|
@@ -8,11 +8,11 @@
|
|
|
8
8
|
* of these and route the extensions_* relay frames through it.
|
|
9
9
|
*
|
|
10
10
|
* Only the user's OWN packages surface here. The Privateer moat (privateer-*,
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* rpiv-web-tools, …) never appears in settings.json "packages": the launcher passes it to
|
|
12
|
+
* Pi as `-e` arguments, and the in-process entries build it from factories. So
|
|
13
|
+
* listConfiguredPackages(), which reads only "packages", excludes it naturally. The
|
|
14
|
+
* RESERVED guard is defence in depth for a user who hand-adds one of our names — which
|
|
15
|
+
* would load a second copy of an extension the moat already supplies.
|
|
16
16
|
*
|
|
17
17
|
* Framework-agnostic: nothing here imports React or the relay. The caller owns the
|
|
18
18
|
* frame plumbing and hands us a SettingsManager (the REPL reuses the session's;
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { DefaultPackageManager } from "@earendil-works/pi-coding-agent";
|
|
22
22
|
import type { ProgressEvent, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import { reservedNames } from "../config/moatManifest.ts";
|
|
23
24
|
|
|
24
25
|
// One installed extension as surfaced to the app. NON-PII: a package source
|
|
25
26
|
// (npm:/git: spec) plus its scope — no cwd, no absolute paths beyond what Pi
|
|
@@ -49,23 +50,12 @@ export interface ExtensionsControl {
|
|
|
49
50
|
// Package names we never manage from the app: the Privateer moat + adopted packs
|
|
50
51
|
// installed as shims by the launcher. A guard only — listConfiguredPackages()
|
|
51
52
|
// already omits them since they aren't settings "packages".
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
"privateer-tools",
|
|
59
|
-
"privateer-privacy",
|
|
60
|
-
"pi-privacy",
|
|
61
|
-
"pi-web-access",
|
|
62
|
-
"rpiv-web-tools",
|
|
63
|
-
"@juicesharp/rpiv-web-tools",
|
|
64
|
-
"pi-mcp-adapter",
|
|
65
|
-
"pi-hypa",
|
|
66
|
-
"@hypabolic/pi-hypa",
|
|
67
|
-
"pi-subagents",
|
|
68
|
-
]);
|
|
53
|
+
//
|
|
54
|
+
// Derived from the shipping manifest rather than hand-listed. The hand-written version
|
|
55
|
+
// had drifted: privateer-models and privateer-connect were shimmed by the launcher but
|
|
56
|
+
// missing here, so the app would have offered to install a user package under a name the
|
|
57
|
+
// launcher overwrites on every launch. See src/config/moatManifest.ts.
|
|
58
|
+
const RESERVED = new Set(reservedNames());
|
|
69
59
|
|
|
70
60
|
// The bare package name inside a source spec, for the RESERVED check. Strips the
|
|
71
61
|
// npm:/git: scheme and any @version / #ref suffix; leaves scoped names intact.
|
|
@@ -18,19 +18,13 @@ import {
|
|
|
18
18
|
import { agentDir } from "../config/paths.ts";
|
|
19
19
|
import { agentVersion } from "../config/version.ts";
|
|
20
20
|
import { createEngineEventAdapter } from "../bridge/engineAdapter.ts";
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
makeAccountProvider,
|
|
25
|
-
privateerChannel,
|
|
26
|
-
rememberAccountCredential,
|
|
27
|
-
dropPersistedAccountCredential,
|
|
28
|
-
} from "../providers/account.ts";
|
|
21
|
+
import { isRemoteUnsafeTool, type GateController } from "../ext/permissionGate.ts";
|
|
22
|
+
import { moatResourceOptions } from "../config/moat.ts";
|
|
23
|
+
import { rememberAccountCredential, dropPersistedAccountCredential } from "../providers/account.ts";
|
|
29
24
|
import { RelayClient, type TaskSpec } from "./relayClient.ts";
|
|
30
25
|
import { RemoteBridge } from "./remoteBridge.ts";
|
|
31
|
-
import { makeRelayFileTools } from "../tools/relayFileTools.ts";
|
|
32
26
|
import { AttachmentStore, type StoredAttachment } from "../util/attachmentStore.ts";
|
|
33
|
-
import { spawnAccountCredentials, revokeAccountSession
|
|
27
|
+
import { spawnAccountCredentials, revokeAccountSession } from "../auth/privateer.ts";
|
|
34
28
|
import { createUIContext } from "../ext/headlessUi.ts";
|
|
35
29
|
import { noQuarterActive } from "../permissions/noQuarter.ts";
|
|
36
30
|
|
|
@@ -207,24 +201,21 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
207
201
|
onRemoteBlocked: (toolName) => bridge.sendNotice(`${toolName} is disabled while driving remotely — its prompts can't reach the app.`),
|
|
208
202
|
};
|
|
209
203
|
|
|
204
|
+
// relayFiles binds send_file_to_client / save_attachment to THIS session's bridge — the
|
|
205
|
+
// one whose relay the app is attached to. The shipped gate extension is discovered into
|
|
206
|
+
// this session too but stands its own pair down inside the daemon, so these are the ones
|
|
207
|
+
// the model gets (see tools/relayFileTools.ts). Media generation is on for a live spawn:
|
|
208
|
+
// it is driven from the app, so "make me a video of X" is one of the things it is FOR —
|
|
209
|
+
// and the gate still routes each call to the phone for approval like any other write.
|
|
210
210
|
const services = await createAgentSessionServices({
|
|
211
211
|
cwd,
|
|
212
212
|
agentDir: agentDir(),
|
|
213
213
|
resourceLoaderOptions: {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
privateerVerifiedTee: (m) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
|
|
220
|
-
}),
|
|
221
|
-
makeAccountProvider(),
|
|
222
|
-
// send_file_to_client / save_attachment bound to THIS session's bridge — the one
|
|
223
|
-
// whose relay the app is attached to. The shipped gate extension is discovered
|
|
224
|
-
// into this session too but stands its own pair down inside the daemon, so these
|
|
225
|
-
// are the ones the model gets (see tools/relayFileTools.ts).
|
|
226
|
-
makeRelayFileTools(bridge, attachments),
|
|
227
|
-
] as any,
|
|
214
|
+
...((await moatResourceOptions({
|
|
215
|
+
kind: "live-task",
|
|
216
|
+
gate,
|
|
217
|
+
relayFiles: { bridge, attachments },
|
|
218
|
+
})) as any),
|
|
228
219
|
},
|
|
229
220
|
});
|
|
230
221
|
servicesRef = services as any;
|
|
@@ -118,6 +118,11 @@ export interface RelayCallbacks {
|
|
|
118
118
|
onNoQuarter?: (on: boolean) => void;
|
|
119
119
|
// A controller attached — push a transcript snapshot so it can catch up.
|
|
120
120
|
onControllerAttached: () => void;
|
|
121
|
+
// The last controller went away (app closed / socket reaped). The terminal keeps
|
|
122
|
+
// running; the owner should stop assuming anything it sends up is being read, and
|
|
123
|
+
// deliver finished work durably instead (the cloud outbox → the app's Inbox).
|
|
124
|
+
// Optional so callbacks that predate the frame keep compiling.
|
|
125
|
+
onControllerDetached?: () => void;
|
|
121
126
|
// The app ran a slash command from its composer (e.g. "/model provider/id").
|
|
122
127
|
// Routed to the same command dispatcher the local REPL uses. Optional so
|
|
123
128
|
// callbacks that predate the app command UI keep compiling.
|
|
@@ -353,6 +358,11 @@ export class RelayClient {
|
|
|
353
358
|
private heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
|
354
359
|
private lastInboundAt = 0;
|
|
355
360
|
private connectedAt = 0;
|
|
361
|
+
// Is somebody actually on the other end? An open socket is NOT the same thing —
|
|
362
|
+
// the server holds our frames' route open and simply drops what it forwards when
|
|
363
|
+
// no controller is attached, so without this an unwatched turn writes its answer
|
|
364
|
+
// into a socket nobody is reading. See hasController() and the `handle` note.
|
|
365
|
+
private controllerHere = false;
|
|
356
366
|
// Current backoff delay for the next unqualified scheduleReconnect(); reset on 'open'.
|
|
357
367
|
private reconnectDelay = RECONNECT_MS;
|
|
358
368
|
// Last refusal reason reported, so a 4xx is logged once instead of on every retry.
|
|
@@ -507,6 +517,9 @@ export class RelayClient {
|
|
|
507
517
|
this.stopHeartbeat();
|
|
508
518
|
if (this.ws === ws) this.ws = null;
|
|
509
519
|
this.connectedAt = 0;
|
|
520
|
+
// A dead socket means no controller is reachable, whatever the last
|
|
521
|
+
// attach/detach frame said. Re-learned on the next attach or inbound frame.
|
|
522
|
+
this.controllerHere = false;
|
|
510
523
|
this.cb.onDisconnected?.();
|
|
511
524
|
if (!this.closed) {
|
|
512
525
|
this.cb.onStatus?.(
|
|
@@ -660,6 +673,12 @@ export class RelayClient {
|
|
|
660
673
|
return;
|
|
661
674
|
}
|
|
662
675
|
this.debug(`recv ${frame.type}`);
|
|
676
|
+
// Presence, learned from the traffic itself. The server forwards a frame to us
|
|
677
|
+
// only when a controller sent it (or when it announces one attaching), so ANY
|
|
678
|
+
// inbound frame is proof somebody is on the other end — which matters because
|
|
679
|
+
// `controller_attached` is missed by a terminal whose own socket reconnected
|
|
680
|
+
// mid-session. Cleared by `controller_detached` and by a dead socket.
|
|
681
|
+
if (frame.type !== "controller_detached") this.controllerHere = true;
|
|
663
682
|
switch (frame.type) {
|
|
664
683
|
case "prompt":
|
|
665
684
|
// Forward even an empty/whitespace prompt: a file-only send carries no text,
|
|
@@ -685,6 +704,16 @@ export class RelayClient {
|
|
|
685
704
|
case "controller_attached":
|
|
686
705
|
this.cb.onControllerAttached();
|
|
687
706
|
break;
|
|
707
|
+
// The app's socket went away (closed the app, lost the network, backgrounded
|
|
708
|
+
// long enough for the server's heartbeat to reap it). The relay stays up — the
|
|
709
|
+
// terminal is still reachable — but anything we send now lands nowhere, so the
|
|
710
|
+
// owner delivers finished work through the outbox instead. Published only when
|
|
711
|
+
// the LAST controller left (CAS-guarded server-side), so a take-over doesn't
|
|
712
|
+
// masquerade as "nobody is watching".
|
|
713
|
+
case "controller_detached":
|
|
714
|
+
this.controllerHere = false;
|
|
715
|
+
this.cb.onControllerDetached?.();
|
|
716
|
+
break;
|
|
688
717
|
case "command":
|
|
689
718
|
if (typeof frame.text === "string") this.cb.onCommand?.(frame.text);
|
|
690
719
|
break;
|
|
@@ -876,6 +905,16 @@ export class RelayClient {
|
|
|
876
905
|
return this.ws?.readyState === WebSocket.OPEN;
|
|
877
906
|
}
|
|
878
907
|
|
|
908
|
+
// Is the app actually on the other end right now? Requires an open socket AND a
|
|
909
|
+
// controller known to be attached — learned from `controller_attached`, from any
|
|
910
|
+
// frame a controller sent us, and un-learned by `controller_detached` or a dropped
|
|
911
|
+
// socket. Conservative in the useful direction: it only reads true when we have
|
|
912
|
+
// positive evidence someone is there, so "deliver it durably instead" is the
|
|
913
|
+
// default for a turn whose audience we can't account for.
|
|
914
|
+
hasController(): boolean {
|
|
915
|
+
return this.isConnected() && this.controllerHere;
|
|
916
|
+
}
|
|
917
|
+
|
|
879
918
|
// Connection health, for `privateer harbor status` / the IPC status reply. `quietSec`
|
|
880
919
|
// is how long since the server last said anything: a connected socket that has been
|
|
881
920
|
// quiet for longer than the server's 25s ping cadence is the shape of the half-open
|
|
@@ -996,13 +1035,14 @@ export class RelayClient {
|
|
|
996
1035
|
// stance (the server/controller learns as little as possible about the machine).
|
|
997
1036
|
// Empty/absent fields are omitted so the app renders less rather than blank.
|
|
998
1037
|
//
|
|
999
|
-
// `cwd` is the one scoped exception,
|
|
1000
|
-
//
|
|
1001
|
-
//
|
|
1002
|
-
//
|
|
1003
|
-
//
|
|
1004
|
-
//
|
|
1005
|
-
// the
|
|
1038
|
+
// `cwd` is the one scoped exception, sent by BOTH session kinds: it is where
|
|
1039
|
+
// everything the agent reads, writes and `@`-mentions lives, so a driver who can't
|
|
1040
|
+
// see it is guessing at the blast radius of every prompt they send. (It used to be
|
|
1041
|
+
// harbor-spawned sessions only — on the theory that a human sits in an interactive
|
|
1042
|
+
// terminal and already knows the folder. They don't when they're driving it from a
|
|
1043
|
+
// phone, which is the entire point of this transport.) It is home-collapsed (`~/…`)
|
|
1044
|
+
// on the way out, so the banner reads like the CLI's own and the OS username still
|
|
1045
|
+
// never crosses the relay.
|
|
1006
1046
|
sendContext(ctx: { model?: string; version?: string; cwd?: string; terminalPub?: string }): void {
|
|
1007
1047
|
const frame: Record<string, unknown> = { type: "context" };
|
|
1008
1048
|
if (typeof ctx.model === "string" && ctx.model) frame.model = ctx.model;
|
|
@@ -16,11 +16,20 @@ import type { PermissionRequest } from "../permissions/gate.ts";
|
|
|
16
16
|
import type { AskOutcome } from "../permissions/modeGate.ts";
|
|
17
17
|
import type { RelayCallbacks } from "./relayClient.ts";
|
|
18
18
|
|
|
19
|
+
// How much of a driven turn's reply we hold for possible outbox delivery. The
|
|
20
|
+
// sealed item is capped at 45k plaintext anyway; this just stops a pathological
|
|
21
|
+
// turn from growing the buffer without bound.
|
|
22
|
+
const MAX_TURN_CAPTURE = 60_000;
|
|
23
|
+
|
|
19
24
|
// The outbound surface the bridge needs; RelayClient implements all of it.
|
|
20
25
|
export interface RelayLike {
|
|
21
26
|
requestApproval(id: string, req: PermissionRequest): void;
|
|
22
27
|
sendEvent(ev: EngineEvent): void;
|
|
23
28
|
isConnected(): boolean;
|
|
29
|
+
// Is a controller actually attached (not merely "our socket is up")? Optional:
|
|
30
|
+
// a relay that can't tell is treated as attached, so an unknown audience never
|
|
31
|
+
// turns into a duplicate of something the app already displayed.
|
|
32
|
+
hasController?(): boolean;
|
|
24
33
|
sendNoQuarter(on: boolean): void;
|
|
25
34
|
sendFile(file: { name: string; mediaType: string; base64: string; size: number }): Promise<{ ok: boolean; reason?: string }>;
|
|
26
35
|
sendNotice(text: string): void;
|
|
@@ -99,6 +108,15 @@ export interface RemoteBridgeConfig {
|
|
|
99
108
|
onFilesSearch?: (id: string, query: string) => void;
|
|
100
109
|
// A controller (re)attached — the owner should push a transcript snapshot.
|
|
101
110
|
onControllerAttached?: () => void;
|
|
111
|
+
// The last controller went away. Informational for the owner (the bridge already
|
|
112
|
+
// fails pending approvals closed); a driven turn in flight keeps running.
|
|
113
|
+
onControllerDetached?: () => void;
|
|
114
|
+
// A driven turn finished with NOBODY on the other end — the app was closed, killed,
|
|
115
|
+
// or its socket was reaped while the agent worked. Everything the turn streamed up
|
|
116
|
+
// was dropped by the relay, so the owner should deliver this durably instead (seal
|
|
117
|
+
// it to the account outbox → the app's Inbox). Called once per unwatched turn, after
|
|
118
|
+
// the turn settles; `prompt` is what was asked, `content` the reply as streamed.
|
|
119
|
+
onUnwatchedResult?: (result: { prompt: string; content: string }) => void;
|
|
102
120
|
onStatus?: (text: string) => void;
|
|
103
121
|
// A file finished transferring down from the app. The owner registers it (e.g. into
|
|
104
122
|
// an AttachmentStore) so the save_attachment tool can persist it.
|
|
@@ -113,6 +131,17 @@ export class RemoteBridge {
|
|
|
113
131
|
private readonly pendingSelects = new Map<string, (v: string | null) => void>();
|
|
114
132
|
private readonly pendingInputs = new Map<string, (v: string | null) => void>();
|
|
115
133
|
private pendingAttachments: RemoteAttachment[] = [];
|
|
134
|
+
// The driven turn in flight, kept only so it can be delivered to the outbox if it
|
|
135
|
+
// turns out nobody was watching (see settleTurn). Bounded: the outbox truncates at
|
|
136
|
+
// 45k anyway, and this must not grow with a runaway turn.
|
|
137
|
+
//
|
|
138
|
+
// `turnDriven` is deliberately NOT `remote`: a mid-turn disconnect clears `remote`
|
|
139
|
+
// (so the gate stops waiting on a controller that's gone) and that is exactly the
|
|
140
|
+
// case this feature exists for — the turn was still driven by the app, and its
|
|
141
|
+
// answer still has to reach the account. Only settleTurn clears it.
|
|
142
|
+
private turnDriven = false;
|
|
143
|
+
private turnPrompt = "";
|
|
144
|
+
private turnText = "";
|
|
116
145
|
|
|
117
146
|
constructor(private readonly cfg: RemoteBridgeConfig) {}
|
|
118
147
|
|
|
@@ -128,6 +157,9 @@ export class RemoteBridge {
|
|
|
128
157
|
readonly callbacks: Required<RelayCallbacks> = {
|
|
129
158
|
onPrompt: (text) => {
|
|
130
159
|
this.remote = true; // a remote turn is now in flight → gate relays each action
|
|
160
|
+
this.turnDriven = true;
|
|
161
|
+
this.turnPrompt = text;
|
|
162
|
+
this.turnText = "";
|
|
131
163
|
const attachments = this.pendingAttachments;
|
|
132
164
|
this.pendingAttachments = [];
|
|
133
165
|
this.cfg.onPrompt(text, attachments);
|
|
@@ -195,6 +227,16 @@ export class RemoteBridge {
|
|
|
195
227
|
this.relay?.sendNoQuarter(on); // echo the ack back so the app's toggle syncs
|
|
196
228
|
},
|
|
197
229
|
onControllerAttached: () => this.cfg.onControllerAttached?.(),
|
|
230
|
+
// The app left while we're still running. Same posture as a dropped socket: stop
|
|
231
|
+
// treating the turn as remote (the gate must not wait on a controller that isn't
|
|
232
|
+
// there) and fail every pending approval closed. The turn itself keeps going —
|
|
233
|
+
// and settleTurn will deliver its answer to the outbox, since `turnDriven` (unlike
|
|
234
|
+
// `remote`) survives the departure.
|
|
235
|
+
onControllerDetached: () => {
|
|
236
|
+
this.remote = false;
|
|
237
|
+
this.rejectAllPending();
|
|
238
|
+
this.cfg.onControllerDetached?.();
|
|
239
|
+
},
|
|
198
240
|
onAttachment: (file) => {
|
|
199
241
|
this.pendingAttachments.push(file);
|
|
200
242
|
this.cfg.onAttachment?.(file);
|
|
@@ -307,13 +349,36 @@ export class RemoteBridge {
|
|
|
307
349
|
|
|
308
350
|
// Mark the end of a turn so the next (possibly local) turn isn't treated as
|
|
309
351
|
// remote. Call after each driven turn completes.
|
|
352
|
+
//
|
|
353
|
+
// Also the one moment we can tell whether the turn had an audience. If the app
|
|
354
|
+
// drove it and is now gone, everything the turn streamed up was dropped by the
|
|
355
|
+
// relay — so hand the answer to the owner for durable delivery rather than letting
|
|
356
|
+
// a completed piece of work evaporate because someone closed their phone.
|
|
310
357
|
settleTurn(): void {
|
|
358
|
+
const driven = this.turnDriven;
|
|
359
|
+
const prompt = this.turnPrompt;
|
|
360
|
+
const content = this.turnText.trim();
|
|
311
361
|
this.remote = false;
|
|
362
|
+
this.turnDriven = false;
|
|
363
|
+
this.turnPrompt = "";
|
|
364
|
+
this.turnText = "";
|
|
365
|
+
if (!driven || !content) return;
|
|
366
|
+
// Unknown (a relay that can't report presence) counts as watched: better to skip
|
|
367
|
+
// delivery than to duplicate something the app already showed in its feed.
|
|
368
|
+
const watched = this.relay?.hasController ? this.relay.hasController() : !!this.relay?.isConnected();
|
|
369
|
+
if (watched) return;
|
|
370
|
+
this.cfg.onUnwatchedResult?.({ prompt, content });
|
|
312
371
|
}
|
|
313
372
|
|
|
314
373
|
// Forward an EngineEvent up to the app. Safe to call for every event of every
|
|
315
374
|
// turn (local included) — the relay only sends when a socket is open.
|
|
316
375
|
forwardEvent(ev: EngineEvent): void {
|
|
376
|
+
// Keep the driven turn's reply as it streams, in case settleTurn finds nobody
|
|
377
|
+
// was there to read it. Text only: the outbox item is the answer, not a
|
|
378
|
+
// transcript of every tool call.
|
|
379
|
+
if (this.turnDriven && ev.type === "text" && this.turnText.length < MAX_TURN_CAPTURE) {
|
|
380
|
+
this.turnText += String(ev.text ?? "");
|
|
381
|
+
}
|
|
317
382
|
this.relay?.sendEvent(ev);
|
|
318
383
|
}
|
|
319
384
|
|