@peakflo/agent-harness 0.2.13 → 0.2.14-rc.1

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.
@@ -7,6 +7,7 @@
7
7
  * CHANGED: imports only — `../../shared/reasoning-effort` ->
8
8
  * `../shared/reasoning-effort`. Source formatting (no semicolons) preserved.
9
9
  */
10
+ import type { McpToolsetSnapshot } from '../runtime/mcp-toolset';
10
11
  import type { ReasoningEffort } from '../shared/reasoning-effort';
11
12
  export declare enum SessionStatusType {
12
13
  IDLE = "idle",
@@ -61,6 +62,12 @@ export interface SessionConfig {
61
62
  tools?: Record<string, boolean>;
62
63
  promptAbort?: AbortController;
63
64
  mcpServers?: Record<string, McpServerConfig>;
65
+ /**
66
+ * Tool listings the harness preflight took for `mcpServers` on this turn.
67
+ * A backend may use them to skip its own MCP tool listing; a listing applies
68
+ * only while its `url` equals the server's url here. Contains no credential.
69
+ */
70
+ mcpToolSnapshot?: McpToolsetSnapshot;
64
71
  /** Claude Code auth method: 'subscription' (OAuth/Pro/Max) or 'api_key' (pay-per-use). Defaults to 'subscription'. */
65
72
  authMethod?: 'subscription' | 'api_key';
66
73
  permissionMode?: 'ask' | 'allow';
@@ -192,6 +199,18 @@ export interface CodingAgentAdapter {
192
199
  * Get current session status
193
200
  */
194
201
  getStatus(sessionId: string, config: SessionConfig): Promise<SessionStatus>;
202
+ /**
203
+ * OPTIONAL. `true` when this backend reports IDLE only once the whole turn
204
+ * has settled (never between tool calls) and buffers every part of the turn
205
+ * BEFORE it reports that IDLE.
206
+ *
207
+ * The runner still needs two consecutive quiet polls to end a turn, but for
208
+ * such a backend the confirming poll runs after a short pause instead of the
209
+ * normal poll interval, so the turn ends a few hundred milliseconds after the
210
+ * last part rather than ~2 s later. Leave unset (the safe default) for a
211
+ * backend whose status can flip to IDLE between tool rounds.
212
+ */
213
+ readonly idleIsTerminal?: boolean;
195
214
  /**
196
215
  * Get new messages since last poll
197
216
  * @param seenMessageIds Set of message IDs already processed
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import type { CodingAgentAdapter, MessagePart, SessionConfig, SessionMessage, SessionStatus } from './coding-agent-adapter';
8
8
  import { type ModelGatewayConfigSource } from './model-gateway';
9
+ import { type PiMetadataCacheApi } from './pi-mcp-cache-seed';
9
10
  export declare const PI_DEV_SERVER_POLICY_ENV = "PF_DEV_SERVER_POLICY_FILE";
10
11
  /**
11
12
  * Most model providers cap function/tool `name` at 64 characters. Pi forwards
@@ -43,13 +44,28 @@ export declare function withProviderNameLimitHint(error: string): string;
43
44
  * Providers commonly reject the entire request when any tool exceeds 64
44
45
  * characters, before the model can call a tool.
45
46
  */
46
- export declare function buildPiMcpConfigDocument(servers: NonNullable<SessionConfig['mcpServers']>, onRename?: (name: string, slug: string) => void): Record<string, unknown>;
47
+ export declare function buildPiMcpConfigDocument(servers: NonNullable<SessionConfig['mcpServers']>, onRename?: (name: string, slug: string) => void, options?: {
48
+ /**
49
+ * Harness server names whose tools are already in Pi's metadata cache.
50
+ * Only these may be lazy; see `pi-mcp-cache-seed.ts`.
51
+ */
52
+ lazyServers?: ReadonlySet<string>;
53
+ /** Receives the Pi key chosen for each harness server name. */
54
+ slugs?: Map<string, string>;
55
+ }): Record<string, unknown>;
47
56
  export declare const PI_PERMISSION_EXTENSION_SOURCE: string;
48
57
  export declare class PiAdapter implements CodingAgentAdapter {
49
58
  private modelGateway;
50
59
  private sessions;
51
60
  private piExecutablePath;
52
61
  onDataAvailable?: (sessionId: string) => void;
62
+ /**
63
+ * Pi's status is BUSY from `sendPrompt` until `agent_settled`, which Pi
64
+ * emits once after the whole agent loop (every tool round) has finished and
65
+ * after the turn's message events, so IDLE here means the turn is over and
66
+ * its parts are already buffered. See `CodingAgentAdapter.idleIsTerminal`.
67
+ */
68
+ readonly idleIsTerminal = true;
53
69
  constructor(modelGateway: ModelGatewayConfigSource);
54
70
  private agentDirectory;
55
71
  /** Identity of the Pi session store that can honour native session files. */
@@ -62,6 +78,15 @@ export declare class PiAdapter implements CodingAgentAdapter {
62
78
  * value only to the child process.
63
79
  */
64
80
  private installPeakfloGateway;
81
+ /** Replaceable in tests; the real loader needs the installed adapter. */
82
+ protected loadMcpCacheApi: (agentDir: string) => PiMetadataCacheApi | null;
83
+ /**
84
+ * Item 4: write the harness preflight's tool listings into Pi's metadata
85
+ * cache, so Pi does not repeat initialize + tools/list for those servers on
86
+ * this turn. Returns the harness server names that were seeded; only these
87
+ * may be lazy. Any failure seeds nothing and keeps every server eager.
88
+ */
89
+ private seedMcpMetadataCache;
65
90
  private buildMcpConfig;
66
91
  private installPermissionExtension;
67
92
  private processEnv;
@@ -83,6 +108,7 @@ export declare class PiAdapter implements CodingAgentAdapter {
83
108
  private splitModel;
84
109
  private applySelection;
85
110
  private spawnSession;
111
+ private startPiProcess;
86
112
  createSession(config: SessionConfig): Promise<string>;
87
113
  resumeSession(sessionId: string, config: SessionConfig): Promise<SessionMessage[]>;
88
114
  private attachProcess;
@@ -21,6 +21,7 @@ const string_decoder_1 = require("string_decoder");
21
21
  const util_1 = require("util");
22
22
  const coding_agent_adapter_1 = require("./coding-agent-adapter");
23
23
  const model_gateway_1 = require("./model-gateway");
24
+ const pi_mcp_cache_seed_1 = require("./pi-mcp-cache-seed");
24
25
  const vendor_environment_1 = require("./vendor-environment");
25
26
  const browser_takeover_1 = require("../runtime/browser-takeover");
26
27
  const cold_start_steps_1 = require("../runtime/cold-start-steps");
@@ -32,6 +33,33 @@ const MINIMUM_PI_VERSION = [0, 80, 5];
32
33
  const PI_PERMISSION_MODE_ENV = 'PEAKFLO_PI_PERMISSION_MODE';
33
34
  exports.PI_DEV_SERVER_POLICY_ENV = 'PF_DEV_SERVER_POLICY_FILE';
34
35
  const TERMINAL_ERROR_SETTLE_GRACE_MS = 1_000;
36
+ /**
37
+ * How long a SIGTERMed Pi gets to exit before its process group is SIGKILLed.
38
+ * An upper bound, not a fixed wait: `terminateProcess` returns as soon as the
39
+ * child's `exit` event fires.
40
+ */
41
+ const PI_TERMINATE_GRACE_MS = 1_000;
42
+ /**
43
+ * Resolves when `child` exits or after `timeoutMs`, whichever comes first.
44
+ * `true` means the exit was observed. Already-exited children resolve at once.
45
+ */
46
+ function waitForExit(child, timeoutMs) {
47
+ if (child.exitCode !== null || child.signalCode != null) {
48
+ return Promise.resolve(true);
49
+ }
50
+ return new Promise((resolve) => {
51
+ const wait = {};
52
+ const onExit = () => {
53
+ clearTimeout(wait.timer);
54
+ resolve(true);
55
+ };
56
+ wait.timer = setTimeout(() => {
57
+ child.removeListener('exit', onExit);
58
+ resolve(false);
59
+ }, timeoutMs);
60
+ child.once('exit', onExit);
61
+ });
62
+ }
35
63
  // A tool may be silent for the full progress-guard window. Turn finalization
36
64
  // has its own short bound; this timer protects an active turn with no echo.
37
65
  const STEER_CONSUMPTION_TIMEOUT_MS = 20 * 60_000;
@@ -139,12 +167,13 @@ function withProviderNameLimitHint(error) {
139
167
  * Providers commonly reject the entire request when any tool exceeds 64
140
168
  * characters, before the model can call a tool.
141
169
  */
142
- function buildPiMcpConfigDocument(servers, onRename) {
170
+ function buildPiMcpConfigDocument(servers, onRename, options = {}) {
143
171
  const usedSlugs = new Set();
144
172
  const mcpServers = Object.fromEntries(Object.entries(servers).map(([name, server]) => {
145
173
  const slug = sanitizePiMcpServerName(name, usedSlugs);
146
174
  if (slug !== name)
147
175
  onRename?.(name, slug);
176
+ options.slugs?.set(name, slug);
148
177
  if (server.type === 'stdio') {
149
178
  return [
150
179
  slug,
@@ -164,7 +193,10 @@ function buildPiMcpConfigDocument(servers, onRename) {
164
193
  // already has cached tool metadata. A new session has no cache for
165
194
  // this URL, so the model can see fewer servers than the lease gave
166
195
  // it. Eager mode lists tools before the first model prompt.
167
- lifecycle: 'eager',
196
+ // A server whose preflight listing was seeded into Pi's cache is
197
+ // lazy: Pi registers it from the cache and connects only when the
198
+ // model calls one of its tools.
199
+ lifecycle: options.lazyServers?.has(name) ? 'lazy' : 'eager',
168
200
  // The managed integrations gateway can wait for a cold pfMCP
169
201
  // instance. Its own upstream deadline is 270 seconds, so Pi must
170
202
  // wait long enough to receive the gateway's result or clear error.
@@ -275,6 +307,13 @@ class PiAdapter {
275
307
  sessions = new Map();
276
308
  piExecutablePath = null;
277
309
  onDataAvailable;
310
+ /**
311
+ * Pi's status is BUSY from `sendPrompt` until `agent_settled`, which Pi
312
+ * emits once after the whole agent loop (every tool round) has finished and
313
+ * after the turn's message events, so IDLE here means the turn is over and
314
+ * its parts are already buffered. See `CodingAgentAdapter.idleIsTerminal`.
315
+ */
316
+ idleIsTerminal = true;
278
317
  constructor(modelGateway) {
279
318
  this.modelGateway = modelGateway;
280
319
  }
@@ -389,7 +428,98 @@ class PiAdapter {
389
428
  (0, fs_1.chmodSync)(modelsPath, 0o600);
390
429
  return { apiKey: gateway.apiKey, providerModels: gateway.models ?? [] };
391
430
  }
392
- buildMcpConfig(config) {
431
+ /** Replaceable in tests; the real loader needs the installed adapter. */
432
+ loadMcpCacheApi = pi_mcp_cache_seed_1.loadPiMetadataCacheApi;
433
+ /**
434
+ * Item 4: write the harness preflight's tool listings into Pi's metadata
435
+ * cache, so Pi does not repeat initialize + tools/list for those servers on
436
+ * this turn. Returns the harness server names that were seeded; only these
437
+ * may be lazy. Any failure seeds nothing and keeps every server eager.
438
+ */
439
+ seedMcpMetadataCache(config, document, slugs, seedOwner) {
440
+ const snapshot = config.mcpToolSnapshot;
441
+ const lazy = new Set();
442
+ if (!snapshot || !config.mcpServers)
443
+ return lazy;
444
+ const definitions = (document.mcpServers ?? {});
445
+ const skipped = {};
446
+ const candidates = [];
447
+ for (const [name, server] of Object.entries(config.mcpServers)) {
448
+ const slug = slugs.get(name) ?? name;
449
+ const definition = definitions[slug];
450
+ if (server.type === 'stdio' || !definition) {
451
+ skipped[slug] = 'not an http server';
452
+ continue;
453
+ }
454
+ const listing = snapshot.servers[name];
455
+ const refusal = (0, pi_mcp_cache_seed_1.piSeedRefusal)(definition, listing);
456
+ if (refusal || !listing) {
457
+ skipped[slug] = refusal ?? 'no preflight listing';
458
+ continue;
459
+ }
460
+ candidates.push({ name, slug, definition, listing });
461
+ }
462
+ let seeded = [];
463
+ // `cold_start_step` is one of the two event names the control-plane
464
+ // cold-start endpoint accepts (workflow-api handlers/runners.ts). Its
465
+ // presence in Cloud Logging shows this turn seeded Pi's cache.
466
+ const endSeed = candidates.length > 0 && config.turnId
467
+ ? new cold_start_steps_1.ColdStartSteps(config.taskId, config.turnId, (line) => {
468
+ console.log(line);
469
+ config.coldStartLog?.(line);
470
+ }).begin('pi_mcp_cache_seed')
471
+ : undefined;
472
+ if (candidates.length > 0) {
473
+ const agentDir = this.agentDirectory();
474
+ const api = this.loadMcpCacheApi(agentDir);
475
+ if (!api) {
476
+ for (const { slug } of candidates)
477
+ skipped[slug] = 'pi-mcp-adapter cache module unavailable';
478
+ }
479
+ else {
480
+ // Another live session must not have the same slug with another
481
+ // hash: both share mcp-cache.json. See `claimPiMcpSlugs`.
482
+ const hashes = new Map();
483
+ for (const [slug, definition] of Object.entries(definitions)) {
484
+ if (typeof definition?.url !== 'string')
485
+ continue;
486
+ try {
487
+ hashes.set(slug, api.computeServerHash(JSON.parse(JSON.stringify(definition))));
488
+ }
489
+ catch {
490
+ // Unhashable: it cannot be seeded either.
491
+ }
492
+ }
493
+ const conflicts = (0, pi_mcp_cache_seed_1.claimPiMcpSlugs)(agentDir, seedOwner, hashes);
494
+ for (let index = candidates.length - 1; index >= 0; index--) {
495
+ const { slug } = candidates[index];
496
+ if (conflicts.has(slug)) {
497
+ skipped[slug] = 'slug in use by a concurrent session';
498
+ candidates.splice(index, 1);
499
+ }
500
+ }
501
+ seeded = (0, pi_mcp_cache_seed_1.seedPiMcpMetadataCache)(agentDir, api, candidates);
502
+ for (const { name, slug } of candidates) {
503
+ if (seeded.includes(slug))
504
+ lazy.add(name);
505
+ else
506
+ skipped[slug] = 'cache write failed';
507
+ }
508
+ }
509
+ }
510
+ endSeed?.();
511
+ // The per-server detail goes to the harness log only: the control-plane
512
+ // endpoint refuses any other event name with a 400.
513
+ console.log(JSON.stringify({
514
+ event: 'pi_mcp_cache_seed',
515
+ sessionId: config.taskId,
516
+ turnId: config.turnId,
517
+ seeded,
518
+ eager: skipped
519
+ }));
520
+ return lazy;
521
+ }
522
+ buildMcpConfig(config, seedOwner) {
393
523
  if (!config.mcpServers || Object.keys(config.mcpServers).length === 0)
394
524
  return undefined;
395
525
  const dir = (0, path_1.join)((0, os_1.homedir)(), '.pf-agent-harness', 'pi-mcp');
@@ -398,9 +528,19 @@ class PiAdapter {
398
528
  // Pi forwards MCP tools to the model as <server>_<tool> names, which most
399
529
  // providers cap at 64 characters. Sanitize server keys so a long display
400
530
  // name (e.g. "[Workflo] Organisation Workspace") cannot overflow the limit.
401
- const document = buildPiMcpConfigDocument(config.mcpServers, (name, slug) => {
531
+ const slugs = new Map();
532
+ const onRename = (name, slug) => {
402
533
  console.warn(`[PiAdapter] Renamed MCP server "${name}" to "${slug}" to fit the provider 64-char tool name limit`);
534
+ };
535
+ let document = buildPiMcpConfigDocument(config.mcpServers, onRename, {
536
+ slugs
403
537
  });
538
+ const lazyServers = this.seedMcpMetadataCache(config, document, slugs, seedOwner);
539
+ if (lazyServers.size > 0) {
540
+ document = buildPiMcpConfigDocument(config.mcpServers, undefined, {
541
+ lazyServers
542
+ });
543
+ }
404
544
  (0, fs_1.writeFileSync)(path, `${JSON.stringify(document, null, 2)}\n`, {
405
545
  mode: 0o600
406
546
  });
@@ -562,6 +702,18 @@ class PiAdapter {
562
702
  }
563
703
  }
564
704
  async spawnSession(config, resumeId) {
705
+ // The slugs this process seeds are held until it stops (destroySession),
706
+ // or released here when it never starts.
707
+ const seedOwner = (0, crypto_1.randomUUID)();
708
+ try {
709
+ return await this.startPiProcess(config, seedOwner, resumeId);
710
+ }
711
+ catch (error) {
712
+ (0, pi_mcp_cache_seed_1.releasePiMcpSlugs)(this.agentDirectory(), seedOwner);
713
+ throw error;
714
+ }
715
+ }
716
+ async startPiProcess(config, seedOwner, resumeId) {
565
717
  const steps = config.turnId
566
718
  ? new cold_start_steps_1.ColdStartSteps(config.taskId, config.turnId, (line) => {
567
719
  console.log(line);
@@ -571,7 +723,7 @@ class PiAdapter {
571
723
  const executable = await this.findPiExecutable();
572
724
  const gateway = this.installPeakfloGateway();
573
725
  const endMcpConfig = steps?.begin('mcp_config_write');
574
- const mcpConfigPath = this.buildMcpConfig(config);
726
+ const mcpConfigPath = this.buildMcpConfig(config, seedOwner);
575
727
  endMcpConfig?.();
576
728
  const endSkills = steps?.begin('skills_install');
577
729
  const skills = this.buildSkillFiles(config);
@@ -617,6 +769,7 @@ class PiAdapter {
617
769
  const tempId = resumeId || (0, crypto_1.randomUUID)();
618
770
  const session = this.createSessionState(tempId, child, config, mcpConfigPath, skills.directory);
619
771
  session.devServerPolicyPath = devServerPolicyPath;
772
+ session.mcpSeedOwner = seedOwner;
620
773
  this.sessions.set(tempId, session);
621
774
  this.attachProcess(session);
622
775
  try {
@@ -679,6 +832,11 @@ class PiAdapter {
679
832
  this.onDataAvailable?.(session.id);
680
833
  });
681
834
  session.process.on('exit', (code, signal) => {
835
+ // A Pi process that has exited no longer writes mcp-cache.json, so its
836
+ // slugs are free even if destroySession is never reached.
837
+ if (session.mcpSeedOwner) {
838
+ (0, pi_mcp_cache_seed_1.releasePiMcpSlugs)(this.agentDirectory(), session.mcpSeedOwner);
839
+ }
682
840
  if (!session.closing && session.status === 'busy') {
683
841
  session.status = 'error';
684
842
  session.lastError = `Pi exited before the turn completed (${signal || `code ${code}`})`;
@@ -1491,7 +1649,10 @@ class PiAdapter {
1491
1649
  session.process.kill();
1492
1650
  return;
1493
1651
  }
1494
- await new Promise((resolve) => setTimeout(resolve, 1_000));
1652
+ // Wait for Pi to exit, up to the grace period, instead of always sleeping
1653
+ // the whole second: this runs at the end of every turn, before the final
1654
+ // ingest, and Pi normally exits on SIGTERM in a few milliseconds.
1655
+ await waitForExit(session.process, PI_TERMINATE_GRACE_MS);
1495
1656
  if (session.process.exitCode !== null)
1496
1657
  return;
1497
1658
  try {
@@ -1520,6 +1681,9 @@ class PiAdapter {
1520
1681
  session.pendingUiRequests.clear();
1521
1682
  await this.terminateProcess(session);
1522
1683
  this.sessions.delete(sessionId);
1684
+ if (session.mcpSeedOwner) {
1685
+ (0, pi_mcp_cache_seed_1.releasePiMcpSlugs)(this.agentDirectory(), session.mcpSeedOwner);
1686
+ }
1523
1687
  if (session.mcpConfigPath && (0, fs_1.existsSync)(session.mcpConfigPath)) {
1524
1688
  (0, fs_1.unlinkSync)(session.mcpConfigPath);
1525
1689
  }
@@ -0,0 +1,66 @@
1
+ import type { McpServerToolListing } from '../runtime/mcp-toolset';
2
+ /**
3
+ * Seed pi-mcp-adapter's metadata cache from the harness MCP preflight (item 4).
4
+ *
5
+ * WHAT pi-mcp-adapter 2.33.0 SUPPORTS (read from its published source):
6
+ * - `<agentDir>/mcp-cache.json`, `{ version: 1, servers: { <server>: entry } }`
7
+ * (metadata-cache.ts). An entry is valid while `configHash` equals
8
+ * `computeServerHash(definition)` and it is less than 7 days old, or less
9
+ * than its `ttlMs`. The hash covers url and headers, not `lifecycle`.
10
+ * - At startup (init.ts) every server with a valid entry gets its tools from
11
+ * the cache. Only `eager` and `keep-alive` servers connect at startup,
12
+ * unless the cache FILE is missing: then every server connects.
13
+ * - A `lazy` server (the default) with a valid entry is registered from the
14
+ * cache and connects only when the model first calls one of its tools. A
15
+ * lazy server WITHOUT a valid entry gets no tools at all, so a server may be
16
+ * lazy only when this module wrote a valid entry for it.
17
+ * - The namespace proxy tool for a server is re-derived from the cache file on
18
+ * every tool-surface sync, so a seeded entry must stay valid for the turn.
19
+ *
20
+ * The hash and the serialisers come from the installed adapter itself
21
+ * (`pi-mcp-adapter/metadata-cache`, a public export), so the seeded entry is
22
+ * exactly what Pi would write. If the module cannot be loaded, nothing is
23
+ * seeded and every server stays eager, which is the previous behaviour.
24
+ *
25
+ * The file holds tool names, descriptions and schemas, and a hash of the
26
+ * server definition. Pi already writes the same data there after each eager
27
+ * connect. It never holds a token: the per-turn MCP token is added by the
28
+ * loopback proxy and is not in the Pi definition.
29
+ */
30
+ export interface PiMetadataCacheApi {
31
+ computeServerHash(definition: Record<string, unknown>): string;
32
+ serializeTools(tools: unknown[]): unknown[];
33
+ serializeResources(resources: unknown[]): unknown[];
34
+ }
35
+ export declare const PI_MCP_CACHE_FILE = "mcp-cache.json";
36
+ /** Where `pi install npm:pi-mcp-adapter@x` puts the adapter (Dockerfile). */
37
+ export declare function piMcpAdapterDirectory(agentDir: string): string;
38
+ /**
39
+ * Load the installed adapter's cache helpers. The module is ESM; Node 22.12+
40
+ * (the harness floor is 22.19) loads it with `require`.
41
+ */
42
+ export declare function loadPiMetadataCacheApi(agentDir: string): PiMetadataCacheApi | null;
43
+ /** Why a server cannot be seeded, or `null` when it can. */
44
+ export declare function piSeedRefusal(definition: Record<string, unknown>, listing: McpServerToolListing | undefined): string | null;
45
+ interface SeedCandidate {
46
+ /** The Pi server key (the sanitised slug). */
47
+ slug: string;
48
+ definition: Record<string, unknown>;
49
+ listing: McpServerToolListing;
50
+ }
51
+ /**
52
+ * Write valid entries for `candidates` into `<agentDir>/mcp-cache.json`,
53
+ * merging with the entries already there (as Pi does). Returns the slugs that
54
+ * were written; the caller must keep every other server eager.
55
+ */
56
+ export declare function seedPiMcpMetadataCache(agentDir: string, api: PiMetadataCacheApi, candidates: readonly SeedCandidate[]): string[];
57
+ /**
58
+ * Register `hashes` (slug -> config hash) for one live Pi process and return
59
+ * the slugs another live process holds with a different hash. Every slug is
60
+ * registered, also a conflicting one, because Pi writes an eager server's
61
+ * entry itself after it connects.
62
+ */
63
+ export declare function claimPiMcpSlugs(agentDir: string, owner: string, hashes: ReadonlyMap<string, string>): Set<string>;
64
+ /** Forget one Pi process's slugs, when it stops or fails to start. */
65
+ export declare function releasePiMcpSlugs(agentDir: string, owner: string): void;
66
+ export {};
@@ -0,0 +1,188 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PI_MCP_CACHE_FILE = void 0;
4
+ exports.piMcpAdapterDirectory = piMcpAdapterDirectory;
5
+ exports.loadPiMetadataCacheApi = loadPiMetadataCacheApi;
6
+ exports.piSeedRefusal = piSeedRefusal;
7
+ exports.seedPiMcpMetadataCache = seedPiMcpMetadataCache;
8
+ exports.claimPiMcpSlugs = claimPiMcpSlugs;
9
+ exports.releasePiMcpSlugs = releasePiMcpSlugs;
10
+ const fs_1 = require("fs");
11
+ const path_1 = require("path");
12
+ exports.PI_MCP_CACHE_FILE = 'mcp-cache.json';
13
+ const PI_MCP_CACHE_VERSION = 1;
14
+ /*
15
+ * SERVER CACHE HINTS (`ttlMs`, `cacheScope`) ARE NOT WRITTEN TO PI'S FILE.
16
+ *
17
+ * Measured: the harness preflight negotiates protocol 2026-07-28 and receives
18
+ * the workspace server's `tools/list` hint `ttlMs: 0, cacheScope: 'private'`.
19
+ * Pi 0.85.1 + pi-mcp-adapter 2.33.0 connect in the legacy era, never receive
20
+ * it, and write their own entry with no ttl. An entry with `ttlMs: 0` is
21
+ * invalid to Pi at once, which would leave a lazy server with no tools, and a
22
+ * short ttl would expire it during the turn.
23
+ *
24
+ * So the seeded entry is what Pi itself writes, and the harness honours the
25
+ * hint where it decides freshness: a listing is used for the turn that took it
26
+ * (as Pi's own eager connect would), and the per-session tool store reuses it
27
+ * on a later turn only while it is younger than its `ttlMs`. See
28
+ * `runtime/mcp-tool-store.ts`.
29
+ */
30
+ const apiCache = new Map();
31
+ /** Where `pi install npm:pi-mcp-adapter@x` puts the adapter (Dockerfile). */
32
+ function piMcpAdapterDirectory(agentDir) {
33
+ return (0, path_1.join)(agentDir, 'npm', 'node_modules', 'pi-mcp-adapter');
34
+ }
35
+ /**
36
+ * Load the installed adapter's cache helpers. The module is ESM; Node 22.12+
37
+ * (the harness floor is 22.19) loads it with `require`.
38
+ */
39
+ function loadPiMetadataCacheApi(agentDir) {
40
+ const modulePath = (0, path_1.join)(piMcpAdapterDirectory(agentDir), 'dist', 'metadata-cache.js');
41
+ if (apiCache.has(modulePath))
42
+ return apiCache.get(modulePath) ?? null;
43
+ let api = null;
44
+ try {
45
+ if ((0, fs_1.existsSync)(modulePath)) {
46
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
47
+ const loaded = require(modulePath);
48
+ if (typeof loaded.computeServerHash === 'function' &&
49
+ typeof loaded.serializeTools === 'function' &&
50
+ typeof loaded.serializeResources === 'function') {
51
+ api = loaded;
52
+ }
53
+ }
54
+ }
55
+ catch {
56
+ api = null;
57
+ }
58
+ apiCache.set(modulePath, api);
59
+ return api;
60
+ }
61
+ /** Why a server cannot be seeded, or `null` when it can. */
62
+ function piSeedRefusal(definition, listing) {
63
+ if (!listing)
64
+ return 'no preflight listing';
65
+ if (typeof definition.url !== 'string' || definition.url !== listing.url)
66
+ return 'url changed';
67
+ // Pi interpolates `${VAR}` and `!command` in urls and headers inside its own
68
+ // process. Seed only definitions with nothing to interpolate.
69
+ if (definition.url.includes('$'))
70
+ return 'url uses interpolation';
71
+ const headers = definition.headers;
72
+ if (headers !== undefined &&
73
+ (typeof headers !== 'object' ||
74
+ headers === null ||
75
+ Object.keys(headers).length > 0))
76
+ return 'server has headers';
77
+ return null;
78
+ }
79
+ /**
80
+ * Write valid entries for `candidates` into `<agentDir>/mcp-cache.json`,
81
+ * merging with the entries already there (as Pi does). Returns the slugs that
82
+ * were written; the caller must keep every other server eager.
83
+ */
84
+ function seedPiMcpMetadataCache(agentDir, api, candidates) {
85
+ const entries = {};
86
+ for (const { slug, definition, listing } of candidates) {
87
+ try {
88
+ // Hash the definition as Pi reads it: from JSON.
89
+ const parsed = JSON.parse(JSON.stringify(definition));
90
+ entries[slug] = {
91
+ configHash: api.computeServerHash(parsed),
92
+ tools: api.serializeTools(listing.tools),
93
+ resources: api.serializeResources(listing.resources),
94
+ ...(listing.instructions !== undefined
95
+ ? { instructions: listing.instructions }
96
+ : {}),
97
+ cachedAt: listing.listedAt
98
+ };
99
+ }
100
+ catch {
101
+ // This server stays eager.
102
+ }
103
+ }
104
+ const slugs = Object.keys(entries);
105
+ if (slugs.length === 0)
106
+ return [];
107
+ const path = (0, path_1.join)(agentDir, exports.PI_MCP_CACHE_FILE);
108
+ let servers = {};
109
+ try {
110
+ const existing = JSON.parse((0, fs_1.readFileSync)(path, 'utf8'));
111
+ if (existing?.version === PI_MCP_CACHE_VERSION &&
112
+ existing.servers &&
113
+ typeof existing.servers === 'object') {
114
+ servers = existing.servers;
115
+ }
116
+ }
117
+ catch {
118
+ // Missing or unreadable: start a new file, as Pi does.
119
+ }
120
+ try {
121
+ (0, fs_1.mkdirSync)(agentDir, { recursive: true });
122
+ const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
123
+ (0, fs_1.writeFileSync)(temporaryPath, JSON.stringify({
124
+ version: PI_MCP_CACHE_VERSION,
125
+ servers: { ...servers, ...entries }
126
+ }), { mode: 0o600 });
127
+ (0, fs_1.chmodSync)(temporaryPath, 0o600);
128
+ (0, fs_1.renameSync)(temporaryPath, path);
129
+ }
130
+ catch {
131
+ return [];
132
+ }
133
+ return slugs;
134
+ }
135
+ /*
136
+ * CONCURRENT SESSIONS SHARE ONE CACHE FILE (item 1(b), #2112 open risk).
137
+ *
138
+ * Every Pi process of this harness uses the same `<agentDir>/mcp-cache.json`,
139
+ * keyed by server slug, and re-reads it on every tool-surface sync. The
140
+ * loopback URL differs per session, so two live sessions with the same slug
141
+ * have different config hashes. If both were seeded, the second write would
142
+ * make the first session's entry invalid and its lazy server would lose its
143
+ * tools in the middle of a turn.
144
+ *
145
+ * The file cannot be split without a per-session agent directory (Pi's
146
+ * settings, extension install and auth all live there), so this registry
147
+ * serialises the conflict instead: a turn seeds a slug only when no other
148
+ * live Pi process of this harness holds that slug with a different hash. A
149
+ * conflicting server stays eager for that turn, which is the previous
150
+ * behaviour. The registry is per agent directory and per harness process; the
151
+ * sandbox runs one session at a time, so it only matters on a desktop runner
152
+ * with several sessions.
153
+ */
154
+ const liveSlugs = new Map();
155
+ /**
156
+ * Register `hashes` (slug -> config hash) for one live Pi process and return
157
+ * the slugs another live process holds with a different hash. Every slug is
158
+ * registered, also a conflicting one, because Pi writes an eager server's
159
+ * entry itself after it connects.
160
+ */
161
+ function claimPiMcpSlugs(agentDir, owner, hashes) {
162
+ let owners = liveSlugs.get(agentDir);
163
+ if (!owners) {
164
+ owners = new Map();
165
+ liveSlugs.set(agentDir, owners);
166
+ }
167
+ const conflicts = new Set();
168
+ for (const [other, slugs] of owners) {
169
+ if (other === owner)
170
+ continue;
171
+ for (const [slug, hash] of hashes) {
172
+ const held = slugs.get(slug);
173
+ if (held !== undefined && held !== hash)
174
+ conflicts.add(slug);
175
+ }
176
+ }
177
+ owners.set(owner, new Map(hashes));
178
+ return conflicts;
179
+ }
180
+ /** Forget one Pi process's slugs, when it stops or fails to start. */
181
+ function releasePiMcpSlugs(agentDir, owner) {
182
+ const owners = liveSlugs.get(agentDir);
183
+ if (!owners)
184
+ return;
185
+ owners.delete(owner);
186
+ if (owners.size === 0)
187
+ liveSlugs.delete(agentDir);
188
+ }
@@ -178,7 +178,7 @@ export declare const ROUTES: {
178
178
  readonly register: () => string;
179
179
  /** Bearer: runner credential. 30s period. */
180
180
  readonly heartbeat: (runnerId: string) => string;
181
- /** Bearer: runner credential. Best-effort timing event; no transcript data. */
181
+ /** Bearer: runner credential. Best-effort timing events of one turn, batched; no transcript data. */
182
182
  readonly coldStart: (runnerId: string) => string;
183
183
  /**
184
184
  * Bearer: runner credential. Bounded long-poll: 204 idle, 200 with work.
package/dist/contract.js CHANGED
@@ -215,7 +215,7 @@ exports.ROUTES = {
215
215
  register: () => '/api/runners/register',
216
216
  /** Bearer: runner credential. 30s period. */
217
217
  heartbeat: (runnerId) => `/api/runners/${encodeURIComponent(runnerId)}/heartbeat`,
218
- /** Bearer: runner credential. Best-effort timing event; no transcript data. */
218
+ /** Bearer: runner credential. Best-effort timing events of one turn, batched; no transcript data. */
219
219
  coldStart: (runnerId) => `/api/runners/${encodeURIComponent(runnerId)}/cold-start`,
220
220
  /**
221
221
  * Bearer: runner credential. Bounded long-poll: 204 idle, 200 with work.
@@ -1,3 +1,4 @@
1
+ import type { ColdStartBatchBody } from './runtime/cold-start-batch';
1
2
  import { ControlPlaneErrorBody, CredentialRotateResponse, DeregisterResponse, IngestRequest, IngestResponse, KnowledgeDownloadResponse, KnowledgeFilesResponse, LeaseRenewResponse, LeaseResponse, RunnerCapabilities, RunnerHeartbeatResponse, RunnerRegisterResponse, RunnerRuntime, RunnerStatus, SessionHistoryResponse } from './types';
2
3
  export declare class ControlPlaneError extends Error {
3
4
  readonly status: number;
@@ -81,8 +82,14 @@ export declare class ControlPlaneClient {
81
82
  /** Used only for a private IPC handoff to the baked sandbox supervisor. */
82
83
  getRunnerCredential(): string | null;
83
84
  getRunnerId(): string | null;
84
- /** Diagnostics must never delay a turn. The caller handles any failure. */
85
- reportColdStart(line: string): Promise<void>;
85
+ /**
86
+ * All cold-start timing events of one turn in ONE request. Diagnostics
87
+ * must never delay a turn; the caller handles any failure.
88
+ *
89
+ * This replaced one POST per step. The control plane accepts both shapes,
90
+ * so an older harness on an existing sandbox keeps working.
91
+ */
92
+ reportColdStartBatch(body: ColdStartBatchBody): Promise<void>;
86
93
  /**
87
94
  * Give the lease-renewal worker the minimum information it needs.
88
95
  *