privateer-agent 0.11.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 +9 -0
- package/bin/privateer-launch.mjs +144 -68
- 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 +16 -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 +61 -18
- 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 +113 -0
- package/src/providers/account.ts +88 -4
- package/src/providers/defaultModel.ts +66 -11
- package/src/remote/extensionsControl.ts +12 -24
- package/src/remote/liveTaskSession.ts +15 -24
- package/src/remote/relayClient.ts +39 -0
- 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
package/src/providers/account.ts
CHANGED
|
@@ -37,7 +37,7 @@ import {
|
|
|
37
37
|
|
|
38
38
|
// Seed/fallback catalog: registered synchronously so the account provider has real
|
|
39
39
|
// models the instant it loads (before the live /api/models fetch resolves) — in
|
|
40
|
-
// particular
|
|
40
|
+
// particular ACCOUNT_DEFAULT_MODEL_ID resolves at startup without a "model not
|
|
41
41
|
// found" warning, which matters more than ever now that a signed-OUT terminal also
|
|
42
42
|
// launches on it. The first two entries are the TEE tiers (Tinfoil, then NEAR); the
|
|
43
43
|
// rest are the familiar names. Also the fallback list if the live listing is
|
|
@@ -46,6 +46,11 @@ import {
|
|
|
46
46
|
const DEFAULT_MODELS = [
|
|
47
47
|
ACCOUNT_DEFAULT_MODEL_ID,
|
|
48
48
|
ACCOUNT_NEAR_MODEL_ID,
|
|
49
|
+
// The default until 2026-08-01 (see TINFOIL_MODEL_ID). It stays in the floor so a
|
|
50
|
+
// user who saved it as their own default still resolves it synchronously at launch,
|
|
51
|
+
// rather than falling through to "first model with configured auth" — the BYO dead
|
|
52
|
+
// end this seed list exists to prevent.
|
|
53
|
+
"tinfoil/glm-5-2",
|
|
49
54
|
"anthropic/claude-opus-5",
|
|
50
55
|
"anthropic/claude-sonnet-5",
|
|
51
56
|
"openai/gpt-5.6-sol",
|
|
@@ -56,7 +61,9 @@ function seedModel(id: string) {
|
|
|
56
61
|
return {
|
|
57
62
|
id,
|
|
58
63
|
name: id,
|
|
59
|
-
reasoning
|
|
64
|
+
// reasoning + how to steer it, for the enclave models where we verified the
|
|
65
|
+
// control shape live; `reasoning: false` (Pi's "not a thinking model") for the rest.
|
|
66
|
+
...(thinkingProfile(id) ?? { reasoning: false as const }),
|
|
60
67
|
input: ["text"] as ("text" | "image")[],
|
|
61
68
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
62
69
|
contextWindow: 128000,
|
|
@@ -64,6 +71,83 @@ function seedModel(id: string) {
|
|
|
64
71
|
};
|
|
65
72
|
}
|
|
66
73
|
|
|
74
|
+
// ── Thinking control ─────────────────────────────────────────────────────────
|
|
75
|
+
//
|
|
76
|
+
// Every account model used to register with `reasoning: false`, and that one field
|
|
77
|
+
// silently pinned the whole catalog to maximum thinking. Pi gates EVERY
|
|
78
|
+
// thinking-control branch on `model.reasoning` (pi-ai api/openai-completions.js
|
|
79
|
+
// buildParams) and AgentSession.cycleThinkingLevel() returns undefined without it. So
|
|
80
|
+
// Privateer sent no thinking parameter at all — a thinking model ran at whatever its
|
|
81
|
+
// server-side default was, forever — and the user's thinking toggle was inert.
|
|
82
|
+
//
|
|
83
|
+
// What that cost, measured live against the account channel on 2026-08-01 with
|
|
84
|
+
// "Write a haiku about the sea": the default model emitted 77 reasoning deltas and
|
|
85
|
+
// ZERO content, spending all 300 tokens thinking. The same prompt with thinking off
|
|
86
|
+
// answered in 18 tokens / 1.8s.
|
|
87
|
+
//
|
|
88
|
+
// Annotating a model is a promise that the dial actually moves, so ONLY shapes
|
|
89
|
+
// verified against the live enclave appear below. Pi's default level is "medium", so
|
|
90
|
+
// nothing here turns thinking off behind the user's back — it makes the toggle real.
|
|
91
|
+
interface ThinkingProfile {
|
|
92
|
+
reasoning: true;
|
|
93
|
+
thinkingLevelMap?: Record<string, string | null>;
|
|
94
|
+
compat?: { thinkingFormat: string };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The vLLM chat-template family (GLM, Qwen). Honours
|
|
98
|
+
// `chat_template_kwargs.enable_thinking`, which is exactly what Pi's
|
|
99
|
+
// "qwen-chat-template" format emits. Verified — enable_thinking=false → 0 reasoning
|
|
100
|
+
// deltas and a direct answer, true → thinking restored, neither errors — on
|
|
101
|
+
// tinfoil/glm-5-2, near/zai-org/GLM-5.1-FP8, near/Qwen/Qwen3.6-35B-A3B-FP8 and
|
|
102
|
+
// phala/z-ai/glm-5.2.
|
|
103
|
+
//
|
|
104
|
+
// The switch is binary (there is no effort dial), so publish exactly two levels
|
|
105
|
+
// instead of five that all mean "on": a null in thinkingLevelMap marks a level
|
|
106
|
+
// unsupported and pi-ai's getSupportedThinkingLevels drops it.
|
|
107
|
+
const CHAT_TEMPLATE_THINKING: ThinkingProfile = {
|
|
108
|
+
reasoning: true,
|
|
109
|
+
thinkingLevelMap: { minimal: null, low: null, high: null, xhigh: null },
|
|
110
|
+
compat: { thinkingFormat: "qwen-chat-template" },
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// gpt-oss (harmony) is the other way round: it IGNORES chat_template_kwargs and
|
|
114
|
+
// honours `reasoning_effort` — which is Pi's default format for our baseUrl, so this
|
|
115
|
+
// profile deliberately carries no compat override. Verified on tinfoil/gpt-oss-120b:
|
|
116
|
+
// low → 9 reasoning deltas, high → 61.
|
|
117
|
+
//
|
|
118
|
+
// Harmony has no "none", so "off" is pinned to the floor rather than left unset —
|
|
119
|
+
// unset would send nothing and let the model fall back to its own default (medium),
|
|
120
|
+
// i.e. an "off" that thinks harder than "low". This is the toggle's lowest setting,
|
|
121
|
+
// not silence.
|
|
122
|
+
const REASONING_EFFORT_THINKING: ThinkingProfile = {
|
|
123
|
+
reasoning: true,
|
|
124
|
+
thinkingLevelMap: { off: "low", minimal: "low", xhigh: null },
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// Only the TEE prefixes are annotated. Those are enclaves we drive directly and can
|
|
128
|
+
// probe. The rest of the catalog is proxied to a third-party gateway whose thinking
|
|
129
|
+
// shape we have NOT verified from here, and an unsupported parameter fails the whole
|
|
130
|
+
// turn — decisively worse than a turn that thinks too much. They keep the old
|
|
131
|
+
// behaviour exactly.
|
|
132
|
+
//
|
|
133
|
+
// Two deliberate omissions inside the TEE set: `*-instruct` ids are the
|
|
134
|
+
// non-thinking variants, and tinfoil/kimi-k2-6 reasons but ignored BOTH levers when
|
|
135
|
+
// probed, so annotating it would hand the user a dial connected to nothing.
|
|
136
|
+
const TEE_MODEL = /^(tinfoil|phala|near)\//;
|
|
137
|
+
|
|
138
|
+
export function thinkingProfile(id: string): ThinkingProfile | null {
|
|
139
|
+
if (!TEE_MODEL.test(id)) return null;
|
|
140
|
+
if (/instruct/i.test(id)) return null;
|
|
141
|
+
const profile = /gpt-oss/i.test(id)
|
|
142
|
+
? REASONING_EFFORT_THINKING
|
|
143
|
+
: /glm|qwen/i.test(id)
|
|
144
|
+
? CHAT_TEMPLATE_THINKING
|
|
145
|
+
: null;
|
|
146
|
+
// Hand out a COPY. These entries end up on hundreds of registered models, and a
|
|
147
|
+
// shared nested object is one careless mutation away from retuning the whole catalog.
|
|
148
|
+
return profile && { ...profile, thinkingLevelMap: { ...profile.thinkingLevelMap }, ...(profile.compat ? { compat: { ...profile.compat } } : {}) };
|
|
149
|
+
}
|
|
150
|
+
|
|
67
151
|
// ── Catalog cache ────────────────────────────────────────────────────────────
|
|
68
152
|
//
|
|
69
153
|
// The live catalog (241 models and counting) can only be registered once the network
|
|
@@ -396,7 +480,7 @@ const TEE_PREFIXES = ["near/", "tinfoil/", "phala/"];
|
|
|
396
480
|
// Which privacy channel an account model routes through: confidential compute (TEE)
|
|
397
481
|
// for the prefixes above, else a server-side ZDR channel. Ported from tree-cli
|
|
398
482
|
// resolve.ts, then widened — it used to say `near/` only, which quietly labelled the
|
|
399
|
-
// default model (tinfoil/glm-5-2, a TEE model) as a mere ZDR policy claim.
|
|
483
|
+
// then-default model (tinfoil/glm-5-2, a TEE model) as a mere ZDR policy claim.
|
|
400
484
|
export function privateerChannel(modelId: string): "tee" | "zdr" {
|
|
401
485
|
return TEE_PREFIXES.some((p) => modelId.startsWith(p)) ? "tee" : "zdr";
|
|
402
486
|
}
|
|
@@ -498,7 +582,7 @@ export function accountProviderConfig(ids: string[]): Record<string, unknown> {
|
|
|
498
582
|
// REPLACES a provider's model list and its request config, so whichever registration
|
|
499
583
|
// lands last wins — and pi extensions are discovered with an unsorted readdirSync, which
|
|
500
584
|
// on a typical box puts privateer-privacy after privateer-account. The account channel's
|
|
501
|
-
// whole catalog was then replaced by that one model, so the default
|
|
585
|
+
// whole catalog was then replaced by that one model, so the account default no
|
|
502
586
|
// longer resolved ("not found for provider privateer. Using custom model id") and the
|
|
503
587
|
// synthesized model inherited the PUBLIC endpoint instead of `/api/agent/v1`.
|
|
504
588
|
//
|
|
@@ -15,13 +15,29 @@ import { join } from "node:path";
|
|
|
15
15
|
import { hasCredentials } from "../auth/privateer.ts";
|
|
16
16
|
import { agentDir } from "../config/paths.ts";
|
|
17
17
|
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
18
|
+
// A capable Tinfoil chat model, and Privateer's default everywhere. Tinfoil runs it
|
|
19
|
+
// inside an attestable TEE (the serving enclave's quote is published and the live TLS
|
|
20
|
+
// key is bound to it), which is the strongest privacy tier we offer — so a capable
|
|
21
|
+
// model on that tier is what a privacy-first agent should boot on.
|
|
22
22
|
// One definition, three consumers: this resolver, providers/account.ts's seed catalog,
|
|
23
23
|
// and bin/privateer-launch.mjs (which mirrors the id — keep them in step).
|
|
24
|
-
|
|
24
|
+
//
|
|
25
|
+
// It was `tinfoil/glm-5-2` until 2026-08-01, and the swap is a LATENCY decision, not a
|
|
26
|
+
// capability one. Measured over 22 requests spaced 20s apart on the account channel,
|
|
27
|
+
// glm-5-2 stalled before its first token on 9 of them — 33s to 98s each, with the
|
|
28
|
+
// model demonstrably warm 20 seconds earlier, so it is contention in that deployment
|
|
29
|
+
// rather than a cold start anything here can warm up. kimi-k2-6 and gpt-oss-120b, same
|
|
30
|
+
// enclave provider, same tier, same transport, stalled 0 times in 20 (medians 1.2s and
|
|
31
|
+
// 1.0s). The same run reproduced glm-5-2's stalls on BOTH the sealed and the cleartext
|
|
32
|
+
// path, which is what rules out the shim, the relay and the proxy as the cause.
|
|
33
|
+
//
|
|
34
|
+
// Two consequences worth knowing when revisiting this: glm-5-2 remains in the live
|
|
35
|
+
// catalog and is one pick away for anyone who wants it, and kimi-k2-6 reasons on every
|
|
36
|
+
// turn with no working off switch (see thinkingProfile in providers/account.ts — its
|
|
37
|
+
// levers were probed and none of them moved the reasoning volume). Its reasoning is
|
|
38
|
+
// short and it always reaches an answer, which is why that is acceptable here and was
|
|
39
|
+
// not for glm-5-2.
|
|
40
|
+
export const TINFOIL_MODEL_ID = "tinfoil/kimi-k2-6";
|
|
25
41
|
|
|
26
42
|
// Same model, reached two ways:
|
|
27
43
|
// - TINFOIL_DEFAULT_SPEC — direct to inference.tinfoil.sh with the user's own
|
|
@@ -63,6 +79,31 @@ export interface ResolveDefaultModelOptions {
|
|
|
63
79
|
env?: NodeJS.ProcessEnv;
|
|
64
80
|
// Override the signed-in check (testing). Defaults to hasCredentials().
|
|
65
81
|
signedIn?: boolean;
|
|
82
|
+
// The user's saved Pi default ("provider/id"). undefined (the default) reads it
|
|
83
|
+
// from agentDir()/settings.json; null skips it entirely — resolveSignedInModel
|
|
84
|
+
// uses null because the sign-in TARGET must stay the confidential model (the
|
|
85
|
+
// decision to stay on a saved pick is made explicitly at its call sites, where
|
|
86
|
+
// the account channel still gets armed either way).
|
|
87
|
+
saved?: string | null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The user's own persisted model pick: Pi writes defaultProvider + defaultModel to
|
|
91
|
+
// agentDir()/settings.json on EVERY interactive switch (AgentSession.setModel →
|
|
92
|
+
// setDefaultModelAndProvider — both the built-in selector and pi-privacy's /models
|
|
93
|
+
// picker land there). That makes it the strongest non-env signal of deliberate
|
|
94
|
+
// intent we have, so resolveDefaultModel ranks it right after PRIVATEER_MODEL.
|
|
95
|
+
// Returns "provider/id", or null when either half is missing.
|
|
96
|
+
export function savedPiDefaultSpec(): string | null {
|
|
97
|
+
try {
|
|
98
|
+
const raw = readFileSync(join(agentDir(), "settings.json"), "utf8").trim();
|
|
99
|
+
if (!raw) return null;
|
|
100
|
+
const s = JSON.parse(raw) as Record<string, unknown>;
|
|
101
|
+
const provider = typeof s.defaultProvider === "string" ? s.defaultProvider.trim() : "";
|
|
102
|
+
const modelId = typeof s.defaultModel === "string" ? s.defaultModel.trim() : "";
|
|
103
|
+
return provider && modelId ? `${provider}/${modelId}` : null;
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
66
107
|
}
|
|
67
108
|
|
|
68
109
|
// Resolve the model spec ("provider/id") to use when no model is named. Pure and
|
|
@@ -71,10 +112,13 @@ export interface ResolveDefaultModelOptions {
|
|
|
71
112
|
// so the launcher, the REPL, and the next-launch seed all agree):
|
|
72
113
|
// 1. explicit user choice (config/channel) — deliberate, always wins
|
|
73
114
|
// 2. PRIVATEER_MODEL env — dev/global override
|
|
74
|
-
// 3.
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
115
|
+
// 3. the SAVED Pi default (settings.json) — the model the user last picked
|
|
116
|
+
// interactively; Pi persists every switch, so honoring it here is what makes a
|
|
117
|
+
// /models pick actually stick across launches on every entry point
|
|
118
|
+
// 4. Tinfoil key present → the Tinfoil default — strongest (client-attested) privacy
|
|
119
|
+
// 5. signed into Privateer → the same model over the subscription
|
|
120
|
+
// 6. a BYO provider whose key is present — anthropic, openai, openrouter
|
|
121
|
+
// 7. nothing at all → the account default anyway — so the failure names Privateer
|
|
78
122
|
// and /login is the visible fix, instead of a keyless OpenRouter dead end
|
|
79
123
|
export function resolveDefaultModel(opts: ResolveDefaultModelOptions = {}): string {
|
|
80
124
|
const env = opts.env ?? process.env;
|
|
@@ -85,6 +129,9 @@ export function resolveDefaultModel(opts: ResolveDefaultModelOptions = {}): stri
|
|
|
85
129
|
const fromEnv = env.PRIVATEER_MODEL?.trim();
|
|
86
130
|
if (fromEnv) return fromEnv;
|
|
87
131
|
|
|
132
|
+
const saved = opts.saved === undefined ? savedPiDefaultSpec() : opts.saved;
|
|
133
|
+
if (saved) return saved;
|
|
134
|
+
|
|
88
135
|
// Privacy-first: a Tinfoil key means we can run verifiable TEE inference right now,
|
|
89
136
|
// which we prefer even over the account's NEAR channel — same order the launcher uses.
|
|
90
137
|
if (env.TINFOIL_API_KEY?.trim()) return TINFOIL_DEFAULT_SPEC;
|
|
@@ -110,8 +157,11 @@ export function resolveDefaultModel(opts: ResolveDefaultModelOptions = {}): stri
|
|
|
110
157
|
// model sign-in should activate RIGHT AWAY: Tinfoil GLM 5.2, direct when a Tinfoil key
|
|
111
158
|
// is present and over the subscription otherwise — no BYO key needed.
|
|
112
159
|
// PRIVATEER_MODEL still wins — a deliberate override is never stomped.
|
|
160
|
+
// `saved: null` on purpose: this is the sign-in TARGET, and the target is always the
|
|
161
|
+
// confidential model. Whether to actually move a session that sits on a deliberate
|
|
162
|
+
// saved pick is decided at the call sites (which arm the account channel either way).
|
|
113
163
|
export function resolveSignedInModel(env: NodeJS.ProcessEnv = process.env): string {
|
|
114
|
-
return resolveDefaultModel({ env, signedIn: true });
|
|
164
|
+
return resolveDefaultModel({ env, signedIn: true, saved: null });
|
|
115
165
|
}
|
|
116
166
|
|
|
117
167
|
// Split a "provider/id" spec on its first slash (model ids themselves contain "/", so
|
|
@@ -135,7 +185,12 @@ function splitSpec(spec: string): { provider: string; modelId: string } | null {
|
|
|
135
185
|
// `defaultModel` key), so a deliberate /model choice is never stomped. Best-effort —
|
|
136
186
|
// any read/parse/write failure is swallowed; a missing seed just means the user picks
|
|
137
187
|
// a model once via /model. Returns the spec written, or null if we left it alone.
|
|
138
|
-
|
|
188
|
+
//
|
|
189
|
+
// The default seed is resolveSignedInModel(), not the static account spec: now that
|
|
190
|
+
// the launcher HONORS a saved default (omits --model when one exists), seeding
|
|
191
|
+
// `privateer/…` for a Tinfoil-keyed user would demote them from direct
|
|
192
|
+
// client-attested inference to the subscription proxy on every later launch.
|
|
193
|
+
export function ensurePiDefaultModel(spec: string = resolveSignedInModel()): string | null {
|
|
139
194
|
const parts = splitSpec(spec);
|
|
140
195
|
if (!parts) return null;
|
|
141
196
|
const settingsPath = join(agentDir(), "settings.json");
|
|
@@ -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,25 +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
|
-
"rpiv-ask-user-question",
|
|
65
|
-
"@juicesharp/rpiv-ask-user-question",
|
|
66
|
-
"pi-mcp-adapter",
|
|
67
|
-
"pi-hypa",
|
|
68
|
-
"@hypabolic/pi-hypa",
|
|
69
|
-
"pi-subagents",
|
|
70
|
-
]);
|
|
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());
|
|
71
59
|
|
|
72
60
|
// The bare package name inside a source spec, for the RESERVED check. Strips the
|
|
73
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
|
|
@@ -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
|
|