@peakflo/agent-harness 0.2.13-rc.1 → 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.
- package/dist/adapters/coding-agent-adapter.d.ts +19 -0
- package/dist/adapters/pi-adapter.d.ts +27 -1
- package/dist/adapters/pi-adapter.js +170 -6
- package/dist/adapters/pi-mcp-cache-seed.d.ts +66 -0
- package/dist/adapters/pi-mcp-cache-seed.js +188 -0
- package/dist/contract.d.ts +1 -1
- package/dist/contract.js +1 -1
- package/dist/control-plane.d.ts +9 -2
- package/dist/control-plane.js +11 -3
- package/dist/harness.d.ts +3 -0
- package/dist/harness.js +28 -7
- package/dist/runtime/cold-start-batch.d.ts +36 -0
- package/dist/runtime/cold-start-batch.js +96 -0
- package/dist/runtime/mcp-preflight.d.ts +15 -1
- package/dist/runtime/mcp-preflight.js +54 -2
- package/dist/runtime/mcp-tool-store.d.ts +79 -0
- package/dist/runtime/mcp-tool-store.js +151 -0
- package/dist/runtime/mcp-toolset.d.ts +51 -0
- package/dist/runtime/mcp-toolset.js +40 -0
- package/dist/runtime/session-runner.d.ts +25 -0
- package/dist/runtime/session-runner.js +133 -9
- package/dist/types.d.ts +8 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/contract.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
package/dist/control-plane.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
85
|
-
|
|
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
|
*
|