@ai-sdk/harness-pi 1.0.63 → 1.0.65

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/harness-pi",
3
- "version": "1.0.63",
3
+ "version": "1.0.65",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -26,10 +26,12 @@
26
26
  }
27
27
  },
28
28
  "dependencies": {
29
+ "@earendil-works/pi-ai": "0.74.2",
29
30
  "@earendil-works/pi-coding-agent": "^0.80.10",
31
+ "pi-mcp-adapter": "2.12.1",
30
32
  "typebox": "^1.1.38",
31
- "@ai-sdk/harness": "1.0.63",
32
- "@ai-sdk/provider-utils": "5.0.24"
33
+ "@ai-sdk/provider-utils": "5.0.26",
34
+ "@ai-sdk/harness": "1.0.65"
33
35
  },
34
36
  "peerDependencies": {
35
37
  "zod": "^3.25.76 || ^4.1.8"
package/src/pi-harness.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  type HarnessV1BuiltinTool,
5
5
  } from '@ai-sdk/harness';
6
6
  import { tool } from '@ai-sdk/provider-utils';
7
+ import type { ExtensionFactory } from '@earendil-works/pi-coding-agent';
7
8
  import { z } from 'zod/v4';
8
9
  import type { PiAuthOptions } from './pi-auth';
9
10
  import { piResumeStateSchema } from './pi-resume-state';
@@ -40,6 +41,17 @@ export type PiHarnessSettings = {
40
41
  * model settings.
41
42
  */
42
43
  readonly agentDir?: string;
44
+ /**
45
+ * MCP server definitions keyed by server name. Each definition uses the
46
+ * underlying runtime's native MCP server configuration format.
47
+ */
48
+ readonly mcpServers?: Record<string, unknown>;
49
+ /**
50
+ * Trusted inline Pi extensions loaded for each harness session.
51
+ *
52
+ * Filesystem-discovered user and project extensions remain disabled.
53
+ */
54
+ readonly extensionFactories?: ReadonlyArray<ExtensionFactory>;
43
55
  };
44
56
 
45
57
  const PI_BUILTIN_TOOLS = {
@@ -144,6 +156,10 @@ export function createPi(
144
156
  ...(settings.thinkingLevel
145
157
  ? { thinkingLevel: settings.thinkingLevel }
146
158
  : {}),
159
+ ...(settings.mcpServers ? { mcpServers: settings.mcpServers } : {}),
160
+ ...(settings.extensionFactories
161
+ ? { extensionFactories: settings.extensionFactories }
162
+ : {}),
147
163
  },
148
164
  clientApp: PI_CLIENT_APP,
149
165
  isResume: lifecycleState != null,
package/src/pi-session.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  SettingsManager,
9
9
  type AgentSession,
10
10
  type AgentToolResult,
11
+ type ExtensionFactory,
11
12
  type Skill,
12
13
  type ToolDefinition,
13
14
  } from '@earendil-works/pi-coding-agent';
@@ -63,6 +64,26 @@ import { syncHostWorkspaceFromSandbox } from './pi-workspace-mirror';
63
64
 
64
65
  const HARNESS_ID = 'pi';
65
66
 
