@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.
- package/LICENSE +21 -0
- package/README.md +68 -2
- package/dist/adapters/acp-adapter.d.ts +177 -0
- package/dist/adapters/acp-adapter.js +1732 -0
- package/dist/adapters/claude-code-adapter.d.ts +171 -0
- package/dist/adapters/claude-code-adapter.js +1918 -0
- package/dist/adapters/codex-adapter.d.ts +67 -0
- package/dist/adapters/codex-adapter.js +681 -0
- package/dist/adapters/codex-app-server-adapter.d.ts +105 -0
- package/dist/adapters/codex-app-server-adapter.js +1309 -0
- package/dist/adapters/coding-agent-adapter.d.ts +289 -0
- package/dist/adapters/coding-agent-adapter.js +31 -0
- package/dist/adapters/index.d.ts +17 -0
- package/dist/adapters/index.js +35 -0
- package/dist/adapters/model-gateway.d.ts +84 -0
- package/dist/adapters/model-gateway.js +270 -0
- package/dist/adapters/opencode-adapter.d.ts +403 -0
- package/dist/adapters/opencode-adapter.js +2710 -0
- package/dist/adapters/opencode-config.d.ts +41 -0
- package/dist/adapters/opencode-config.js +147 -0
- package/dist/adapters/opencode-server-policy.d.ts +82 -0
- package/dist/adapters/opencode-server-policy.js +148 -0
- package/dist/adapters/pi-adapter.d.ts +168 -0
- package/dist/adapters/pi-adapter.js +1468 -0
- package/dist/capabilities.d.ts +58 -0
- package/dist/capabilities.js +161 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +231 -0
- package/dist/contract.d.ts +273 -0
- package/dist/contract.js +357 -0
- package/dist/control-plane.d.ts +178 -0
- package/dist/control-plane.js +431 -0
- package/dist/credential-store.d.ts +110 -0
- package/dist/credential-store.js +181 -0
- package/dist/enrol/browser-enrol.d.ts +85 -0
- package/dist/enrol/browser-enrol.js +387 -0
- package/dist/github-command-guards.d.ts +20 -0
- package/dist/github-command-guards.js +330 -0
- package/dist/github-proxy-certificates.d.ts +25 -0
- package/dist/github-proxy-certificates.js +145 -0
- package/dist/github-proxy.d.ts +37 -0
- package/dist/github-proxy.js +476 -0
- package/dist/github-workspace.d.ts +55 -0
- package/dist/github-workspace.js +537 -0
- package/dist/harness.d.ts +333 -0
- package/dist/harness.js +1414 -0
- package/dist/health.d.ts +45 -0
- package/dist/health.js +116 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +123 -0
- package/dist/model-probe.d.ts +96 -0
- package/dist/model-probe.js +195 -0
- package/dist/options.d.ts +15 -0
- package/dist/options.js +212 -0
- package/dist/ports-default.d.ts +212 -0
- package/dist/ports-default.js +333 -0
- package/dist/ports.d.ts +436 -0
- package/dist/ports.js +27 -0
- package/dist/runtime/attachments.d.ts +92 -0
- package/dist/runtime/attachments.js +377 -0
- package/dist/runtime/child-stream-guards.d.ts +45 -0
- package/dist/runtime/child-stream-guards.js +111 -0
- package/dist/runtime/conversation.d.ts +87 -0
- package/dist/runtime/conversation.js +156 -0
- package/dist/runtime/lease-renewal.d.ts +169 -0
- package/dist/runtime/lease-renewal.js +433 -0
- package/dist/runtime/mcp-auth-proxy.d.ts +82 -0
- package/dist/runtime/mcp-auth-proxy.js +300 -0
- package/dist/runtime/permission-mode.d.ts +116 -0
- package/dist/runtime/permission-mode.js +146 -0
- package/dist/runtime/projection.d.ts +71 -0
- package/dist/runtime/projection.js +211 -0
- package/dist/runtime/session-runner.d.ts +520 -0
- package/dist/runtime/session-runner.js +2122 -0
- package/dist/shared/reasoning-effort.d.ts +11 -0
- package/dist/shared/reasoning-effort.js +20 -0
- package/dist/store/conversation-store.d.ts +100 -0
- package/dist/store/conversation-store.js +305 -0
- package/dist/store/sqlite-store.d.ts +120 -0
- package/dist/store/sqlite-store.js +346 -0
- package/dist/types.d.ts +952 -0
- package/dist/types.js +55 -0
- package/dist/version.d.ts +32 -0
- package/dist/version.js +77 -0
- 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;
|