@ai-sdk/harness-pi 0.0.0 → 1.0.0-beta.11

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.
@@ -0,0 +1,1179 @@
1
+ import {
2
+ AuthStorage,
3
+ createAgentSession,
4
+ DefaultResourceLoader,
5
+ defineTool,
6
+ ModelRegistry,
7
+ SessionManager,
8
+ SettingsManager,
9
+ type AgentSession,
10
+ type AgentToolResult,
11
+ type Skill,
12
+ type ToolDefinition,
13
+ } from '@earendil-works/pi-coding-agent';
14
+ import { mkdir, rm } from 'node:fs/promises';
15
+ import { tmpdir } from 'node:os';
16
+ import path from 'node:path';
17
+ import { Type } from 'typebox';
18
+ import type {
19
+ HarnessV1ContinueTurnOptions,
20
+ HarnessV1ContinueTurnState,
21
+ HarnessV1PromptControl,
22
+ HarnessV1PromptTurnOptions,
23
+ HarnessV1NetworkSandboxSession,
24
+ HarnessV1PermissionMode,
25
+ HarnessV1ResumeSessionState,
26
+ HarnessV1Session,
27
+ HarnessV1Skill,
28
+ HarnessV1StreamPart,
29
+ HarnessV1ToolSpec,
30
+ } from '@ai-sdk/harness';
31
+ import type { Experimental_SandboxSession } from '@ai-sdk/provider-utils';
32
+ import { resolvePiAuth, type PiAuthOptions } from './pi-auth';
33
+ import { getPiTerminalError, parseNativeEvent } from './pi-events';
34
+ import { createPiModelResolver } from './pi-model-resolver';
35
+ import { createPiPathMapper } from './pi-paths';
36
+ import { createPiRemoteOps, type PiRemoteOps } from './pi-remote-ops';
37
+ import { writePiSkills } from './pi-skills';
38
+ import {
39
+ persistSessionFileToSandbox,
40
+ pullSessionFileFromSandbox,
41
+ } from './pi-resume-state';
42
+ import {
43
+ createPiTranslatorState,
44
+ translatePiEvent,
45
+ type PiTranslatorState,
46
+ } from './pi-translate';
47
+ import { toolSpecToTypeBoxParameters } from './pi-typebox-adapter';
48
+ import {
49
+ extractUserText,
50
+ frameInstructions,
51
+ safePiMetadataSegment,
52
+ serializeToolOutput,
53
+ } from './pi-utils';
54
+ import { PiWorkspaceVfs } from './pi-workspace-vfs';
55
+ import { syncHostWorkspaceFromSandbox } from './pi-workspace-mirror';
56
+
57
+ const HARNESS_ID = 'pi';
58
+
59
+ /*
60
+ * Pi runs in this Node process, not behind an attachable in-sandbox bridge.
61
+ * During a tool approval pause the Pi turn is still alive and blocked on the
62
+ * custom tool promise, so detach must park that live session for the next
63
+ * same-process resume instead of stopping it and resolving the promise as an
64
+ * error. Cross-process resume still falls back to the persisted session file.
65
+ */
66
+ const parkedPiSessions = new Map<string, HarnessV1Session>();
67
+
68
+ /**
69
+ * Whether a discovered resource path belongs to a specific directory.
70
+ */
71
+ function isWithinDirectory(parent: string, child: string): boolean {
72
+ const rel = path.relative(parent, child);
73
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
74
+ }
75
+
76
+ /**
77
+ * Whether a discovered resource path belongs to the session workspace — either
78
+ * the sandbox-side working directory the model sees (`sessionWorkDir`) or its
79
+ * host-side mirror (`hostWorkDir`).
80
+ */
81
+ function isWithinWorkspace(
82
+ candidate: string,
83
+ sessionWorkDir: string,
84
+ hostWorkDir: string,
85
+ ): boolean {
86
+ return (
87
+ isWithinDirectory(sessionWorkDir, candidate) ||
88
+ isWithinDirectory(hostWorkDir, candidate)
89
+ );
90
+ }
91
+
92
+ function createHarnessPiSkills({
93
+ skills,
94
+ sandboxSkillRootDir,
95
+ }: {
96
+ skills: ReadonlyArray<HarnessV1Skill>;
97
+ sandboxSkillRootDir: string;
98
+ }): Skill[] {
99
+ return skills.map(skill => {
100
+ const name = safePiMetadataSegment(skill.name, 'skill');
101
+ const baseDir = path.posix.join(sandboxSkillRootDir, name);
102
+ const filePath = path.posix.join(baseDir, 'SKILL.md');
103
+ return {
104
+ name: skill.name,
105
+ description: skill.description,
106
+ filePath,
107
+ baseDir,
108
+ sourceInfo: {
109
+ path: filePath,
110
+ source: 'harness',
111
+ scope: 'temporary',
112
+ origin: 'top-level',
113
+ baseDir,
114
+ },
115
+ disableModelInvocation: false,
116
+ };
117
+ });
118
+ }
119
+
120
+ async function resolveSandboxHomeDir({
121
+ sandbox,
122
+ abortSignal,
123
+ }: {
124
+ sandbox: Experimental_SandboxSession;
125
+ abortSignal?: AbortSignal;
126
+ }): Promise<string> {
127
+ const result = await sandbox.run({
128
+ command: 'printf "%s" "$HOME"',
129
+ ...(abortSignal ? { abortSignal } : {}),
130
+ });
131
+ const homeDir = result.stdout.trim();
132
+ if (result.exitCode !== 0 || !homeDir || !path.posix.isAbsolute(homeDir)) {
133
+ throw new Error(
134
+ `Unable to resolve sandbox HOME directory: ${result.stderr || result.stdout}`,
135
+ );
136
+ }
137
+ return homeDir;
138
+ }
139
+
140
+ const PI_NATIVE_BUILTIN_NAMES = [
141
+ 'read',
142
+ 'write',
143
+ 'edit',
144
+ 'bash',
145
+ 'grep',
146
+ 'find',
147
+ 'ls',
148
+ ] as const;
149
+
150
+ const NATIVE_TO_COMMON: Readonly<Record<string, string>> = {
151
+ find: 'glob',
152
+ };
153
+
154
+ const PI_NATIVE_TOOL_KINDS: Readonly<
155
+ Record<(typeof PI_NATIVE_BUILTIN_NAMES)[number], 'readonly' | 'edit' | 'bash'>
156
+ > = {
157
+ read: 'readonly',
158
+ write: 'edit',
159
+ edit: 'edit',
160
+ bash: 'bash',
161
+ grep: 'readonly',
162
+ find: 'readonly',
163
+ ls: 'readonly',
164
+ };
165
+
166
+ export type PiThinkingLevel =
167
+ | 'off'
168
+ | 'minimal'
169
+ | 'low'
170
+ | 'medium'
171
+ | 'high'
172
+ | 'xhigh';
173
+
174
+ export interface PiSessionSettings {
175
+ readonly auth?: PiAuthOptions;
176
+ readonly model?: string;
177
+ readonly thinkingLevel?: PiThinkingLevel;
178
+ }
179
+
180
+ export interface CreatePiSessionInput {
181
+ readonly sessionId: string;
182
+ readonly sandboxSession: HarnessV1NetworkSandboxSession;
183
+ readonly sessionWorkDir: string;
184
+ readonly skills: ReadonlyArray<HarnessV1Skill>;
185
+ readonly settings: PiSessionSettings;
186
+ readonly isResume: boolean;
187
+ readonly permissionMode?: HarnessV1PermissionMode;
188
+ readonly resumeSessionFileName?: string;
189
+ readonly abortSignal?: AbortSignal;
190
+ }
191
+
192
+ interface PendingToolResult {
193
+ resolve: (value: unknown) => void;
194
+ }
195
+
196
+ interface PendingToolApproval {
197
+ resolve: (value: { approved: boolean; reason?: string }) => void;
198
+ }
199
+
200
+ interface ActivePiTurn {
201
+ readonly token: object;
202
+ readonly done: Promise<void>;
203
+ }
204
+
205
+ export async function createPiSession(
206
+ input: CreatePiSessionInput,
207
+ ): Promise<HarnessV1Session> {
208
+ if (input.isResume) {
209
+ const parkedSession = parkedPiSessions.get(input.sessionId);
210
+ if (parkedSession) {
211
+ parkedPiSessions.delete(input.sessionId);
212
+ return {
213
+ ...parkedSession,
214
+ isResume: true,
215
+ };
216
+ }
217
+ }
218
+
219
+ // Host-side mirror layout under tmpdir. Replace path-separator characters
220
+ // that would otherwise turn a session id like `2026-05-29T17:54:27` into a
221
+ // sub-directory tree on disk.
222
+ const safeSessionId = input.sessionId.replace(/[\\/: ]/g, '-');
223
+ const hostRoot = path.join(tmpdir(), 'ai-sdk-harness', 'pi', safeSessionId);
224
+ const hostWorkDir = path.join(hostRoot, 'workspace');
225
+ const hostAgentDir = path.join(hostRoot, 'agent');
226
+ const hostSessionDir = path.join(hostRoot, 'sessions');
227
+
228
+ // Pi runs in this host process but must behave as though it lives in the
229
+ // sandbox workspace: its working directory is the real `sessionWorkDir`
230
+ // (where `setup()` clones and where the sandbox-backed tools operate), so the
231
+ // paths Pi advertises to the model — most notably the "Current working
232
+ // directory" line in its system prompt — resolve inside the sandbox. The
233
+ // workspace VFS maps that sandbox path to the host-side mirror so Pi's own
234
+ // `fs`-based resource loading (`.pi/`, `AGENTS.md`) still works on the host.
235
+ // `sessionWorkDir` is a sandbox path (e.g. `/vercel/sandbox/...`) that does
236
+ // not exist on the host, so it is a safe, collision-free VFS mount point.
237
+ const sessionWorkDir = input.sessionWorkDir;
238
+
239
+ await mkdir(hostWorkDir, { recursive: true });
240
+ await mkdir(hostAgentDir, { recursive: true });
241
+ await mkdir(hostSessionDir, { recursive: true });
242
+
243
+ const sandbox = input.sandboxSession.restricted();
244
+ const permissionMode = input.permissionMode ?? 'allow-all';
245
+ let sandboxSkillRootDir: string | undefined;
246
+ let harnessSkills: Skill[] = [];
247
+
248
+ // Materialise harness-provided skills into sandbox HOME, not the workspace.
249
+ if (input.skills.length > 0) {
250
+ const sandboxHomeDir = await resolveSandboxHomeDir({
251
+ sandbox,
252
+ ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}),
253
+ });
254
+ sandboxSkillRootDir = path.posix.join(sandboxHomeDir, '.agents', 'skills');
255
+ harnessSkills = createHarnessPiSkills({
256
+ skills: input.skills,
257
+ sandboxSkillRootDir,
258
+ });
259
+ await writePiSkills({
260
+ sandbox,
261
+ sandboxHomeDir,
262
+ skills: input.skills,
263
+ ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}),
264
+ });
265
+ }
266
+
267
+ // On resume: pull the Pi session file out of the sandbox into the fresh
268
+ // host mirror so SessionManager.open can read it.
269
+ let resumeSessionFilePath: string | undefined;
270
+ if (input.isResume && input.resumeSessionFileName) {
271
+ resumeSessionFilePath = await pullSessionFileFromSandbox({
272
+ sandbox,
273
+ sessionWorkDir: input.sessionWorkDir,
274
+ hostSessionDir,
275
+ sessionFileName: input.resumeSessionFileName,
276
+ ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}),
277
+ });
278
+ }
279
+
280
+ // Snapshot sandbox state into the host mirror BEFORE the VFS goes live so
281
+ // Pi sees the workspace as soon as it boots.
282
+ await syncHostWorkspaceFromSandbox({
283
+ sandbox,
284
+ sandboxWorkDir: input.sessionWorkDir,
285
+ hostWorkDir,
286
+ });
287
+
288
+ // Mount only the workspace: the model's view of the workspace lives at
289
+ // `sessionWorkDir` and is backed by `hostWorkDir`. The agent and session
290
+ // directories stay on the real host filesystem (below) — they are host-only
291
+ // Pi state (auth, model registry, session journal) that must never surface
292
+ // in the sandbox or the workspace mirror.
293
+ const workspaceVfs = new PiWorkspaceVfs();
294
+ workspaceVfs.mount(hostWorkDir, sessionWorkDir);
295
+
296
+ const paths = createPiPathMapper({
297
+ hostWorkDir,
298
+ sandboxWorkDir: sessionWorkDir,
299
+ readableRoots: sandboxSkillRootDir
300
+ ? [{ sandboxDir: sandboxSkillRootDir }]
301
+ : [],
302
+ });
303
+
304
+ // Pi auth + model registry are global to this Pi session. These live on the
305
+ // real host filesystem (`hostAgentDir`), never in the sandbox/workspace.
306
+ const authStorage = AuthStorage.create(path.join(hostAgentDir, 'auth.json'));
307
+ const modelRegistry = ModelRegistry.create(
308
+ authStorage,
309
+ path.join(hostAgentDir, 'models.json'),
310
+ );
311
+ const settingsManager = SettingsManager.inMemory();
312
+
313
+ // Run-scoped env (for the model resolver's gateway fallback heuristic).
314
+ const resolverEnv = resolvePiAuth(input.settings.auth, process.env, {
315
+ authStorage,
316
+ modelRegistry,
317
+ });
318
+ const resolveModel = createPiModelResolver(modelRegistry, resolverEnv);
319
+ // Resolve once: deterministic given the configured model. This is the Pi
320
+ // `Model` object handed to `createAgentSession`.
321
+ const resolvedModel = resolveModel(input.settings.model);
322
+
323
+ const resourceLoader = new DefaultResourceLoader({
324
+ cwd: sessionWorkDir,
325
+ agentDir: hostAgentDir,
326
+ settingsManager,
327
+ appendSystemPromptOverride: () => [],
328
+ extensionFactories: [],
329
+ // Pi runs in the host process, so its default resource discovery reaches
330
+ // the host developer's personal config (`~/.pi/agent/*`, `~/.agents/*`).
331
+ // The harness does not expose extensions, themes, or prompt templates, so
332
+ // disable those entirely — this also avoids loading and executing a host
333
+ // developer's personal Pi extensions inside the server process. Skills are
334
+ // kept but filtered to workspace project skills plus harness-provided
335
+ // skills whose files live in sandbox HOME.
336
+ noExtensions: true,
337
+ noThemes: true,
338
+ noPromptTemplates: true,
339
+ skillsOverride: base => ({
340
+ ...base,
341
+ skills: [
342
+ ...base.skills.filter(skill =>
343
+ isWithinWorkspace(skill.filePath, sessionWorkDir, hostWorkDir),
344
+ ),
345
+ ...harnessSkills,
346
+ ],
347
+ }),
348
+ });
349
+ await resourceLoader.reload();
350
+
351
+ // Per-session mutable state we hold across prompts.
352
+ let piSession: AgentSession | undefined;
353
+ let unsubscribe: (() => void) | undefined;
354
+ let lastToolsSignature: string | undefined;
355
+ let sessionFileName: string | undefined;
356
+ let stopped = false;
357
+ /*
358
+ * Set by `doSuspendTurn` before it aborts the in-flight host turn at a slice
359
+ * boundary. The turn's catch settles silently when this is set, so the stream
360
+ * closes cleanly (no spurious `error` chunk) — the next slice rerun-continues
361
+ * from the persisted journal.
362
+ */
363
+ let suspending = false;
364
+ /*
365
+ * Instructions are prepended to the first user message of a fresh session
366
+ * only. A resumed session already carried them in its original first
367
+ * message (preserved in the persisted session file), so it starts "applied".
368
+ */
369
+ let instructionsApplied = input.isResume;
370
+ const pendingToolResults = new Map<string, PendingToolResult>();
371
+ const pendingToolApprovals = new Map<string, PendingToolApproval>();
372
+
373
+ // Emit channel set at the start of every doPromptTurn and cleared on end.
374
+ let currentEmit: ((part: HarnessV1StreamPart) => void) | undefined;
375
+ let translatorState: PiTranslatorState | undefined;
376
+ let activeTurn: ActivePiTurn | undefined;
377
+ /*
378
+ * Compaction parts produced while no turn is active. Pi's `compact()` aborts
379
+ * the current turn before it summarizes, so a manually triggered compaction
380
+ * (and any compaction that lands between turns) emits its `compaction_end`
381
+ * after `currentEmit` has been cleared. Buffer those parts and flush them on
382
+ * the next turn's stream so the observation is not lost. Auto-compaction that
383
+ * runs mid-turn still emits inline via `currentEmit`.
384
+ */
385
+ const pendingCompactionParts: HarnessV1StreamPart[] = [];
386
+
387
+ const remoteOps = createPiRemoteOps({
388
+ sandbox,
389
+ paths,
390
+ onFileChange: (event, relPath) => {
391
+ currentEmit?.({ type: 'file-change', event, path: relPath });
392
+ },
393
+ });
394
+
395
+ function settlePendingToolResults(reason: string): void {
396
+ for (const pending of pendingToolResults.values()) {
397
+ pending.resolve({ error: reason });
398
+ }
399
+ pendingToolResults.clear();
400
+ }
401
+
402
+ function settlePendingToolApprovals(reason: string): void {
403
+ for (const pending of pendingToolApprovals.values()) {
404
+ pending.resolve({ approved: false, reason });
405
+ }
406
+ pendingToolApprovals.clear();
407
+ }
408
+
409
+ async function persistSessionFile(): Promise<void> {
410
+ if (!sessionFileName) return;
411
+ await persistSessionFileToSandbox({
412
+ sandbox,
413
+ sessionWorkDir: input.sessionWorkDir,
414
+ hostSessionDir,
415
+ sessionFileName,
416
+ });
417
+ }
418
+
419
+ function createPromptControl(input: {
420
+ done: Promise<void>;
421
+ abortSignal?: AbortSignal;
422
+ }): HarnessV1PromptControl {
423
+ const abortHandler = () => {
424
+ piSession?.abort().catch(() => {});
425
+ };
426
+ if (input.abortSignal) {
427
+ input.abortSignal.addEventListener('abort', abortHandler, {
428
+ once: true,
429
+ });
430
+ void input.done.then(
431
+ () => {
432
+ input.abortSignal?.removeEventListener('abort', abortHandler);
433
+ },
434
+ () => {
435
+ input.abortSignal?.removeEventListener('abort', abortHandler);
436
+ },
437
+ );
438
+ }
439
+
440
+ return {
441
+ async submitToolResult(args) {
442
+ const pending = pendingToolResults.get(args.toolCallId);
443
+ if (!pending) return;
444
+ pendingToolResults.delete(args.toolCallId);
445
+ /*
446
+ * Preserve the original output so the result projection can surface it
447
+ * unchanged. The tool handler stringifies the output for the runtime
448
+ * (so the model reads it), and Pi echoes that text back — without this
449
+ * the consumer-facing result would be the serialized string instead of
450
+ * the original object.
451
+ */
452
+ translatorState?.hostToolResults.set(args.toolCallId, args.output);
453
+ pending.resolve(args.output);
454
+ },
455
+ async submitToolApproval(args) {
456
+ const pending = pendingToolApprovals.get(args.approvalId);
457
+ if (!pending) return;
458
+ pendingToolApprovals.delete(args.approvalId);
459
+ pending.resolve({
460
+ approved: args.approved,
461
+ reason: args.reason,
462
+ });
463
+ },
464
+ async submitUserMessage(text) {
465
+ await piSession?.steer(text);
466
+ },
467
+ done: input.done,
468
+ };
469
+ }
470
+
471
+ async function requestBuiltinToolApproval(args: {
472
+ toolCallId: string;
473
+ nativeName: (typeof PI_NATIVE_BUILTIN_NAMES)[number];
474
+ }): Promise<{ approved: boolean; reason?: string }> {
475
+ if (
476
+ !piBuiltinToolRequiresApproval({
477
+ permissionMode,
478
+ kind: PI_NATIVE_TOOL_KINDS[args.nativeName],
479
+ })
480
+ ) {
481
+ return { approved: true };
482
+ }
483
+ currentEmit?.({
484
+ type: 'tool-approval-request',
485
+ approvalId: args.toolCallId,
486
+ toolCallId: args.toolCallId,
487
+ });
488
+ return new Promise(resolve => {
489
+ pendingToolApprovals.set(args.toolCallId, { resolve });
490
+ });
491
+ }
492
+
493
+ function buildToolDefinitions(userTools: ReadonlyArray<HarnessV1ToolSpec>): {
494
+ customTools: ToolDefinition[];
495
+ builtinNames: string[];
496
+ } {
497
+ const customTools: ToolDefinition[] = [
498
+ ...PI_NATIVE_BUILTIN_NAMES.map(native =>
499
+ buildBuiltinToolDefinition({
500
+ native,
501
+ remoteOps,
502
+ requestApproval: requestBuiltinToolApproval,
503
+ }),
504
+ ),
505
+ ...userTools.map(spec =>
506
+ buildUserToolDefinition(spec, pendingToolResults),
507
+ ),
508
+ ];
509
+ return {
510
+ customTools,
511
+ builtinNames: [...PI_NATIVE_BUILTIN_NAMES],
512
+ };
513
+ }
514
+
515
+ async function rebuildPiSession(
516
+ userTools: ReadonlyArray<HarnessV1ToolSpec>,
517
+ isFirstBuild: boolean,
518
+ ): Promise<void> {
519
+ if (piSession) {
520
+ unsubscribe?.();
521
+ unsubscribe = undefined;
522
+ piSession.dispose();
523
+ piSession = undefined;
524
+ // Original adapter waits 25 ms here to let Pi's teardown microtasks
525
+ // settle before the next createAgentSession. Port verbatim.
526
+ // TODO(pi-0.77): verify the race still exists; original SDK had a
527
+ // teardown microtask the host needed to wait on.
528
+ await new Promise(resolve => setTimeout(resolve, 25));
529
+ }
530
+
531
+ const { customTools, builtinNames } = buildToolDefinitions(userTools);
532
+ const toolNames = customTools.map(t => t.name);
533
+
534
+ // SessionManager: open the resumed file on the first build of a resumed
535
+ // session; create fresh otherwise.
536
+ const sessionManager =
537
+ isFirstBuild && resumeSessionFilePath
538
+ ? SessionManager.open(
539
+ resumeSessionFilePath,
540
+ hostSessionDir,
541
+ sessionWorkDir,
542
+ )
543
+ : SessionManager.create(sessionWorkDir, hostSessionDir);
544
+
545
+ const { session } = await createAgentSession({
546
+ cwd: sessionWorkDir,
547
+ agentDir: hostAgentDir,
548
+ authStorage,
549
+ modelRegistry,
550
+ sessionManager,
551
+ settingsManager,
552
+ resourceLoader,
553
+ customTools,
554
+ tools: toolNames,
555
+ ...(input.settings.thinkingLevel
556
+ ? { thinkingLevel: input.settings.thinkingLevel }
557
+ : {}),
558
+ ...(resolvedModel ? { model: resolvedModel } : {}),
559
+ });
560
+ piSession = session;
561
+
562
+ // Pick up the actual session file path so doStop can persist it. Pi
563
+ // 0.77 emits `.jsonl` files; older builds used `.json`. Persist the
564
+ // basename verbatim — including the extension — so the resume path can
565
+ // round-trip it without guessing the extension.
566
+ const candidatePath = sessionManager.getSessionFile();
567
+ if (candidatePath) {
568
+ sessionFileName = path.basename(candidatePath);
569
+ }
570
+
571
+ translatorState = createPiTranslatorState({
572
+ builtinToolNames: builtinNames,
573
+ nativeToCommon: NATIVE_TO_COMMON,
574
+ });
575
+
576
+ unsubscribe = piSession.subscribe(rawEvent => {
577
+ if (!translatorState) return;
578
+ const event = parseNativeEvent(rawEvent);
579
+ if (!event) return;
580
+ for (const part of translatePiEvent(event, translatorState)) {
581
+ if (currentEmit) {
582
+ currentEmit(part);
583
+ } else if (part.type === 'compaction') {
584
+ // No active turn: defer compaction observations to the next turn.
585
+ pendingCompactionParts.push(part);
586
+ }
587
+ // Other event types outside a turn have no consumer and are dropped.
588
+ }
589
+ });
590
+ }
591
+
592
+ /*
593
+ * Drive one turn against the Pi session and return the control surface.
594
+ * Shared by `doPromptTurn` (a fresh user prompt) and `doContinueTurn` (an empty
595
+ * prompt that asks Pi to continue its own thread after a rerun resume).
596
+ */
597
+ async function runTurn(turnOpts: {
598
+ text: string;
599
+ tools: ReadonlyArray<HarnessV1ToolSpec>;
600
+ emit: (part: HarnessV1StreamPart) => void;
601
+ abortSignal?: AbortSignal;
602
+ }): Promise<HarnessV1PromptControl> {
603
+ if (stopped) {
604
+ throw new Error('Pi session has been stopped.');
605
+ }
606
+
607
+ const userTools = turnOpts.tools;
608
+ const signature = JSON.stringify(userTools.map(t => t.name).sort());
609
+ const needsRebuild = piSession == null || signature !== lastToolsSignature;
610
+ if (needsRebuild) {
611
+ await rebuildPiSession(userTools, piSession == null);
612
+ lastToolsSignature = signature;
613
+ }
614
+
615
+ await resourceLoader.reload();
616
+ await syncHostWorkspaceFromSandbox({
617
+ sandbox,
618
+ sandboxWorkDir: input.sessionWorkDir,
619
+ hostWorkDir,
620
+ });
621
+
622
+ currentEmit = turnOpts.emit;
623
+ // Fresh translator state for the new turn — keep the tool sets the
624
+ // session was built with.
625
+ translatorState = createPiTranslatorState({
626
+ builtinToolNames: [...PI_NATIVE_BUILTIN_NAMES],
627
+ nativeToCommon: NATIVE_TO_COMMON,
628
+ });
629
+
630
+ turnOpts.emit({ type: 'stream-start' });
631
+
632
+ const turnPromise = (async () => {
633
+ let terminalError: string | undefined;
634
+ const session = piSession!;
635
+
636
+ // We subscribed in rebuild, but the translator may need to detect
637
+ // terminal errors too — wrap a second listener that records them.
638
+ const unsubErr = session.subscribe(raw => {
639
+ const ev = parseNativeEvent(raw);
640
+ if (!ev) return;
641
+ const err = getPiTerminalError(ev);
642
+ if (err && !terminalError) {
643
+ terminalError = err;
644
+ }
645
+ });
646
+
647
+ try {
648
+ await session.prompt(turnOpts.text);
649
+
650
+ if (terminalError) {
651
+ /*
652
+ * A `doSuspendTurn` aborts the in-flight turn on purpose. Pi surfaces
653
+ * that abort as a *resolved* prompt with a recorded terminal error
654
+ * ("This operation was aborted") rather than a thrown exception, so the
655
+ * `catch` guard below never sees it. Swallow it here too — but only if
656
+ * it's actually the abort: the stream then closes cleanly (no spurious
657
+ * `error` chunk) and the next slice rerun-continues from the journal.
658
+ * Any other terminal error mid-suspend is unanticipated and must
659
+ * surface.
660
+ */
661
+ if (suspending && isAbortError(terminalError)) return;
662
+ currentEmit?.({ type: 'error', error: new Error(terminalError) });
663
+ return;
664
+ }
665
+
666
+ const stats = session.getSessionStats();
667
+ const finishReason = {
668
+ unified: 'stop' as const,
669
+ raw: undefined,
670
+ };
671
+ const usage = {
672
+ inputTokens: {
673
+ total: stats.tokens.input,
674
+ noCache: undefined,
675
+ cacheRead: stats.tokens.cacheRead,
676
+ cacheWrite: stats.tokens.cacheWrite,
677
+ },
678
+ outputTokens: {
679
+ total: stats.tokens.output,
680
+ text: undefined,
681
+ reasoning: undefined,
682
+ },
683
+ };
684
+ // `finish-step` populates the step's finishReason + usage (the
685
+ // agent's result builder reads this); `finish` marks the turn end
686
+ // with totalUsage.
687
+ currentEmit?.({ type: 'finish-step', finishReason, usage });
688
+ currentEmit?.({
689
+ type: 'finish',
690
+ finishReason,
691
+ totalUsage: usage,
692
+ });
693
+ } catch (err) {
694
+ // A `doSuspendTurn` aborts the in-flight turn on purpose — settle silently
695
+ // so the stream closes cleanly without a spurious `error` chunk; the
696
+ // next slice rerun-continues from the persisted journal.
697
+ // Same rule as the resolved-with-terminalError path: only swallow the
698
+ // abort our own suspend caused; surface anything unanticipated.
699
+ if (suspending && isAbortError(err)) return;
700
+ currentEmit?.({ type: 'error', error: err });
701
+ } finally {
702
+ unsubErr();
703
+ }
704
+ })();
705
+
706
+ const activeTurnToken = {};
707
+ const done = turnPromise.finally(() => {
708
+ if (activeTurn?.token === activeTurnToken) {
709
+ activeTurn = undefined;
710
+ }
711
+ currentEmit = undefined;
712
+ });
713
+ activeTurn = {
714
+ token: activeTurnToken,
715
+ done,
716
+ };
717
+
718
+ return createPromptControl({
719
+ done,
720
+ abortSignal: turnOpts.abortSignal,
721
+ });
722
+ }
723
+
724
+ const doStop = async (): Promise<HarnessV1ResumeSessionState> => {
725
+ if (stopped) {
726
+ throw new Error('Pi session has been stopped.');
727
+ }
728
+ stopped = true;
729
+ parkedPiSessions.delete(input.sessionId);
730
+ settlePendingToolResults('Pi session stopped');
731
+ settlePendingToolApprovals('Pi session stopped');
732
+
733
+ // Persist the Pi session file into the sandbox so a future process
734
+ // can pick it up after `provider.resumeSession({ sessionId })` reattaches.
735
+ if (sessionFileName) {
736
+ try {
737
+ await persistSessionFile();
738
+ } catch {
739
+ // Best-effort: a missing session file means resume returns to a
740
+ // fresh conversation rather than failing stop.
741
+ }
742
+ }
743
+
744
+ unsubscribe?.();
745
+ unsubscribe = undefined;
746
+ piSession?.dispose();
747
+ piSession = undefined;
748
+ workspaceVfs.unmount();
749
+ await rm(hostRoot, { recursive: true, force: true });
750
+
751
+ return {
752
+ type: 'resume-session',
753
+ harnessId: HARNESS_ID,
754
+ specificationVersion: 'harness-v1',
755
+ data: sessionFileName ? { sessionFileName } : {},
756
+ };
757
+ };
758
+
759
+ const sessionImpl: HarnessV1Session = {
760
+ sessionId: input.sessionId,
761
+ isResume: input.isResume,
762
+ // The model Pi actually resolves to (the configured id, or its default when
763
+ // unset) — `gen_ai.request.model`.
764
+ ...(resolvedModel?.id ? { modelId: resolvedModel.id } : {}),
765
+
766
+ // Pi has no bridge to attach to and no on-disk event log to replay; its
767
+ // only resume path is restoring the session file on a fresh/snapshotted
768
+ // sandbox, i.e. `rerun`.
769
+
770
+ doPromptTurn: async (
771
+ promptOpts: HarnessV1PromptTurnOptions,
772
+ ): Promise<HarnessV1PromptControl> => {
773
+ let text = extractUserText(promptOpts.prompt);
774
+ if (!instructionsApplied && promptOpts.instructions) {
775
+ text = frameInstructions(promptOpts.instructions, text);
776
+ }
777
+ instructionsApplied = true;
778
+
779
+ return runTurn({
780
+ text,
781
+ tools: promptOpts.tools ?? [],
782
+ emit: promptOpts.emit,
783
+ abortSignal: promptOpts.abortSignal,
784
+ });
785
+ },
786
+
787
+ doContinueTurn: async (
788
+ continueOpts: HarnessV1ContinueTurnOptions,
789
+ ): Promise<HarnessV1PromptControl> => {
790
+ if (activeTurn != null) {
791
+ currentEmit = continueOpts.emit;
792
+ return createPromptControl({
793
+ done: activeTurn.done,
794
+ abortSignal: continueOpts.abortSignal,
795
+ });
796
+ }
797
+
798
+ /*
799
+ * Pi runs the model on the host, so there is no live turn in the sandbox
800
+ * to attach to — the previous slice's turn died with its process.
801
+ * Rerun-continue: re-drive the agent from the journal restored on resume.
802
+ * An empty prompt asks Pi to continue its own thread. Lossy — any work in
803
+ * flight at the slice boundary is recomputed because a host-resident
804
+ * runtime cannot do a lossless attach.
805
+ */
806
+ return runTurn({
807
+ text: '',
808
+ tools: continueOpts.tools ?? [],
809
+ emit: continueOpts.emit,
810
+ abortSignal: continueOpts.abortSignal,
811
+ });
812
+ },
813
+
814
+ doCompact: async (customInstructions?: string) => {
815
+ if (stopped) {
816
+ throw new Error('Pi session has been stopped.');
817
+ }
818
+ /*
819
+ * Pi owns the compaction. We just request it; the resulting
820
+ * `compaction_end` event is observed by the session subscription and
821
+ * translated into a `compaction` stream part. The returned
822
+ * `CompactionResult` is intentionally discarded here.
823
+ */
824
+ await piSession?.compact(customInstructions);
825
+ },
826
+
827
+ doDestroy: async () => {
828
+ if (stopped) return;
829
+ stopped = true;
830
+ parkedPiSessions.delete(input.sessionId);
831
+ settlePendingToolResults('Pi session stopped');
832
+ settlePendingToolApprovals('Pi session stopped');
833
+ unsubscribe?.();
834
+ unsubscribe = undefined;
835
+ piSession?.dispose();
836
+ piSession = undefined;
837
+ workspaceVfs.unmount();
838
+ await rm(hostRoot, { recursive: true, force: true });
839
+ },
840
+
841
+ doStop,
842
+
843
+ doDetach: async (): Promise<HarnessV1ResumeSessionState> => {
844
+ if (activeTurn != null || pendingToolResults.size > 0) {
845
+ parkedPiSessions.set(input.sessionId, sessionImpl);
846
+ if (sessionFileName) {
847
+ try {
848
+ await persistSessionFile();
849
+ } catch {
850
+ /*
851
+ * The parked in-process session is the authoritative continuation
852
+ * path while the live turn is waiting on host input. Persistence is
853
+ * only a fallback for later non-live resumes.
854
+ */
855
+ }
856
+ }
857
+ return {
858
+ type: 'resume-session',
859
+ harnessId: HARNESS_ID,
860
+ specificationVersion: 'harness-v1',
861
+ data: sessionFileName ? { sessionFileName } : {},
862
+ };
863
+ }
864
+ return doStop();
865
+ },
866
+
867
+ doSuspendTurn: async (): Promise<HarnessV1ContinueTurnState> => {
868
+ if (stopped) {
869
+ throw new Error('Pi session has been stopped.');
870
+ }
871
+ /*
872
+ * Pi's model runs in this host process, which is about to be suspended at
873
+ * the slice boundary — the in-flight turn cannot survive it. Abort it (the
874
+ * turn settles silently via the `suspending` guard so the stream closes
875
+ * cleanly), persist the journal into the sandbox, and tear down host-side
876
+ * resources. The sandbox itself is left running; the next slice pulls the
877
+ * journal after `provider.resumeSession({ sessionId })` and rerun-continues. The
878
+ * tail in flight at the boundary is recomputed — Pi cannot freeze a live
879
+ * turn the way a bridge adapter can.
880
+ */
881
+ suspending = true;
882
+ await Promise.resolve(piSession?.abort()).catch(() => {});
883
+
884
+ if (sessionFileName) {
885
+ try {
886
+ await persistSessionFile();
887
+ } catch {
888
+ // Best-effort: a missing/failed copy leaves the previously persisted
889
+ // journal in place, so the next slice resumes from a slightly older
890
+ // (still valid) state.
891
+ }
892
+ }
893
+
894
+ stopped = true;
895
+ parkedPiSessions.delete(input.sessionId);
896
+ settlePendingToolResults('Pi session suspended');
897
+ settlePendingToolApprovals('Pi session suspended');
898
+ unsubscribe?.();
899
+ unsubscribe = undefined;
900
+ piSession?.dispose();
901
+ piSession = undefined;
902
+ workspaceVfs.unmount();
903
+ await rm(hostRoot, { recursive: true, force: true });
904
+
905
+ return {
906
+ type: 'continue-turn',
907
+ harnessId: HARNESS_ID,
908
+ specificationVersion: 'harness-v1',
909
+ data: sessionFileName ? { sessionFileName } : {},
910
+ };
911
+ },
912
+ };
913
+
914
+ return sessionImpl;
915
+ }
916
+
917
+ /**
918
+ * Whether a terminal error (string from Pi's event stream, or a thrown error)
919
+ * is an abort — the expected result of `doSuspendTurn` aborting the in-flight
920
+ * turn. Only these are safe to swallow while `suspending`; any other error is
921
+ * unanticipated and must surface as an `error` chunk.
922
+ */
923
+ function isAbortError(value: unknown): boolean {
924
+ if (value == null) return false;
925
+ if (
926
+ typeof value === 'object' &&
927
+ (value as { name?: unknown }).name === 'AbortError'
928
+ ) {
929
+ return true;
930
+ }
931
+ const text =
932
+ typeof value === 'string'
933
+ ? value
934
+ : value instanceof Error
935
+ ? value.message
936
+ : String(value);
937
+ return /\baborted\b|AbortError|operation was aborted/i.test(text);
938
+ }
939
+
940
+ function asPiToolResult(text: string): AgentToolResult<unknown> {
941
+ return {
942
+ content: [{ type: 'text', text }],
943
+ details: undefined,
944
+ };
945
+ }
946
+
947
+ async function maybeDenyPiBuiltinTool(input: {
948
+ toolCallId: string;
949
+ nativeName: (typeof PI_NATIVE_BUILTIN_NAMES)[number];
950
+ requestApproval: (args: {
951
+ toolCallId: string;
952
+ nativeName: (typeof PI_NATIVE_BUILTIN_NAMES)[number];
953
+ }) => Promise<{ approved: boolean; reason?: string }>;
954
+ }): Promise<AgentToolResult<unknown> | undefined> {
955
+ const decision = await input.requestApproval({
956
+ toolCallId: input.toolCallId,
957
+ nativeName: input.nativeName,
958
+ });
959
+ if (decision.approved) return undefined;
960
+ return asPiToolResult(
961
+ serializeToolOutput({
962
+ type: 'execution-denied',
963
+ reason: decision.reason,
964
+ }),
965
+ );
966
+ }
967
+
968
+ function piBuiltinToolRequiresApproval(input: {
969
+ permissionMode: HarnessV1PermissionMode;
970
+ kind: 'readonly' | 'edit' | 'bash';
971
+ }): boolean {
972
+ if (input.permissionMode === 'allow-all') return false;
973
+ if (input.permissionMode === 'allow-edits') return input.kind === 'bash';
974
+ return input.kind === 'edit' || input.kind === 'bash';
975
+ }
976
+
977
+ function buildBuiltinToolDefinition(input: {
978
+ native: (typeof PI_NATIVE_BUILTIN_NAMES)[number];
979
+ remoteOps: PiRemoteOps;
980
+ requestApproval: (args: {
981
+ toolCallId: string;
982
+ nativeName: (typeof PI_NATIVE_BUILTIN_NAMES)[number];
983
+ }) => Promise<{ approved: boolean; reason?: string }>;
984
+ }): ToolDefinition {
985
+ switch (input.native) {
986
+ case 'read':
987
+ return defineTool({
988
+ name: 'read',
989
+ label: 'read',
990
+ description: 'Read file contents.',
991
+ parameters: Type.Object({ file_path: Type.String() }),
992
+ async execute(toolCallId, params) {
993
+ const denied = await maybeDenyPiBuiltinTool({
994
+ toolCallId,
995
+ nativeName: 'read',
996
+ requestApproval: input.requestApproval,
997
+ });
998
+ if (denied) return denied;
999
+ const buf = await input.remoteOps.readBuffer(params.file_path);
1000
+ return asPiToolResult(buf.toString('utf8'));
1001
+ },
1002
+ });
1003
+ case 'write':
1004
+ return defineTool({
1005
+ name: 'write',
1006
+ label: 'write',
1007
+ description: 'Write content to a file.',
1008
+ parameters: Type.Object({
1009
+ file_path: Type.String(),
1010
+ content: Type.String(),
1011
+ }),
1012
+ async execute(toolCallId, params) {
1013
+ const denied = await maybeDenyPiBuiltinTool({
1014
+ toolCallId,
1015
+ nativeName: 'write',
1016
+ requestApproval: input.requestApproval,
1017
+ });
1018
+ if (denied) return denied;
1019
+ await input.remoteOps.writeFile(params.file_path, params.content);
1020
+ return asPiToolResult(`Wrote ${params.file_path}`);
1021
+ },
1022
+ });
1023
+ case 'edit':
1024
+ return defineTool({
1025
+ name: 'edit',
1026
+ label: 'edit',
1027
+ description: 'Edit a file by exact-string replacement.',
1028
+ parameters: Type.Object({
1029
+ file_path: Type.String(),
1030
+ old_string: Type.String(),
1031
+ new_string: Type.String(),
1032
+ }),
1033
+ async execute(toolCallId, params) {
1034
+ const denied = await maybeDenyPiBuiltinTool({
1035
+ toolCallId,
1036
+ nativeName: 'edit',
1037
+ requestApproval: input.requestApproval,
1038
+ });
1039
+ if (denied) return denied;
1040
+ await input.remoteOps.editFile(
1041
+ params.file_path,
1042
+ params.old_string,
1043
+ params.new_string,
1044
+ );
1045
+ return asPiToolResult(`Edited ${params.file_path}`);
1046
+ },
1047
+ });
1048
+ case 'bash':
1049
+ return defineTool({
1050
+ name: 'bash',
1051
+ label: 'bash',
1052
+ description: 'Execute a shell command.',
1053
+ parameters: Type.Object({
1054
+ command: Type.String(),
1055
+ timeout: Type.Optional(
1056
+ Type.Number({ description: 'Timeout in seconds.' }),
1057
+ ),
1058
+ }),
1059
+ async execute(toolCallId, params, signal) {
1060
+ const denied = await maybeDenyPiBuiltinTool({
1061
+ toolCallId,
1062
+ nativeName: 'bash',
1063
+ requestApproval: input.requestApproval,
1064
+ });
1065
+ if (denied) return denied;
1066
+ const chunks: Buffer[] = [];
1067
+ const result = await input.remoteOps.exec(params.command, '.', {
1068
+ onData(data) {
1069
+ chunks.push(data);
1070
+ },
1071
+ ...(signal ? { signal } : {}),
1072
+ ...(typeof params.timeout === 'number'
1073
+ ? { timeout: params.timeout }
1074
+ : {}),
1075
+ });
1076
+ const out = Buffer.concat(chunks).toString('utf8');
1077
+ const text = `${out}${
1078
+ result.exitCode != null ? `\n\n(exit ${result.exitCode})` : ''
1079
+ }`.trim();
1080
+ return asPiToolResult(text);
1081
+ },
1082
+ });
1083
+ case 'grep':
1084
+ return defineTool({
1085
+ name: 'grep',
1086
+ label: 'grep',
1087
+ description: 'Search file contents with regex.',
1088
+ parameters: Type.Object({
1089
+ pattern: Type.String(),
1090
+ path: Type.Optional(Type.String()),
1091
+ glob: Type.Optional(Type.String()),
1092
+ ignoreCase: Type.Optional(Type.Boolean()),
1093
+ literal: Type.Optional(Type.Boolean()),
1094
+ context: Type.Optional(Type.Number()),
1095
+ limit: Type.Optional(Type.Number()),
1096
+ }),
1097
+ async execute(toolCallId, params) {
1098
+ const denied = await maybeDenyPiBuiltinTool({
1099
+ toolCallId,
1100
+ nativeName: 'grep',
1101
+ requestApproval: input.requestApproval,
1102
+ });
1103
+ if (denied) return denied;
1104
+ const out = await input.remoteOps.grepFiles(params.pattern, params);
1105
+ return asPiToolResult(out);
1106
+ },
1107
+ });
1108
+ case 'find':
1109
+ return defineTool({
1110
+ name: 'find',
1111
+ label: 'find',
1112
+ description: 'Find files matching a glob pattern.',
1113
+ parameters: Type.Object({
1114
+ pattern: Type.String(),
1115
+ path: Type.Optional(Type.String()),
1116
+ limit: Type.Optional(Type.Number()),
1117
+ }),
1118
+ async execute(toolCallId, params) {
1119
+ const denied = await maybeDenyPiBuiltinTool({
1120
+ toolCallId,
1121
+ nativeName: 'find',
1122
+ requestApproval: input.requestApproval,
1123
+ });
1124
+ if (denied) return denied;
1125
+ const matches = await input.remoteOps.findFiles(
1126
+ params.pattern,
1127
+ params.path ?? '.',
1128
+ params.limit ?? 1_000,
1129
+ );
1130
+ return asPiToolResult(matches.join('\n'));
1131
+ },
1132
+ });
1133
+ case 'ls':
1134
+ return defineTool({
1135
+ name: 'ls',
1136
+ label: 'ls',
1137
+ description: 'List directory entries.',
1138
+ parameters: Type.Object({
1139
+ path: Type.Optional(Type.String()),
1140
+ limit: Type.Optional(Type.Number()),
1141
+ }),
1142
+ async execute(toolCallId, params) {
1143
+ const denied = await maybeDenyPiBuiltinTool({
1144
+ toolCallId,
1145
+ nativeName: 'ls',
1146
+ requestApproval: input.requestApproval,
1147
+ });
1148
+ if (denied) return denied;
1149
+ const entries = await input.remoteOps.listDirectory(
1150
+ params.path ?? '.',
1151
+ params.limit ?? 500,
1152
+ );
1153
+ return asPiToolResult(entries.join('\n'));
1154
+ },
1155
+ });
1156
+ }
1157
+ }
1158
+
1159
+ function buildUserToolDefinition(
1160
+ spec: HarnessV1ToolSpec,
1161
+ pending: Map<string, PendingToolResult>,
1162
+ ): ToolDefinition {
1163
+ const schema = spec.inputSchema ?? {
1164
+ type: 'object',
1165
+ properties: {},
1166
+ additionalProperties: true,
1167
+ };
1168
+ return defineTool({
1169
+ name: spec.name,
1170
+ label: spec.name,
1171
+ description: spec.description ?? `User-registered tool ${spec.name}`,
1172
+ parameters: toolSpecToTypeBoxParameters(schema),
1173
+ async execute(toolCallId) {
1174
+ return new Promise<unknown>(resolve => {
1175
+ pending.set(toolCallId, { resolve });
1176
+ }).then(output => asPiToolResult(serializeToolOutput(output)));
1177
+ },
1178
+ });
1179
+ }