privateer-agent 0.9.2 → 0.10.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 +79 -5
- package/bin/privateer-acp.mjs +40 -0
- package/bin/privateer-launch.mjs +13 -0
- package/extensions/privateer-gate.ts +17 -1
- package/package.json +2 -1
- package/src/acp/protocol.ts +145 -0
- package/src/acp/run.ts +376 -0
- package/src/acp/server.ts +0 -0
- package/src/channels/bridge.ts +170 -24
- package/src/channels/platforms.ts +64 -0
- package/src/channels/run.ts +24 -7
- package/src/channels/types.ts +84 -13
- package/src/channels/whatsapp.ts +40 -4
- package/src/cli/chat.ts +3 -2
- package/src/config/inlineMoat.ts +67 -0
- package/src/ext/headlessUi.ts +116 -0
- package/src/harbor/buildLock.ts +19 -0
- package/src/harbor/index.ts +9 -12
- package/src/nostr/bech32.ts +117 -0
- package/src/nostr/event.ts +92 -0
- package/src/nostr/keys.ts +172 -0
- package/src/nostr/tags.ts +60 -0
- package/src/permissions/classify.ts +52 -1
- package/src/remote/channelsControl.ts +8 -16
- package/src/remote/liveTaskSession.ts +16 -3
- package/src/remote/relayClient.ts +87 -12
package/src/acp/run.ts
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
// Headless ACP entry — `privateer acp`.
|
|
2
|
+
//
|
|
3
|
+
// An ACP host (Buzz's `buzz-acp`, Zed, …) spawns this process and drives it over
|
|
4
|
+
// newline-delimited JSON-RPC on stdio. This file is the Pi-side wiring; the protocol
|
|
5
|
+
// surface is ./server.ts and the pure mappings are ./protocol.ts.
|
|
6
|
+
//
|
|
7
|
+
// ⚠️ STDOUT IS THE PROTOCOL. Anything written to stdout that isn't a JSON-RPC frame
|
|
8
|
+
// corrupts the stream and the host drops the connection — usually with an opaque
|
|
9
|
+
// parse error. Every diagnostic here goes to STDERR, which hosts capture as the
|
|
10
|
+
// agent's log. This is the single easiest way to break an ACP agent, so the rule is
|
|
11
|
+
// absolute: no console.log, no process.stdout.write, anywhere under this entry.
|
|
12
|
+
//
|
|
13
|
+
// SECURITY POSTURE. The host renders approvals; it does not grant them. Privateer's
|
|
14
|
+
// permission gate still classifies every action, `localAsk` is a fail-closed deny,
|
|
15
|
+
// an unparseable or cancelled answer is a deny, and `tools` is a hard ceiling the
|
|
16
|
+
// host cannot widen. See src/acp/server.ts:askOverAcp.
|
|
17
|
+
//
|
|
18
|
+
// Config lives in ~/.privateer/config.json (the same file the harbor reads):
|
|
19
|
+
// {
|
|
20
|
+
// "acp": {
|
|
21
|
+
// "model": "openrouter/openai/gpt-4o-mini", // optional
|
|
22
|
+
// "tools": ["read","grep","find","ls"], // optional ceiling
|
|
23
|
+
// "posture": "approve", // readonly | approve | auto
|
|
24
|
+
// "cwd": "/path/to/project" // optional; else this process's cwd
|
|
25
|
+
// }
|
|
26
|
+
// }
|
|
27
|
+
//
|
|
28
|
+
// ⚠️ THE WORKING DIRECTORY IS PROCESS-WIDE, NOT PER SESSION. `baseCwd` is fixed at
|
|
29
|
+
// startup from `acp.cwd` (or, absent that, whatever cwd the host spawned us with),
|
|
30
|
+
// and it is what BOTH the tools and the permission gate use. The `cwd` a host sends
|
|
31
|
+
// in `session/new` is effectively ignored: we hand it to `SessionManager.inMemory`,
|
|
32
|
+
// but Pi's `createAgentSessionFromServices` passes `services.cwd` to
|
|
33
|
+
// `createAgentSession`, and `options.cwd` wins over `sessionManager.getCwd()` — so
|
|
34
|
+
// the session manager's copy never reaches a tool.
|
|
35
|
+
//
|
|
36
|
+
// This is currently FAIL-SAFE and must stay that way: gate and tools agree on one
|
|
37
|
+
// root, so there is no split-brain where the gate judges a path in one directory
|
|
38
|
+
// while a tool acts in another. Do NOT "fix" this by threading the host's cwd into
|
|
39
|
+
// `SessionManager` alone — that would move the tools without moving the gate, which
|
|
40
|
+
// is precisely the class of bug that made `~/…` paths bypass confinement. Honouring
|
|
41
|
+
// a per-session cwd properly means building services + gate per session, or scoping
|
|
42
|
+
// `gate.cwd` through an AsyncLocalStorage the way `turnCtx` scopes approvals.
|
|
43
|
+
|
|
44
|
+
import "../boot.ts"; // env + attestation dispatcher, before any Pi import
|
|
45
|
+
import { WEB_TOOL_NAMES } from "../tools/web.ts";
|
|
46
|
+
|
|
47
|
+
// Read-only by default, exactly as the channels runtime and the routines harbor do:
|
|
48
|
+
// a turn nobody is watching must not be able to mutate the filesystem or shell out
|
|
49
|
+
// until a human widens it in config.
|
|
50
|
+
const SAFE_TOOLS = ["read", "grep", "find", "ls"];
|
|
51
|
+
const WEB_TOOLS: string[] = [...WEB_TOOL_NAMES];
|
|
52
|
+
|
|
53
|
+
type Posture = "readonly" | "approve" | "auto";
|
|
54
|
+
const POSTURES: Posture[] = ["readonly", "approve", "auto"];
|
|
55
|
+
|
|
56
|
+
function normalizePosture(v: unknown): Posture | undefined {
|
|
57
|
+
return typeof v === "string" && (POSTURES as string[]).includes(v) ? (v as Posture) : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// STDERR only — see the header.
|
|
61
|
+
function log(msg: string): void {
|
|
62
|
+
process.stderr.write(`[${new Date().toISOString()}] acp: ${msg}\n`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseSpec(spec: string): { provider: string; modelId: string } {
|
|
66
|
+
const i = spec.indexOf(":");
|
|
67
|
+
const j = spec.indexOf("/");
|
|
68
|
+
const sep = i === -1 ? j : j === -1 ? i : Math.min(i, j);
|
|
69
|
+
if (sep <= 0) return { provider: spec, modelId: "" };
|
|
70
|
+
return { provider: spec.slice(0, sep), modelId: spec.slice(sep + 1) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function runAcp(): Promise<void> {
|
|
74
|
+
const { readFileSync } = await import("node:fs");
|
|
75
|
+
// Aliased: `resolve` is already the model-spec resolver below.
|
|
76
|
+
const { resolve: resolveFsPath } = await import("node:path");
|
|
77
|
+
const { Readable, Writable } = await import("node:stream");
|
|
78
|
+
const { AgentSideConnection, ndJsonStream } = await import("@zed-industries/agent-client-protocol");
|
|
79
|
+
const { createAgentSessionServices, createAgentSessionFromServices, SessionManager } = await import(
|
|
80
|
+
"@earendil-works/pi-coding-agent"
|
|
81
|
+
);
|
|
82
|
+
const { createEngineEventAdapter } = await import("../bridge/engineAdapter.ts");
|
|
83
|
+
const { makePermissionGate } = await import("../ext/permissionGate.ts");
|
|
84
|
+
type GateController = import("../ext/permissionGate.ts").GateController;
|
|
85
|
+
const { makePiPrivacyExtension } = await import("pi-privacy");
|
|
86
|
+
const { makeAccountProvider, privateerChannel, rememberAccountCredential, dropPersistedAccountCredential } =
|
|
87
|
+
await import("../providers/account.ts");
|
|
88
|
+
const { hasCredentials, acquireAccountCredential, revokeAccountSession } = await import("../auth/privateer.ts");
|
|
89
|
+
const { makeWebTools } = await import("../tools/web.ts");
|
|
90
|
+
const { webEnabled } = await import("../config/hosted.ts");
|
|
91
|
+
const { resolveDefaultModel } = await import("../providers/defaultModel.ts");
|
|
92
|
+
const { agentDir, configPath } = await import("../config/paths.ts");
|
|
93
|
+
const { PrivateerAcpAgent, askOverAcp } = await import("./server.ts");
|
|
94
|
+
type AcpSession = import("./server.ts").AcpSession;
|
|
95
|
+
type TurnEvents = import("./server.ts").TurnEvents;
|
|
96
|
+
|
|
97
|
+
let cfg: any = {};
|
|
98
|
+
try {
|
|
99
|
+
cfg = JSON.parse(readFileSync(configPath(), "utf8"));
|
|
100
|
+
} catch {
|
|
101
|
+
/* no config is fine — defaults below are safe */
|
|
102
|
+
}
|
|
103
|
+
const block = cfg.acp ?? {};
|
|
104
|
+
const defaultModel: string = resolveDefaultModel({ explicit: block.model ?? cfg.defaultModel });
|
|
105
|
+
const web = webEnabled();
|
|
106
|
+
const tools: string[] = Array.isArray(block.tools) && block.tools.length
|
|
107
|
+
? (web ? block.tools : block.tools.filter((t: string) => !WEB_TOOLS.includes(t)))
|
|
108
|
+
: (web ? [...SAFE_TOOLS, ...WEB_TOOLS] : [...SAFE_TOOLS]);
|
|
109
|
+
const posture: Posture = normalizePosture(block.posture) ?? "approve";
|
|
110
|
+
const baseCwd: string = block.cwd ?? process.cwd();
|
|
111
|
+
|
|
112
|
+
// The gate. Identical posture semantics to the channels runtime: "readonly" maps to
|
|
113
|
+
// plan mode (hard-deny writes), "auto" relaxes non-dangerous actions, and every ask
|
|
114
|
+
// routes REMOTELY — to the ACP host — because there is no terminal here. localAsk
|
|
115
|
+
// stays a deny so a missing host can never mean "allowed".
|
|
116
|
+
const gate: GateController = {
|
|
117
|
+
getMode: () => (posture === "readonly" ? "plan" : "default"),
|
|
118
|
+
setMode: () => {},
|
|
119
|
+
allowlist: [],
|
|
120
|
+
allowedOutsideRoots: [],
|
|
121
|
+
cwd: baseCwd,
|
|
122
|
+
confineToCwd: true,
|
|
123
|
+
getRemote: () => true,
|
|
124
|
+
getNoQuarter: () => posture === "auto",
|
|
125
|
+
async localAsk() {
|
|
126
|
+
return "deny";
|
|
127
|
+
},
|
|
128
|
+
async remoteAsk(req, signal) {
|
|
129
|
+
if (posture === "readonly") return "deny"; // read-only: deny outright, don't prompt
|
|
130
|
+
return askOverAcp(req, signal);
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const services = await createAgentSessionServices({
|
|
135
|
+
cwd: baseCwd,
|
|
136
|
+
agentDir: agentDir(),
|
|
137
|
+
resourceLoaderOptions: {
|
|
138
|
+
// ⚠️ LOAD THE MOAT ONCE. ~/.privateer/agent/extensions holds shims for the
|
|
139
|
+
// interactive TUI's extensions, which Pi auto-discovers into every session
|
|
140
|
+
// built against that agentDir. privateer-gate.ts is one of them, and it
|
|
141
|
+
// installs its OWN permission gate wired to the relay bridge that only
|
|
142
|
+
// `/remote-access` ever attaches. In this process that bridge is permanently
|
|
143
|
+
// unattached, so its gate fails closed and DENIES every action before ours is
|
|
144
|
+
// ever consulted — the host is never asked, and the model is told "denied by
|
|
145
|
+
// the permission gate" with no prompt shown anywhere.
|
|
146
|
+
//
|
|
147
|
+
// Verified live: without this, `bash` was denied and remoteAsk never fired.
|
|
148
|
+
//
|
|
149
|
+
// So: discover nothing, and load exactly the moat we want as in-code
|
|
150
|
+
// factories below. Same shape as bin/privateer-subagent.mjs's
|
|
151
|
+
// `--no-extensions -e …`, and for the same reason. This disables discovered
|
|
152
|
+
// MCP too; an ACP host supplies its own MCP servers in session/new, which is
|
|
153
|
+
// the more correct source anyway.
|
|
154
|
+
noExtensions: true,
|
|
155
|
+
extensionFactories: [
|
|
156
|
+
makePermissionGate(gate),
|
|
157
|
+
makePiPrivacyExtension({
|
|
158
|
+
privateerVerifiedTee: (m: any) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
|
|
159
|
+
}),
|
|
160
|
+
makeAccountProvider(),
|
|
161
|
+
...(webEnabled() ? [makeWebTools()] : []),
|
|
162
|
+
] as any,
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const registry = services.modelRegistry as any;
|
|
167
|
+
const resolve = (spec: string) => {
|
|
168
|
+
const { provider, modelId } = parseSpec(spec);
|
|
169
|
+
return registry.find(provider, modelId) ?? null;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const model = resolve(defaultModel);
|
|
173
|
+
if (!model) {
|
|
174
|
+
log(`model "${defaultModel}" not found — check the spec and provider keys`);
|
|
175
|
+
process.exit(1);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// The id we hand the host must round-trip back through parseSpec, so it is the
|
|
179
|
+
// same "<provider>:<modelId>" spec the config file uses. Anything else and
|
|
180
|
+
// session/set_model would hand us back a string we can't resolve.
|
|
181
|
+
const specOf = (m: any): string => `${m.provider}:${m.id}`;
|
|
182
|
+
|
|
183
|
+
// Every model with credentials configured — the same set /models offers. Marking
|
|
184
|
+
// the confidential ones matters here: in a shared channel the humans reading the
|
|
185
|
+
// picker cannot otherwise tell which choices keep their prompts inside a TEE.
|
|
186
|
+
function listModels(): { available: any[]; currentModelId: string } | undefined {
|
|
187
|
+
try {
|
|
188
|
+
const byId = new Map<string, any>();
|
|
189
|
+
// Models with an API key configured in authStorage.
|
|
190
|
+
for (const m of (registry.getAvailable?.() ?? []) as any[]) byId.set(specOf(m), m);
|
|
191
|
+
// …but the ACCOUNT provider authenticates with a child token rather than a
|
|
192
|
+
// stored apiKey, so getAvailable() omits every privateer/* model — including,
|
|
193
|
+
// absurdly, the default one we are already running on. Add back everything
|
|
194
|
+
// from the current model's provider: if that provider works for the model in
|
|
195
|
+
// use, its siblings are reachable too. This is what puts the confidential
|
|
196
|
+
// TEE models in the host's picker at all.
|
|
197
|
+
for (const m of (registry.getAll?.() ?? []) as any[]) {
|
|
198
|
+
if (m.provider === model.provider) byId.set(specOf(m), m);
|
|
199
|
+
}
|
|
200
|
+
// Belt and braces: whatever else is true, the model we booted with is selectable.
|
|
201
|
+
byId.set(specOf(model), model);
|
|
202
|
+
|
|
203
|
+
const all = [...byId.values()];
|
|
204
|
+
if (all.length === 0) return undefined;
|
|
205
|
+
const available = all.map((m) => {
|
|
206
|
+
const confidential = m.provider === "privateer" && privateerChannel(m.id ?? "") === "tee";
|
|
207
|
+
return {
|
|
208
|
+
modelId: specOf(m),
|
|
209
|
+
name: m.name ?? m.id,
|
|
210
|
+
description: [m.provider, confidential ? "confidential (TEE)" : undefined]
|
|
211
|
+
.filter(Boolean)
|
|
212
|
+
.join(" · "),
|
|
213
|
+
};
|
|
214
|
+
});
|
|
215
|
+
return { available, currentModelId: specOf(model) };
|
|
216
|
+
} catch (e) {
|
|
217
|
+
log(`could not list models: ${e instanceof Error ? e.message : String(e)}`);
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── arm the account channel ───────────────────────────────────────────────────
|
|
223
|
+
//
|
|
224
|
+
// A `privateer/*` model runs on the account subscription, not an API key. Nothing
|
|
225
|
+
// arms that automatically here: the TUI does it from an interactive hook and the
|
|
226
|
+
// harbor does it explicitly per run (harbor/index.ts:958). Without this the model
|
|
227
|
+
// call fails with Pi's "This terminal isn't signed in to Privateer" — which is
|
|
228
|
+
// misleading, because the login is fine; it's the SESSION that never armed.
|
|
229
|
+
//
|
|
230
|
+
// This bit us silently: early runs worked only because a `privateer` entry left in
|
|
231
|
+
// ~/.privateer/agent/auth.json by a previous TUI session happened to still be
|
|
232
|
+
// there. The moment anything revoked it, every ACP turn started failing.
|
|
233
|
+
let armedAccount = false;
|
|
234
|
+
async function ensureAccountArmed(providerName: string): Promise<void> {
|
|
235
|
+
if (armedAccount || providerName !== "privateer") return;
|
|
236
|
+
if (!hasCredentials()) {
|
|
237
|
+
log("not signed in to Privateer — run `privateer` and /login, or pick a BYO-key model");
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
const creds = await acquireAccountCredential();
|
|
242
|
+
(services.authStorage as any).set("privateer", { type: "oauth", ...creds });
|
|
243
|
+
rememberAccountCredential(creds); // claim it, so teardown drops OUR entry only
|
|
244
|
+
armedAccount = true;
|
|
245
|
+
log("account channel armed");
|
|
246
|
+
} catch (e) {
|
|
247
|
+
log(`account channel unavailable: ${e instanceof Error ? e.message : String(e)}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
await ensureAccountArmed(model.provider);
|
|
251
|
+
|
|
252
|
+
const modelCount = listModels()?.available.length ?? 0;
|
|
253
|
+
log(
|
|
254
|
+
`ready — model ${defaultModel}, ${modelCount} selectable, ` +
|
|
255
|
+
`ceiling [${tools.join(", ")}], posture ${posture}`,
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
// One Pi session per ACP session. A single subscription routes streamed text into a
|
|
259
|
+
// mutable holder, which the running turn owns — safe because a session runs at most
|
|
260
|
+
// one turn at a time (enforced in PrivateerAcpAgent.prompt).
|
|
261
|
+
// NOTE `cwd` is the host's per-session directory and is NOT authoritative — see the
|
|
262
|
+
// header. It is passed to SessionManager for bookkeeping only; tools and the gate
|
|
263
|
+
// both run at `baseCwd`. Logged on session creation so a mismatch is visible rather
|
|
264
|
+
// than silent.
|
|
265
|
+
async function createSession(cwd: string, mcpServers: unknown[] = []): Promise<AcpSession> {
|
|
266
|
+
if (cwd && resolveFsPath(cwd) !== resolveFsPath(baseCwd)) {
|
|
267
|
+
log(`host asked for cwd ${cwd} but this process is confined to ${baseCwd} — using ${baseCwd}`);
|
|
268
|
+
}
|
|
269
|
+
// NOT SUPPORTED YET, and said out loud rather than dropped in silence. A host
|
|
270
|
+
// may offer MCP servers in session/new; we can't connect them because
|
|
271
|
+
// `noExtensions: true` (the fix for the discovered-gate collision) also
|
|
272
|
+
// disables the MCP adapter. A user whose connector never appears deserves a
|
|
273
|
+
// reason in the log instead of a mystery.
|
|
274
|
+
if (Array.isArray(mcpServers) && mcpServers.length > 0) {
|
|
275
|
+
log(`ignoring ${mcpServers.length} MCP server(s) offered by the host — not supported on the ACP path yet`);
|
|
276
|
+
}
|
|
277
|
+
const { session } = await createAgentSessionFromServices({
|
|
278
|
+
services,
|
|
279
|
+
sessionManager: SessionManager.inMemory(cwd || baseCwd),
|
|
280
|
+
model,
|
|
281
|
+
tools,
|
|
282
|
+
} as any);
|
|
283
|
+
const adapter = createEngineEventAdapter();
|
|
284
|
+
const noop: TurnEvents = { onText: () => {}, onToolStart: () => {}, onToolEnd: () => {} };
|
|
285
|
+
const holder: { events: TurnEvents; error?: string } = { events: noop };
|
|
286
|
+
session.subscribe((ev: any) => {
|
|
287
|
+
for (const ee of adapter.toEngineEvents(ev)) {
|
|
288
|
+
if (ee.type === "text") holder.events.onText(ee.text);
|
|
289
|
+
else if (ee.type === "error") holder.error = ee.error;
|
|
290
|
+
// Tool activity goes BOTH to the host (rendered as live progress — without
|
|
291
|
+
// it a minute of tool work looks like a hang) and to stderr, where it is
|
|
292
|
+
// the only way to tell a gate denial apart from the model simply choosing
|
|
293
|
+
// not to call a tool. The model will happily narrate a refusal it invented.
|
|
294
|
+
else if (ee.type === "tool-call") {
|
|
295
|
+
log(`tool → ${ee.name}`);
|
|
296
|
+
holder.events.onToolStart({ id: ee.id, name: ee.name });
|
|
297
|
+
} else if (ee.type === "tool-error") {
|
|
298
|
+
const error = String(ee.error).slice(0, 200);
|
|
299
|
+
log(`tool ✗ ${ee.name}: ${error}`);
|
|
300
|
+
holder.events.onToolEnd({ id: ee.id, name: ee.name, error });
|
|
301
|
+
} else if (ee.type === "tool-result") {
|
|
302
|
+
log(`tool ✓ ${ee.name}`);
|
|
303
|
+
holder.events.onToolEnd({ id: ee.id, name: ee.name });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
async setModel(spec: string) {
|
|
310
|
+
const next = resolve(spec);
|
|
311
|
+
if (!next) throw new Error(`unknown model: ${spec}`);
|
|
312
|
+
// Switching INTO the account channel needs it armed too — a session that
|
|
313
|
+
// started on a BYO-key model and moved to privateer/* would otherwise fail
|
|
314
|
+
// with the same misleading "not signed in".
|
|
315
|
+
await ensureAccountArmed(next.provider);
|
|
316
|
+
await session.setModel(next);
|
|
317
|
+
},
|
|
318
|
+
async prompt(text, events, signal) {
|
|
319
|
+
holder.events = events;
|
|
320
|
+
holder.error = undefined;
|
|
321
|
+
try {
|
|
322
|
+
await session.prompt(text);
|
|
323
|
+
} catch (e) {
|
|
324
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
325
|
+
} finally {
|
|
326
|
+
holder.events = noop;
|
|
327
|
+
}
|
|
328
|
+
if (signal.aborted) return { ok: false, error: "cancelled" };
|
|
329
|
+
return holder.error ? { ok: false, error: holder.error } : { ok: true };
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const input = Readable.toWeb(process.stdin) as unknown as ReadableStream<Uint8Array>;
|
|
335
|
+
const output = Writable.toWeb(process.stdout) as unknown as WritableStream<Uint8Array>;
|
|
336
|
+
const stream = ndJsonStream(output, input);
|
|
337
|
+
|
|
338
|
+
let agent: InstanceType<typeof PrivateerAcpAgent> | undefined;
|
|
339
|
+
new AgentSideConnection((conn) => {
|
|
340
|
+
agent = new PrivateerAcpAgent(conn, { createSession, models: listModels, onLog: log });
|
|
341
|
+
return agent;
|
|
342
|
+
}, stream);
|
|
343
|
+
|
|
344
|
+
// The host closing stdin is the shutdown signal.
|
|
345
|
+
let shuttingDown = false;
|
|
346
|
+
const shutdown = async () => {
|
|
347
|
+
if (shuttingDown) return; // stdin emits both "end" and "close"
|
|
348
|
+
shuttingDown = true;
|
|
349
|
+
log("host disconnected — shutting down");
|
|
350
|
+
await agent?.shutdown();
|
|
351
|
+
// Release this process's account inference session. The drop is OWNERSHIP-CHECKED
|
|
352
|
+
// (providers/account.ts), which is what lets an interactive terminal — or another
|
|
353
|
+
// ACP process, since a host may run several in parallel — keep its own auth.json
|
|
354
|
+
// entry through our teardown instead of being signed out by it.
|
|
355
|
+
if (armedAccount) {
|
|
356
|
+
try {
|
|
357
|
+
await revokeAccountSession();
|
|
358
|
+
} catch {
|
|
359
|
+
/* best effort — the server-side TTL is the fallback */
|
|
360
|
+
}
|
|
361
|
+
try {
|
|
362
|
+
dropPersistedAccountCredential({ modelRegistry: { authStorage: services.authStorage } } as any);
|
|
363
|
+
} catch {
|
|
364
|
+
/* nothing persisted */
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
process.exit(0);
|
|
368
|
+
};
|
|
369
|
+
process.stdin.on("end", () => void shutdown());
|
|
370
|
+
process.stdin.on("close", () => void shutdown());
|
|
371
|
+
process.on("SIGINT", () => void shutdown());
|
|
372
|
+
process.on("SIGTERM", () => void shutdown());
|
|
373
|
+
|
|
374
|
+
// Hold the process open; the connection lives on the stdio streams.
|
|
375
|
+
await new Promise<void>(() => {});
|
|
376
|
+
}
|
|
Binary file
|