67
+ /*
68
+ * pi-mcp-adapter publishes TypeScript source as its package entry point. A
69
+ * non-literal specifier keeps the repository type-check focused on this
70
+ * package's compatibility boundary instead of compiling dependency internals.
71
+ */
72
+ const PI_MCP_ADAPTER_PACKAGE: string = 'pi-mcp-adapter';
73
+
74
+ type PiMcpAdapterModule = {
75
+ createMcpAdapter(options: {
76
+ config: {
77
+ mcpServers: Record<string, unknown>;
78
+ settings: {
79
+ directTools: boolean;
80
+ toolPrefix: string;
81
+ disableProxyTool: boolean;
82
+ };
83
+ };
84
+ }): ExtensionFactory;
85
+ };
86
+
66
87
  /*
67
88
  * Pi runs in this Node process, not behind an attachable in-sandbox bridge.
68
89
  * During a tool approval pause the Pi turn is still alive and blocked on the
@@ -192,6 +213,8 @@ export interface PiSessionSettings {
192
213
  readonly auth?: PiAuthOptions;
193
214
  readonly model?: string;
194
215
  readonly thinkingLevel?: PiThinkingLevel;
216
+ readonly mcpServers?: Record<string, unknown>;
217
+ readonly extensionFactories?: ReadonlyArray<ExtensionFactory>;
195
218
  }
196
219
 
197
220
  export interface CreatePiSessionInput {
@@ -367,19 +390,69 @@ export async function createPiSession(
367
390
  // Resolve once: deterministic given the configured model. This is the Pi
368
391
  // `Model` object handed to `createAgentSession`.
369
392
  const resolvedModel = resolveModel(input.settings.model);
393
+ const mcpServers = resolvePiMcpServers({
394
+ mcpServers: input.settings.mcpServers,
395
+ });
396
+ const hasMcpServers = Object.keys(mcpServers).length > 0;
370
397
 
398
+ /*
399
+ * Configured MCP servers are served by an inline Pi extension, so they share
400
+ * the extension runtime with the caller-supplied factories: both are loaded
401
+ * by the resource loader below and both are subject to the reload handling
402
+ * that keeps the active runtime alive across resource-only reloads.
403
+ */
404
+ const extensionFactories: ExtensionFactory[] = [
405
+ ...(input.settings.extensionFactories ?? []),
406
+ ];
407
+ if (hasMcpServers) {
408
+ const { createMcpAdapter } = (await import(
409
+ PI_MCP_ADAPTER_PACKAGE
410
+ )) as PiMcpAdapterModule;
411
+ extensionFactories.push(
412
+ createMcpAdapter({
413
+ config: {
414
+ mcpServers,
415
+ settings: {
416
+ directTools: true,
417
+ toolPrefix: 'mcp',
418
+ disableProxyTool: true,
419
+ },
420
+ },
421
+ }),
422
+ );
423
+ }
424
+ const hasExtensionFactories = extensionFactories.length > 0;
425
+ let preserveExtensionsResult = false;
426
+ let currentExtensionsResult:
427
+ | ReturnType<DefaultResourceLoader['getExtensions']>
428
+ | undefined;
371
429
  const resourceLoader = new DefaultResourceLoader({
372
430
  cwd: sessionWorkDir,
373
431
  agentDir: hostAgentDir,
374
432
  settingsManager,
375
433
  appendSystemPromptOverride: () => [],
376
- extensionFactories: [],
434
+ extensionFactories,
435
+ ...(hasExtensionFactories
436
+ ? {
437
+ // DefaultResourceLoader invokes inline factories on every reload.
438
+ // Resource-only reloads retain the active extension runtime, while a
439
+ // genuine Pi session rebuild is allowed to replace that runtime.
440
+ extensionsOverride: extensions => {
441
+ if (preserveExtensionsResult && currentExtensionsResult != null) {
442
+ return currentExtensionsResult;
443
+ }
444
+ currentExtensionsResult = extensions;
445
+ return extensions;
446
+ },
447
+ }
448
+ : {}),
377
449
  // Pi runs in the host process, so its default resource discovery reaches
378
450
  // the host developer's personal config (`~/.pi/agent/*`, `~/.agents/*`).
379
- // The harness does not expose extensions, themes, or prompt templates, so
380
- // disable those entirely this also avoids loading and executing a host
381
- // developer's personal Pi extensions inside the server process. Skills are
382
- // kept but filtered to workspace project skills plus harness-provided
451
+ // The harness exposes only explicitly supplied inline extension factories;
452
+ // disable filesystem extension discovery entirely to avoid loading and
453
+ // executing a host developer's personal or project Pi extensions inside
454
+ // the server process. Themes and prompt templates stay disabled. Skills
455
+ // are kept but filtered to workspace project skills plus harness-provided
383
456
  // skills whose files live in sandbox HOME.
384
457
  noExtensions: true,
385
458
  noThemes: true,
@@ -396,6 +469,22 @@ export async function createPiSession(
396
469
  });
397
470
  await resourceLoader.reload();
398
471
 
472
+ async function reloadResourcesOnly(): Promise<void> {
473
+ if (!hasExtensionFactories) {
474
+ await resourceLoader.reload();
475
+ return;
476
+ }
477
+
478
+ const factories = extensionFactories.splice(0);
479
+ preserveExtensionsResult = true;
480
+ try {
481
+ await resourceLoader.reload();
482
+ } finally {
483
+ preserveExtensionsResult = false;
484
+ extensionFactories.push(...factories);
485
+ }
486
+ }
487
+
399
488
  // Per-session mutable state we hold across prompts.
400
489
  let piSession: AgentSession | undefined;
401
490
  let unsubscribe: (() => void) | undefined;
@@ -571,20 +660,38 @@ export async function createPiSession(
571
660
  };
572
661
  }
