privateer-agent 0.14.6 → 0.15.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.
@@ -1,5 +1,28 @@
1
+ diff --git a/node_modules/pi-background-tasks/src/core/anthropic-attribution.ts b/node_modules/pi-background-tasks/src/core/anthropic-attribution.ts
2
+ index 2a4d905..3a689f5 100644
3
+ --- a/node_modules/pi-background-tasks/src/core/anthropic-attribution.ts
4
+ +++ b/node_modules/pi-background-tasks/src/core/anthropic-attribution.ts
5
+ @@ -7,6 +7,7 @@ import type {
6
+ Context as HostContext,
7
+ Model as HostModel,
8
+ SimpleStreamOptions as HostSimpleStreamOptions,
9
+ + TranscriptContext as HostTranscriptContext,
10
+ } from '@earendil-works/pi-ai';
11
+
12
+ export const CLAUDE_CODE_SESSION_HEADER = 'X-Claude-Code-Session-Id';
13
+ @@ -3024,7 +3025,9 @@ async function fetchAnthropicResponse(
14
+ }
15
+
16
+ type HostForwardingModel = PiModelLike & HostModel<'anthropic-messages'>;
17
+ -type HostForwardingContext = PiStreamContext & HostContext;
18
+ +// Privateer: pi-ai 1.x brands the host's context type (TranscriptContext). The value
19
+ +// passed through is the host's own context object, so only the type needs the brand.
20
+ +type HostForwardingContext = PiStreamContext & HostContext & HostTranscriptContext;
21
+ type HostForwardingOptions = PiSimpleStreamOptions & HostSimpleStreamOptions;
22
+ type HostForwardingStream = AssistantMessageEventStreamLike & HostAssistantMessageEventStream;
23
+
1
24
  diff --git a/node_modules/pi-background-tasks/src/core/registry.ts b/node_modules/pi-background-tasks/src/core/registry.ts
2
- index cfbf133..c632390 100644
25
+ index df6e6f4..cbab9f2 100644
3
26
  --- a/node_modules/pi-background-tasks/src/core/registry.ts
4
27
  +++ b/node_modules/pi-background-tasks/src/core/registry.ts
5
28
  @@ -1,11 +1,11 @@
@@ -7,7 +30,7 @@ index cfbf133..c632390 100644
7
30
  -import { randomBytes } from 'node:crypto';
8
31
  +import { createHash, randomBytes } from 'node:crypto';
9
32
  import { createWriteStream, existsSync } from 'node:fs';
10
- import { mkdir, realpath, writeFile } from 'node:fs/promises';
33
+ import { mkdir, realpath, rm, writeFile } from 'node:fs/promises';
11
34
  -import { join } from 'node:path';
12
35
  +import { join, resolve } from 'node:path';
13
36
  import type { Api, Model } from '@earendil-works/pi-ai';
