@peakflo/agent-harness 0.0.0-stage → 0.2.2-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.
Files changed (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -2
  3. package/dist/adapters/acp-adapter.d.ts +177 -0
  4. package/dist/adapters/acp-adapter.js +1732 -0
  5. package/dist/adapters/claude-code-adapter.d.ts +171 -0
  6. package/dist/adapters/claude-code-adapter.js +1918 -0
  7. package/dist/adapters/codex-adapter.d.ts +67 -0
  8. package/dist/adapters/codex-adapter.js +681 -0
  9. package/dist/adapters/codex-app-server-adapter.d.ts +105 -0
  10. package/dist/adapters/codex-app-server-adapter.js +1309 -0
  11. package/dist/adapters/coding-agent-adapter.d.ts +289 -0
  12. package/dist/adapters/coding-agent-adapter.js +31 -0
  13. package/dist/adapters/index.d.ts +17 -0
  14. package/dist/adapters/index.js +35 -0
  15. package/dist/adapters/model-gateway.d.ts +84 -0
  16. package/dist/adapters/model-gateway.js +270 -0
  17. package/dist/adapters/opencode-adapter.d.ts +403 -0
  18. package/dist/adapters/opencode-adapter.js +2710 -0
  19. package/dist/adapters/opencode-config.d.ts +41 -0
  20. package/dist/adapters/opencode-config.js +147 -0
  21. package/dist/adapters/opencode-server-policy.d.ts +82 -0
  22. package/dist/adapters/opencode-server-policy.js +148 -0
  23. package/dist/adapters/pi-adapter.d.ts +168 -0
  24. package/dist/adapters/pi-adapter.js +1468 -0
  25. package/dist/capabilities.d.ts +58 -0
  26. package/dist/capabilities.js +161 -0
  27. package/dist/cli.d.ts +2 -0
  28. package/dist/cli.js +231 -0
  29. package/dist/contract.d.ts +273 -0
  30. package/dist/contract.js +357 -0
  31. package/dist/control-plane.d.ts +178 -0
  32. package/dist/control-plane.js +431 -0
  33. package/dist/credential-store.d.ts +110 -0
  34. package/dist/credential-store.js +181 -0
  35. package/dist/enrol/browser-enrol.d.ts +85 -0
  36. package/dist/enrol/browser-enrol.js +387 -0
  37. package/dist/github-command-guards.d.ts +20 -0
  38. package/dist/github-command-guards.js +330 -0
  39. package/dist/github-proxy-certificates.d.ts +25 -0
  40. package/dist/github-proxy-certificates.js +145 -0
  41. package/dist/github-proxy.d.ts +37 -0
  42. package/dist/github-proxy.js +476 -0
  43. package/dist/github-workspace.d.ts +55 -0
  44. package/dist/github-workspace.js +537 -0
  45. package/dist/harness.d.ts +333 -0
  46. package/dist/harness.js +1414 -0
  47. package/dist/health.d.ts +45 -0
  48. package/dist/health.js +116 -0
  49. package/dist/index.d.ts +53 -0
  50. package/dist/index.js +123 -0
  51. package/dist/model-probe.d.ts +96 -0
  52. package/dist/model-probe.js +195 -0
  53. package/dist/options.d.ts +15 -0
  54. package/dist/options.js +212 -0
  55. package/dist/ports-default.d.ts +212 -0
  56. package/dist/ports-default.js +333 -0
  57. package/dist/ports.d.ts +436 -0
  58. package/dist/ports.js +27 -0
  59. package/dist/runtime/attachments.d.ts +92 -0
  60. package/dist/runtime/attachments.js +377 -0
  61. package/dist/runtime/child-stream-guards.d.ts +45 -0
  62. package/dist/runtime/child-stream-guards.js +111 -0
  63. package/dist/runtime/conversation.d.ts +87 -0
  64. package/dist/runtime/conversation.js +156 -0
  65. package/dist/runtime/lease-renewal.d.ts +169 -0
  66. package/dist/runtime/lease-renewal.js +433 -0
  67. package/dist/runtime/mcp-auth-proxy.d.ts +82 -0
  68. package/dist/runtime/mcp-auth-proxy.js +300 -0
  69. package/dist/runtime/permission-mode.d.ts +116 -0
  70. package/dist/runtime/permission-mode.js +146 -0
  71. package/dist/runtime/projection.d.ts +71 -0
  72. package/dist/runtime/projection.js +211 -0
  73. package/dist/runtime/session-runner.d.ts +520 -0
  74. package/dist/runtime/session-runner.js +2122 -0
  75. package/dist/shared/reasoning-effort.d.ts +11 -0
  76. package/dist/shared/reasoning-effort.js +20 -0
  77. package/dist/store/conversation-store.d.ts +100 -0
  78. package/dist/store/conversation-store.js +305 -0
  79. package/dist/store/sqlite-store.d.ts +120 -0
  80. package/dist/store/sqlite-store.js +346 -0
  81. package/dist/types.d.ts +952 -0
  82. package/dist/types.js +55 -0
  83. package/dist/version.d.ts +32 -0
  84. package/dist/version.js +77 -0
  85. package/package.json +53 -4
@@ -0,0 +1,1468 @@
1
+ "use strict";
2
+ /**
3
+ * Pi coding-agent adapter.
4
+ *
5
+ * Pi exposes a JSONL RPC protocol over stdin/stdout. The harness keeps one Pi
6
+ * process per live turn and converts Pi events to the shared adapter shape.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.PiAdapter = exports.PI_MCP_SERVER_ALIASES = exports.MAX_PI_MCP_SERVER_SLUG_LENGTH = exports.MAX_PI_NAME_LENGTH = void 0;
10
+ exports.findPiNodeRuntime = findPiNodeRuntime;
11
+ exports.sanitizePiSessionName = sanitizePiSessionName;
12
+ exports.sanitizePiMcpServerName = sanitizePiMcpServerName;
13
+ exports.withProviderNameLimitHint = withProviderNameLimitHint;
14
+ exports.buildPiMcpConfigDocument = buildPiMcpConfigDocument;
15
+ const child_process_1 = require("child_process");
16
+ const crypto_1 = require("crypto");
17
+ const fs_1 = require("fs");
18
+ const os_1 = require("os");
19
+ const path_1 = require("path");
20
+ const string_decoder_1 = require("string_decoder");
21
+ const util_1 = require("util");
22
+ const coding_agent_adapter_1 = require("./coding-agent-adapter");
23
+ const model_gateway_1 = require("./model-gateway");
24
+ const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
25
+ const RPC_TIMEOUT_MS = 15_000;
26
+ const MAX_BUFFERED_PARTS = 1_000;
27
+ const MINIMUM_PI_VERSION = [0, 80, 5];
28
+ const PI_PERMISSION_MODE_ENV = 'PEAKFLO_PI_PERMISSION_MODE';
29
+ const TERMINAL_ERROR_SETTLE_GRACE_MS = 1_000;
30
+ const piInstanceIdCache = new Map();
31
+ /**
32
+ * Most model providers cap function/tool `name` at 64 characters. Pi forwards
33
+ * MCP tools as `<server>_<tool>`-style names, so a long MCP server name (e.g.
34
+ * "[Workflo] Organisation Workspace") plus a long tool name overflows the
35
+ * limit and the whole turn fails with "name must be at most 64 characters".
36
+ * Keep server slugs short to leave room for the tool suffix.
37
+ */
38
+ exports.MAX_PI_NAME_LENGTH = 64;
39
+ exports.MAX_PI_MCP_SERVER_SLUG_LENGTH = 24;
40
+ /**
41
+ * Electron's main macOS executable belongs to the foreground application
42
+ * bundle. Reusing it as Node makes LaunchServices register every Pi process as
43
+ * another foreground 20x application, which adds a Dock icon. Electron's
44
+ * standard Helper.app is an LSUIElement and exposes the same bundled Node
45
+ * runtime without participating in the Dock.
46
+ */
47
+ function findPiNodeRuntime(platform, electronExecutable, pathExists = fs_1.existsSync) {
48
+ if (platform !== 'darwin')
49
+ return electronExecutable;
50
+ const executableName = (0, path_1.basename)(electronExecutable);
51
+ const contentsDirectory = (0, path_1.dirname)((0, path_1.dirname)(electronExecutable));
52
+ const helperExecutable = (0, path_1.join)(contentsDirectory, 'Frameworks', `${executableName} Helper.app`, 'Contents', 'MacOS', `${executableName} Helper`);
53
+ return pathExists(helperExecutable) ? helperExecutable : null;
54
+ }
55
+ /**
56
+ * Short Pi-side aliases for MCP servers whose display names are too long to
57
+ * fit the provider 64-char tool-name limit. The alias is only used in the
58
+ * Pi `--mcp-config` file — display names in settings, docs, and other
59
+ * backends are untouched.
60
+ */
61
+ exports.PI_MCP_SERVER_ALIASES = {
62
+ '[Workflo] Organisation Workspace': 'workflo',
63
+ '[Workflo] MCP Dev Server': 'workflo-dev'
64
+ };
65
+ function slugifyPiName(value, maxLength) {
66
+ return (value
67
+ .toLowerCase()
68
+ .replace(/[^a-z0-9-_]+/g, '-')
69
+ .replace(/-+/g, '-')
70
+ .replace(/^[-_]+|[-_]+$/g, '')
71
+ .slice(0, maxLength)
72
+ .replace(/-+$/g, '') || 'mcp');
73
+ }
74
+ /**
75
+ * Generic rule for bracket-prefixed servers ("[Workflo] My tasks" →
76
+ * "workflo-my-tasks"). Returns null when the name has no bracket prefix so
77
+ * the caller falls back to plain slugification.
78
+ */
79
+ function slugifyBracketPrefix(name) {
80
+ const match = /^\[([^\]]+)\]\s*(.*)$/.exec(name);
81
+ if (!match)
82
+ return null;
83
+ const combined = `${match[1]}-${match[2]}`.trim();
84
+ if (!combined.replace(/-/g, ''))
85
+ return null;
86
+ return slugifyPiName(combined, exports.MAX_PI_MCP_SERVER_SLUG_LENGTH);
87
+ }
88
+ function sanitizePiSessionName(taskId) {
89
+ const slug = taskId
90
+ .replace(/[^a-zA-Z0-9-_]+/g, '-')
91
+ .replace(/-+/g, '-')
92
+ .replace(/^[-_]+|[-_]+$/g, '')
93
+ .slice(0, exports.MAX_PI_NAME_LENGTH)
94
+ .replace(/-+$/g, '');
95
+ return slug || 'pi-session';
96
+ }
97
+ function sanitizePiMcpServerName(name, used) {
98
+ // Exact aliases first (e.g. "[Workflo] Organisation Workspace" → "workflo").
99
+ const alias = exports.PI_MCP_SERVER_ALIASES[name];
100
+ const base = alias ??
101
+ slugifyBracketPrefix(name) ??
102
+ slugifyPiName(name, exports.MAX_PI_MCP_SERVER_SLUG_LENGTH);
103
+ if (!used.has(base)) {
104
+ used.add(base);
105
+ return base;
106
+ }
107
+ for (let index = 2; index < 1000; index++) {
108
+ const suffix = `-${index}`;
109
+ const candidate = `${base.slice(0, exports.MAX_PI_MCP_SERVER_SLUG_LENGTH - suffix.length)}${suffix}`;
110
+ if (!used.has(candidate)) {
111
+ used.add(candidate);
112
+ return candidate;
113
+ }
114
+ }
115
+ const fallback = `mcp-${used.size + 1}`;
116
+ used.add(fallback);
117
+ return fallback;
118
+ }
119
+ function withProviderNameLimitHint(error) {
120
+ if (!/name must be at most 64/i.test(error))
121
+ return error;
122
+ return `${error}\n\nHint: a tool name exceeded the provider 64-character limit. The Peakflo harness keeps MCP tools behind short namespace proxies. Stop and start the agent to rebuild its tool list.`;
123
+ }
124
+ /**
125
+ * Build the pi-mcp-adapter document used by Peakflo harness sessions.
126
+ *
127
+ * Keep MCP servers behind namespace proxy tools (`mcp__<server>`). Direct MCP
128
+ * tools concatenate the server and tool names; a 24-character server slug and
129
+ * a 50-character generated workflow tool already produce a 76-character name.
130
+ * Providers commonly reject the entire request when any tool exceeds 64
131
+ * characters, before the model can call a tool.
132
+ */
133
+ function buildPiMcpConfigDocument(servers, onRename) {
134
+ const usedSlugs = new Set();
135
+ const mcpServers = Object.fromEntries(Object.entries(servers).map(([name, server]) => {
136
+ const slug = sanitizePiMcpServerName(name, usedSlugs);
137
+ if (slug !== name)
138
+ onRename?.(name, slug);
139
+ if (server.type === 'stdio') {
140
+ return [
141
+ slug,
142
+ {
143
+ command: server.command,
144
+ args: server.args ?? [],
145
+ env: server.env ?? {}
146
+ }
147
+ ];
148
+ }
149
+ return [
150
+ slug,
151
+ {
152
+ url: server.url,
153
+ headers: server.headers ?? {}
154
+ }
155
+ ];
156
+ }));
157
+ return {
158
+ settings: {
159
+ // This must be explicit because MCP_DIRECT_TOOLS and user-level Pi
160
+ // settings can otherwise expose every `<server>_<tool>` directly.
161
+ directTools: false
162
+ },
163
+ mcpServers
164
+ };
165
+ }
166
+ const PI_PERMISSION_EXTENSION_SOURCE = `\
167
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
168
+
169
+ const READ_ONLY_TOOLS = new Set(["read", "grep", "find", "ls"]);
170
+
171
+ function inputSummary(input: unknown): string {
172
+ try {
173
+ return JSON.stringify(input, null, 2).slice(0, 4000);
174
+ } catch {
175
+ return String(input).slice(0, 4000);
176
+ }
177
+ }
178
+
179
+ export default function permissions(pi: ExtensionAPI) {
180
+ pi.on("tool_call", async (event, ctx) => {
181
+ if (process.env.${PI_PERMISSION_MODE_ENV} === "allow" || READ_ONLY_TOOLS.has(event.toolName)) return;
182
+ const approved = await ctx.ui.confirm(
183
+ \`Allow \${event.toolName}?\`,
184
+ inputSummary(event.input),
185
+ );
186
+ if (!approved) return { block: true, reason: \`\${event.toolName} was declined.\` };
187
+ });
188
+ }
189
+ `;
190
+ function textFromContent(content) {
191
+ if (typeof content === 'string')
192
+ return content;
193
+ if (!Array.isArray(content))
194
+ return '';
195
+ return content
196
+ .filter((block) => !!block && typeof block === 'object')
197
+ .filter((block) => block.type === 'text')
198
+ .map((block) => String(block.text ?? ''))
199
+ .join('');
200
+ }
201
+ function resultText(result) {
202
+ if (!result || typeof result !== 'object')
203
+ return '';
204
+ const content = result.content;
205
+ if (!Array.isArray(content))
206
+ return '';
207
+ return content
208
+ .filter((block) => !!block && typeof block === 'object')
209
+ .map((block) => block.type === 'text' ? String(block.text ?? '') : JSON.stringify(block))
210
+ .join('\n');
211
+ }
212
+ class PiAdapter {
213
+ modelGateway;
214
+ sessions = new Map();
215
+ piExecutablePath = null;
216
+ onDataAvailable;
217
+ constructor(modelGateway) {
218
+ this.modelGateway = modelGateway;
219
+ }
220
+ agentDirectory() {
221
+ return process.env.PI_CODING_AGENT_DIR || (0, path_1.join)((0, os_1.homedir)(), '.pi', 'agent');
222
+ }
223
+ /** Identity of the Pi session store that can honour native session files. */
224
+ backendInstanceId() {
225
+ const directory = this.agentDirectory();
226
+ const cached = piInstanceIdCache.get(directory);
227
+ if (cached)
228
+ return cached;
229
+ const marker = (0, path_1.join)(directory, '.pf-backend-instance-id');
230
+ const readExisting = () => {
231
+ try {
232
+ return (0, fs_1.readFileSync)(marker, 'utf8').trim() || null;
233
+ }
234
+ catch {
235
+ return null;
236
+ }
237
+ };
238
+ const existing = readExisting();
239
+ if (existing) {
240
+ piInstanceIdCache.set(directory, existing);
241
+ return existing;
242
+ }
243
+ const minted = (0, crypto_1.randomUUID)();
244
+ try {
245
+ (0, fs_1.mkdirSync)(directory, { recursive: true, mode: 0o700 });
246
+ (0, fs_1.writeFileSync)(marker, minted, { flag: 'wx', mode: 0o600 });
247
+ piInstanceIdCache.set(directory, minted);
248
+ return minted;
249
+ }
250
+ catch (error) {
251
+ if (error.code === 'EEXIST') {
252
+ const winner = readExisting();
253
+ if (winner) {
254
+ piInstanceIdCache.set(directory, winner);
255
+ return winner;
256
+ }
257
+ }
258
+ // An unpersisted id matches no later checkpoint, which safely forces a
259
+ // replay instead of resuming a session file from uncertain storage.
260
+ return minted;
261
+ }
262
+ }
263
+ async initialize() {
264
+ await this.findPiExecutable();
265
+ }
266
+ async findPiExecutable() {
267
+ if (this.piExecutablePath)
268
+ return this.piExecutablePath;
269
+ const isWin = process.platform === 'win32';
270
+ try {
271
+ const { stdout } = await execFileAsync(isWin ? 'where' : 'which', ['pi'], {
272
+ timeout: 10_000,
273
+ windowsHide: true
274
+ });
275
+ this.piExecutablePath = stdout.trim().split(/\r?\n/)[0];
276
+ return this.piExecutablePath;
277
+ }
278
+ catch {
279
+ const home = (0, os_1.homedir)();
280
+ const candidates = isWin
281
+ ? [
282
+ (0, path_1.join)(home, 'AppData', 'Roaming', 'npm', 'pi.cmd'),
283
+ (0, path_1.join)(home, 'AppData', 'Roaming', 'npm', 'pi.exe')
284
+ ]
285
+ : [
286
+ '/opt/homebrew/bin/pi',
287
+ '/usr/local/bin/pi',
288
+ (0, path_1.join)(home, '.local', 'bin', 'pi'),
289
+ (0, path_1.join)(home, '.npm-global', 'bin', 'pi'),
290
+ (0, path_1.join)(home, '.volta', 'bin', 'pi')
291
+ ];
292
+ const found = candidates.find(fs_1.existsSync);
293
+ if (!found) {
294
+ throw new Error('Pi CLI not found. Install it with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent');
295
+ }
296
+ this.piExecutablePath = found;
297
+ return found;
298
+ }
299
+ }
300
+ /**
301
+ * Install or update the Peakflo provider in Pi's normal models file. The key
302
+ * remains an environment reference; the harness supplies the session-scoped
303
+ * value only to the child process.
304
+ */
305
+ installPeakfloGateway() {
306
+ const gateway = this.modelGateway.readModelGatewayConfig();
307
+ if (!gateway)
308
+ return null;
309
+ const agentDir = this.agentDirectory();
310
+ const modelsPath = (0, path_1.join)(agentDir, 'models.json');
311
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(modelsPath), { recursive: true });
312
+ let root = {};
313
+ if ((0, fs_1.existsSync)(modelsPath)) {
314
+ try {
315
+ root = JSON.parse((0, fs_1.readFileSync)(modelsPath, 'utf8'));
316
+ }
317
+ catch (error) {
318
+ throw new Error(`Pi models file is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
319
+ }
320
+ }
321
+ const providers = { ...(root.providers ?? {}) };
322
+ providers[model_gateway_1.ENTERPRISE_AI_GATEWAY_PROVIDER_ID] =
323
+ (0, model_gateway_1.buildPiAiGatewayProviderConfig)(gateway);
324
+ const temporaryPath = `${modelsPath}.pf-harness-${process.pid}.tmp`;
325
+ (0, fs_1.writeFileSync)(temporaryPath, `${JSON.stringify({ ...root, providers }, null, 2)}\n`, { mode: 0o600 });
326
+ (0, fs_1.chmodSync)(temporaryPath, 0o600);
327
+ (0, fs_1.renameSync)(temporaryPath, modelsPath);
328
+ (0, fs_1.chmodSync)(modelsPath, 0o600);
329
+ return { apiKey: gateway.apiKey, providerModels: gateway.models ?? [] };
330
+ }
331
+ buildMcpConfig(config) {
332
+ if (!config.mcpServers || Object.keys(config.mcpServers).length === 0)
333
+ return undefined;
334
+ const dir = (0, path_1.join)((0, os_1.homedir)(), '.pf-agent-harness', 'pi-mcp');
335
+ (0, fs_1.mkdirSync)(dir, { recursive: true });
336
+ const path = (0, path_1.join)(dir, `${sanitizePiSessionName(config.taskId)}-${(0, crypto_1.randomUUID)()}.json`);
337
+ // Pi forwards MCP tools to the model as <server>_<tool> names, which most
338
+ // providers cap at 64 characters. Sanitize server keys so a long display
339
+ // name (e.g. "[Workflo] Organisation Workspace") cannot overflow the limit.
340
+ const document = buildPiMcpConfigDocument(config.mcpServers, (name, slug) => {
341
+ console.warn(`[PiAdapter] Renamed MCP server "${name}" to "${slug}" to fit the provider 64-char tool name limit`);
342
+ });
343
+ (0, fs_1.writeFileSync)(path, `${JSON.stringify(document, null, 2)}\n`, {
344
+ mode: 0o600
345
+ });
346
+ (0, fs_1.chmodSync)(path, 0o600);
347
+ return path;
348
+ }
349
+ installPermissionExtension() {
350
+ const dir = (0, path_1.join)((0, os_1.homedir)(), '.pf-agent-harness', 'pi');
351
+ const path = (0, path_1.join)(dir, 'permissions.ts');
352
+ (0, fs_1.mkdirSync)(dir, { recursive: true });
353
+ const current = (0, fs_1.existsSync)(path) ? (0, fs_1.readFileSync)(path, 'utf8') : '';
354
+ if (current !== PI_PERMISSION_EXTENSION_SOURCE) {
355
+ const temporaryPath = `${path}.${process.pid}.tmp`;
356
+ (0, fs_1.writeFileSync)(temporaryPath, PI_PERMISSION_EXTENSION_SOURCE, {
357
+ mode: 0o600
358
+ });
359
+ (0, fs_1.chmodSync)(temporaryPath, 0o600);
360
+ (0, fs_1.renameSync)(temporaryPath, path);
361
+ }
362
+ (0, fs_1.chmodSync)(path, 0o600);
363
+ return path;
364
+ }
365
+ processEnv(config, gateway) {
366
+ const env = {
367
+ ...process.env,
368
+ ...(config.environment ?? {}),
369
+ ...(config.secretEnvVars ?? {}),
370
+ ...(gateway ? { PEAKFLO_AI_GATEWAY_API_KEY: gateway.apiKey } : {}),
371
+ [PI_PERMISSION_MODE_ENV]: config.permissionMode ?? 'ask'
372
+ };
373
+ delete env.AI_AGENT;
374
+ delete env.PI_CODING_AGENT;
375
+ // A parent shell may set this globally. pi-mcp-adapter gives the variable
376
+ // precedence over its config, which would undo `directTools: false` and
377
+ // recreate overlong provider-facing names.
378
+ delete env.MCP_DIRECT_TOOLS;
379
+ return env;
380
+ }
381
+ buildSkillFiles(config) {
382
+ if (!config.skills || config.skills.length === 0)
383
+ return { paths: [] };
384
+ const directory = (0, path_1.join)((0, os_1.homedir)(), '.pf-agent-harness', 'pi-skills', `${sanitizePiSessionName(config.taskId)}-${(0, crypto_1.randomUUID)()}`);
385
+ (0, fs_1.mkdirSync)(directory, { recursive: true, mode: 0o700 });
386
+ (0, fs_1.chmodSync)(directory, 0o700);
387
+ const used = new Set();
388
+ const paths = config.skills.map((skill) => {
389
+ const base = slugifyPiName(skill.name, 48);
390
+ let fileName = `${base}.md`;
391
+ let suffix = 2;
392
+ while (used.has(fileName))
393
+ fileName = `${base}-${suffix++}.md`;
394
+ used.add(fileName);
395
+ const path = (0, path_1.join)(directory, fileName);
396
+ const content = skill.content.endsWith('\n')
397
+ ? skill.content
398
+ : `${skill.content}\n`;
399
+ (0, fs_1.writeFileSync)(path, content, { mode: 0o600 });
400
+ (0, fs_1.chmodSync)(path, 0o600);
401
+ return path;
402
+ });
403
+ return { directory, paths };
404
+ }
405
+ /**
406
+ * Pi's npm launcher uses `#!/usr/bin/env node`. Launching it directly would
407
+ * therefore make Pi depend on whichever Node happens to be first on the
408
+ * desktop app's PATH. Recent Pi releases require Node >=22.19 and use zstd
409
+ * APIs that are absent from older runtimes. On Linux, run the launcher with
410
+ * Electron's main executable. On macOS, use Electron's LSUIElement helper;
411
+ * launching the foreground app executable creates another Dock icon for
412
+ * every Pi session. Both expose the same bundled Node runtime.
413
+ *
414
+ * Windows npm launchers are .cmd files, so they must continue to run through
415
+ * the shell.
416
+ */
417
+ piInvocation(executable, args, env) {
418
+ if (process.platform === 'win32') {
419
+ return { command: executable, args, env, shell: true };
420
+ }
421
+ const nodeRuntime = findPiNodeRuntime(process.platform, process.execPath);
422
+ if (!nodeRuntime) {
423
+ // A valid Electron macOS package always contains Helper.app. If a custom
424
+ // package does not, prefer Pi's shebang-selected Node over spawning the
425
+ // foreground Electron app and reintroducing a Dock icon.
426
+ return { command: executable, args, env, shell: false };
427
+ }
428
+ return {
429
+ command: nodeRuntime,
430
+ args: [executable, ...args],
431
+ env: { ...env, ELECTRON_RUN_AS_NODE: '1' },
432
+ shell: false
433
+ };
434
+ }
435
+ createSessionState(id, child, config, mcpConfigPath, skillDirectory) {
436
+ return {
437
+ id,
438
+ projectionId: (0, crypto_1.randomUUID)(),
439
+ process: child,
440
+ config,
441
+ status: 'idle',
442
+ lastError: null,
443
+ pending: new Map(),
444
+ parts: [],
445
+ allMessages: [],
446
+ stdoutBuffer: '',
447
+ stdoutDecoder: new string_decoder_1.StringDecoder('utf8'),
448
+ textByBlock: new Map(),
449
+ reasoningByBlock: new Map(),
450
+ toolParts: new Map(),
451
+ pendingUiRequests: new Map(),
452
+ turn: 0,
453
+ assistantMessageOrdinal: 0,
454
+ pendingTurnError: null,
455
+ pendingTurnErrorMonotonicTime: null,
456
+ sawAgentActivity: false,
457
+ promptMayBeCommandOnly: false,
458
+ closing: false,
459
+ mcpConfigPath,
460
+ skillDirectory
461
+ };
462
+ }
463
+ splitModel(model) {
464
+ if (!model)
465
+ return {};
466
+ const separator = model.indexOf('/');
467
+ if (separator < 0)
468
+ return { modelId: model };
469
+ return {
470
+ provider: model.slice(0, separator),
471
+ modelId: model.slice(separator + 1)
472
+ };
473
+ }
474
+ async applySelection(session, config) {
475
+ if (config.model && config.model !== 'default') {
476
+ const { provider, modelId } = this.splitModel(config.model);
477
+ if (!provider || !modelId) {
478
+ throw new Error(`Pi model must use provider/model format: ${config.model}`);
479
+ }
480
+ await this.command(session, { type: 'set_model', provider, modelId });
481
+ }
482
+ if (config.reasoningEffort) {
483
+ await this.command(session, {
484
+ type: 'set_thinking_level',
485
+ level: config.reasoningEffort
486
+ });
487
+ }
488
+ }
489
+ async spawnSession(config, resumeId) {
490
+ const executable = await this.findPiExecutable();
491
+ const gateway = this.installPeakfloGateway();
492
+ const mcpConfigPath = this.buildMcpConfig(config);
493
+ const skills = this.buildSkillFiles(config);
494
+ const permissionExtension = this.installPermissionExtension();
495
+ const args = [
496
+ '--mode',
497
+ 'rpc',
498
+ '--approve',
499
+ '--name',
500
+ sanitizePiSessionName(config.taskId),
501
+ '--extension',
502
+ permissionExtension
503
+ ];
504
+ if (config.systemPrompt?.trim()) {
505
+ args.push('--append-system-prompt', config.systemPrompt.trim());
506
+ }
507
+ if (resumeId)
508
+ args.push('--session', resumeId);
509
+ if (mcpConfigPath)
510
+ args.push('--mcp-config', mcpConfigPath);
511
+ for (const skillPath of skills.paths)
512
+ args.push('--skill', skillPath);
513
+ const invocation = this.piInvocation(executable, args, this.processEnv(config, gateway));
514
+ const child = (0, child_process_1.spawn)(invocation.command, invocation.args, {
515
+ cwd: config.workspaceDir,
516
+ env: invocation.env,
517
+ stdio: ['pipe', 'pipe', 'pipe'],
518
+ windowsHide: true,
519
+ shell: invocation.shell,
520
+ detached: process.platform !== 'win32'
521
+ });
522
+ const tempId = resumeId || (0, crypto_1.randomUUID)();
523
+ const session = this.createSessionState(tempId, child, config, mcpConfigPath, skills.directory);
524
+ this.sessions.set(tempId, session);
525
+ this.attachProcess(session);
526
+ try {
527
+ const state = await this.command(session, { type: 'get_state' });
528
+ const realId = typeof state.data?.sessionFile === 'string'
529
+ ? state.data.sessionFile
530
+ : typeof state.data?.sessionId === 'string'
531
+ ? state.data.sessionId
532
+ : tempId;
533
+ session.id = realId;
534
+ if (realId !== tempId) {
535
+ this.sessions.delete(tempId);
536
+ this.sessions.set(realId, session);
537
+ }
538
+ await this.applySelection(session, config);
539
+ return session;
540
+ }
541
+ catch (error) {
542
+ this.sessions.delete(tempId);
543
+ this.sessions.delete(session.id);
544
+ await this.terminateProcess(session);
545
+ if (mcpConfigPath && (0, fs_1.existsSync)(mcpConfigPath))
546
+ (0, fs_1.unlinkSync)(mcpConfigPath);
547
+ if (skills.directory && (0, fs_1.existsSync)(skills.directory))
548
+ (0, fs_1.rmSync)(skills.directory, { recursive: true, force: true });
549
+ throw error;
550
+ }
551
+ }
552
+ async createSession(config) {
553
+ const session = await this.spawnSession(config);
554
+ return session.id;
555
+ }
556
+ async resumeSession(sessionId, config) {
557
+ const session = await this.spawnSession(config, sessionId);
558
+ const response = await this.command(session, { type: 'get_messages' });
559
+ const messages = Array.isArray(response.data?.messages)
560
+ ? response.data.messages
561
+ : [];
562
+ session.allMessages = this.convertMessages(messages);
563
+ return session.allMessages;
564
+ }
565
+ attachProcess(session) {
566
+ session.process.stdout.on('data', (chunk) => {
567
+ this.consumeStdout(session, typeof chunk === 'string' ? chunk : session.stdoutDecoder.write(chunk));
568
+ });
569
+ session.process.stdout.on('end', () => {
570
+ this.consumeStdout(session, session.stdoutDecoder.end(), true);
571
+ });
572
+ session.process.stderr.on('data', (chunk) => {
573
+ const length = chunk.toString().trim().length;
574
+ if (length > 0)
575
+ console.warn(`[PiAdapter/${session.id}] Pi wrote ${length} character(s) to stderr`);
576
+ });
577
+ session.process.on('error', (error) => {
578
+ session.status = 'error';
579
+ session.lastError = error.message;
580
+ this.rejectPending(session, error);
581
+ this.onDataAvailable?.(session.id);
582
+ });
583
+ session.process.on('exit', (code, signal) => {
584
+ if (!session.closing && session.status === 'busy') {
585
+ session.status = 'error';
586
+ session.lastError = `Pi exited before the turn completed (${signal || `code ${code}`})`;
587
+ }
588
+ this.rejectPending(session, new Error(session.lastError ||
589
+ (session.closing ? 'Pi process closed' : 'Pi process exited')));
590
+ this.onDataAvailable?.(session.id);
591
+ });
592
+ }
593
+ consumeStdout(session, chunk, flush = false) {
594
+ session.stdoutBuffer += chunk;
595
+ while (session.stdoutBuffer.includes('\n')) {
596
+ const newline = session.stdoutBuffer.indexOf('\n');
597
+ const line = session.stdoutBuffer.slice(0, newline);
598
+ session.stdoutBuffer = session.stdoutBuffer.slice(newline + 1);
599
+ this.handleJsonlLine(session, line);
600
+ }
601
+ if (flush && session.stdoutBuffer.length > 0) {
602
+ const line = session.stdoutBuffer;
603
+ session.stdoutBuffer = '';
604
+ this.handleJsonlLine(session, line);
605
+ }
606
+ }
607
+ handleJsonlLine(session, rawLine) {
608
+ const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
609
+ if (!line.trim())
610
+ return;
611
+ try {
612
+ this.handleEvent(session, JSON.parse(line));
613
+ }
614
+ catch (error) {
615
+ console.warn('[PiAdapter] Invalid RPC output:', error);
616
+ }
617
+ }
618
+ rejectPending(session, error) {
619
+ for (const request of session.pending.values()) {
620
+ clearTimeout(request.timer);
621
+ request.reject(error);
622
+ }
623
+ session.pending.clear();
624
+ }
625
+ handleEvent(session, event) {
626
+ if (event.type === 'response') {
627
+ const response = event;
628
+ if (response.id) {
629
+ const pending = session.pending.get(response.id);
630
+ if (pending) {
631
+ clearTimeout(pending.timer);
632
+ session.pending.delete(response.id);
633
+ if (response.success)
634
+ pending.resolve(response);
635
+ else
636
+ pending.reject(new Error(response.error || `Pi ${response.command} failed`));
637
+ return;
638
+ }
639
+ }
640
+ if (response.command === 'prompt' && !response.success) {
641
+ session.status = 'error';
642
+ session.lastError = response.error || 'Pi rejected the prompt';
643
+ }
644
+ else if (response.command === 'prompt' &&
645
+ response.success &&
646
+ session.promptMayBeCommandOnly &&
647
+ !session.sawAgentActivity) {
648
+ void this.command(session, { type: 'get_state' })
649
+ .then((state) => {
650
+ const pendingMessageCount = typeof state.data?.pendingMessageCount === 'number'
651
+ ? state.data.pendingMessageCount
652
+ : 0;
653
+ if (state.data?.isStreaming !== true &&
654
+ pendingMessageCount === 0 &&
655
+ !session.sawAgentActivity) {
656
+ session.status =
657
+ session.pendingUiRequests.size > 0 ? 'busy' : 'idle';
658
+ this.onDataAvailable?.(session.id);
659
+ }
660
+ })
661
+ .catch(() => undefined);
662
+ }
663
+ this.onDataAvailable?.(session.id);
664
+ return;
665
+ }
666
+ switch (event.type) {
667
+ case 'agent_start':
668
+ session.status = 'busy';
669
+ session.lastError = null;
670
+ session.pendingTurnError = null;
671
+ session.pendingTurnErrorMonotonicTime = null;
672
+ session.turn++;
673
+ session.assistantMessageOrdinal = 0;
674
+ session.sawAgentActivity = true;
675
+ break;
676
+ case 'agent_settled': {
677
+ this.cancelPendingUiRequests(session, 'This request is no longer active.');
678
+ if (session.pendingTurnError)
679
+ this.settlePendingTurnError(session);
680
+ else
681
+ session.status = 'idle';
682
+ session.promptMayBeCommandOnly = false;
683
+ break;
684
+ }
685
+ case 'message_start': {
686
+ const message = event.message;
687
+ if (message?.role === 'assistant')
688
+ session.assistantMessageOrdinal++;
689
+ break;
690
+ }
691
+ case 'message_update':
692
+ this.handleMessageUpdate(session, event.assistantMessageEvent);
693
+ break;
694
+ case 'message_end': {
695
+ const message = event.message;
696
+ this.reconcileAssistantMessage(session, message);
697
+ if (message?.role === 'assistant' && message.stopReason === 'error') {
698
+ session.pendingTurnError =
699
+ message.errorMessage || 'Pi model request failed';
700
+ session.pendingTurnErrorMonotonicTime = performance.now();
701
+ }
702
+ break;
703
+ }
704
+ case 'tool_execution_start':
705
+ this.handleToolEvent(session, event, 'running');
706
+ break;
707
+ case 'tool_execution_update':
708
+ this.handleToolEvent(session, event, 'running');
709
+ break;
710
+ case 'tool_execution_end':
711
+ this.handleToolEvent(session, event, event.isError ? 'error' : 'completed');
712
+ break;
713
+ case 'auto_retry_start':
714
+ session.status = 'busy';
715
+ session.pendingTurnErrorMonotonicTime = null;
716
+ break;
717
+ case 'auto_retry_end':
718
+ session.pendingTurnError =
719
+ event.success === true
720
+ ? null
721
+ : String(event.finalError || 'Pi retry failed');
722
+ session.pendingTurnErrorMonotonicTime =
723
+ event.success === true ? null : performance.now();
724
+ break;
725
+ case 'compaction_end':
726
+ if (event.result)
727
+ session.pendingTurnError = null;
728
+ else if (event.aborted !== true && event.errorMessage) {
729
+ session.parts.push({
730
+ id: `pi-compaction-error-${session.turn}-${(0, crypto_1.randomUUID)()}`,
731
+ type: coding_agent_adapter_1.MessagePartType.ERROR,
732
+ text: String(event.errorMessage)
733
+ });
734
+ }
735
+ break;
736
+ case 'extension_error':
737
+ session.parts.push({
738
+ id: `pi-extension-error-${(0, crypto_1.randomUUID)()}`,
739
+ type: coding_agent_adapter_1.MessagePartType.ERROR,
740
+ text: String(event.error || 'Pi extension failed')
741
+ });
742
+ break;
743
+ case 'extension_ui_request':
744
+ this.handleExtensionUiRequest(session, event);
745
+ break;
746
+ }
747
+ if (session.parts.length > MAX_BUFFERED_PARTS) {
748
+ session.parts.splice(0, session.parts.length - MAX_BUFFERED_PARTS);
749
+ }
750
+ this.onDataAvailable?.(session.id);
751
+ }
752
+ handleExtensionUiRequest(session, event) {
753
+ const method = event.method;
754
+ const id = event.id;
755
+ if (method === 'notify') {
756
+ const message = String(event.message || event.title || 'Pi notification');
757
+ session.parts.push({
758
+ id: `pi-notify-${(0, crypto_1.randomUUID)()}`,
759
+ type: coding_agent_adapter_1.MessagePartType.TEXT,
760
+ text: message
761
+ });
762
+ return;
763
+ }
764
+ if (typeof id !== 'string' ||
765
+ (method !== 'confirm' &&
766
+ method !== 'select' &&
767
+ method !== 'input' &&
768
+ method !== 'editor'))
769
+ return;
770
+ const request = {
771
+ id,
772
+ method,
773
+ title: String(event.title ||
774
+ (method === 'confirm' ? 'Permission Required' : 'Input Required')),
775
+ message: this.uiRequestMessage(method, event),
776
+ options: Array.isArray(event.options)
777
+ ? event.options.filter((option) => typeof option === 'string')
778
+ : []
779
+ };
780
+ if (method === 'confirm' && session.config.permissionMode === 'allow') {
781
+ this.sendRecord(session, {
782
+ type: 'extension_ui_response',
783
+ id,
784
+ confirmed: true
785
+ });
786
+ return;
787
+ }
788
+ session.pendingUiRequests.set(id, request);
789
+ session.status = 'busy';
790
+ if (method !== 'confirm') {
791
+ session.parts.push({
792
+ id: `pi-question-${id}`,
793
+ type: coding_agent_adapter_1.MessagePartType.QUESTION,
794
+ tool: {
795
+ name: 'question',
796
+ status: 'running',
797
+ title: request.title,
798
+ requestId: request.id,
799
+ questions: [
800
+ {
801
+ header: request.title,
802
+ question: request.message,
803
+ options: request.options.map((label) => ({
804
+ label,
805
+ description: label
806
+ }))
807
+ }
808
+ ]
809
+ }
810
+ });
811
+ }
812
+ }
813
+ uiRequestMessage(method, event) {
814
+ const message = String(event.message ||
815
+ event.placeholder ||
816
+ event.title ||
817
+ 'Pi needs your response');
818
+ const prefill = method === 'editor' && typeof event.prefill === 'string'
819
+ ? event.prefill.slice(0, 2_000)
820
+ : '';
821
+ return prefill ? `${message}\n\nCurrent value:\n${prefill}` : message;
822
+ }
823
+ cancelPendingUiRequests(session, output, notifyPi = false) {
824
+ for (const request of session.pendingUiRequests.values()) {
825
+ if (notifyPi) {
826
+ try {
827
+ this.sendRecord(session, {
828
+ type: 'extension_ui_response',
829
+ id: request.id,
830
+ cancelled: true
831
+ });
832
+ }
833
+ catch {
834
+ // The process may have closed while the turn was being stopped.
835
+ }
836
+ }
837
+ session.parts.push({
838
+ id: request.method === 'confirm'
839
+ ? `question-${request.id}`
840
+ : `pi-question-${request.id}`,
841
+ type: coding_agent_adapter_1.MessagePartType.QUESTION,
842
+ update: true,
843
+ tool: {
844
+ name: request.method === 'confirm' ? 'permission' : 'question',
845
+ status: 'cancelled',
846
+ requestId: request.id,
847
+ output
848
+ }
849
+ });
850
+ }
851
+ session.pendingUiRequests.clear();
852
+ }
853
+ handleMessageUpdate(session, update) {
854
+ if (!update)
855
+ return;
856
+ const index = String(update.contentIndex ?? 0);
857
+ const key = `${session.projectionId}:${session.turn}:${session.assistantMessageOrdinal}:${index}`;
858
+ if (update.type === 'text_delta') {
859
+ const text = (session.textByBlock.get(key) || '') + String(update.delta || '');
860
+ session.textByBlock.set(key, text);
861
+ session.parts.push({
862
+ id: `pi-text-${key}`,
863
+ type: coding_agent_adapter_1.MessagePartType.TEXT,
864
+ role: coding_agent_adapter_1.MessageRole.ASSISTANT,
865
+ text,
866
+ update: true
867
+ });
868
+ }
869
+ else if (update.type === 'text_end') {
870
+ const text = String(update.content ?? session.textByBlock.get(key) ?? '');
871
+ session.textByBlock.set(key, text);
872
+ session.parts.push({
873
+ id: `pi-text-${key}`,
874
+ type: coding_agent_adapter_1.MessagePartType.TEXT,
875
+ role: coding_agent_adapter_1.MessageRole.ASSISTANT,
876
+ text,
877
+ update: true
878
+ });
879
+ }
880
+ else if (update.type === 'thinking_delta') {
881
+ const text = (session.reasoningByBlock.get(key) || '') + String(update.delta || '');
882
+ session.reasoningByBlock.set(key, text);
883
+ session.parts.push({
884
+ id: `pi-reasoning-${key}`,
885
+ type: coding_agent_adapter_1.MessagePartType.REASONING,
886
+ role: coding_agent_adapter_1.MessageRole.ASSISTANT,
887
+ text,
888
+ update: true
889
+ });
890
+ }
891
+ else if (update.type === 'thinking_end') {
892
+ const text = String(update.content ??
893
+ update.thinking ??
894
+ session.reasoningByBlock.get(key) ??
895
+ '');
896
+ session.reasoningByBlock.set(key, text);
897
+ session.parts.push({
898
+ id: `pi-reasoning-${key}`,
899
+ type: coding_agent_adapter_1.MessagePartType.REASONING,
900
+ role: coding_agent_adapter_1.MessageRole.ASSISTANT,
901
+ text,
902
+ update: true
903
+ });
904
+ }
905
+ }
906
+ reconcileAssistantMessage(session, message) {
907
+ if (message?.role !== 'assistant')
908
+ return;
909
+ const blocks = typeof message.content === 'string'
910
+ ? [{ type: 'text', text: message.content }]
911
+ : Array.isArray(message.content)
912
+ ? message.content
913
+ : [];
914
+ blocks.forEach((block, index) => {
915
+ const key = `${session.projectionId}:${session.turn}:${session.assistantMessageOrdinal}:${index}`;
916
+ if (block.type === 'text') {
917
+ const text = String(block.text ?? '');
918
+ session.textByBlock.set(key, text);
919
+ session.parts.push({
920
+ id: `pi-text-${key}`,
921
+ type: coding_agent_adapter_1.MessagePartType.TEXT,
922
+ role: coding_agent_adapter_1.MessageRole.ASSISTANT,
923
+ text,
924
+ update: true
925
+ });
926
+ }
927
+ else if (block.type === 'thinking') {
928
+ const text = String(block.thinking ?? '');
929
+ session.reasoningByBlock.set(key, text);
930
+ session.parts.push({
931
+ id: `pi-reasoning-${key}`,
932
+ type: coding_agent_adapter_1.MessagePartType.REASONING,
933
+ role: coding_agent_adapter_1.MessageRole.ASSISTANT,
934
+ text,
935
+ update: true
936
+ });
937
+ }
938
+ });
939
+ }
940
+ handleToolEvent(session, event, status) {
941
+ const id = String(event.toolCallId || (0, crypto_1.randomUUID)());
942
+ const existing = session.toolParts.get(id);
943
+ const output = resultText(event.result ?? event.partialResult);
944
+ const tool = {
945
+ name: String(event.toolName || existing?.tool?.name || 'tool'),
946
+ status,
947
+ input: event.args ?? existing?.tool?.input,
948
+ output: output || existing?.tool?.output,
949
+ error: status === 'error' ? output || 'Tool failed' : undefined
950
+ };
951
+ const part = {
952
+ id: `pi-tool-${id}`,
953
+ type: coding_agent_adapter_1.MessagePartType.TOOL,
954
+ tool,
955
+ update: !!existing
956
+ };
957
+ session.toolParts.set(id, part);
958
+ session.parts.push(part);
959
+ }
960
+ command(session, command) {
961
+ if (session.process.exitCode !== null || !session.process.stdin.writable) {
962
+ return Promise.reject(new Error('Pi process is not running'));
963
+ }
964
+ const id = (0, crypto_1.randomUUID)();
965
+ return new Promise((resolve, reject) => {
966
+ const timer = setTimeout(() => {
967
+ session.pending.delete(id);
968
+ reject(new Error(`Pi RPC command timed out: ${String(command.type)}`));
969
+ }, RPC_TIMEOUT_MS);
970
+ session.pending.set(id, { resolve, reject, timer });
971
+ session.process.stdin.write(`${JSON.stringify({ ...command, id })}\n`, (error) => {
972
+ if (!error)
973
+ return;
974
+ clearTimeout(timer);
975
+ session.pending.delete(id);
976
+ reject(error);
977
+ });
978
+ });
979
+ }
980
+ sendRecord(session, record) {
981
+ if (session.process.exitCode !== null || !session.process.stdin.writable) {
982
+ throw new Error('Pi process is not running');
983
+ }
984
+ session.process.stdin.write(`${JSON.stringify(record)}\n`, (error) => {
985
+ if (!error)
986
+ return;
987
+ session.status = 'error';
988
+ session.lastError = error.message;
989
+ this.onDataAvailable?.(session.id);
990
+ });
991
+ }
992
+ async sendPrompt(sessionId, parts, _config) {
993
+ const session = this.sessions.get(sessionId);
994
+ if (!session)
995
+ throw new Error(`Session not found: ${sessionId}`);
996
+ const message = parts
997
+ .filter((part) => part.type === coding_agent_adapter_1.MessagePartType.TEXT && part.text)
998
+ .map((part) => part.text)
999
+ .join('\n');
1000
+ if (!message)
1001
+ throw new Error('No text content in prompt parts');
1002
+ const wasBusy = session.status === 'busy';
1003
+ session.status = 'busy';
1004
+ session.lastError = null;
1005
+ session.sawAgentActivity = false;
1006
+ session.promptMayBeCommandOnly = message.trimStart().startsWith('/');
1007
+ this.sendRecord(session, {
1008
+ type: 'prompt',
1009
+ message,
1010
+ // A message sent from the task composer while Pi is already running is
1011
+ // user steering, not deferred work. Pi delivers `steer` after the current
1012
+ // tool finishes and before the next model call; `followUp` would wait for
1013
+ // the whole run to settle and lets a misdirected turn continue unchecked.
1014
+ ...(wasBusy ? { streamingBehavior: 'steer' } : {})
1015
+ });
1016
+ }
1017
+ /**
1018
+ * THE LONG-TURNS SOFT WARNING'S DELIVERY MECHANISM.
1019
+ *
1020
+ * Reuses exactly the `streamingBehavior: 'steer'` path `sendPrompt` already
1021
+ * takes for a message sent while Pi is busy — Pi delivers it after the
1022
+ * current tool finishes and before the next model call. Deliberately does
1023
+ * NOT touch `session.status`/`sawAgentActivity`/`promptMayBeCommandOnly`:
1024
+ * those are `sendPrompt`'s own bookkeeping for a NEW prompt, and a steer
1025
+ * into an already-running turn must not reset them.
1026
+ */
1027
+ async steerTurn(sessionId, text, _config) {
1028
+ const session = this.sessions.get(sessionId);
1029
+ if (!session || session.status !== 'busy' || !text) {
1030
+ return false;
1031
+ }
1032
+ try {
1033
+ this.sendRecord(session, {
1034
+ type: 'prompt',
1035
+ message: text,
1036
+ streamingBehavior: 'steer'
1037
+ });
1038
+ return true;
1039
+ }
1040
+ catch {
1041
+ return false;
1042
+ }
1043
+ }
1044
+ async getStatus(sessionId, _config) {
1045
+ const session = this.sessions.get(sessionId);
1046
+ if (!session)
1047
+ return { type: coding_agent_adapter_1.SessionStatusType.ERROR, message: 'Session not found' };
1048
+ // Some providers end the model stream with stopReason=error but omit the
1049
+ // final agent_settled event. Confirm Pi is no longer streaming so that a
1050
+ // terminal provider error cannot leave the task BUSY forever. A retry keeps
1051
+ // isStreaming=true (and auto_retry_start clears/replaces the pending error).
1052
+ if (session.pendingTurnError &&
1053
+ session.pendingTurnErrorMonotonicTime !== null &&
1054
+ performance.now() - session.pendingTurnErrorMonotonicTime >=
1055
+ TERMINAL_ERROR_SETTLE_GRACE_MS &&
1056
+ session.status === 'busy') {
1057
+ try {
1058
+ const state = await this.command(session, { type: 'get_state' });
1059
+ const pendingMessageCount = typeof state.data?.pendingMessageCount === 'number'
1060
+ ? state.data.pendingMessageCount
1061
+ : 0;
1062
+ if (state.data?.isStreaming !== true && pendingMessageCount === 0) {
1063
+ this.settlePendingTurnError(session);
1064
+ }
1065
+ }
1066
+ catch {
1067
+ // Keep polling; process exit/error handling remains authoritative.
1068
+ }
1069
+ }
1070
+ if (session.lastError)
1071
+ return { type: coding_agent_adapter_1.SessionStatusType.ERROR, message: session.lastError };
1072
+ if (session.pendingUiRequests.size > 0) {
1073
+ return {
1074
+ type: coding_agent_adapter_1.SessionStatusType.WAITING_APPROVAL,
1075
+ message: 'Pi is waiting for input'
1076
+ };
1077
+ }
1078
+ return {
1079
+ type: session.status === 'busy'
1080
+ ? coding_agent_adapter_1.SessionStatusType.BUSY
1081
+ : coding_agent_adapter_1.SessionStatusType.IDLE
1082
+ };
1083
+ }
1084
+ settlePendingTurnError(session) {
1085
+ if (!session.pendingTurnError)
1086
+ return;
1087
+ const error = withProviderNameLimitHint(session.pendingTurnError);
1088
+ session.pendingTurnError = null;
1089
+ session.pendingTurnErrorMonotonicTime = null;
1090
+ session.lastError = error;
1091
+ session.status = 'error';
1092
+ session.parts.push({
1093
+ id: `pi-error-${session.projectionId}:${session.turn}`,
1094
+ type: coding_agent_adapter_1.MessagePartType.ERROR,
1095
+ text: error
1096
+ });
1097
+ }
1098
+ async pollMessages(sessionId) {
1099
+ const session = this.sessions.get(sessionId);
1100
+ if (!session)
1101
+ return [];
1102
+ return session.parts.splice(0);
1103
+ }
1104
+ getPendingApproval(sessionId) {
1105
+ const session = this.sessions.get(sessionId);
1106
+ const request = session
1107
+ ? Array.from(session.pendingUiRequests.values()).find((item) => item.method === 'confirm')
1108
+ : undefined;
1109
+ if (!request)
1110
+ return null;
1111
+ return {
1112
+ requestId: request.id,
1113
+ toolCallId: request.id,
1114
+ question: [request.title, request.message].filter(Boolean).join('\n\n'),
1115
+ options: [
1116
+ { optionId: 'approved', name: 'Yes', kind: 'allow_once' },
1117
+ { optionId: 'abort', name: 'No', kind: 'reject_once' }
1118
+ ]
1119
+ };
1120
+ }
1121
+ async respondToApproval(sessionId, approved, _optionId, requestId) {
1122
+ const session = this.sessions.get(sessionId);
1123
+ if (!session)
1124
+ throw new Error(`Session not found: ${sessionId}`);
1125
+ const request = requestId
1126
+ ? session.pendingUiRequests.get(requestId)
1127
+ : Array.from(session.pendingUiRequests.values()).find((item) => item.method === 'confirm');
1128
+ if (request?.method !== 'confirm')
1129
+ return false;
1130
+ this.sendRecord(session, {
1131
+ type: 'extension_ui_response',
1132
+ id: request.id,
1133
+ confirmed: approved
1134
+ });
1135
+ session.pendingUiRequests.delete(request.id);
1136
+ session.parts.push({
1137
+ id: `question-${request.id}`,
1138
+ type: coding_agent_adapter_1.MessagePartType.QUESTION,
1139
+ update: true,
1140
+ tool: {
1141
+ name: 'permission',
1142
+ status: approved ? 'completed' : 'cancelled',
1143
+ requestId: request.id,
1144
+ output: approved ? 'Approved' : 'Declined'
1145
+ }
1146
+ });
1147
+ session.status = 'busy';
1148
+ this.onDataAvailable?.(session.id);
1149
+ return true;
1150
+ }
1151
+ async respondToQuestion(sessionId, answers, _config, requestId) {
1152
+ const session = this.sessions.get(sessionId);
1153
+ if (!session)
1154
+ throw new Error(`Session not found: ${sessionId}`);
1155
+ // The runner receives the transcript part id (`pi-question-<requestId>`),
1156
+ // while Pi's JSONL protocol addresses the underlying request id. Accept
1157
+ // either form so a stale card can never fall through to a newer request.
1158
+ const piRequestId = requestId?.startsWith('pi-question-')
1159
+ ? requestId.slice('pi-question-'.length)
1160
+ : requestId;
1161
+ const request = piRequestId
1162
+ ? session.pendingUiRequests.get(piRequestId)
1163
+ : Array.from(session.pendingUiRequests.values()).find((item) => item.method !== 'confirm');
1164
+ // A restored transcript or another client can submit a response after Pi
1165
+ // has already consumed the request. Treat that response as stale.
1166
+ if (!request || request.method === 'confirm') {
1167
+ if (!request && piRequestId) {
1168
+ return {
1169
+ handled: false,
1170
+ resolutionPart: {
1171
+ id: `pi-question-${piRequestId}`,
1172
+ type: coding_agent_adapter_1.MessagePartType.QUESTION,
1173
+ update: true,
1174
+ tool: {
1175
+ name: 'question',
1176
+ status: 'cancelled',
1177
+ requestId: piRequestId,
1178
+ output: 'This request expired when the session ended. Restart the turn to continue.'
1179
+ }
1180
+ }
1181
+ };
1182
+ }
1183
+ return false;
1184
+ }
1185
+ const value = answers[request.title] ??
1186
+ answers[request.message] ??
1187
+ answers.answer ??
1188
+ Object.values(answers)[0] ??
1189
+ '';
1190
+ this.sendRecord(session, {
1191
+ type: 'extension_ui_response',
1192
+ id: request.id,
1193
+ value
1194
+ });
1195
+ session.pendingUiRequests.delete(request.id);
1196
+ session.parts.push({
1197
+ id: `pi-question-${request.id}`,
1198
+ type: coding_agent_adapter_1.MessagePartType.QUESTION,
1199
+ update: true,
1200
+ tool: {
1201
+ name: 'question',
1202
+ status: 'completed',
1203
+ title: request.title,
1204
+ requestId: request.id,
1205
+ output: value
1206
+ }
1207
+ });
1208
+ session.status = 'busy';
1209
+ this.onDataAvailable?.(session.id);
1210
+ return true;
1211
+ }
1212
+ async getRunningTools(sessionId) {
1213
+ const session = this.sessions.get(sessionId);
1214
+ if (!session)
1215
+ return [];
1216
+ return Array.from(session.toolParts.values()).flatMap((part) => {
1217
+ if (part.tool?.status !== 'running')
1218
+ return [];
1219
+ return [
1220
+ {
1221
+ partId: part.id || `pi-tool-${part.tool.name}`,
1222
+ toolName: part.tool.name,
1223
+ ...(part.tool.input && typeof part.tool.input === 'object'
1224
+ ? { input: part.tool.input }
1225
+ : {})
1226
+ }
1227
+ ];
1228
+ });
1229
+ }
1230
+ convertMessages(messages) {
1231
+ return messages.flatMap((message, index) => {
1232
+ if (message.role !== 'user' && message.role !== 'assistant')
1233
+ return [];
1234
+ const text = textFromContent(message.content);
1235
+ if (!text)
1236
+ return [];
1237
+ return [
1238
+ {
1239
+ id: `pi-history-${message.timestamp ?? index}`,
1240
+ role: message.role === 'user' ? coding_agent_adapter_1.MessageRole.USER : coding_agent_adapter_1.MessageRole.ASSISTANT,
1241
+ parts: [
1242
+ {
1243
+ id: `pi-history-part-${message.timestamp ?? index}`,
1244
+ type: coding_agent_adapter_1.MessagePartType.TEXT,
1245
+ text
1246
+ }
1247
+ ]
1248
+ }
1249
+ ];
1250
+ });
1251
+ }
1252
+ async getAllMessages(sessionId) {
1253
+ const session = this.sessions.get(sessionId);
1254
+ if (!session)
1255
+ return [];
1256
+ try {
1257
+ const response = await this.command(session, { type: 'get_messages' });
1258
+ const messages = Array.isArray(response.data?.messages)
1259
+ ? response.data.messages
1260
+ : [];
1261
+ session.allMessages = this.convertMessages(messages);
1262
+ }
1263
+ catch {
1264
+ // Use the last successful snapshot when the process has already ended.
1265
+ }
1266
+ return session.allMessages;
1267
+ }
1268
+ async abortPrompt(sessionId) {
1269
+ const session = this.sessions.get(sessionId);
1270
+ if (!session)
1271
+ return;
1272
+ this.cancelPendingUiRequests(session, 'This request was cancelled when the turn stopped.', true);
1273
+ await this.command(session, { type: 'clear_queue' }).catch(() => undefined);
1274
+ await this.command(session, { type: 'abort' });
1275
+ session.status = 'idle';
1276
+ }
1277
+ async terminateProcess(session) {
1278
+ session.closing = true;
1279
+ if (session.process.exitCode !== null || !session.process.pid)
1280
+ return;
1281
+ if (process.platform === 'win32') {
1282
+ await execFileAsync('taskkill', ['/PID', String(session.process.pid), '/T', '/F'], {
1283
+ timeout: 5_000,
1284
+ windowsHide: true
1285
+ }).catch(() => session.process.kill());
1286
+ return;
1287
+ }
1288
+ const processGroup = -session.process.pid;
1289
+ try {
1290
+ process.kill(processGroup, 'SIGTERM');
1291
+ }
1292
+ catch {
1293
+ session.process.kill();
1294
+ return;
1295
+ }
1296
+ await new Promise((resolve) => setTimeout(resolve, 1_000));
1297
+ if (session.process.exitCode !== null)
1298
+ return;
1299
+ try {
1300
+ process.kill(processGroup, 'SIGKILL');
1301
+ }
1302
+ catch {
1303
+ // The process group exited during the grace period.
1304
+ }
1305
+ }
1306
+ async destroySession(sessionId) {
1307
+ const session = this.sessions.get(sessionId);
1308
+ if (!session)
1309
+ return;
1310
+ for (const request of session.pendingUiRequests.values()) {
1311
+ try {
1312
+ this.sendRecord(session, {
1313
+ type: 'extension_ui_response',
1314
+ id: request.id,
1315
+ cancelled: true
1316
+ });
1317
+ }
1318
+ catch {
1319
+ break;
1320
+ }
1321
+ }
1322
+ session.pendingUiRequests.clear();
1323
+ await this.terminateProcess(session);
1324
+ this.sessions.delete(sessionId);
1325
+ if (session.mcpConfigPath && (0, fs_1.existsSync)(session.mcpConfigPath)) {
1326
+ (0, fs_1.unlinkSync)(session.mcpConfigPath);
1327
+ }
1328
+ if (session.skillDirectory && (0, fs_1.existsSync)(session.skillDirectory)) {
1329
+ (0, fs_1.rmSync)(session.skillDirectory, { recursive: true, force: true });
1330
+ }
1331
+ }
1332
+ /**
1333
+ * SessionRunner creates one adapter per turn. Stop every process owned by
1334
+ * this adapter after the turn while leaving Pi's session files intact for
1335
+ * native resume on the next turn.
1336
+ */
1337
+ async stopServer() {
1338
+ for (const sessionId of Array.from(this.sessions.keys())) {
1339
+ await this.destroySession(sessionId);
1340
+ }
1341
+ }
1342
+ async registerMcpServer(_serverName, _mcpConfig, _workspaceDir) {
1343
+ // MCP servers are supplied per process through pi-mcp-adapter.
1344
+ }
1345
+ async checkHealth() {
1346
+ try {
1347
+ const executable = await this.findPiExecutable();
1348
+ const invocation = this.piInvocation(executable, ['--version'], {
1349
+ ...process.env
1350
+ });
1351
+ const { stdout, stderr } = await execFileAsync(invocation.command, invocation.args, {
1352
+ env: invocation.env,
1353
+ timeout: 10_000,
1354
+ shell: invocation.shell,
1355
+ windowsHide: true
1356
+ });
1357
+ const match = `${stdout}\n${stderr}`.match(/(\d+)\.(\d+)\.(\d+)/);
1358
+ if (!match)
1359
+ return {
1360
+ available: false,
1361
+ reason: 'Could not determine the Pi version'
1362
+ };
1363
+ const installed = match.slice(1, 4).map(Number);
1364
+ for (let index = 0; index < MINIMUM_PI_VERSION.length; index++) {
1365
+ if (installed[index] > MINIMUM_PI_VERSION[index])
1366
+ break;
1367
+ if (installed[index] < MINIMUM_PI_VERSION[index]) {
1368
+ return {
1369
+ available: false,
1370
+ reason: `Pi ${MINIMUM_PI_VERSION.join('.')} or newer is required`
1371
+ };
1372
+ }
1373
+ }
1374
+ return { available: true };
1375
+ }
1376
+ catch (error) {
1377
+ return {
1378
+ available: false,
1379
+ reason: error instanceof Error ? error.message : String(error)
1380
+ };
1381
+ }
1382
+ }
1383
+ async getProviders(_serverUrl, directory) {
1384
+ const executable = await this.findPiExecutable();
1385
+ const gateway = this.installPeakfloGateway();
1386
+ const config = {
1387
+ agentId: 'pi-discovery',
1388
+ taskId: 'pi-discovery',
1389
+ workspaceDir: directory || process.cwd(),
1390
+ permissionMode: 'allow'
1391
+ };
1392
+ const invocation = this.piInvocation(executable, ['--mode', 'rpc', '--no-session', '--approve'], this.processEnv(config, gateway));
1393
+ const child = (0, child_process_1.spawn)(invocation.command, invocation.args, {
1394
+ cwd: config.workspaceDir,
1395
+ env: invocation.env,
1396
+ stdio: ['pipe', 'pipe', 'pipe'],
1397
+ windowsHide: true,
1398
+ shell: invocation.shell,
1399
+ detached: process.platform !== 'win32'
1400
+ });
1401
+ const session = this.createSessionState(`pi-discovery-${(0, crypto_1.randomUUID)()}`, child, config);
1402
+ this.attachProcess(session);
1403
+ try {
1404
+ const [state, available] = await Promise.all([
1405
+ this.command(session, { type: 'get_state' }),
1406
+ this.command(session, { type: 'get_available_models' })
1407
+ ]);
1408
+ const models = Array.isArray(available.data?.models)
1409
+ ? available.data.models
1410
+ : [];
1411
+ const providers = new Map();
1412
+ for (const model of models) {
1413
+ if (!model || typeof model !== 'object')
1414
+ continue;
1415
+ const record = model;
1416
+ if (typeof record.provider !== 'string' ||
1417
+ typeof record.id !== 'string')
1418
+ continue;
1419
+ const provider = providers.get(record.provider) ?? {
1420
+ id: record.provider,
1421
+ name: record.provider === model_gateway_1.ENTERPRISE_AI_GATEWAY_PROVIDER_ID
1422
+ ? model_gateway_1.ENTERPRISE_AI_GATEWAY_PROVIDER_NAME
1423
+ : record.provider,
1424
+ models: []
1425
+ };
1426
+ if (!provider.models.some((item) => item.id === record.id)) {
1427
+ provider.models.push({
1428
+ id: record.id,
1429
+ name: typeof record.name === 'string' ? record.name : record.id
1430
+ });
1431
+ }
1432
+ providers.set(record.provider, provider);
1433
+ }
1434
+ const defaultModel = state.data?.model;
1435
+ const defaultProvider = defaultModel && typeof defaultModel === 'object'
1436
+ ? defaultModel.provider
1437
+ : undefined;
1438
+ const defaultModelId = defaultModel && typeof defaultModel === 'object'
1439
+ ? defaultModel.id
1440
+ : undefined;
1441
+ return {
1442
+ providers: Array.from(providers.values()),
1443
+ default: typeof defaultProvider === 'string' &&
1444
+ typeof defaultModelId === 'string'
1445
+ ? { [defaultProvider]: defaultModelId }
1446
+ : {}
1447
+ };
1448
+ }
1449
+ catch (error) {
1450
+ if (!gateway)
1451
+ throw error;
1452
+ return {
1453
+ providers: [
1454
+ {
1455
+ id: model_gateway_1.ENTERPRISE_AI_GATEWAY_PROVIDER_ID,
1456
+ name: model_gateway_1.ENTERPRISE_AI_GATEWAY_PROVIDER_NAME,
1457
+ models: gateway.providerModels
1458
+ }
1459
+ ],
1460
+ default: {}
1461
+ };
1462
+ }
1463
+ finally {
1464
+ await this.terminateProcess(session);
1465
+ }
1466
+ }
1467
+ }
1468
+ exports.PiAdapter = PiAdapter;