573
662
 
663
+ async function disposePiSession(): Promise<void> {
664
+ unsubscribe?.();
665
+ unsubscribe = undefined;
666
+
667
+ const session = piSession;
668
+ piSession = undefined;
669
+ if (!session) return;
670
+
671
+ if (hasMcpServers) {
672
+ await session.reload().catch(() => {});
673
+ }
674
+ session.dispose();
675
+ }
676
+
574
677
  async function rebuildPiSession(
575
678
  userTools: ReadonlyArray<HarnessV1ToolSpec>,
576
679
  isFirstBuild: boolean,
577
- ): Promise<void> {
680
+ ): Promise<boolean> {
681
+ let resourcesReloaded = false;
578
682
  if (piSession) {
579
- unsubscribe?.();
580
- unsubscribe = undefined;
581
- piSession.dispose();
582
- piSession = undefined;
683
+ await disposePiSession();
583
684
  // Original adapter waits 25 ms here to let Pi's teardown microtasks
584
685
  // settle before the next createAgentSession. Port verbatim.
585
686
  // TODO(pi-0.77): verify the race still exists; original SDK had a
586
687
  // teardown microtask the host needed to wait on.
587
688
  await new Promise(resolve => setTimeout(resolve, 25));
689
+ if (hasExtensionFactories) {
690
+ // dispose() invalidates Pi's current extension runtime, so a replacement
691
+ // AgentSession needs factories to create a fresh runtime before build.
692
+ await resourceLoader.reload();
693
+ resourcesReloaded = true;
694
+ }
588
695
  }
589
696
 
590
697
  const { customTools, builtinNames } = buildToolDefinitions(userTools);
@@ -609,13 +716,18 @@ export async function createPiSession(
609
716
  settingsManager,
610
717
  resourceLoader,
611
718
  customTools,
612
- tools: toolNames,
719
+ ...(hasMcpServers
720
+ ? { noTools: 'builtin' as const }
721
+ : { tools: toolNames }),
613
722
  ...(input.settings.thinkingLevel
614
723
  ? { thinkingLevel: input.settings.thinkingLevel }
615
724
  : {}),
616
725
  ...(resolvedModel ? { model: resolvedModel } : {}),
617
726
  });
618
727
  piSession = session;
728
+ if (hasMcpServers) {
729
+ await piSession.bindExtensions({ mode: 'print' });
730
+ }
619
731
 
620
732
  // Pick up the actual session file path so doStop can persist it. Pi
621
733
  // 0.77 emits `.jsonl` files; older builds used `.json`. Persist the
@@ -628,6 +740,7 @@ export async function createPiSession(
628
740
 
629
741
  translatorState = createPiTranslatorState({
630
742
  builtinToolNames: builtinNames,
743
+ hostToolNames: userTools.map(tool => tool.name),
631
744
  nativeToCommon: NATIVE_TO_COMMON,
632
745
  });
633
746
 
@@ -645,6 +758,7 @@ export async function createPiSession(
645
758
  // Other event types outside a turn have no consumer and are dropped.
646
759
  }
647
760
  });
761
+ return resourcesReloaded;
648
762
  }
649
763
 