@@ -17,7 +40,7 @@ index cfbf133..c632390 100644
17
40
  import {
18
41
  boundedRead,
19
42
  deriveTaskNameFromCommand,
20
- @@ -781,9 +781,29 @@ export class BackgroundTaskRegistry {
43
+ @@ -1263,9 +1263,29 @@ export class BackgroundTaskRegistry {
21
44
  if (this.runtimeDir) return this.runtimeDir;
22
45
  const sessionId = sanitizePathSegment(ctx.sessionId ?? `session-${String(process.pid)}`);
23
46
  const runId = `${sessionId}-${String(process.pid)}`;
@@ -51,29 +74,21 @@ index cfbf133..c632390 100644
51
74
  return this.runtimeDir;
52
75
  }
53
76
  diff --git a/node_modules/pi-background-tasks/src/extension.ts b/node_modules/pi-background-tasks/src/extension.ts
54
- index 3afbd8e..d003a57 100644
77
+ index c966df5..b073a15 100644
55
78
  --- a/node_modules/pi-background-tasks/src/extension.ts
56
79
  +++ b/node_modules/pi-background-tasks/src/extension.ts
57
- @@ -473,14 +473,15 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
58
- pi.on('session_start', async (_event, ctx) => {
59
- registry.setShuttingDown(false);
80
+ @@ -710,8 +710,8 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
60
81
  currentCtx = ctx;
61
- - await registry.ensureRuntimeDir(ctx);
82
+ const nextRegistryCtx = currentRegistryCtx ?? registryContext(ctx);
83
+ currentRegistryCtx = nextRegistryCtx;
84
+ - await registry.ensureRuntimeDir(nextRegistryCtx);
85
+ - if (disposed) return;
62
86
  + // Privateer: no project writes just to open a session. Each task-start path
63
87
  + // creates its runtime directory on demand, with a per-user fallback.
64
88
  updateUi(ctx);
65
- if (statusInterval) clearInterval(statusInterval);
66
- statusInterval = setInterval(() => {
67
- updateUi();
68
- }, STATUS_INTERVAL_MS);
69
- - // One-shot, non-blocking: never awaited on the session-start path or the status tick.
70
- - void scheduleUpdateCheck(ctx);
71
- + // Privateer: update check disabled. Privateer manages updates at the distribution
72
- + // level via `privateer update`; individual tool packs shouldn't check npm or prompt /bg-update.
73
- });
74
-
75
- pi.on('session_shutdown', async (_event, ctx) => {
76
- @@ -565,22 +566,11 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
89
+ hostService.markReady();
90
+ if (disposed) return;
91
+ @@ -830,22 +830,11 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
77
92
  });
78
93
 
79
94
  pi.registerCommand('bg-update', {
@@ -99,7 +114,7 @@ index 3afbd8e..d003a57 100644
99
114
  ];
100
115
  ctx.ui.notify(lines.join('\n'), 'info');
101
116
  return Promise.resolve();
102
- @@ -682,7 +672,7 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
117
+ @@ -958,7 +947,7 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
103
118
  pi.registerTool<typeof BgRunParams, BgRunDetails>({
104
119
  name: 'bg_run',
105
120
  label: 'Background Run',
@@ -80,6 +80,12 @@ export function createEngineEventAdapter() {
80
80
  let inRun = false;
81
81
 
82
82
  function toEngineEvents(ev: PiSessionEvent): EngineEvent[] {
83
+ // A tool call a codemode script made (Pi sets parentToolCallId; the id is
84
+ // "<parent>/<n>"). Not surfaced: the EngineEvent vocabulary has no nesting, so it
85
+ // would land as a top-level tool card for a call that isn't in the transcript. The
86
+ // script's own result reports what it did, and each nested call still went through
87
+ // the permission gate — any approval it needed was asked for.
88
+ if (ev.type.startsWith("tool_execution_") && ev.parentToolCallId) return [];
83
89
  switch (ev.type) {
84
90
  case "agent_start":
85
91
  terminalSent = false;
@@ -39,8 +39,10 @@
39
39
  * loads everything Pi-touching after ../boot.ts, and this must not break that).
40
40
  */
41
41
 
42
+ import { readFileSync } from "node:fs";
42
43
  import { basename } from "node:path";
43
44
  import { managedNames } from "./moatManifest.ts";
45
+ import { migrateMcpProjection } from "../remote/mcpControl.ts";
44
46
  import type { GateController } from "../ext/permissionGate.ts";
45
47
  import type { SendFileBridge } from "../tools/sendFile.ts";
46
48
  import type { CargoSaveBridge } from "../tools/cargo.ts";
@@ -94,7 +96,7 @@ export interface MoatOptions {
94
96
  */
95
97
  webHint?: string;
96
98
  /**
97
- * How to import a THIRD-PARTY dependency (today: pi-mcp-adapter). Defaults to a plain
99
+ * How to import a shared dependency (today: pi-coding-agent, for its MCP extensions). Defaults to a plain
98
100
  * dynamic import, which is right for every process that runs from a normal Node
99
101
  * resolution root.
100
102
  *
@@ -107,6 +109,13 @@ export interface MoatOptions {
107
109
  * teaching this module about Electron.
108
110
  */
109
111
  hostImport?: (spec: string) => Promise<any>;
112
+ /**
113
+ * A window-scoped mcp.json (the desktop: each window activates its own subset of the
114
+ * catalog, projected by its connector manager). Set → this session's MCP support
115
+ * reads ONLY that file, so concurrent windows run different connector sets in one
116
+ * process. Unset → the shared agent/mcp.json plus a trusted project's, as the CLI.
117
+ */
118
+ mcpConfigPath?: string;
110
119
  }
111
120
 
112
121
  /**
@@ -260,6 +269,25 @@ export async function moatResourceOptions(
260
269
  return { extensionFactories: await buildMoat(opts), extensionsOverride: excludeDiscoveredMoat() };
261
270
  }
262
271
 
272
+ // A window's mcp.json, in the shape Pi's MCP extension takes from `loadConfig`. The
273
+ // file is our own projection (mcpService / mcpControl.toPiServerEntry), already in
274
+ // Pi's format; an unreadable one is an empty config with the reason, not a crash.
275
+ // `/mcp` changes save back to this same file (the entries' `source`).
276
+ export function loadWindowMcpConfig(path: string): { servers: unknown[]; errors: string[] } {
277
+ try {
278
+ const raw = JSON.parse(readFileSync(path, "utf8"));
279
+ const servers = Object.entries<Record<string, unknown>>(raw?.mcpServers ?? {}).map(([name, config]) => ({
280
+ name,
281
+ config,
282
+ source: path,
283
+ scope: "global",
284
+ }));
285
+ return { servers, errors: [] };
286
+ } catch (e) {
287
+ return { servers: [], errors: [`${path}: ${e instanceof Error ? e.message : String(e)}`] };
288
+ }
289
+ }
290
+
263
291
  /** Build the ordered extension factory list for one session. */
264
292
  export async function buildMoat(opts: MoatOptions): Promise<ExtensionFactory[]> {
265
293
  const caps = CAPABILITIES[opts.kind];
@@ -282,7 +310,8 @@ export async function buildMoat(opts: MoatOptions): Promise<ExtensionFactory[]>
282
310
  const { privacyExtension } = await import("./privacyPolicy.ts");
283
311
  factories.push(privacyExtension());
284
312
  }
285
- factories.push(makeAccountProvider()); // must follow pi-privacy — see header
313
+ // must follow pi-privacy — see header. The harbor holds its own account lease.
314
+ factories.push(makeAccountProvider({ armOnStart: opts.kind !== "harbor-session" }));
286
315
 
287
316
  // Every kind: a text-only model gets image descriptions from a vision model on the
288
317
  // same key instead of silently losing the image. See providers/visionDelegate.ts.
@@ -375,18 +404,28 @@ export async function buildMoat(opts: MoatOptions): Promise<ExtensionFactory[]>
375
404
  }
376
405
 
377
406
  if (caps.mcp) {
378
- // Registers the tools from the shared agent/mcp.json — the same projection the app's
379
- // MCP manager writes over the relay. No servers configured → a no-op. The specifier is
380
- // a variable so tsc treats it as Promise<any> and doesn't pull the third-party
381
- // adapter's own .ts into our typecheck.
407
+ // Pi's built-in MCP support: servers from the shared agent/mcp.json — the same
408
+ // projection the app's MCP manager writes over the relay — plus codemode, the
409
+ // default way MCP tools are reached, and tool_search for servers set to `deferred`.
410
+ // The CLI loads these as built-ins; SDK sessions don't, so they're added here. No
411
+ // servers configured → no tools. Imported through hostImport so the desktop gets the
412
+ // ONE Pi instance it already runs (see hostImport above).
413
+ //
414
+ // codemode is created with `models: false`. Its `models` global runs classifier and
415
+ // image models with the session's credentials, straight from a script — calls our
416
+ // permission gate never sees and that need not go to the provider this session was
417
+ // chosen for. Scripts keep tool calls, each of which is gated like a direct one.
382
418
  //
383
- // The caller still owns MCP_DIRECT_TOOLS: the adapter reads it when the factory RUNS
384
- // (inside createAgentSessionServices), not when it is imported here, so the env has to
385
- // be set around session creation rather than around this call.
386
- const mcpAdapterSpec = "pi-mcp-adapter";
419
+ // A file an older release wrote in pi-mcp-adapter's format is rewritten first, or
420
+ // every HTTP connector in it would be rejected as invalid.
421
+ migrateMcpProjection();
387
422
  const hostImport = opts.hostImport ?? ((spec: string) => import(spec));
388
- const { default: mcpAdapter } = await hostImport(mcpAdapterSpec);
389
- factories.push(mcpAdapter);
423
+ const pi = await hostImport("@earendil-works/pi-coding-agent");
424
+ factories.push(
425
+ pi.createMcpExtension(opts.mcpConfigPath ? { loadConfig: () => loadWindowMcpConfig(opts.mcpConfigPath!) } : {}),
426
+ pi.createCodemodeExtension({ models: false }),
427
+ pi.createToolSearchExtension(),
428
+ );
390
429
  }
391
430
 
392
431
  return factories;
@@ -5,7 +5,7 @@
5
5
  "@juicesharp/rpiv-ask-user-question",
6
6
  "background-tasks"
7
7
  ],
8
- "retired": ["pi-privacy", "pi-web-access", "pi-hypa", "rpiv-web-tools"],
8
+ "retired": ["pi-privacy", "pi-web-access", "pi-hypa", "rpiv-web-tools", "pi-mcp-adapter"],
9
9
  "shims": [
10
10
  { "entry": "extensions/privateer-brand.ts", "name": "privateer-brand", "note": "banner, ⚓ badge, /signin /signout" },
11
11
  { "entry": "extensions/privateer-context.ts", "name": "privateer-context", "note": "PRIVATEER.md context + /init" },
@@ -16,6 +16,7 @@
16
16
  { "entry": "extensions/privateer-tools.ts", "name": "privateer-tools", "note": "Privateer tool pack" },
17
17
  { "entry": "extensions/privateer-privacy.ts", "name": "privateer-privacy", "note": "pi-privacy + account tier resolver" },
18
18
  { "entry": "extensions/privateer-connect.ts", "name": "privateer-connect", "note": "/connect — MCP connector manager" },
19
+ { "entry": "extensions/privateer-codemode.ts", "name": "privateer-codemode", "note": "codemode — scripts that call tools; how MCP tools are reached (no `models` global)" },
19
20
  { "entry": "extensions/privateer-vision.ts", "name": "privateer-vision", "note": "a vision model on the same key describes images for a text-only model" },
20
21
  { "entry": "extensions/privateer-media.ts", "name": "privateer-media", "note": "image/video/speech/music + ffmpeg compose" },
21
22
  { "entry": "extensions/privateer-computer.ts", "name": "privateer-computer", "note": "screen control — screenshots, mouse and keyboard (off unless armed with --allow-computer-control)" },
@@ -28,8 +29,7 @@
28
29
  { "entry": "extensions/privateer-speak.ts", "name": "privateer-speak", "note": "spoken responses (/speak) + voice input (/talk) — pi-speak + confidential account TTS/STT" },
29
30
  { "entry": "extensions/privateer-web.ts", "name": "privateer-web", "note": "web_search/web_fetch — account search when signed in, else the user's own provider (rpiv-web-tools); crypto_lookup — account market data, in both cases" },
30
31
  { "dep": ["@juicesharp/rpiv-ask-user-question", "index.ts"], "name": "rpiv-ask-user-question", "note": "ask_user_question" },
31
- { "dep": ["pi-mcp-adapter", "index.ts"], "name": "pi-mcp-adapter", "note": "MCP servers as first-class tools" },
32
- { "dep": ["pi-subagents", "src", "extension", "index.ts"], "name": "pi-subagents", "note": "bounded parallel sub-agents" },
32
+ { "dep": ["pi-subagents", "index.js"], "name": "pi-subagents", "note": "bounded parallel sub-agents" },
33
33
  { "dep": ["pi-background-tasks", "extensions", "anthropic-attribution.ts"], "name": "anthropic-attribution", "note": "Anthropic OAuth attribution and prompt sanitization" },
34
34
  { "dep": ["pi-background-tasks", "extensions", "background-tasks.ts"], "name": "pi-background-tasks", "note": "durable background tasks, delegated agents, and fusion" }
35
35
  ]
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "$comment": "The banner's What's New list, newest first: the two to four things a returning user should notice, not a changelog. `reviewedFor` is the release this list was last looked at for. A release can't ship until it equals package.json's version (scripts/check-whats-new.mjs, run by `npm test` and by release.yml before anything builds), so every release, patches included, is a deliberate look at this list. If nothing new is worth a line, bumping `reviewedFor` alone is the answer. `since` is the release an entry arrived in. `cmd` is shown in the accent colour.",
3
- "reviewedFor": "0.14.6",
3
+ "reviewedFor": "0.15.0",
4
4
  "items": [
5
+ { "text": "MCP on Pi's built-in support — sign OAuth connectors back in with", "cmd": "/mcp", "since": "0.15.0" },
6
+ { "text": "Sub-agents are back: hand work to bounded parallel agents", "since": "0.15.0" },
5
7
  { "text": "API keys can expire and carry their own credit limit — set them in the app", "since": "0.14.6" },
6
- { "text": "Save to Cargo with no app attached — it lands on your next app open", "since": "0.14.4" },
7
- { "text": "Several privateer -p runs at once no longer sign each other out", "since": "0.14.3" },
8
- { "text": "A reply cut off at the output limit picks back up on its own", "since": "0.14.2" }
8
+ { "text": "Save to Cargo with no app attached — it lands on your next app open", "since": "0.14.4" }
9
9
  ]
10
10
  }
@@ -284,11 +284,12 @@ export function makePermissionGate(ctrl: GateController) {
284
284
  toolName: string;
285
285
  toolCallId: string;
286
286
  content: Array<{ type: string; text?: string; [k: string]: unknown }>;
287
+ structuredContent?: unknown;
287
288
  details?: unknown;
288
289
  isError?: boolean;
289
290
  },
290
291
  ctx: unknown,
291
- ) => { content?: unknown[] } | undefined,
292
+ ) => { content?: unknown[]; structuredContent?: unknown } | undefined,
292
293
  ): void;
293
294
  }
