@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.
@@ -114,15 +114,23 @@ class ControlPlaneClient {
114
114
  getRunnerId() {
115
115
  return this.runnerId;
116
116
  }
117
- /** Diagnostics must never delay a turn. The caller handles any failure. */
118
- async reportColdStart(line) {
117
+ /**
118
+ * All cold-start timing events of one turn in ONE request. Diagnostics
119
+ * must never delay a turn; the caller handles any failure.
120
+ *
121
+ * This replaced one POST per step. The control plane accepts both shapes,
122
+ * so an older harness on an existing sandbox keeps working.
123
+ */
124
+ async reportColdStartBatch(body) {
119
125
  if (!this.runnerId || !this.runnerCredential)
120
126
  return;
127
+ if (body.events.length === 0)
128
+ return;
121
129
  await this.send({
122
130
  method: 'POST',
123
131
  path: contract_1.ROUTES.coldStart(this.runnerId),
124
132
  token: this.runnerCredential,
125
- body: JSON.parse(line),
133
+ body,
126
134
  timeoutMs: 2_000
127
135
  });
128
136
  }
package/dist/harness.d.ts CHANGED
@@ -249,6 +249,8 @@ export declare class Harness {
249
249
  private readonly activeLeaseIds;
250
250
  /** Renewal workers that must receive a credential rotation in flight. */
251
251
  private readonly activeRenewals;
252
+ /** Cold-start timing buffered per running turn; sent once at turn end. */
253
+ private readonly coldStartBatches;
252
254
  /** commandIds already applied. `SessionCommand` is idempotent by contract. */
253
255
  private readonly appliedCommands;
254
256
  /** Prune lease-bound command IDs when their session turn ends. */
@@ -473,6 +475,7 @@ export declare class Harness {
473
475
  private observeSandboxToolsPin;
474
476
  /** Stop once. A failed install is then the supervisor's terminal decision. */
475
477
  private requestUpgrade;
478
+ private newColdStartBatch;
476
479
  /**
477
480
  * Run one leased turn.
478
481
  *
package/dist/harness.js CHANGED
@@ -18,6 +18,7 @@ const health_1 = require("./health");
18
18
  const model_probe_1 = require("./model-probe");
19
19
  const ports_default_1 = require("./ports-default");
20
20
  const browser_takeover_1 = require("./runtime/browser-takeover");
21
+ const cold_start_batch_1 = require("./runtime/cold-start-batch");
21
22
  const knowledge_tools_1 = require("./runtime/knowledge-tools");
22
23
  const lease_renewal_1 = require("./runtime/lease-renewal");
23
24
  const mcp_auth_proxy_1 = require("./runtime/mcp-auth-proxy");
@@ -200,7 +201,9 @@ class Harness {
200
201
  mcpCheckState = {
201
202
  checked: new Map(),
202
203
  failed: new Map(),
203
- shownNotices: new Set()
204
+ shownNotices: new Set(),
205
+ // Item 1(b): per-session MCP tool listings, reused on warm turns.
206
+ toolsets: new Map()
204
207
  };
205
208
  startedAt = Date.now();
206
209
  status = 'starting';
@@ -265,6 +268,8 @@ class Harness {
265
268
  activeLeaseIds = new Map();
266
269
  /** Renewal workers that must receive a credential rotation in flight. */
267
270
  activeRenewals = new Set();
271
+ /** Cold-start timing buffered per running turn; sent once at turn end. */
272
+ coldStartBatches = new Set();
268
273
  /** commandIds already applied. `SessionCommand` is idempotent by contract. */
269
274
  appliedCommands = new Set();
270
275
  /** Prune lease-bound command IDs when their session turn ends. */
@@ -515,6 +520,11 @@ class Harness {
515
520
  for (const controller of this.active.values()) {
516
521
  controller.abort();
517
522
  }
523
+ // Send buffered turn timing while the credential is still valid.
524
+ // Deregistration below revokes it. Each send has a 2 s timeout.
525
+ const coldStarts = Array.from(this.coldStartBatches);
526
+ this.coldStartBatches.clear();
527
+ await Promise.all(coldStarts.map((batch) => batch.flush()));
518
528
  /*
519
529
  * A DURABLE RUNNER DOES NOT DEREGISTER.
520
530
  *
@@ -1565,7 +1575,9 @@ class Harness {
1565
1575
  durationMs: Date.now() - pollStartedAt
1566
1576
  });
1567
1577
  log(leaseReceived);
1568
- void this.client.reportColdStart(leaseReceived).catch(() => undefined);
1578
+ // Buffered, not sent: the whole turn's timing goes out as ONE request.
1579
+ const coldStart = this.newColdStartBatch(lease);
1580
+ coldStart.add(leaseReceived);
1569
1581
  this.observeHarnessPin(lease.harnessVersion);
1570
1582
  this.observeSandboxToolsPin(lease.sandboxToolsVersion);
1571
1583
  // Protocol 3 uses a short visibility window until the runner confirms
@@ -1593,10 +1605,12 @@ class Harness {
1593
1605
  }
1594
1606
  }
1595
1607
  if (!leaseAcknowledged) {
1608
+ this.coldStartBatches.delete(coldStart);
1609
+ void coldStart.flush();
1596
1610
  await sleep(LEASE_ERROR_BACKOFF_MS);
1597
1611
  continue;
1598
1612
  }
1599
- void this.runLeasedTurn(lease);
1613
+ void this.runLeasedTurn(lease, coldStart);
1600
1614
  }
1601
1615
  await this.stop();
1602
1616
  }
@@ -1680,6 +1694,11 @@ class Harness {
1680
1694
  this.fatal = new UpgradeRequestedError(harnessVersion);
1681
1695
  await this.stop({ preserveRegistration: true });
1682
1696
  }
1697
+ newColdStartBatch(lease) {
1698
+ const batch = new cold_start_batch_1.ColdStartBatch(lease.session.sessionId, lease.session.turnId, (body) => this.client.reportColdStartBatch(body));
1699
+ this.coldStartBatches.add(batch);
1700
+ return batch;
1701
+ }
1683
1702
  /**
1684
1703
  * Run one leased turn.
1685
1704
  *
@@ -1688,7 +1707,7 @@ class Harness {
1688
1707
  * throws must not end the lease loop, or one bad session would take the whole
1689
1708
  * runner offline.
1690
1709
  */
1691
- async runLeasedTurn(lease) {
1710
+ async runLeasedTurn(lease, coldStart = this.newColdStartBatch(lease)) {
1692
1711
  const { sessionId } = lease.session;
1693
1712
  // The exact loopback-proxy targets this session will reach. Keyed on these
1694
1713
  // urls, this session's MCP token cannot be overwritten or revoked by a
@@ -1852,9 +1871,7 @@ class Harness {
1852
1871
  secrets: this.secrets,
1853
1872
  events: this.events,
1854
1873
  telemetry: this.telemetry,
1855
- coldStartLog: (line) => {
1856
- void this.client.reportColdStart(line).catch(() => undefined);
1857
- },
1874
+ coldStartLog: (line) => coldStart.add(line),
1858
1875
  notifier: this.notifier,
1859
1876
  artifacts: this.artifacts,
1860
1877
  conversation: this.conversation,
@@ -1938,6 +1955,10 @@ class Harness {
1938
1955
  finally {
1939
1956
  renewal.stop();
1940
1957
  this.activeRenewals.delete(renewal);
1958
+ // ONE telemetry request for the whole turn, sent before the git
1959
+ // preservation below so slow pushes do not delay it. Fire-and-forget.
1960
+ this.coldStartBatches.delete(coldStart);
1961
+ void coldStart.flush();
1941
1962
  let workProtected = true;
1942
1963
  if ((this.options.autoCommitDirtyWorktree ??
1943
1964
  this.options.runtime === 'sandbox') &&
@@ -0,0 +1,36 @@
1
+ /**
2
+ * One cold-start telemetry POST per turn instead of one per step.
3
+ *
4
+ * `ColdStartSteps` and the Pi adapter emit 13–15 timing lines per turn. Each
5
+ * used to become its own `POST /runners/:id/cold-start` (auth + turn lookup on
6
+ * the server every time). This buffer collects the lines for ONE turn and the
7
+ * harness sends them as a single batch when the turn ends, or when the
8
+ * harness stops. It is diagnostics only: it never throws, never blocks a turn
9
+ * and never retries.
10
+ */
11
+ /** Events the control plane records. Anything else stays in the local log. */
12
+ export declare const COLD_START_EVENTS: ReadonlySet<string>;
13
+ /** Mirrors the server limit in `services/coding-agents/cold-start-telemetry.ts`. */
14
+ export declare const COLD_START_BATCH_MAX_EVENTS = 64;
15
+ export interface ColdStartBatchBody {
16
+ sessionId: string;
17
+ turnId: string;
18
+ events: Array<Record<string, unknown>>;
19
+ }
20
+ export declare class ColdStartBatch {
21
+ readonly sessionId: string;
22
+ readonly turnId: string;
23
+ private readonly send;
24
+ /** Lines that arrive after the turn-end flush are coalesced this long. */
25
+ private readonly lateFlushMs;
26
+ private events;
27
+ private flushed;
28
+ private lateTimer;
29
+ constructor(sessionId: string, turnId: string, send: (body: ColdStartBatchBody) => Promise<void>,
30
+ /** Lines that arrive after the turn-end flush are coalesced this long. */
31
+ lateFlushMs?: number);
32
+ /** Buffer one JSON line from `ColdStartSteps`. Never throws. */
33
+ add(line: string): void;
34
+ /** Send everything buffered as ONE request. Resolves; never rejects. */
35
+ flush(): Promise<void>;
36
+ }
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ /**
3
+ * One cold-start telemetry POST per turn instead of one per step.
4
+ *
5
+ * `ColdStartSteps` and the Pi adapter emit 13–15 timing lines per turn. Each
6
+ * used to become its own `POST /runners/:id/cold-start` (auth + turn lookup on
7
+ * the server every time). This buffer collects the lines for ONE turn and the
8
+ * harness sends them as a single batch when the turn ends, or when the
9
+ * harness stops. It is diagnostics only: it never throws, never blocks a turn
10
+ * and never retries.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.ColdStartBatch = exports.COLD_START_BATCH_MAX_EVENTS = exports.COLD_START_EVENTS = void 0;
14
+ /** Events the control plane records. Anything else stays in the local log. */
15
+ exports.COLD_START_EVENTS = new Set([
16
+ 'cold_start_step',
17
+ 'cold_start_summary',
18
+ 'model_catalogue_deferred'
19
+ ]);
20
+ /** Mirrors the server limit in `services/coding-agents/cold-start-telemetry.ts`. */
21
+ exports.COLD_START_BATCH_MAX_EVENTS = 64;
22
+ class ColdStartBatch {
23
+ sessionId;
24
+ turnId;
25
+ send;
26
+ lateFlushMs;
27
+ events = [];
28
+ flushed = false;
29
+ lateTimer = null;
30
+ constructor(sessionId, turnId, send,
31
+ /** Lines that arrive after the turn-end flush are coalesced this long. */
32
+ lateFlushMs = 1_000) {
33
+ this.sessionId = sessionId;
34
+ this.turnId = turnId;
35
+ this.send = send;
36
+ this.lateFlushMs = lateFlushMs;
37
+ }
38
+ /** Buffer one JSON line from `ColdStartSteps`. Never throws. */
39
+ add(line) {
40
+ try {
41
+ const event = JSON.parse(line);
42
+ if (!event || typeof event !== 'object' || Array.isArray(event))
43
+ return;
44
+ const record = event;
45
+ if (typeof record.event !== 'string' ||
46
+ !exports.COLD_START_EVENTS.has(record.event) ||
47
+ record.sessionId !== this.sessionId ||
48
+ record.turnId !== this.turnId) {
49
+ return;
50
+ }
51
+ // The batch carries the ids once; per-event copies are redundant bytes.
52
+ const rest = { ...record };
53
+ delete rest.sessionId;
54
+ delete rest.turnId;
55
+ this.events.push(rest);
56
+ if (this.events.length >= exports.COLD_START_BATCH_MAX_EVENTS) {
57
+ void this.flush();
58
+ }
59
+ else if (this.flushed && !this.lateTimer) {
60
+ // A step that ends after the turn-end flush (rare) is still sent,
61
+ // coalesced with any others that follow it.
62
+ this.lateTimer = setTimeout(() => {
63
+ this.lateTimer = null;
64
+ void this.flush();
65
+ }, this.lateFlushMs);
66
+ this.lateTimer.unref?.();
67
+ }
68
+ }
69
+ catch {
70
+ // A diagnostic sink must never stop an agent turn.
71
+ }
72
+ }
73
+ /** Send everything buffered as ONE request. Resolves; never rejects. */
74
+ flush() {
75
+ this.flushed = true;
76
+ if (this.lateTimer) {
77
+ clearTimeout(this.lateTimer);
78
+ this.lateTimer = null;
79
+ }
80
+ if (this.events.length === 0)
81
+ return Promise.resolve();
82
+ const events = this.events;
83
+ this.events = [];
84
+ try {
85
+ return this.send({
86
+ sessionId: this.sessionId,
87
+ turnId: this.turnId,
88
+ events
89
+ }).catch(() => undefined);
90
+ }
91
+ catch {
92
+ return Promise.resolve();
93
+ }
94
+ }
95
+ }
96
+ exports.ColdStartBatch = ColdStartBatch;
@@ -1,3 +1,4 @@
1
+ import type { McpServerToolListing } from './mcp-toolset';
1
2
  export interface UnavailableMcpServer {
2
3
  name: string;
3
4
  reason: string;
@@ -14,8 +15,21 @@ export interface FailedMcpCheck {
14
15
  * Check leased HTTP servers and use the caller's bounded, shared cache.
15
16
  * A successful check is only a startup hint. The gateway checks live access
16
17
  * on each tool call, even when a cached success skips this probe.
18
+ *
19
+ * When `listings` is given, a server that was probed keeps its `tools/list`
20
+ * result there (all pages: the client aggregates them when no cursor is
21
+ * passed), plus `resources/list` when the server advertises resources. The
22
+ * Pi backend seeds its MCP metadata cache from it, so Pi does not repeat the
23
+ * handshake on this turn. A server skipped by the success cache, or whose
24
+ * resource listing failed, has no listing.
17
25
  */
18
26
  export declare function verifyMcpServers(servers: Record<string, {
19
27
  type: string;
20
28
  url?: string;
21
- }>, signal?: AbortSignal, successful?: Map<string, number>, failed?: Map<string, FailedMcpCheck>): Promise<UnavailableMcpServer[]>;
29
+ }>, signal?: AbortSignal, successful?: Map<string, number>, failed?: Map<string, FailedMcpCheck>, listings?: Map<string, McpServerToolListing>,
30
+ /**
31
+ * Servers to probe and list even inside the success-cache window: their
32
+ * stored listing is no longer valid (item 1(b)), so without a fresh one Pi
33
+ * would fall back to its own eager handshake.
34
+ */
35
+ relist?: ReadonlySet<string>): Promise<UnavailableMcpServer[]>;
@@ -16,12 +16,45 @@ function safeHttpStatus(cause) {
16
16
  const match = String(cause).match(/^(?:Error:\s*)?HTTP\s+([45]\d\d)\b/i);
17
17
  return match ? Number(match[1]) : null;
18
18
  }
19
+ function toolListing(url, client, result, resources, resourcesAdvertised) {
20
+ const hints = result;
21
+ const instructions = client.getInstructions?.();
22
+ return {
23
+ url,
24
+ tools: (result.tools ?? []).filter((tool) => typeof tool?.name === 'string' && tool.name.length > 0),
25
+ resources,
26
+ resourcesAdvertised,
27
+ ...(typeof instructions === 'string' ? { instructions } : {}),
28
+ ...(typeof hints.ttlMs === 'number' &&
29
+ Number.isSafeInteger(hints.ttlMs) &&
30
+ hints.ttlMs >= 0
31
+ ? { ttlMs: hints.ttlMs }
32
+ : {}),
33
+ ...(hints.cacheScope === 'public' || hints.cacheScope === 'private'
34
+ ? { cacheScope: hints.cacheScope }
35
+ : {}),
36
+ listedAt: Date.now()
37
+ };
38
+ }
19
39
  /**
20
40
  * Check leased HTTP servers and use the caller's bounded, shared cache.
21
41
  * A successful check is only a startup hint. The gateway checks live access
22
42
  * on each tool call, even when a cached success skips this probe.
43
+ *
44
+ * When `listings` is given, a server that was probed keeps its `tools/list`
45
+ * result there (all pages: the client aggregates them when no cursor is
46
+ * passed), plus `resources/list` when the server advertises resources. The
47
+ * Pi backend seeds its MCP metadata cache from it, so Pi does not repeat the
48
+ * handshake on this turn. A server skipped by the success cache, or whose
49
+ * resource listing failed, has no listing.
50
+ */
51
+ async function verifyMcpServers(servers, signal, successful, failed, listings,
52
+ /**
53
+ * Servers to probe and list even inside the success-cache window: their
54
+ * stored listing is no longer valid (item 1(b)), so without a fresh one Pi
55
+ * would fall back to its own eager handshake.
23
56
  */
24
- async function verifyMcpServers(servers, signal, successful, failed) {
57
+ relist) {
25
58
  const results = await Promise.all(Object.entries(servers).map(async ([name, server]) => {
26
59
  if (server.type !== 'http' || !server.url)
27
60
  return null;
@@ -30,6 +63,7 @@ async function verifyMcpServers(servers, signal, successful, failed) {
30
63
  const key = `${name}\n${server.url}`;
31
64
  const checkedAt = successful?.get(key);
32
65
  if (checkedAt !== undefined &&
66
+ !relist?.has(name) &&
33
67
  Date.now() - checkedAt < SUCCESS_CACHE_TTL_MS)
34
68
  return null;
35
69
  const priorFailure = failed?.get(key);
@@ -55,7 +89,25 @@ async function verifyMcpServers(servers, signal, successful, failed) {
55
89
  : deadline;
56
90
  try {
57
91
  await client.connect(new client_1.StreamableHTTPClientTransport(new URL(server.url)), { timeout: 10_000, signal: requestSignal });
58
- await client.listTools({}, { timeout: 10_000, signal: requestSignal });
92
+ const listed = await client.listTools({}, { timeout: 10_000, signal: requestSignal });
93
+ if (listings) {
94
+ const resourcesAdvertised = Boolean(client.getServerCapabilities?.()?.resources);
95
+ let resources = [];
96
+ if (resourcesAdvertised) {
97
+ // Pi lists resources on connect. A seeded entry without them
98
+ // would hide the server's `read_*` tools, so a failed resource
99
+ // listing leaves the server unseeded (Pi then connects as before).
100
+ resources = await client
101
+ .listResources({}, { timeout: 10_000, signal: requestSignal })
102
+ .then((result) => (result.resources ?? []))
103
+ .catch(() => null);
104
+ if (signal?.aborted)
105
+ throw new Error('MCP check was cancelled.');
106
+ }
107
+ if (resources) {
108
+ listings.set(name, toolListing(server.url, client, listed, resources, resourcesAdvertised));
109
+ }
110
+ }
59
111
  successful?.set(key, Date.now());
60
112
  failed?.delete(key);
61
113
  if (successful && successful.size > MAX_CACHE_ENTRIES) {
@@ -0,0 +1,79 @@
1
+ import { type McpServerToolListing, type McpToolsetMember } from './mcp-toolset';
2
+ /**
3
+ * ITEM 1(b): THE PER-SESSION MCP TOOL STORE.
4
+ *
5
+ * Pi is started again on every turn. Without this store a warm turn cannot
6
+ * seed Pi's metadata cache: the 5-minute preflight success cache skips the
7
+ * probe, so nothing is listed, every server stays eager and Pi repeats the
8
+ * whole MCP handshake before the first prompt (~19.6 s on the baseline warm
9
+ * turn). The store keeps the last listing of each server per session and
10
+ * gives it back while the toolset is unchanged, so a warm turn sends no MCP
11
+ * request before the prompt.
12
+ *
13
+ * WHEN A LISTING IS REUSED. Only while all of these still hold:
14
+ * - the lease carries `mcpToolScope` (the speaker and the Space whose live
15
+ * permissions the gateway applies). An older control plane does not send
16
+ * it, and then nothing is reused, so a list never crosses speakers;
17
+ * - the scope is the one the listing was taken under. A different speaker in
18
+ * a shared chat drops every stored listing of the session;
19
+ * - the server's own fingerprint (name, upstream URL, allowlist) is the same;
20
+ * - the server is still configured at the URL the listing was taken from;
21
+ * - the listing is younger than `MCP_TOOL_STORE_MAX_AGE_MS` and than the
22
+ * server's own `ttlMs` hint. A `ttlMs: 0` listing (the workspace server
23
+ * sends one) is never reused: that server is listed again by the preflight
24
+ * on every turn, two requests in the 2026-07-28 era instead of Pi's four,
25
+ * until the gateway declares a non-zero private ttl (PR follow-up).
26
+ *
27
+ * Any other server is checked by the preflight as before. A server that had a
28
+ * stored listing which is no longer valid is re-listed even inside the success
29
+ * cache window, so the changed server is seeded again rather than left eager.
30
+ *
31
+ * The store holds only tool metadata (names, descriptions, schemas) and never
32
+ * a token. It is advisory: the gateway still checks every `tools/call`.
33
+ */
34
+ /**
35
+ * How long a stored listing may be reused. Longer than the 5-minute preflight
36
+ * success cache, so a turn a few minutes after the last one skips the
37
+ * preflight too (the baseline turn `91c442db` came 6 minutes after the one
38
+ * before it and spent 9.7 s in it). Short, because a catalogue change that is
39
+ * not in the lease (an app connected, or a grant revoked, for the same speaker)
40
+ * shows only after this; Pi also replaces the cached tools as soon as it
41
+ * connects to a server. A revoked tool can stay listed for up to this long,
42
+ * but the gateway refuses the call: a stale name, never extra access.
43
+ */
44
+ export declare const MCP_TOOL_STORE_MAX_AGE_MS: number;
45
+ interface StoredServer {
46
+ memberKey: string;
47
+ listing: McpServerToolListing;
48
+ }
49
+ interface StoredToolset {
50
+ scopeKey: string;
51
+ servers: Map<string, StoredServer>;
52
+ }
53
+ /** Shared by the harness across the per-turn runners, keyed by session id. */
54
+ export type McpToolStore = Map<string, StoredToolset>;
55
+ export type McpToolScope = Record<string, string>;
56
+ export interface McpToolStorePlan {
57
+ /** Stored listings that this turn uses instead of a preflight. */
58
+ reused: Record<string, McpServerToolListing>;
59
+ /** Servers whose stored listing was dropped; list them again. */
60
+ relist: Set<string>;
61
+ /** Why each dropped listing was dropped (server names only). */
62
+ dropped: Record<string, string>;
63
+ }
64
+ /**
65
+ * Decide which servers this turn can take from the store. Drops every entry
66
+ * that is no longer valid, so a later turn cannot reuse it either.
67
+ */
68
+ export declare function planMcpToolReuse(store: McpToolStore, sessionId: string, members: readonly McpToolsetMember[], scope: McpToolScope | null, configured: Readonly<Record<string, {
69
+ type: string;
70
+ url?: string;
71
+ }>>, now?: number): McpToolStorePlan;
72
+ /**
73
+ * Keep this turn's fresh listings for the next turn of the session. Nothing is
74
+ * kept without a scope. A `ttlMs: 0` listing is kept only as a marker: it is
75
+ * never reused, but the next turn knows to list that server again rather than
76
+ * leave it to Pi's eager handshake.
77
+ */
78
+ export declare function rememberMcpToolListings(store: McpToolStore, sessionId: string, members: readonly McpToolsetMember[], scope: McpToolScope | null, listings: ReadonlyMap<string, McpServerToolListing>): string[];
79
+ export {};
@@ -0,0 +1,151 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MCP_TOOL_STORE_MAX_AGE_MS = void 0;
4
+ exports.planMcpToolReuse = planMcpToolReuse;
5
+ exports.rememberMcpToolListings = rememberMcpToolListings;
6
+ const mcp_toolset_1 = require("./mcp-toolset");
7
+ /**
8
+ * ITEM 1(b): THE PER-SESSION MCP TOOL STORE.
9
+ *
10
+ * Pi is started again on every turn. Without this store a warm turn cannot
11
+ * seed Pi's metadata cache: the 5-minute preflight success cache skips the
12
+ * probe, so nothing is listed, every server stays eager and Pi repeats the
13
+ * whole MCP handshake before the first prompt (~19.6 s on the baseline warm
14
+ * turn). The store keeps the last listing of each server per session and
15
+ * gives it back while the toolset is unchanged, so a warm turn sends no MCP
16
+ * request before the prompt.
17
+ *
18
+ * WHEN A LISTING IS REUSED. Only while all of these still hold:
19
+ * - the lease carries `mcpToolScope` (the speaker and the Space whose live
20
+ * permissions the gateway applies). An older control plane does not send
21
+ * it, and then nothing is reused, so a list never crosses speakers;
22
+ * - the scope is the one the listing was taken under. A different speaker in
23
+ * a shared chat drops every stored listing of the session;
24
+ * - the server's own fingerprint (name, upstream URL, allowlist) is the same;
25
+ * - the server is still configured at the URL the listing was taken from;
26
+ * - the listing is younger than `MCP_TOOL_STORE_MAX_AGE_MS` and than the
27
+ * server's own `ttlMs` hint. A `ttlMs: 0` listing (the workspace server
28
+ * sends one) is never reused: that server is listed again by the preflight
29
+ * on every turn, two requests in the 2026-07-28 era instead of Pi's four,
30
+ * until the gateway declares a non-zero private ttl (PR follow-up).
31
+ *
32
+ * Any other server is checked by the preflight as before. A server that had a
33
+ * stored listing which is no longer valid is re-listed even inside the success
34
+ * cache window, so the changed server is seeded again rather than left eager.
35
+ *
36
+ * The store holds only tool metadata (names, descriptions, schemas) and never
37
+ * a token. It is advisory: the gateway still checks every `tools/call`.
38
+ */
39
+ /**
40
+ * How long a stored listing may be reused. Longer than the 5-minute preflight
41
+ * success cache, so a turn a few minutes after the last one skips the
42
+ * preflight too (the baseline turn `91c442db` came 6 minutes after the one
43
+ * before it and spent 9.7 s in it). Short, because a catalogue change that is
44
+ * not in the lease (an app connected, or a grant revoked, for the same speaker)
45
+ * shows only after this; Pi also replaces the cached tools as soon as it
46
+ * connects to a server. A revoked tool can stay listed for up to this long,
47
+ * but the gateway refuses the call: a stale name, never extra access.
48
+ */
49
+ exports.MCP_TOOL_STORE_MAX_AGE_MS = 15 * 60_000;
50
+ const MAX_STORED_SESSIONS = 256;
51
+ function memberKey(member) {
52
+ return (0, mcp_toolset_1.mcpToolsetFingerprint)([member]);
53
+ }
54
+ function scopeKeyOf(scope) {
55
+ return (0, mcp_toolset_1.mcpToolsetFingerprint)([], scope);
56
+ }
57
+ function reusableUntil(listing) {
58
+ const ttl = listing.ttlMs === undefined
59
+ ? exports.MCP_TOOL_STORE_MAX_AGE_MS
60
+ : Math.min(listing.ttlMs, exports.MCP_TOOL_STORE_MAX_AGE_MS);
61
+ return listing.listedAt + ttl;
62
+ }
63
+ /**
64
+ * Decide which servers this turn can take from the store. Drops every entry
65
+ * that is no longer valid, so a later turn cannot reuse it either.
66
+ */
67
+ function planMcpToolReuse(store, sessionId, members, scope, configured, now = Date.now()) {
68
+ const plan = { reused: {}, relist: new Set(), dropped: {} };
69
+ const stored = store.get(sessionId);
70
+ if (!stored)
71
+ return plan;
72
+ const dropAll = (reason) => {
73
+ for (const name of stored.servers.keys()) {
74
+ plan.relist.add(name);
75
+ plan.dropped[name] = reason;
76
+ }
77
+ store.delete(sessionId);
78
+ };
79
+ if (!scope) {
80
+ dropAll('lease has no tool scope');
81
+ return plan;
82
+ }
83
+ if (stored.scopeKey !== scopeKeyOf(scope)) {
84
+ dropAll('speaker or space changed');
85
+ return plan;
86
+ }
87
+ const current = new Map(members.map((member) => [member.name, member]));
88
+ for (const [name, entry] of [...stored.servers]) {
89
+ const member = current.get(name);
90
+ let reason = null;
91
+ if (!member)
92
+ reason = 'server removed';
93
+ else if (entry.memberKey !== memberKey(member))
94
+ reason = 'server changed';
95
+ else if (configured[name]?.type !== 'http')
96
+ reason = 'server not configured';
97
+ else if (configured[name]?.url !== entry.listing.url)
98
+ reason = 'url changed';
99
+ else if (now >= reusableUntil(entry.listing))
100
+ reason =
101
+ entry.listing.ttlMs !== undefined &&
102
+ entry.listing.ttlMs < exports.MCP_TOOL_STORE_MAX_AGE_MS
103
+ ? 'server ttl'
104
+ : 'expired';
105
+ if (reason) {
106
+ stored.servers.delete(name);
107
+ plan.dropped[name] = reason;
108
+ if (member)
109
+ plan.relist.add(name);
110
+ continue;
111
+ }
112
+ plan.reused[name] = entry.listing;
113
+ }
114
+ // A reuse counts as use: move the session to the newest end.
115
+ store.delete(sessionId);
116
+ if (stored.servers.size > 0)
117
+ store.set(sessionId, stored);
118
+ return plan;
119
+ }
120
+ /**
121
+ * Keep this turn's fresh listings for the next turn of the session. Nothing is
122
+ * kept without a scope. A `ttlMs: 0` listing is kept only as a marker: it is
123
+ * never reused, but the next turn knows to list that server again rather than
124
+ * leave it to Pi's eager handshake.
125
+ */
126
+ function rememberMcpToolListings(store, sessionId, members, scope, listings) {
127
+ if (!scope)
128
+ return [];
129
+ const scopeKey = scopeKeyOf(scope);
130
+ let stored = store.get(sessionId);
131
+ if (!stored || stored.scopeKey !== scopeKey) {
132
+ stored = { scopeKey, servers: new Map() };
133
+ }
134
+ const current = new Map(members.map((member) => [member.name, member]));
135
+ const kept = [];
136
+ for (const [name, listing] of listings) {
137
+ const member = current.get(name);
138
+ if (!member)
139
+ continue;
140
+ stored.servers.set(name, { memberKey: memberKey(member), listing });
141
+ kept.push(name);
142
+ }
143
+ // Most recently used last, so the oldest session is evicted first.
144
+ store.delete(sessionId);
145
+ if (stored.servers.size > 0)
146
+ store.set(sessionId, stored);
147
+ while (store.size > MAX_STORED_SESSIONS) {
148
+ store.delete(store.keys().next().value);
149
+ }
150
+ return kept;
151
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * One MCP server's tool listing, taken by the harness preflight.
3
+ *
4
+ * The values are what the server returned for `tools/list` (all pages) and,
5
+ * when the server advertises resources, `resources/list`. They contain no
6
+ * credential: the per-turn MCP token is added by the loopback proxy and never
7
+ * appears in a URL, a header or a listing here.
8
+ */
9
+ export interface McpServerToolListing {
10
+ /** The exact URL the listing was taken from (the loopback proxy URL). */
11
+ url: string;
12
+ tools: Array<Record<string, unknown> & {
13
+ name: string;
14
+ }>;
15
+ resources: Array<Record<string, unknown>>;
16
+ /** True when the server advertises the resources capability. */
17
+ resourcesAdvertised: boolean;
18
+ instructions?: string;
19
+ /** Server cache hints from the first `tools/list` page, when present. */
20
+ ttlMs?: number;
21
+ cacheScope?: 'public' | 'private';
22
+ listedAt: number;
23
+ }
24
+ /**
25
+ * The tool listings for one toolset, keyed by the harness server name.
26
+ *
27
+ * Item 4 uses this only within the turn that took it. Item 1(b) can keep one
28
+ * per session and reuse it on a later turn while `fingerprint` still matches.
29
+ */
30
+ export interface McpToolsetSnapshot {
31
+ fingerprint: string;
32
+ servers: Record<string, McpServerToolListing>;
33
+ }
34
+ /** One leased server, as it enters the toolset fingerprint. */
35
+ export interface McpToolsetMember {
36
+ name: string;
37
+ /** The upstream URL from the lease, not the loopback proxy URL. */
38
+ url: string;
39
+ /** `null` means every tool the server exposes. */
40
+ allowedTools: readonly string[] | null;
41
+ }
42
+ /**
43
+ * A stable hash of what decides a toolset: the server set, each server's URL
44
+ * and its allowlist, plus any caller scope (for example the speaker, the space
45
+ * and a permission version, once the lease carries them).
46
+ *
47
+ * A change to any input gives a different fingerprint, so a cached listing for
48
+ * the old toolset is not reused. Order does not matter. `allowedTools: null`
49
+ * (every tool) and `[]` (no tool) are different toolsets.
50
+ */
51
+ export declare function mcpToolsetFingerprint(members: readonly McpToolsetMember[], scope?: Record<string, string | number | null | undefined>): string;