650
764
  /*
@@ -665,12 +779,15 @@ export async function createPiSession(
665
779
  const userTools = turnOpts.tools;
666
780
  const signature = JSON.stringify(userTools.map(t => t.name).sort());
667
781
  const needsRebuild = piSession == null || signature !== lastToolsSignature;
782
+ let resourcesReloaded = false;
668
783
  if (needsRebuild) {
669
- await rebuildPiSession(userTools, piSession == null);
784
+ resourcesReloaded = await rebuildPiSession(userTools, piSession == null);
670
785
  lastToolsSignature = signature;
671
786
  }
672
787
 
673
- await resourceLoader.reload();
788
+ if (!resourcesReloaded) {
789
+ await reloadResourcesOnly();
790
+ }
674
791
  await syncHostWorkspaceFromSandbox({
675
792
  sandbox,
676
793
  sandboxWorkDir: input.sessionWorkDir,
@@ -682,6 +799,7 @@ export async function createPiSession(
682
799
  // session was built with.
683
800
  translatorState = createPiTranslatorState({
684
801
  builtinToolNames: [...PI_NATIVE_BUILTIN_NAMES],
802
+ hostToolNames: userTools.map(tool => tool.name),
685
803
  nativeToCommon: NATIVE_TO_COMMON,
686
804
  });
687
805
 
@@ -795,10 +913,7 @@ export async function createPiSession(
795
913
  }
796
914
  }
797
915
 
798
- unsubscribe?.();
799
- unsubscribe = undefined;
800
- piSession?.dispose();
801
- piSession = undefined;
916
+ await disposePiSession();
802
917
  workspaceVfs.unmount();
803
918
  await rm(hostRoot, { recursive: true, force: true });
804
919
 
@@ -884,10 +999,7 @@ export async function createPiSession(
884
999
  parkedPiSessions.delete(input.sessionId);
885
1000
  settlePendingToolResults('Pi session stopped');
886
1001
  settlePendingToolApprovals('Pi session stopped');
887
- unsubscribe?.();
888
- unsubscribe = undefined;
889
- piSession?.dispose();
890
- piSession = undefined;
1002
+ await disposePiSession();
891
1003
  workspaceVfs.unmount();
892
1004
  await rm(hostRoot, { recursive: true, force: true });
893
1005
  },
@@ -972,10 +1084,7 @@ export async function createPiSession(
972
1084
  parkedPiSessions.delete(input.sessionId);
973
1085
  settlePendingToolResults('Pi session suspended');
974
1086
  settlePendingToolApprovals('Pi session suspended');
975
- unsubscribe?.();
976
- unsubscribe = undefined;
977
- piSession?.dispose();
978
- piSession = undefined;
1087
+ await disposePiSession();
979
1088
  workspaceVfs.unmount();
980
1089
  await rm(hostRoot, { recursive: true, force: true });
981
1090
 
@@ -991,6 +1100,22 @@ export async function createPiSession(
991
1100
  return sessionImpl;
992
1101
  }
993
1102
 
1103
+ function resolvePiMcpServers({
1104
+ mcpServers,
1105
+ }: {
1106
+ mcpServers: Record<string, unknown> | undefined;
1107
+ }): Record<string, unknown> {
1108
+ if (mcpServers == null) return {};
1109
+ for (const [name, value] of Object.entries(mcpServers)) {
1110
+ if (value == null || typeof value !== 'object' || Array.isArray(value)) {
1111
+ throw new Error(
1112
+ `Pi MCP server ${JSON.stringify(name)} must be configured with an object value.`,
1113
+ );
1114
+ }
1115
+ }
1116
+ return mcpServers;
1117
+ }
1118
+
994
1119
  /**
995
1120
  * Whether a terminal error (string from Pi's event stream, or a thrown error)
996
1121
  * is an abort — the expected result of `doSuspendTurn` aborting the in-flight
@@ -1,5 +1,6 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import type { HarnessV1StreamPart } from '@ai-sdk/harness';
3
+ import { secureJsonParse } from '@ai-sdk/provider-utils';
3
4
  import { extractAssistantText, type PiSessionEvent } from './pi-events';
4
5
  import { serializeToolOutput } from './pi-utils';
5
6
 
@@ -43,6 +44,7 @@ export interface PiTranslatorState {
43
44
  * the matching `tool_result`/`tool_execution_end` event is translated.
44
45
  */
45
46
  hostToolResults: Map<string, unknown>;
47
+ dynamicToolCallIds: Set<string>;
46
48
  /**
47
49
  * Names of tools that Pi executes natively (read/write/edit/bash/grep/
48
50
  * find/ls). `tool-call` events for these get `providerExecuted: true`
@@ -50,6 +52,7 @@ export interface PiTranslatorState {
50
52
  * tools are not in this set.
51
53
  */
52
54
  readonly builtinToolNames: ReadonlySet<string>;