294
295
 
@@ -304,6 +305,12 @@ export function makePermissionGate(ctrl: GateController) {
304
305
  // tool_result delivers content PARTS (per the Pi extension contract), not a
305
306
  // bare string — patch text parts and return a partial { content }; omitted
306
307
  // fields keep their current values.
308
+ //
309
+ // structuredContent too. A codemode script receives a nested call's
310
+ // structuredContent INSTEAD of its text (bash's full output, an MCP tool's JSON),
311
+ // and whatever the script prints goes to the model — so redacting only `content`
312
+ // would leave the script a clean path around it. Pi also drops structuredContent
313
+ // when a handler replaces `content` without it, so it is always returned alongside.
307
314
  pi.on("tool_result", (event) => {
308
315
  let touched = false;
309
316
  const content = event.content.map((part) => {
@@ -314,11 +321,41 @@ export function makePermissionGate(ctrl: GateController) {
314
321
  }
315
322
  return part;
316
323
  });
317
- return touched ? { content } : undefined;
324
+ if (event.structuredContent === undefined) return touched ? { content } : undefined;
325
+ const structured = redactJson(event.structuredContent, redact);
326
+ if (structured !== event.structuredContent) touched = true;
327
+ return touched ? { content, structuredContent: structured } : undefined;
318
328
  });
319
329
  };
320
330
  }
321
331
 
332
+ // Redact every string in a JSON value, keys included. Returns the SAME value when
333
+ // nothing changed, so the caller can tell a no-op from a rewrite without a deep compare.
334
+ export function redactJson(value: unknown, redact: (s: string) => string): unknown {
335
+ if (typeof value === "string") return redact(value);
336
+ if (Array.isArray(value)) {
337
+ let changed = false;
338
+ const out = value.map((v) => {
339
+ const r = redactJson(v, redact);
340
+ if (r !== v) changed = true;
341
+ return r;
342
+ });
343
+ return changed ? out : value;
344
+ }
345
+ if (value && typeof value === "object") {
346
+ let changed = false;
347
+ const out: Record<string, unknown> = {};
348
+ for (const [k, v] of Object.entries(value)) {
349
+ const rk = redact(k);
350
+ const rv = redactJson(v, redact);
351
+ if (rk !== k || rv !== v) changed = true;
352
+ out[rk] = rv;
353
+ }
354
+ return changed ? out : value;
355
+ }
356
+ return value;
357
+ }
358
+
322
359
  function withTimeout<T>(p: Promise<T>, ms: number | undefined, signal?: AbortSignal): Promise<T> {
323
360
  if (!ms && !signal) return p;
324
361
  return new Promise<T>((resolve, reject) => {
@@ -1,6 +1,6 @@
1
1
  // Tear a headless session all the way down. dispose() alone stops the agent but never
2
2
  // emits session_shutdown — that is AgentSessionRuntime's job, and we build sessions
3
- // without one — so every extension that cleans up on it (pi-mcp-adapter's connections,
3
+ // without one — so every extension that cleans up on it (Pi's MCP server connections,
4
4
  // OAuth runtime and lifecycle state among them) leaked once per run, for the life of a
5
5
  // resident process. Reason "new", not "quit": to our own extensions "quit" means the
6
6
  // PROCESS is exiting (privateer-brand revokes the machine's sessions on it), and the
@@ -1,5 +1,5 @@
1
1
  import type { Server } from "node:net";
2
- import { readFileSync, rmSync } from "node:fs";
2
+ import { readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { spawn } from "node:child_process";
5
5
  import { randomUUID } from "node:crypto";
@@ -55,7 +55,7 @@ import {
55
55
  import type { Routine } from "../routines/schema.ts";
56
56
  import { triggerError, computeNextRun, advanceAfterRun } from "../routines/trigger.ts";
57
57
  import { splitRoutineTools } from "../routines/toolSelect.ts";
58
- import { resolveMcpSelection, readMcpInventory, type ResolvedMcpTools } from "../mcp/toolNames.ts";
58
+ import { resolveMcpTools, isToolOfServer } from "../mcp/toolNames.ts";
59
59
  import { deliver, type RelayPusher, type CloudPusher } from "../routines/delivery.ts";
60
60
  import { ResultMedia, type StagedMedia } from "../routines/resultMedia.ts";
61
61
  import { withBrief } from "../routines/resultBrief.ts";
@@ -63,7 +63,6 @@ import { ATTACH_RESULT_TOOL } from "../tools/attachResult.ts";
63
63
  import { postOutbox as sealToOutbox, type OutboxSource } from "../outbox/cloudOutbox.ts";
64
64
  import { redactText, collectSecrets } from "../util/redact.ts";
65
65
  import { startIpcServer, sendToHarbor, describeRelay, formatDuration, HarborAlreadyRunningError, type IpcRequest, type IpcResponse, type RelayStatus } from "./ipc.ts";
66
- import { serializeBuild } from "./buildLock.ts";
67
66
  import { createAccountLease } from "./accountLease.ts";
68
67
  import { closeSession } from "./closeSession.ts";
69
68
  import { rotateLogIfLarge } from "./logRotate.ts";
@@ -133,11 +132,9 @@ const HOSTED_SUSPEND_MIN_LEAD_MS = Number(process.env.HARBOR_SUSPEND_MIN_LEAD_MS
133
132
  // answer before it fail-closes to "no response" (the runner then defers the run). Bounds
134
133
  // a stuck graph from pinning a `running` slot forever when the controller wanders off.
135
134
  const GATE_TIMEOUT_MS = 5 * 60_000;
136
- // How long a run waits for a newly-selected connector to hand over its tool list
137
- // before giving up and saying so in the result (see warmMcpCache). Bounds a dead
138
- // endpoint or an expired OAuth token from stalling a scheduled run.
139
- const WARM_TIMEOUT_MS = Number(process.env.PRIVATEER_MCP_WARM_MS) || 30_000;
140
- const WARM_POLL_MS = 250;
135
+ // Every tool Pi's MCP support registers: server tools, and the resource tools that
136
+ // reach every server at once.
137
+ const MCP_TOOL_PATTERNS = ["mcp__*", "list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource"];
141
138
  // Hard ceiling on one unattended run (a routine, a submitted task, a workflow agent
142
139
  // step). Nothing else bounds a provider stream that stops sending without closing, and a
143
140
  // run stuck there holds its routine's `running` slot until the harbor restarts — the
@@ -208,9 +205,6 @@ function formatResult(routine: Routine, body: string, status: "ok" | "error", er
208
205
  return `${head}${body.trim() || "(no output)"}\n${formatNotes(notes)}`;
209
206
  }
210
207
 
211
- // Session construction is serialized process-wide — see harbor/buildLock.ts for why.
212
- // Imported rather than defined here so the channels runtime can share the same lock.
213
-
214
208
  // A short human title for an ad-hoc task: the app's explicit title, else the first
215
209
  // non-empty line of the prompt, clipped. Exported for the signed-args round-trip test.
216
210
  export function deriveTaskTitle(spec: TaskSpec): string {
@@ -281,8 +275,6 @@ export class Harbor {
281
275
  // + no live work rather than on controllerAttached, which never resets while the
282
276
  // socket stays open).
283
277
  private lastActivityAt = Date.now();
284
- // In-flight MCP tool-inventory rebuild, shared by every run that needs one.
285
- private warmInFlight?: Promise<void>;
286
278
  // Live, app-drivable sessions spawned on demand (task_spawn). Each has its OWN relay
287
279
  // terminal (task-<uuid>); the harbor just keeps handles so it can reap them on shutdown.
288
280
  private readonly liveTasks = new Map<string, LiveTaskHandle>();
@@ -878,22 +870,15 @@ export class Harbor {
878
870
  }
879
871
 
880
872
  // Build the Pi services for one unattended run: the auto-approve gate, the account
881
- // provider, web tools when the agent has them, and the MCP adapter.
882
- //
883
- // `directTools` is the ONLY way to make per-tool MCP names exist at all. Without it
884
- // pi-mcp-adapter exposes every connector through a single proxy tool named "mcp" —
885
- // one grant, all servers, all tools — which is exactly what a per-routine allow-list
886
- // is supposed to prevent. The adapter reads it from process.env at extension
887
- // activation, so the build is serialized (serializeBuild) and the previous value is
888
- // restored. "__none__" is its sentinel for "register none": an unattended run with
889
- // no connector selectors gets no direct tools, whatever a shared mcp.json says.
890
- private buildSessionServices(
873
+ // provider, web tools when the agent has them, and Pi's MCP support. Which MCP tools
874
+ // the run may reach is decided by the session's `tools` allow-list (resolveRunTools),
875
+ // not here, so concurrent builds share nothing.
876
+ private async buildSessionServices(
891
877
  cwd: string,
892
- directTools: string[],
893
878
  media?: ResultMedia,
894
879
  spendGrant: readonly string[] = [],
895
880
  ): Promise<any> {
896
- return serializeBuild(async () => {
881
+ {
897
882
  const granted = new Set(spendGrant);
898
883
  const gate: GateController = {
899
884
  getMode: () => "bypass",
@@ -919,122 +904,39 @@ export class Harbor {
919
904
  // grant cover a call that leaves cwd or reads a protected file.
920
905
  isSpendPreauthorized: (req) => granted.has(req.tool),
921
906
  };
922
- // Every extension this session gets, in the one canonical order — including the MCP
923
- // adapter (Phase 5), the web/media capability shaping, and the filter that ignores
907
+ // Every extension this session gets, in the one canonical order — including MCP
908
+ // and codemode, the web/media capability shaping, and the filter that ignores
924
909
  // any moat shim an older release left in the shared agent dir (releases up to 0.11
925
910
  // installed one per extension; the launcher sweeps them, but a daemon can outlive
926
911
  // the launch that would have). See config/moat.ts.
927
912
  const moat = await moatResourceOptions({ kind: "harbor-session", gate, resultMedia: media });
928
- // MCP_DIRECT_TOOLS is read by the adapter when its factory RUNS, inside
929
- // createAgentSessionServices — so it has to wrap the session creation, not the
930
- // moatResourceOptions() call that imported it.
931
- const prevDirect = process.env.MCP_DIRECT_TOOLS;
932
- process.env.MCP_DIRECT_TOOLS = directTools.length > 0 ? directTools.join(",") : "__none__";
933
- try {
934
- return await createAgentSessionServices({
935
- cwd,
936
- agentDir: agentDir(),
937
- resourceLoaderOptions: moat as any,
938
- });
939
- } finally {
940
- if (prevDirect === undefined) delete process.env.MCP_DIRECT_TOOLS;
941
- else process.env.MCP_DIRECT_TOOLS = prevDirect;
942
- }
943
- });
913
+ return await createAgentSessionServices({
914
+ cwd,
915
+ agentDir: agentDir(),
916
+ resourceLoaderOptions: moat as any,
917
+ });
918
+ }
944
919
  }
945
920
 
946
921
  // Turn a routine's stored tool list into what Pi will actually accept.
947
922
  //
948
923
  // Builtins pass through builtinToolsFor (explicit list, or the safe read/web set).
949
- // MCP entries are SELECTORS — "<server>__<tool>" / "<server>__*" — and have to be
950
- // translated into the adapter's registered names before Pi's exact-match `tools:`
951
- // Set can grant anything at all. A "<server>__*" needs the adapter's metadata cache
952
- // to enumerate; when that is cold we warm it (below) and re-resolve once.
953
- private async resolveRunTools(
954
- raw: string[],
955
- cwd: string,
956
- modelSpec: string,
957
- ): Promise<{ tools: string[]; directToolsEnv: string[]; notes: string[] }> {
924
+ // MCP entries are SELECTORS — "<server>__<tool>" / "<server>__*" — translated into
925
+ // Pi's "mcp__<server>__<tool>" names and patterns. Once any `tools` entry starts with
926
+ // "mcp__", Pi registers ONLY the MCP tools that match, so the run can reach exactly
927
+ // the connectors it named. codemode rides along because it is how MCP tools are
928
+ // called by default; it can only call tools the session registered.
929
+ //
930
+ // With no selectors, `excludeTools` keeps every MCP tool unregistered. A builtin-only
931
+ // list would leave them registered (Pi only filters MCP tools once an entry names
932
+ // one) and merely unreachable — a run nobody watches gets the stronger guarantee.
933
+ private resolveRunTools(raw: string[]): { tools: string[]; excludeTools: string[]; servers: string[] } {
958
934
  const split = splitRoutineTools(raw);
959
935
  const builtin = builtinToolsFor(split.builtin);
960
- if (split.mcp.length === 0) return { tools: builtin, directToolsEnv: [], notes: [] };
961
-
962
- let resolved: ResolvedMcpTools = resolveMcpSelection(split.mcp);
963
- if (resolved.coldServers.length > 0) {
964
- await this.warmMcpCache(cwd, modelSpec, resolved.coldServers);
965
- resolved = resolveMcpSelection(split.mcp);
966
- }
967
-
968
- const notes: string[] = [];
969
- for (const server of resolved.coldServers) {
970
- notes.push(
971
- `Connector "${server}" didn't provide a tool list, so none of its tools were available to this run. ` +
972
- `Check it is configured and, if it uses OAuth, that it is still authorized.`,
973
- );
974
- }
975
- for (const selector of resolved.unknownTools) {
976
- notes.push(`Connector tool "${selector}" doesn't exist on that connector, so it was not available to this run.`);
977
- }
978
- if (resolved.names.length > 0) {
979
- log(` connectors: ${resolved.servers.join(", ")} → ${resolved.names.length} tools`);
980
- }
981
- for (const note of notes) log(` ${note}`);
982
- return { tools: [...builtin, ...resolved.names], directToolsEnv: resolved.directToolsEnv, notes };
983
- }
984
-
985
- // Make pi-mcp-adapter build a fresh tool inventory for every configured connector.
986
- //
987
- // The adapter only connects to ALL of them when agent/mcp-cache.json is absent
988
- // (init.ts → bootstrapAll); with the file present it connects lazily, so a connector
989
- // added since the last session never gets an inventory and a "<server>__*" selector
990
- // over it expands to nothing, for ever. Dropping the cache is safe — it is a cache —
991
- // and makes this one throwaway session rebuild it. The session is never prompted
992
- // (empty allow-list) and its MCP connections close on the adapter's idle timeout.
993
- //
994
- // The adapter does NOT await its own initialization from session_start — it stashes
995
- // the promise and returns — so session creation resolving means nothing here. We
996
- // watch the cache file instead, and give up after WARM_TIMEOUT_MS: a connector that
997
- // never answers (dead endpoint, expired OAuth) must not hold a scheduled run open.
998
- //
999
- // A warm rebuilds the inventory for EVERY configured connector, so two runs that
1000
- // both hit a cold selector share one pass rather than racing to wipe the file out
1001
- // from under each other.
1002
- private warmMcpCache(cwd: string, modelSpec: string, servers: string[]): Promise<void> {
1003
- if (this.warmInFlight) return this.warmInFlight;
1004
- const p = this.doWarmMcpCache(cwd, modelSpec, servers).finally(() => {
1005
- if (this.warmInFlight === p) this.warmInFlight = undefined;
1006
- });
1007
- this.warmInFlight = p;
1008
- return p;
1009
- }
1010
-
1011
- private async doWarmMcpCache(cwd: string, modelSpec: string, servers: string[]): Promise<void> {
1012
- log(` warming connector tool lists: ${servers.join(", ")}`);
1013
- try {
1014
- rmSync(join(agentDir(), "mcp-cache.json"), { force: true });
1015
- const services = await this.buildSessionServices(cwd, []);
1016
- const { provider, modelId } = parseSpec(modelSpec);
1017
- const model = (modelRegistryOf(services) as any).find(provider, modelId);
1018
- if (!model) return;
1019
- const { session } = await createAgentSessionFromServices({
1020
- services,
1021
- sessionManager: SessionManager.inMemory(cwd),
1022
- model,
1023
- tools: [],
1024
- } as any);
1025
- try {
1026
- const deadline = Date.now() + WARM_TIMEOUT_MS;
1027
- while (Date.now() < deadline) {
1028
- const inventory = readMcpInventory(agentDir());
1029
- if (servers.every((s) => (inventory[s]?.length ?? 0) > 0)) break;
1030
- await new Promise((r) => setTimeout(r, WARM_POLL_MS));
1031
- }
1032
- } finally {
1033
- await closeSession(session);
1034
- }
1035
- } catch (e) {
1036
- log(` connector warm-up failed: ${e instanceof Error ? e.message : String(e)}`);
1037
- }
936
+ if (split.mcp.length === 0) return { tools: builtin, excludeTools: [...MCP_TOOL_PATTERNS], servers: [] };
937
+ const { patterns, servers } = resolveMcpTools(split.mcp);
938
+ log(` connectors: ${servers.join(", ")}`);
939
+ return { tools: [...builtin, "codemode", ...patterns], excludeTools: [], servers };
1038
940
  }
1039
941
 
1040
942
  // Drive one headless Pi turn to completion and return its collected text + status.
@@ -1055,14 +957,14 @@ export class Harbor {
1055
957
  let error: string | undefined;
1056
958
  let leasedAccount = false;
1057
959
  let session: any;
1058
- const resolved = await this.resolveRunTools(spec.tools, spec.cwd, spec.model);
960
+ const resolved = this.resolveRunTools(spec.tools);
1059
961
  // attach_to_result rides with the staging area rather than the allow-list: it is
1060
962
  // how the answer is DELIVERED, not a capability the run gains. A routine that
1061
963
  // named an explicit tool list still gets it, because the alternative is a run
1062
964
  // that can generate a still and then has no way to hand it over — and the file
1063
965
  // is going to the user's own mailbox, which `delivery: cloud` already chose.
1064
966
  const allowedTools = spec.media ? [...resolved.tools, ATTACH_RESULT_TOOL] : resolved.tools;
1065
- const { directToolsEnv, notes } = resolved;
967
+ const notes: string[] = [];
1066
968
  // The billing tools this run may use: the ones its own allow-list names, and no
1067
969
  // others. Authorizes the run's own calls (the gate, below) and its subagents' —
1068
970
  // children are a separate process and read the grant from the environment, released
@@ -1070,7 +972,7 @@ export class Harbor {
1070
972
  const spendGrant = allowedTools.filter((t) => BILLED_MEDIA_TOOLS.has(t));
1071
973
  const releaseChildSpend = grantChildSpend(`run:${++runSeq}`, spendGrant);
1072
974
  try {
1073
- const services = await this.buildSessionServices(spec.cwd, directToolsEnv, spec.media, spendGrant);
975
+ const services = await this.buildSessionServices(spec.cwd, spec.media, spendGrant);
1074
976
 
1075
977
  const { provider, modelId } = parseSpec(spec.model);
1076
978
  if (provider === "privateer") {
@@ -1090,7 +992,16 @@ export class Harbor {
1090
992
  sessionManager: SessionManager.inMemory(spec.cwd),
1091
993
  model,
1092
994
  tools: allowedTools,
995
+ excludeTools: resolved.excludeTools,
1093
996
  } as any));
997
+ // session_start is what connects the MCP servers (Pi's MCP support). Bound with
998
+ // NO uiContext on purpose: Pi then keeps its own no-op UI and reports hasUI
999
+ // false, which is the truth for a run nobody watches. Any context object we
1000
+ // passed — even an empty one — reads as a UI to extensions, and pi-privacy
1001
+ // would then "ask" about personal data in a tool call, get no answer, and
1002
+ // block it, where with no UI it allows the call with a notice. The gate's own
1003
+ // localAsk (deny) answers anything that would have prompted.
1004
+ await session.bindExtensions({});
1094
1005
  const adapter = createEngineEventAdapter();
1095
1006
  session.subscribe((ev: any) => {
1096
1007
  for (const ee of adapter.toEngineEvents(ev)) {
@@ -1115,6 +1026,17 @@ export class Harbor {
1115
1026
  status = "error";
1116
1027
  error = `run stopped after ${Math.round(RUN_TIMEOUT_MS / 60_000)} minutes (PRIVATEER_RUN_TIMEOUT_MS)`;
1117
1028
  }
1029
+ // A connector that never connected registers no tools, and the run quietly comes
1030
+ // back thinner than asked for. Say which, so the result explains itself.
1031
+ const registered: string[] = (session.getAllTools?.() ?? []).map((t: { name: string }) => t.name);
1032
+ for (const server of resolved.servers) {
1033
+ if (registered.some((name) => isToolOfServer(name, server))) continue;
1034
+ const note =
1035
+ `Connector "${server}" didn't provide any tools, so none were available to this run. ` +
1036
+ `Check it is configured and, if it uses OAuth, that it is still signed in (/mcp login ${server}).`;
1037
+ notes.push(note);
1038
+ log(` ${note}`);
1039
+ }
1118
1040
  }
1119
1041
  } catch (err) {
1120
1042
  status = "error";