55
+ readonly hostToolNames: ReadonlySet<string>;
53
56
  /**
54
57
  * Map of native tool name → common name. `find` → `glob`, etc. Pi emits
55
58
  * native names on its events; the wire `toolName` is the common name when
@@ -60,6 +63,7 @@ export interface PiTranslatorState {
60
63
 
61
64
  export interface PiTranslatorStateOptions {
62
65
  readonly builtinToolNames?: ReadonlyArray<string>;
66
+ readonly hostToolNames?: ReadonlyArray<string>;
63
67
  readonly nativeToCommon?:
64
68
  | ReadonlyMap<string, string>
65
69
  | Record<string, string>;
@@ -82,7 +86,9 @@ export function createPiTranslatorState(
82
86
  pendingStepToolCallIds: new Set(),
83
87
  stepOpen: false,
84
88
  hostToolResults: new Map(),
89
+ dynamicToolCallIds: new Set(),
85
90
  builtinToolNames: new Set(options.builtinToolNames ?? []),
91
+ hostToolNames: new Set(options.hostToolNames ?? []),
86
92
  nativeToCommonNameMap: map,
87
93
  };
88
94
  }
@@ -128,6 +134,15 @@ function unwrapPiToolResult(event: PiSessionEvent): never {
128
134
  return (event.result ?? event.content ?? null) as never;
129
135
  }
130
136
 
137
+ function parseMcpToolResult(content: unknown): unknown {
138
+ if (typeof content !== 'string') return content;
139
+ try {
140
+ return secureJsonParse(content);
141
+ } catch {
142
+ return content;
143
+ }
144
+ }
145
+
131
146
  function resolveToolName(
132
147
  state: PiTranslatorState,
133
148
  nativeName: string,
@@ -307,7 +322,11 @@ export function translatePiEvent(
307
322
  if (!event.toolCallId || !event.toolName) return [];
308
323
  const { wire, native } = resolveToolName(state, event.toolName);
309
324
  state.observedToolNames.set(event.toolCallId, wire);
310
- const providerExecuted = state.builtinToolNames.has(native);
325
+ const isMcpTool =
326
+ !state.hostToolNames.has(native) &&
327
+ (native === 'mcp' || native.startsWith('mcp__'));
328
+ const providerExecuted = state.builtinToolNames.has(native) || isMcpTool;
329
+ if (isMcpTool) state.dynamicToolCallIds.add(event.toolCallId);
311
330
  const input = serializeToolOutput(event.args ?? event.input ?? {});
312
331
  return [
313
332
  {
@@ -317,6 +336,7 @@ export function translatePiEvent(
317
336
  input,
318
337
  ...(wire !== native ? { nativeName: native } : {}),
319
338
  ...(providerExecuted ? { providerExecuted: true } : {}),
339
+ ...(isMcpTool ? { dynamic: true } : {}),
320
340
  } as HarnessV1StreamPart,
321
341
  ];
322
342
  }
@@ -330,6 +350,7 @@ export function translatePiEvent(
330
350
  recordedName ??
331
351
  (nativeName ? resolveToolName(state, nativeName).wire : undefined);
332
352
  if (!wire) return [];
353
+ const dynamic = state.dynamicToolCallIds.delete(event.toolCallId);
333
354
  /*
334
355
  * Prefer the exact value the host submitted for user-registered tools
335
356
  * (see `hostToolResults`). Built-in tools, whose results Pi produces and
@@ -341,7 +362,9 @@ export function translatePiEvent(
341
362
  HarnessV1StreamPart,
342
363
  { type: 'tool-result' }
343
364
  >['result'])
344
- : unwrapPiToolResult(event);
365
+ : dynamic
366
+ ? parseMcpToolResult(unwrapPiToolResult(event))
367
+ : unwrapPiToolResult(event);
345
368
  state.hostToolResults.delete(event.toolCallId);
346
369
  state.pendingStepToolCallIds.delete(event.toolCallId);
347
370
  return [
@@ -351,6 +374,7 @@ export function translatePiEvent(
351
374
  toolName: wire,
352
375
  result,
353
376
  ...(event.isError ? { isError: true } : {}),
377
+ ...(dynamic ? { dynamic: true } : {}),
354
378
  } as HarnessV1StreamPart,
355
379
  ...finishStep(state),
356
380
  ];