@agent-link/agent 0.1.323 → 0.1.325
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/backends/index.js +2 -0
- package/dist/backends/index.js.map +1 -1
- package/dist/backends/types.d.ts +11 -1
- package/dist/backends/types.js +74 -1
- package/dist/backends/types.js.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/connection.js +322 -30
- package/dist/connection.js.map +1 -1
- package/dist/copilot.d.ts +1 -0
- package/dist/copilot.js +16 -0
- package/dist/copilot.js.map +1 -1
- package/dist/directory-handlers.d.ts +1 -0
- package/dist/directory-handlers.js +2 -0
- package/dist/directory-handlers.js.map +1 -1
- package/dist/pi.d.ts +109 -0
- package/dist/pi.js +1742 -0
- package/dist/pi.js.map +1 -0
- package/dist/providers.d.ts +2 -2
- package/dist/providers.js +17 -1
- package/dist/providers.js.map +1 -1
- package/dist/runtime.d.ts +3 -1
- package/dist/runtime.js +6 -1
- package/dist/runtime.js.map +1 -1
- package/dist/sdk.d.ts +3 -1
- package/dist/sdk.js +42 -1
- package/dist/sdk.js.map +1 -1
- package/package.json +1 -1
package/dist/pi.js
ADDED
|
@@ -0,0 +1,1742 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent PI RPC driver and PI-owned session history operations.
|
|
3
|
+
*
|
|
4
|
+
* PI is intentionally integrated through its public JSONL RPC protocol and
|
|
5
|
+
* session file format. No PI runtime package is imported by AgentLink.
|
|
6
|
+
*/
|
|
7
|
+
import { randomUUID } from 'crypto';
|
|
8
|
+
import { spawn } from 'child_process';
|
|
9
|
+
import { appendFileSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from 'fs';
|
|
10
|
+
import { homedir, platform } from 'os';
|
|
11
|
+
import { basename, isAbsolute, join, relative, resolve, } from 'path';
|
|
12
|
+
import { StringDecoder } from 'string_decoder';
|
|
13
|
+
import { piCapabilities, } from './backends/types.js';
|
|
14
|
+
import { CONFIG_DIR } from './config.js';
|
|
15
|
+
import { getCleanEnv, resetCachedExecutablePath, resolveWindowsNpmWrapper, } from './sdk.js';
|
|
16
|
+
import { getProvider, resolveProviderCommand } from './providers.js';
|
|
17
|
+
const conversations = new Map();
|
|
18
|
+
const sendFns = new Set();
|
|
19
|
+
const THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
|
|
20
|
+
const MODEL_PART_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/;
|
|
21
|
+
const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*[A-Za-z0-9]$|^[A-Za-z0-9]$/;
|
|
22
|
+
const MIME_PATTERN = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/i;
|
|
23
|
+
const IMAGE_MIME_PATTERN = /^image\/(?:png|jpeg|gif|webp)$/i;
|
|
24
|
+
const TURN_HEARTBEAT_INTERVAL_MS = 15_000;
|
|
25
|
+
const RPC_TIMEOUT_MS = 30_000;
|
|
26
|
+
const ABORT_TIMEOUT_MS = 5_000;
|
|
27
|
+
const MAX_STDERR_CHARS = 64 * 1024;
|
|
28
|
+
const MAX_ATTACHMENTS = 5;
|
|
29
|
+
const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
30
|
+
const PI_SESSION_LIST_LIMIT = 150;
|
|
31
|
+
const ATTACHMENT_DIR = join(CONFIG_DIR, 'tmp-attachments', 'pi');
|
|
32
|
+
function sendFn(message) {
|
|
33
|
+
for (const fn of sendFns) {
|
|
34
|
+
try {
|
|
35
|
+
fn(message);
|
|
36
|
+
}
|
|
37
|
+
catch { /* subscriber failures are isolated */ }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function addPiSendFn(fn) {
|
|
41
|
+
sendFns.add(fn);
|
|
42
|
+
return () => { sendFns.delete(fn); };
|
|
43
|
+
}
|
|
44
|
+
function generateTurnId() {
|
|
45
|
+
return `turn_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
46
|
+
}
|
|
47
|
+
function sessionRef(sessionId) {
|
|
48
|
+
return {
|
|
49
|
+
backendType: 'pi',
|
|
50
|
+
backendSessionId: sessionId,
|
|
51
|
+
backendThreadId: sessionId,
|
|
52
|
+
providerSessionId: sessionId || null,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function createState(conversationId, workDir, providerId = 'pi') {
|
|
56
|
+
return {
|
|
57
|
+
conversationId,
|
|
58
|
+
child: null,
|
|
59
|
+
childProviderId: null,
|
|
60
|
+
piSessionId: null,
|
|
61
|
+
lastPiSessionId: null,
|
|
62
|
+
sessionFile: null,
|
|
63
|
+
workDir,
|
|
64
|
+
providerId,
|
|
65
|
+
turnActive: false,
|
|
66
|
+
turnId: null,
|
|
67
|
+
stateVersion: 0,
|
|
68
|
+
heartbeatTimer: null,
|
|
69
|
+
model: null,
|
|
70
|
+
thinking: null,
|
|
71
|
+
availableModels: [],
|
|
72
|
+
sessionNotified: false,
|
|
73
|
+
cancelRequested: false,
|
|
74
|
+
protocolFailed: false,
|
|
75
|
+
stderrBuffer: '',
|
|
76
|
+
pendingRpc: new Map(),
|
|
77
|
+
attachmentPaths: new Set(),
|
|
78
|
+
turnAttachmentPaths: new Map(),
|
|
79
|
+
launching: null,
|
|
80
|
+
setupPromise: null,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export function getPiConversation(conversationId) {
|
|
84
|
+
return conversations.get(conversationId);
|
|
85
|
+
}
|
|
86
|
+
export function getPiConversations() {
|
|
87
|
+
return conversations;
|
|
88
|
+
}
|
|
89
|
+
export function createPiPlaceholder(conversationId, options = {}) {
|
|
90
|
+
if (conversations.has(conversationId))
|
|
91
|
+
return;
|
|
92
|
+
conversations.set(conversationId, createState(conversationId, options.workDir || process.cwd(), options.providerId || 'pi'));
|
|
93
|
+
}
|
|
94
|
+
export function rebindPiConversation(piSessionId, newConversationId, providerId = 'pi', workDir) {
|
|
95
|
+
for (const [oldConversationId, state] of conversations) {
|
|
96
|
+
if ((state.piSessionId === piSessionId || state.lastPiSessionId === piSessionId)
|
|
97
|
+
&& state.providerId === providerId
|
|
98
|
+
&& (!workDir || normalizePathForCompare(state.workDir) === normalizePathForCompare(workDir))
|
|
99
|
+
&& oldConversationId !== newConversationId) {
|
|
100
|
+
const displaced = conversations.get(newConversationId);
|
|
101
|
+
if (displaced && displaced !== state)
|
|
102
|
+
cleanupPiConversation(newConversationId);
|
|
103
|
+
conversations.delete(oldConversationId);
|
|
104
|
+
state.conversationId = newConversationId;
|
|
105
|
+
conversations.set(newConversationId, state);
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
function startPiTurn(state) {
|
|
112
|
+
state.turnActive = true;
|
|
113
|
+
state.cancelRequested = false;
|
|
114
|
+
state.lastUsage = undefined;
|
|
115
|
+
state.turnId = generateTurnId();
|
|
116
|
+
state.stateVersion++;
|
|
117
|
+
sendFn({
|
|
118
|
+
type: 'turn_started',
|
|
119
|
+
conversationId: state.conversationId,
|
|
120
|
+
claudeSessionId: state.piSessionId,
|
|
121
|
+
turnId: state.turnId,
|
|
122
|
+
stateVersion: state.stateVersion,
|
|
123
|
+
});
|
|
124
|
+
sendFn({
|
|
125
|
+
type: 'backend_event',
|
|
126
|
+
conversationId: state.conversationId,
|
|
127
|
+
event: {
|
|
128
|
+
type: 'turn_started',
|
|
129
|
+
session: sessionRef(state.piSessionId || 'pending'),
|
|
130
|
+
turnId: state.turnId,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
if (!state.heartbeatTimer) {
|
|
134
|
+
state.heartbeatTimer = setInterval(() => {
|
|
135
|
+
if (!state.turnActive) {
|
|
136
|
+
if (state.heartbeatTimer)
|
|
137
|
+
clearInterval(state.heartbeatTimer);
|
|
138
|
+
state.heartbeatTimer = null;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
sendFn({
|
|
142
|
+
type: 'turn_heartbeat',
|
|
143
|
+
conversationId: state.conversationId,
|
|
144
|
+
claudeSessionId: state.piSessionId,
|
|
145
|
+
turnId: state.turnId,
|
|
146
|
+
stateVersion: state.stateVersion,
|
|
147
|
+
phase: 'running',
|
|
148
|
+
lastActivityAt: Date.now(),
|
|
149
|
+
});
|
|
150
|
+
}, TURN_HEARTBEAT_INTERVAL_MS);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function cleanPiAttachments(state, turnId) {
|
|
154
|
+
const paths = turnId
|
|
155
|
+
? state.turnAttachmentPaths.get(turnId) || new Set()
|
|
156
|
+
: state.attachmentPaths;
|
|
157
|
+
for (const filePath of paths) {
|
|
158
|
+
try {
|
|
159
|
+
rmSync(filePath, { force: true });
|
|
160
|
+
}
|
|
161
|
+
catch { /* best effort */ }
|
|
162
|
+
state.attachmentPaths.delete(filePath);
|
|
163
|
+
}
|
|
164
|
+
if (turnId)
|
|
165
|
+
state.turnAttachmentPaths.delete(turnId);
|
|
166
|
+
else
|
|
167
|
+
state.turnAttachmentPaths.clear();
|
|
168
|
+
try {
|
|
169
|
+
if (existsSync(ATTACHMENT_DIR) && readdirSync(ATTACHMENT_DIR).length === 0) {
|
|
170
|
+
rmSync(ATTACHMENT_DIR, { force: true });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch { /* best effort */ }
|
|
174
|
+
}
|
|
175
|
+
function finishPiTurn(state, outcome) {
|
|
176
|
+
if (!state.turnActive)
|
|
177
|
+
return;
|
|
178
|
+
state.turnActive = false;
|
|
179
|
+
state.stateVersion++;
|
|
180
|
+
if (state.heartbeatTimer) {
|
|
181
|
+
clearInterval(state.heartbeatTimer);
|
|
182
|
+
state.heartbeatTimer = null;
|
|
183
|
+
}
|
|
184
|
+
cleanPiAttachments(state, state.turnId);
|
|
185
|
+
const ref = sessionRef(state.piSessionId || '');
|
|
186
|
+
if (outcome === 'cancelled') {
|
|
187
|
+
sendFn({
|
|
188
|
+
type: 'backend_event',
|
|
189
|
+
conversationId: state.conversationId,
|
|
190
|
+
event: { type: 'turn_cancelled', session: ref },
|
|
191
|
+
});
|
|
192
|
+
sendFn({
|
|
193
|
+
type: 'execution_cancelled',
|
|
194
|
+
conversationId: state.conversationId,
|
|
195
|
+
claudeSessionId: state.piSessionId,
|
|
196
|
+
turnId: state.turnId,
|
|
197
|
+
stateVersion: state.stateVersion,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
sendFn({
|
|
202
|
+
type: 'backend_event',
|
|
203
|
+
conversationId: state.conversationId,
|
|
204
|
+
event: {
|
|
205
|
+
type: 'turn_completed',
|
|
206
|
+
session: ref,
|
|
207
|
+
...(state.lastUsage ? { usage: state.lastUsage } : {}),
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
sendFn({
|
|
211
|
+
type: 'turn_completed',
|
|
212
|
+
conversationId: state.conversationId,
|
|
213
|
+
workDir: state.workDir,
|
|
214
|
+
claudeSessionId: state.piSessionId,
|
|
215
|
+
turnId: state.turnId,
|
|
216
|
+
stateVersion: state.stateVersion,
|
|
217
|
+
...(state.lastUsage ? { usage: state.lastUsage } : {}),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
state.cancelRequested = false;
|
|
221
|
+
}
|
|
222
|
+
function emitPiError(state, message) {
|
|
223
|
+
sendFn({
|
|
224
|
+
type: 'backend_event',
|
|
225
|
+
conversationId: state.conversationId,
|
|
226
|
+
event: {
|
|
227
|
+
type: 'error',
|
|
228
|
+
...(state.piSessionId ? { session: sessionRef(state.piSessionId) } : {}),
|
|
229
|
+
message,
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
function failPiTurn(state, message) {
|
|
234
|
+
emitPiError(state, message);
|
|
235
|
+
finishPiTurn(state, 'completed');
|
|
236
|
+
}
|
|
237
|
+
function appendBoundedDiagnostics(state, chunk) {
|
|
238
|
+
state.stderrBuffer = (state.stderrBuffer + chunk).slice(-MAX_STDERR_CHARS);
|
|
239
|
+
}
|
|
240
|
+
function rejectPendingRpc(state, error) {
|
|
241
|
+
for (const pending of state.pendingRpc.values()) {
|
|
242
|
+
clearTimeout(pending.timer);
|
|
243
|
+
pending.reject(error);
|
|
244
|
+
}
|
|
245
|
+
state.pendingRpc.clear();
|
|
246
|
+
}
|
|
247
|
+
function terminatePiChild(state) {
|
|
248
|
+
const child = state.child;
|
|
249
|
+
state.child = null;
|
|
250
|
+
state.childProviderId = null;
|
|
251
|
+
rejectPendingRpc(state, new Error('PI RPC process was terminated'));
|
|
252
|
+
if (!child)
|
|
253
|
+
return;
|
|
254
|
+
try {
|
|
255
|
+
child.stdin?.end();
|
|
256
|
+
}
|
|
257
|
+
catch { /* already closed */ }
|
|
258
|
+
try {
|
|
259
|
+
child.kill('SIGTERM');
|
|
260
|
+
}
|
|
261
|
+
catch { /* already exited */ }
|
|
262
|
+
}
|
|
263
|
+
function protocolFailure(state, message) {
|
|
264
|
+
if (state.protocolFailed)
|
|
265
|
+
return;
|
|
266
|
+
state.protocolFailed = true;
|
|
267
|
+
const error = new Error(`PI RPC protocol error: ${message}`);
|
|
268
|
+
rejectPendingRpc(state, error);
|
|
269
|
+
if (state.turnActive)
|
|
270
|
+
failPiTurn(state, error.message);
|
|
271
|
+
else
|
|
272
|
+
emitPiError(state, error.message);
|
|
273
|
+
terminatePiChild(state);
|
|
274
|
+
}
|
|
275
|
+
function sendRpcCommand(state, command, timeoutMs = RPC_TIMEOUT_MS) {
|
|
276
|
+
const child = state.child;
|
|
277
|
+
if (!child?.stdin || child.stdin.destroyed) {
|
|
278
|
+
return Promise.reject(new Error('PI RPC process is not available'));
|
|
279
|
+
}
|
|
280
|
+
const id = `agentlink-${randomUUID()}`;
|
|
281
|
+
return new Promise((resolveResponse, rejectResponse) => {
|
|
282
|
+
const timer = setTimeout(() => {
|
|
283
|
+
state.pendingRpc.delete(id);
|
|
284
|
+
rejectResponse(new Error(`PI RPC ${command.type} timed out after ${timeoutMs}ms`));
|
|
285
|
+
}, timeoutMs);
|
|
286
|
+
timer.unref?.();
|
|
287
|
+
state.pendingRpc.set(id, {
|
|
288
|
+
command: command.type,
|
|
289
|
+
resolve: resolveResponse,
|
|
290
|
+
reject: rejectResponse,
|
|
291
|
+
timer,
|
|
292
|
+
});
|
|
293
|
+
try {
|
|
294
|
+
child.stdin.write(`${JSON.stringify({ ...command, id })}\n`, 'utf8', (error) => {
|
|
295
|
+
if (!error)
|
|
296
|
+
return;
|
|
297
|
+
const pending = state.pendingRpc.get(id);
|
|
298
|
+
if (!pending)
|
|
299
|
+
return;
|
|
300
|
+
clearTimeout(pending.timer);
|
|
301
|
+
state.pendingRpc.delete(id);
|
|
302
|
+
pending.reject(error);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
const pending = state.pendingRpc.get(id);
|
|
307
|
+
if (pending) {
|
|
308
|
+
clearTimeout(pending.timer);
|
|
309
|
+
state.pendingRpc.delete(id);
|
|
310
|
+
}
|
|
311
|
+
rejectResponse(error);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
function writeRpcRecord(state, record) {
|
|
316
|
+
const stdin = state.child?.stdin;
|
|
317
|
+
if (!stdin || stdin.destroyed)
|
|
318
|
+
return;
|
|
319
|
+
try {
|
|
320
|
+
stdin.write(`${JSON.stringify(record)}\n`, 'utf8');
|
|
321
|
+
}
|
|
322
|
+
catch { /* process exit handles failure */ }
|
|
323
|
+
}
|
|
324
|
+
function handleRpcResponse(state, record) {
|
|
325
|
+
const id = record.id;
|
|
326
|
+
const command = record.command;
|
|
327
|
+
if (typeof id !== 'string' || typeof command !== 'string' || typeof record.success !== 'boolean') {
|
|
328
|
+
protocolFailure(state, 'malformed response record');
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const pending = state.pendingRpc.get(id);
|
|
332
|
+
if (!pending) {
|
|
333
|
+
protocolFailure(state, `unexpected response id ${id}`);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (pending.command !== command) {
|
|
337
|
+
protocolFailure(state, `response command mismatch for ${id}`);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
clearTimeout(pending.timer);
|
|
341
|
+
state.pendingRpc.delete(id);
|
|
342
|
+
if (record.success) {
|
|
343
|
+
pending.resolve(record);
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
pending.reject(new Error(typeof record.error === 'string' ? record.error : `PI RPC ${command} failed`));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
function numberOrUndefined(value) {
|
|
350
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
351
|
+
}
|
|
352
|
+
function usageFromAssistantMessage(state, message) {
|
|
353
|
+
const usage = asRecord(message.usage);
|
|
354
|
+
if (!usage)
|
|
355
|
+
return undefined;
|
|
356
|
+
const cost = asRecord(usage.cost);
|
|
357
|
+
return {
|
|
358
|
+
provider: 'pi',
|
|
359
|
+
model: state.model || (typeof message.provider === 'string' && typeof message.model === 'string'
|
|
360
|
+
? `${message.provider}/${message.model}`
|
|
361
|
+
: undefined),
|
|
362
|
+
inputTokens: numberOrUndefined(usage.input),
|
|
363
|
+
outputTokens: numberOrUndefined(usage.output),
|
|
364
|
+
cacheReadInputTokens: numberOrUndefined(usage.cacheRead),
|
|
365
|
+
cacheCreationInputTokens: numberOrUndefined(usage.cacheWrite),
|
|
366
|
+
totalCostUsd: numberOrUndefined(cost?.total),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
function asRecord(value) {
|
|
370
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
371
|
+
? value
|
|
372
|
+
: undefined;
|
|
373
|
+
}
|
|
374
|
+
function textValue(value) {
|
|
375
|
+
return typeof value === 'string' ? value : '';
|
|
376
|
+
}
|
|
377
|
+
function handlePiEvent(state, event) {
|
|
378
|
+
const eventType = textValue(event.type);
|
|
379
|
+
const ref = sessionRef(state.piSessionId || 'pending');
|
|
380
|
+
switch (eventType) {
|
|
381
|
+
case 'message_update': {
|
|
382
|
+
const update = asRecord(event.assistantMessageEvent);
|
|
383
|
+
if (!update || typeof update.type !== 'string') {
|
|
384
|
+
protocolFailure(state, 'message_update is missing assistantMessageEvent');
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (update.type === 'text_delta' || update.type === 'thinking_delta') {
|
|
388
|
+
if (typeof update.delta !== 'string') {
|
|
389
|
+
protocolFailure(state, `${update.type} is missing delta`);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
sendFn({
|
|
393
|
+
type: 'backend_event',
|
|
394
|
+
conversationId: state.conversationId,
|
|
395
|
+
event: {
|
|
396
|
+
type: update.type === 'text_delta' ? 'text_delta' : 'reasoning_delta',
|
|
397
|
+
session: ref,
|
|
398
|
+
text: update.delta,
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
case 'message_end': {
|
|
405
|
+
const message = asRecord(event.message);
|
|
406
|
+
if (message?.role === 'assistant') {
|
|
407
|
+
const usage = usageFromAssistantMessage(state, message);
|
|
408
|
+
if (usage) {
|
|
409
|
+
state.lastUsage = usage;
|
|
410
|
+
sendFn({
|
|
411
|
+
type: 'backend_event',
|
|
412
|
+
conversationId: state.conversationId,
|
|
413
|
+
event: { type: 'usage_update', session: ref, usage },
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
if (message.stopReason === 'error' || (typeof message.errorMessage === 'string' && message.errorMessage)) {
|
|
417
|
+
emitPiError(state, textValue(message.errorMessage) || 'PI assistant response failed');
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
else if (message?.role === 'bashExecution') {
|
|
421
|
+
sendFn({
|
|
422
|
+
type: 'backend_event',
|
|
423
|
+
conversationId: state.conversationId,
|
|
424
|
+
event: {
|
|
425
|
+
type: 'process_exited',
|
|
426
|
+
session: ref,
|
|
427
|
+
processId: textValue(message.id) || 'pi-bash',
|
|
428
|
+
exitCode: numberOrUndefined(message.exitCode) ?? (message.cancelled === true ? -1 : 0),
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
case 'tool_execution_start': {
|
|
435
|
+
const id = textValue(event.toolCallId);
|
|
436
|
+
const name = textValue(event.toolName);
|
|
437
|
+
if (!id || !name) {
|
|
438
|
+
protocolFailure(state, 'tool_execution_start is missing tool identity');
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
sendFn({
|
|
442
|
+
type: 'backend_event',
|
|
443
|
+
conversationId: state.conversationId,
|
|
444
|
+
event: {
|
|
445
|
+
type: 'tool_started',
|
|
446
|
+
session: ref,
|
|
447
|
+
tool: { id, name, itemKind: name, input: event.args, status: 'in_progress' },
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
case 'tool_execution_update': {
|
|
453
|
+
const id = textValue(event.toolCallId);
|
|
454
|
+
const name = textValue(event.toolName);
|
|
455
|
+
if (!id || !name) {
|
|
456
|
+
protocolFailure(state, 'tool_execution_update is missing tool identity');
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
sendFn({
|
|
460
|
+
type: 'backend_event',
|
|
461
|
+
conversationId: state.conversationId,
|
|
462
|
+
event: {
|
|
463
|
+
type: 'tool_updated',
|
|
464
|
+
session: ref,
|
|
465
|
+
tool: { id, name, itemKind: name, input: event.args, output: event.partialResult, status: 'in_progress' },
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
470
|
+
case 'tool_execution_end': {
|
|
471
|
+
const id = textValue(event.toolCallId);
|
|
472
|
+
const name = textValue(event.toolName);
|
|
473
|
+
if (!id || !name) {
|
|
474
|
+
protocolFailure(state, 'tool_execution_end is missing tool identity');
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
sendFn({
|
|
478
|
+
type: 'backend_event',
|
|
479
|
+
conversationId: state.conversationId,
|
|
480
|
+
event: {
|
|
481
|
+
type: 'tool_completed',
|
|
482
|
+
session: ref,
|
|
483
|
+
tool: {
|
|
484
|
+
id,
|
|
485
|
+
name,
|
|
486
|
+
itemKind: name,
|
|
487
|
+
output: event.result,
|
|
488
|
+
isError: event.isError === true,
|
|
489
|
+
status: event.isError === true ? 'error' : 'completed',
|
|
490
|
+
},
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
break;
|
|
494
|
+
}
|
|
495
|
+
case 'bash_execution_update': {
|
|
496
|
+
const delta = textValue(event.delta);
|
|
497
|
+
if (delta) {
|
|
498
|
+
sendFn({
|
|
499
|
+
type: 'backend_event',
|
|
500
|
+
conversationId: state.conversationId,
|
|
501
|
+
event: {
|
|
502
|
+
type: 'process_output',
|
|
503
|
+
session: ref,
|
|
504
|
+
processId: textValue(event.id) || 'pi-bash',
|
|
505
|
+
chunk: delta,
|
|
506
|
+
},
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
break;
|
|
510
|
+
}
|
|
511
|
+
case 'compaction_start':
|
|
512
|
+
sendFn({
|
|
513
|
+
type: 'backend_event',
|
|
514
|
+
conversationId: state.conversationId,
|
|
515
|
+
event: { type: 'compact_started', session: ref },
|
|
516
|
+
});
|
|
517
|
+
break;
|
|
518
|
+
case 'compaction_end':
|
|
519
|
+
sendFn({
|
|
520
|
+
type: 'backend_event',
|
|
521
|
+
conversationId: state.conversationId,
|
|
522
|
+
event: { type: 'compact_completed', session: ref },
|
|
523
|
+
});
|
|
524
|
+
if (typeof event.errorMessage === 'string' && event.errorMessage) {
|
|
525
|
+
emitPiError(state, event.errorMessage);
|
|
526
|
+
}
|
|
527
|
+
break;
|
|
528
|
+
case 'extension_error':
|
|
529
|
+
emitPiError(state, textValue(event.error) || `PI extension failed${event.extensionPath ? `: ${event.extensionPath}` : ''}`);
|
|
530
|
+
break;
|
|
531
|
+
case 'agent_settled':
|
|
532
|
+
finishPiTurn(state, state.cancelRequested ? 'cancelled' : 'completed');
|
|
533
|
+
break;
|
|
534
|
+
case 'thinking_level_changed': {
|
|
535
|
+
const effectiveLevel = textValue(event.level || event.thinkingLevel);
|
|
536
|
+
if (THINKING_LEVELS.has(effectiveLevel)) {
|
|
537
|
+
state.thinking = effectiveLevel;
|
|
538
|
+
}
|
|
539
|
+
break;
|
|
540
|
+
}
|
|
541
|
+
// These are valid informational/state events and need no wire translation.
|
|
542
|
+
case 'agent_start':
|
|
543
|
+
case 'agent_end':
|
|
544
|
+
case 'turn_start':
|
|
545
|
+
case 'turn_end':
|
|
546
|
+
case 'message_start':
|
|
547
|
+
case 'entry_appended':
|
|
548
|
+
case 'queue_update':
|
|
549
|
+
case 'session_info_changed':
|
|
550
|
+
case 'auto_retry_start':
|
|
551
|
+
case 'auto_retry_end':
|
|
552
|
+
case 'summarization_retry_scheduled':
|
|
553
|
+
case 'summarization_retry_attempt_start':
|
|
554
|
+
case 'summarization_retry_finished':
|
|
555
|
+
break;
|
|
556
|
+
case 'extension_ui_request':
|
|
557
|
+
if (typeof event.id === 'string'
|
|
558
|
+
&& ['select', 'confirm', 'input', 'editor'].includes(textValue(event.method))) {
|
|
559
|
+
writeRpcRecord(state, {
|
|
560
|
+
type: 'extension_ui_response',
|
|
561
|
+
id: event.id,
|
|
562
|
+
cancelled: true,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
break;
|
|
566
|
+
default:
|
|
567
|
+
// Extensions can add informational events. Unknown records remain isolated.
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
function handlePiRecord(state, value) {
|
|
572
|
+
const record = asRecord(value);
|
|
573
|
+
if (!record || typeof record.type !== 'string') {
|
|
574
|
+
protocolFailure(state, 'record must be a JSON object with a type');
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
if (record.type === 'response')
|
|
578
|
+
handleRpcResponse(state, record);
|
|
579
|
+
else
|
|
580
|
+
handlePiEvent(state, record);
|
|
581
|
+
}
|
|
582
|
+
function attachStrictJsonlReader(state, child) {
|
|
583
|
+
const decoder = new StringDecoder('utf8');
|
|
584
|
+
let pending = '';
|
|
585
|
+
child.stdout?.on('data', (chunk) => {
|
|
586
|
+
if (state.child !== child || state.protocolFailed)
|
|
587
|
+
return;
|
|
588
|
+
pending += typeof chunk === 'string' ? chunk : decoder.write(chunk);
|
|
589
|
+
let newlineIndex = pending.indexOf('\n');
|
|
590
|
+
while (newlineIndex !== -1) {
|
|
591
|
+
const line = pending.slice(0, newlineIndex);
|
|
592
|
+
pending = pending.slice(newlineIndex + 1);
|
|
593
|
+
if (line.trim()) {
|
|
594
|
+
try {
|
|
595
|
+
handlePiRecord(state, JSON.parse(line));
|
|
596
|
+
}
|
|
597
|
+
catch (error) {
|
|
598
|
+
protocolFailure(state, `malformed JSONL record: ${error.message}`);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
newlineIndex = pending.indexOf('\n');
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
child.stdout?.on('end', () => {
|
|
606
|
+
if (state.child !== child || state.protocolFailed)
|
|
607
|
+
return;
|
|
608
|
+
pending += decoder.end();
|
|
609
|
+
if (pending.trim())
|
|
610
|
+
protocolFailure(state, 'stdout ended with an unterminated record');
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
function directSpawnCommand(command, prefixArgs) {
|
|
614
|
+
const direct = resolveWindowsNpmWrapper(command);
|
|
615
|
+
if (direct) {
|
|
616
|
+
return { command: direct.command, args: [...direct.prefixArgs, ...prefixArgs] };
|
|
617
|
+
}
|
|
618
|
+
if (platform() === 'win32' && command.toLowerCase().endsWith('.cmd')) {
|
|
619
|
+
return {
|
|
620
|
+
command: process.env.COMSPEC || 'C:\\Windows\\system32\\cmd.exe',
|
|
621
|
+
args: ['/d', '/s', '/c', command, ...prefixArgs],
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
return { command, args: prefixArgs };
|
|
625
|
+
}
|
|
626
|
+
async function launchPiProcess(state) {
|
|
627
|
+
if (state.launching)
|
|
628
|
+
return state.launching;
|
|
629
|
+
if (state.child)
|
|
630
|
+
return;
|
|
631
|
+
state.launching = (async () => {
|
|
632
|
+
const provider = getProvider(state.providerId);
|
|
633
|
+
const resolvedCommand = resolveProviderCommand(provider);
|
|
634
|
+
const launchArgs = [
|
|
635
|
+
'--mode', 'rpc',
|
|
636
|
+
'--session-dir', sessionDirForWorkDir(state.workDir, state.providerId),
|
|
637
|
+
];
|
|
638
|
+
if (state.sessionFile)
|
|
639
|
+
launchArgs.push('--session', state.sessionFile);
|
|
640
|
+
else
|
|
641
|
+
launchArgs.push('--session-id', state.piSessionId);
|
|
642
|
+
const direct = directSpawnCommand(resolvedCommand.command, [...resolvedCommand.prefixArgs, ...launchArgs]);
|
|
643
|
+
let child;
|
|
644
|
+
try {
|
|
645
|
+
child = spawn(direct.command, direct.args, {
|
|
646
|
+
cwd: state.workDir,
|
|
647
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
648
|
+
env: { ...getCleanEnv(), ...(provider.env || {}) },
|
|
649
|
+
...resolvedCommand.spawnOpts,
|
|
650
|
+
windowsHide: true,
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
catch (error) {
|
|
654
|
+
throw new Error(`Failed to start PI: ${error.message}`);
|
|
655
|
+
}
|
|
656
|
+
state.child = child;
|
|
657
|
+
state.childProviderId = state.providerId;
|
|
658
|
+
state.protocolFailed = false;
|
|
659
|
+
state.stderrBuffer = '';
|
|
660
|
+
attachStrictJsonlReader(state, child);
|
|
661
|
+
child.stderr?.on('data', (chunk) => {
|
|
662
|
+
appendBoundedDiagnostics(state, String(chunk));
|
|
663
|
+
});
|
|
664
|
+
child.on('error', (error) => {
|
|
665
|
+
if (state.child !== child)
|
|
666
|
+
return;
|
|
667
|
+
state.child = null;
|
|
668
|
+
state.childProviderId = null;
|
|
669
|
+
const wrapped = new Error(`Failed to start PI: ${error.message}`);
|
|
670
|
+
rejectPendingRpc(state, wrapped);
|
|
671
|
+
if (state.turnActive)
|
|
672
|
+
failPiTurn(state, wrapped.message);
|
|
673
|
+
cleanPiAttachments(state);
|
|
674
|
+
});
|
|
675
|
+
child.on('close', (code) => {
|
|
676
|
+
if (state.child !== child)
|
|
677
|
+
return;
|
|
678
|
+
state.child = null;
|
|
679
|
+
state.childProviderId = null;
|
|
680
|
+
const diagnostic = state.stderrBuffer.trim();
|
|
681
|
+
const error = new Error(diagnostic || `PI RPC process exited${code == null ? '' : ` with code ${code}`}`);
|
|
682
|
+
rejectPendingRpc(state, error);
|
|
683
|
+
if (state.turnActive)
|
|
684
|
+
failPiTurn(state, error.message);
|
|
685
|
+
cleanPiAttachments(state);
|
|
686
|
+
});
|
|
687
|
+
const response = await sendRpcCommand(state, { type: 'get_state' });
|
|
688
|
+
const data = asRecord(response.data);
|
|
689
|
+
const actualSessionId = textValue(data?.sessionId);
|
|
690
|
+
if (!actualSessionId || actualSessionId !== state.piSessionId) {
|
|
691
|
+
throw new Error(`PI opened unexpected session "${actualSessionId || '(missing)'}"; expected "${state.piSessionId}"`);
|
|
692
|
+
}
|
|
693
|
+
const reportedSessionFile = textValue(data?.sessionFile);
|
|
694
|
+
if (reportedSessionFile && existsSync(reportedSessionFile)) {
|
|
695
|
+
state.sessionFile = reportedSessionFile;
|
|
696
|
+
}
|
|
697
|
+
state.lastPiSessionId = actualSessionId;
|
|
698
|
+
if (!state.sessionNotified) {
|
|
699
|
+
state.sessionNotified = true;
|
|
700
|
+
sendFn({
|
|
701
|
+
type: 'backend_event',
|
|
702
|
+
conversationId: state.conversationId,
|
|
703
|
+
event: { type: 'session_started', session: sessionRef(actualSessionId) },
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
void refreshAvailableModels(state);
|
|
707
|
+
})();
|
|
708
|
+
try {
|
|
709
|
+
await state.launching;
|
|
710
|
+
}
|
|
711
|
+
catch (error) {
|
|
712
|
+
terminatePiChild(state);
|
|
713
|
+
throw error;
|
|
714
|
+
}
|
|
715
|
+
finally {
|
|
716
|
+
state.launching = null;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
async function refreshAvailableModels(state) {
|
|
720
|
+
try {
|
|
721
|
+
const response = await sendRpcCommand(state, { type: 'get_available_models' });
|
|
722
|
+
const data = asRecord(response.data);
|
|
723
|
+
const models = Array.isArray(data?.models) ? data.models : [];
|
|
724
|
+
state.availableModels = models
|
|
725
|
+
.map(asRecord)
|
|
726
|
+
.filter((model) => !!model)
|
|
727
|
+
.map(model => (typeof model.provider === 'string' && typeof model.id === 'string'
|
|
728
|
+
? `${model.provider}/${model.id}`
|
|
729
|
+
: ''))
|
|
730
|
+
.filter(Boolean);
|
|
731
|
+
sendFn({
|
|
732
|
+
type: 'pi_models_list',
|
|
733
|
+
providerId: state.providerId,
|
|
734
|
+
conversationId: state.conversationId,
|
|
735
|
+
models: state.availableModels,
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
catch {
|
|
739
|
+
// Model discovery is optional and must not fail an otherwise healthy turn.
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
export function getPiAvailableModels(providerId = 'pi') {
|
|
743
|
+
return [...new Set([...conversations.values()]
|
|
744
|
+
.filter(state => state.providerId === providerId)
|
|
745
|
+
.flatMap(state => state.availableModels))].sort();
|
|
746
|
+
}
|
|
747
|
+
function normalizePiOptions(input) {
|
|
748
|
+
if (!input)
|
|
749
|
+
return { value: {} };
|
|
750
|
+
const value = {};
|
|
751
|
+
if (input.model != null && input.model !== '') {
|
|
752
|
+
const slash = input.model.indexOf('/');
|
|
753
|
+
const provider = slash > 0 ? input.model.slice(0, slash) : '';
|
|
754
|
+
const modelId = slash > 0 ? input.model.slice(slash + 1) : '';
|
|
755
|
+
if (input.model.length > 256
|
|
756
|
+
|| !MODEL_PART_PATTERN.test(provider)
|
|
757
|
+
|| !MODEL_PART_PATTERN.test(modelId)) {
|
|
758
|
+
return { value, error: `Invalid PI model identifier: ${input.model}` };
|
|
759
|
+
}
|
|
760
|
+
value.model = `${provider}/${modelId}`;
|
|
761
|
+
}
|
|
762
|
+
if (input.thinking != null) {
|
|
763
|
+
if (!THINKING_LEVELS.has(input.thinking)) {
|
|
764
|
+
return { value, error: `Invalid PI thinking level: ${input.thinking}` };
|
|
765
|
+
}
|
|
766
|
+
value.thinking = input.thinking;
|
|
767
|
+
}
|
|
768
|
+
return { value };
|
|
769
|
+
}
|
|
770
|
+
async function applyPiOptions(state, options) {
|
|
771
|
+
let modelChanged = false;
|
|
772
|
+
if (options.model && options.model !== state.model) {
|
|
773
|
+
const slash = options.model.indexOf('/');
|
|
774
|
+
await sendRpcCommand(state, {
|
|
775
|
+
type: 'set_model',
|
|
776
|
+
provider: options.model.slice(0, slash),
|
|
777
|
+
modelId: options.model.slice(slash + 1),
|
|
778
|
+
});
|
|
779
|
+
state.model = options.model;
|
|
780
|
+
modelChanged = true;
|
|
781
|
+
}
|
|
782
|
+
if (options.thinking && (modelChanged || options.thinking !== state.thinking)) {
|
|
783
|
+
await sendRpcCommand(state, { type: 'set_thinking_level', level: options.thinking });
|
|
784
|
+
}
|
|
785
|
+
if (modelChanged || options.thinking) {
|
|
786
|
+
const response = await sendRpcCommand(state, { type: 'get_state' });
|
|
787
|
+
const data = asRecord(response.data);
|
|
788
|
+
const effectiveThinking = textValue(data?.thinkingLevel);
|
|
789
|
+
state.thinking = THINKING_LEVELS.has(effectiveThinking)
|
|
790
|
+
? effectiveThinking
|
|
791
|
+
: null;
|
|
792
|
+
const model = asRecord(data?.model);
|
|
793
|
+
if (typeof model?.provider === 'string' && typeof model.id === 'string') {
|
|
794
|
+
state.model = `${model.provider}/${model.id}`;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
function decodeBase64Strict(data) {
|
|
799
|
+
if (data.length > Math.ceil(MAX_ATTACHMENT_BYTES / 3) * 4 + 4
|
|
800
|
+
|| !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(data)) {
|
|
801
|
+
throw new Error('Attachment data is not valid base64');
|
|
802
|
+
}
|
|
803
|
+
const buffer = Buffer.from(data, 'base64');
|
|
804
|
+
if (buffer.length > MAX_ATTACHMENT_BYTES) {
|
|
805
|
+
throw new Error(`Attachment exceeds the ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB limit`);
|
|
806
|
+
}
|
|
807
|
+
return buffer;
|
|
808
|
+
}
|
|
809
|
+
function validateAttachmentName(name) {
|
|
810
|
+
if (!name
|
|
811
|
+
|| name.length > 255
|
|
812
|
+
|| basename(name) !== name
|
|
813
|
+
|| /[\\/\u0000-\u001f]/.test(name)
|
|
814
|
+
|| name === '.'
|
|
815
|
+
|| name === '..') {
|
|
816
|
+
throw new Error(`Unsafe attachment filename: ${name || '(empty)'}`);
|
|
817
|
+
}
|
|
818
|
+
return name;
|
|
819
|
+
}
|
|
820
|
+
function preparePiAttachments(state, text, files) {
|
|
821
|
+
if (!files?.length)
|
|
822
|
+
return { message: text };
|
|
823
|
+
if (files.length > MAX_ATTACHMENTS) {
|
|
824
|
+
throw new Error(`PI accepts at most ${MAX_ATTACHMENTS} attachments per message`);
|
|
825
|
+
}
|
|
826
|
+
const images = [];
|
|
827
|
+
const filePaths = [];
|
|
828
|
+
for (const file of files) {
|
|
829
|
+
const name = validateAttachmentName(file.name);
|
|
830
|
+
if (!MIME_PATTERN.test(file.mimeType)) {
|
|
831
|
+
throw new Error(`Invalid attachment MIME type: ${file.mimeType}`);
|
|
832
|
+
}
|
|
833
|
+
const buffer = decodeBase64Strict(file.data);
|
|
834
|
+
if (file.mimeType.toLowerCase().startsWith('image/')) {
|
|
835
|
+
if (!IMAGE_MIME_PATTERN.test(file.mimeType)) {
|
|
836
|
+
throw new Error(`Unsupported PI image type: ${file.mimeType}`);
|
|
837
|
+
}
|
|
838
|
+
images.push({ type: 'image', data: file.data, mimeType: file.mimeType });
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
mkdirSync(ATTACHMENT_DIR, { recursive: true });
|
|
842
|
+
const filePath = join(ATTACHMENT_DIR, `${randomUUID()}-${name}`);
|
|
843
|
+
writeFileSync(filePath, buffer, { flag: 'wx', mode: 0o600 });
|
|
844
|
+
state.attachmentPaths.add(filePath);
|
|
845
|
+
const turnPaths = state.turnAttachmentPaths.get(state.turnId || '');
|
|
846
|
+
if (turnPaths)
|
|
847
|
+
turnPaths.add(filePath);
|
|
848
|
+
else
|
|
849
|
+
state.turnAttachmentPaths.set(state.turnId || '', new Set([filePath]));
|
|
850
|
+
filePaths.push(resolve(filePath));
|
|
851
|
+
}
|
|
852
|
+
const attachmentNote = filePaths.length
|
|
853
|
+
? `\n\nAttached files:\n${filePaths.map(filePath => `- ${filePath}`).join('\n')}`
|
|
854
|
+
: '';
|
|
855
|
+
return {
|
|
856
|
+
message: `${text}${attachmentNote}`,
|
|
857
|
+
...(images.length ? { images } : {}),
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
export function handlePiChat(conversationId, text, workDir, options, files) {
|
|
861
|
+
const resumeId = options?.resumeSessionId;
|
|
862
|
+
if (resumeId) {
|
|
863
|
+
rebindPiConversation(resumeId, conversationId, options?.providerId || 'pi', workDir);
|
|
864
|
+
}
|
|
865
|
+
let state = conversations.get(conversationId);
|
|
866
|
+
if (!state) {
|
|
867
|
+
state = createState(conversationId, workDir, options?.providerId || 'pi');
|
|
868
|
+
conversations.set(conversationId, state);
|
|
869
|
+
}
|
|
870
|
+
if (state.turnActive) {
|
|
871
|
+
emitPiError(state, 'PI is already processing this conversation.');
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
state.workDir = resolve(workDir);
|
|
875
|
+
const requestedProviderId = options?.providerId || state.providerId;
|
|
876
|
+
const providerChanged = state.providerId !== requestedProviderId;
|
|
877
|
+
if (providerChanged) {
|
|
878
|
+
if (state.child)
|
|
879
|
+
terminatePiChild(state);
|
|
880
|
+
state.piSessionId = null;
|
|
881
|
+
state.lastPiSessionId = null;
|
|
882
|
+
state.sessionFile = null;
|
|
883
|
+
state.sessionNotified = false;
|
|
884
|
+
state.model = null;
|
|
885
|
+
state.thinking = null;
|
|
886
|
+
state.availableModels = [];
|
|
887
|
+
}
|
|
888
|
+
else if (state.child && state.childProviderId !== requestedProviderId) {
|
|
889
|
+
terminatePiChild(state);
|
|
890
|
+
}
|
|
891
|
+
state.providerId = requestedProviderId;
|
|
892
|
+
if (resumeId) {
|
|
893
|
+
if (resumeId !== state.piSessionId) {
|
|
894
|
+
if (state.child)
|
|
895
|
+
terminatePiChild(state);
|
|
896
|
+
state.piSessionId = resumeId;
|
|
897
|
+
state.lastPiSessionId = resumeId;
|
|
898
|
+
state.sessionNotified = false;
|
|
899
|
+
}
|
|
900
|
+
if (!state.sessionFile) {
|
|
901
|
+
state.sessionFile = findPiSessionFile(state.workDir, resumeId, state.providerId);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
else if (!state.piSessionId) {
|
|
905
|
+
state.piSessionId = randomUUID();
|
|
906
|
+
state.lastPiSessionId = state.piSessionId;
|
|
907
|
+
state.sessionFile = null;
|
|
908
|
+
state.sessionNotified = false;
|
|
909
|
+
}
|
|
910
|
+
const normalized = normalizePiOptions(options?.piOptions);
|
|
911
|
+
startPiTurn(state);
|
|
912
|
+
if (resumeId && !state.sessionFile) {
|
|
913
|
+
failPiTurn(state, `PI session ${resumeId} was not found in ${state.workDir}`);
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
if (normalized.error) {
|
|
917
|
+
failPiTurn(state, normalized.error);
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
const turnId = state.turnId;
|
|
921
|
+
const priorSetup = state.setupPromise;
|
|
922
|
+
void (async () => {
|
|
923
|
+
try {
|
|
924
|
+
if (priorSetup) {
|
|
925
|
+
try {
|
|
926
|
+
await priorSetup;
|
|
927
|
+
}
|
|
928
|
+
catch { /* the prior turn reports its own setup failure */ }
|
|
929
|
+
}
|
|
930
|
+
if (!state.turnActive || state.turnId !== turnId || state.cancelRequested) {
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
const prompt = preparePiAttachments(state, text, files);
|
|
934
|
+
const setupPromise = (async () => {
|
|
935
|
+
await launchPiProcess(state);
|
|
936
|
+
if (!state.turnActive || state.turnId !== turnId || state.cancelRequested)
|
|
937
|
+
return;
|
|
938
|
+
await applyPiOptions(state, normalized.value);
|
|
939
|
+
})();
|
|
940
|
+
state.setupPromise = setupPromise;
|
|
941
|
+
try {
|
|
942
|
+
await setupPromise;
|
|
943
|
+
}
|
|
944
|
+
finally {
|
|
945
|
+
if (state.setupPromise === setupPromise)
|
|
946
|
+
state.setupPromise = null;
|
|
947
|
+
}
|
|
948
|
+
if (!state.turnActive || state.turnId !== turnId) {
|
|
949
|
+
cleanPiAttachments(state, turnId);
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
if (state.cancelRequested) {
|
|
953
|
+
finishPiTurn(state, 'cancelled');
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
await sendRpcCommand(state, { type: 'prompt', ...prompt });
|
|
957
|
+
if (!state.turnActive || state.turnId !== turnId)
|
|
958
|
+
return;
|
|
959
|
+
const stateResponse = await sendRpcCommand(state, { type: 'get_state' });
|
|
960
|
+
const stateData = asRecord(stateResponse.data);
|
|
961
|
+
if (stateData?.isStreaming === false && state.turnActive && state.turnId === turnId) {
|
|
962
|
+
finishPiTurn(state, 'completed');
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
catch (error) {
|
|
966
|
+
cleanPiAttachments(state, turnId);
|
|
967
|
+
if (state.turnActive && state.turnId === turnId) {
|
|
968
|
+
failPiTurn(state, error.message);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
})();
|
|
972
|
+
}
|
|
973
|
+
export function cancelPiExecution(conversationId) {
|
|
974
|
+
const state = conversations.get(conversationId);
|
|
975
|
+
if (!state) {
|
|
976
|
+
sendFn({ type: 'execution_cancelled', conversationId });
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
if (!state.turnActive) {
|
|
980
|
+
sendFn({
|
|
981
|
+
type: 'execution_cancelled',
|
|
982
|
+
conversationId,
|
|
983
|
+
claudeSessionId: state.piSessionId,
|
|
984
|
+
turnId: state.turnId,
|
|
985
|
+
stateVersion: state.stateVersion,
|
|
986
|
+
});
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
state.cancelRequested = true;
|
|
990
|
+
const cancelledTurnId = state.turnId;
|
|
991
|
+
if (!state.child) {
|
|
992
|
+
finishPiTurn(state, 'cancelled');
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
void sendRpcCommand(state, { type: 'abort' }, ABORT_TIMEOUT_MS)
|
|
996
|
+
.then(() => {
|
|
997
|
+
const fallback = setTimeout(() => {
|
|
998
|
+
if (state.turnActive && state.turnId === cancelledTurnId) {
|
|
999
|
+
// Without agent_settled we cannot distinguish late events from a
|
|
1000
|
+
// subsequent turn, so discard this RPC process before completing.
|
|
1001
|
+
terminatePiChild(state);
|
|
1002
|
+
finishPiTurn(state, 'cancelled');
|
|
1003
|
+
}
|
|
1004
|
+
}, 250);
|
|
1005
|
+
fallback.unref?.();
|
|
1006
|
+
})
|
|
1007
|
+
.catch(() => {
|
|
1008
|
+
terminatePiChild(state);
|
|
1009
|
+
if (state.turnActive && state.turnId === cancelledTurnId)
|
|
1010
|
+
finishPiTurn(state, 'cancelled');
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
function cleanupPiConversation(conversationId) {
|
|
1014
|
+
const state = conversations.get(conversationId);
|
|
1015
|
+
if (!state)
|
|
1016
|
+
return;
|
|
1017
|
+
rejectPendingRpc(state, new Error('PI conversation was closed'));
|
|
1018
|
+
cleanPiAttachments(state);
|
|
1019
|
+
if (state.heartbeatTimer)
|
|
1020
|
+
clearInterval(state.heartbeatTimer);
|
|
1021
|
+
state.heartbeatTimer = null;
|
|
1022
|
+
terminatePiChild(state);
|
|
1023
|
+
conversations.delete(conversationId);
|
|
1024
|
+
}
|
|
1025
|
+
export function cleanupAllPiConversations() {
|
|
1026
|
+
for (const conversationId of [...conversations.keys()])
|
|
1027
|
+
cleanupPiConversation(conversationId);
|
|
1028
|
+
}
|
|
1029
|
+
export function restartPiConversation(conversationId, options = {}) {
|
|
1030
|
+
const old = conversations.get(conversationId);
|
|
1031
|
+
const wasTurnActive = old?.turnActive || false;
|
|
1032
|
+
const providerId = options.providerId || old?.providerId || 'pi';
|
|
1033
|
+
const previousSessionId = old?.piSessionId || old?.lastPiSessionId || null;
|
|
1034
|
+
const piSessionId = previousSessionId && old
|
|
1035
|
+
&& (providerId === old.providerId
|
|
1036
|
+
|| findPiSessionFile(old.workDir, previousSessionId, providerId))
|
|
1037
|
+
? previousSessionId
|
|
1038
|
+
: null;
|
|
1039
|
+
if (old)
|
|
1040
|
+
cleanupPiConversation(conversationId);
|
|
1041
|
+
const state = createState(conversationId, old?.workDir || process.cwd(), providerId);
|
|
1042
|
+
state.piSessionId = piSessionId;
|
|
1043
|
+
state.lastPiSessionId = piSessionId;
|
|
1044
|
+
state.sessionFile = piSessionId
|
|
1045
|
+
? findPiSessionFile(state.workDir, piSessionId, state.providerId)
|
|
1046
|
+
: null;
|
|
1047
|
+
state.model = old?.model || null;
|
|
1048
|
+
state.thinking = old?.thinking || null;
|
|
1049
|
+
state.stateVersion = (old?.stateVersion || 0) + 1;
|
|
1050
|
+
conversations.set(conversationId, state);
|
|
1051
|
+
return { wasTurnActive, piSessionId };
|
|
1052
|
+
}
|
|
1053
|
+
export function evictByPiSessionId(piSessionId, providerId = 'pi', workDir) {
|
|
1054
|
+
for (const [conversationId, state] of conversations) {
|
|
1055
|
+
if ((state.piSessionId === piSessionId || state.lastPiSessionId === piSessionId)
|
|
1056
|
+
&& state.providerId === providerId
|
|
1057
|
+
&& (!workDir || normalizePathForCompare(state.workDir) === normalizePathForCompare(workDir))) {
|
|
1058
|
+
if (state.turnActive)
|
|
1059
|
+
return true;
|
|
1060
|
+
cleanupPiConversation(conversationId);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
return false;
|
|
1064
|
+
}
|
|
1065
|
+
export function _resetResolvedPiPathForTests() {
|
|
1066
|
+
resetCachedExecutablePath('pi');
|
|
1067
|
+
}
|
|
1068
|
+
// ── PI session filesystem ───────────────────────────────────────────────────
|
|
1069
|
+
function expandPiPath(value, baseDir) {
|
|
1070
|
+
if (value === '~')
|
|
1071
|
+
return homedir();
|
|
1072
|
+
if (value.startsWith('~/') || value.startsWith('~\\')) {
|
|
1073
|
+
return resolve(homedir(), value.slice(2));
|
|
1074
|
+
}
|
|
1075
|
+
return isAbsolute(value) ? resolve(value) : resolve(baseDir, value);
|
|
1076
|
+
}
|
|
1077
|
+
function readPiSessionDirSetting(settingsPath) {
|
|
1078
|
+
try {
|
|
1079
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
1080
|
+
return typeof settings.sessionDir === 'string' && settings.sessionDir.trim()
|
|
1081
|
+
? settings.sessionDir.trim()
|
|
1082
|
+
: null;
|
|
1083
|
+
}
|
|
1084
|
+
catch {
|
|
1085
|
+
return null;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
function getPiProviderEnv(providerId = 'pi') {
|
|
1089
|
+
return getProvider(providerId).env || {};
|
|
1090
|
+
}
|
|
1091
|
+
function getPiAgentDir(providerId = 'pi') {
|
|
1092
|
+
const providerEnv = getPiProviderEnv(providerId);
|
|
1093
|
+
return expandPiPath(providerEnv.PI_CODING_AGENT_DIR
|
|
1094
|
+
|| process.env.PI_CODING_AGENT_DIR
|
|
1095
|
+
|| join(homedir(), '.pi', 'agent'), process.cwd());
|
|
1096
|
+
}
|
|
1097
|
+
function getPiSessionLayout(workDir, providerId = 'pi') {
|
|
1098
|
+
const resolvedWorkDir = resolve(workDir || process.cwd());
|
|
1099
|
+
const providerEnv = getPiProviderEnv(providerId);
|
|
1100
|
+
const environmentRoot = providerEnv.PI_CODING_AGENT_SESSION_DIR
|
|
1101
|
+
|| process.env.PI_CODING_AGENT_SESSION_DIR;
|
|
1102
|
+
if (environmentRoot) {
|
|
1103
|
+
return { root: expandPiPath(environmentRoot, resolvedWorkDir), custom: true };
|
|
1104
|
+
}
|
|
1105
|
+
const agentDir = getPiAgentDir(providerId);
|
|
1106
|
+
const globalSettingsPath = join(agentDir, 'settings.json');
|
|
1107
|
+
const globalSessionDir = readPiSessionDirSetting(globalSettingsPath);
|
|
1108
|
+
if (globalSessionDir) {
|
|
1109
|
+
return {
|
|
1110
|
+
root: expandPiPath(globalSessionDir, resolvedWorkDir),
|
|
1111
|
+
custom: true,
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
return { root: join(agentDir, 'sessions'), custom: false };
|
|
1115
|
+
}
|
|
1116
|
+
function getPiSessionRoot(workDir, providerId = 'pi') {
|
|
1117
|
+
return getPiSessionLayout(workDir, providerId).root;
|
|
1118
|
+
}
|
|
1119
|
+
function sessionDirForWorkDir(workDir, providerId = 'pi') {
|
|
1120
|
+
const layout = getPiSessionLayout(workDir, providerId);
|
|
1121
|
+
if (layout.custom)
|
|
1122
|
+
return layout.root;
|
|
1123
|
+
const resolvedWorkDir = resolve(workDir);
|
|
1124
|
+
const safePath = `--${resolvedWorkDir.replace(/^[/\\]/, '').replace(/[/\\:]/g, '-')}--`;
|
|
1125
|
+
return join(layout.root, safePath);
|
|
1126
|
+
}
|
|
1127
|
+
function normalizePathForCompare(value) {
|
|
1128
|
+
const normalized = resolve(value).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
1129
|
+
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
1130
|
+
}
|
|
1131
|
+
function isWithinRoot(filePath, root) {
|
|
1132
|
+
const rel = relative(root, resolve(filePath));
|
|
1133
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
1134
|
+
}
|
|
1135
|
+
function listJsonlFiles(root, includeDirectChildDirs = false) {
|
|
1136
|
+
if (!existsSync(root))
|
|
1137
|
+
return [];
|
|
1138
|
+
const result = [];
|
|
1139
|
+
let entries;
|
|
1140
|
+
try {
|
|
1141
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
1142
|
+
}
|
|
1143
|
+
catch {
|
|
1144
|
+
return result;
|
|
1145
|
+
}
|
|
1146
|
+
for (const entry of entries) {
|
|
1147
|
+
const fullPath = join(root, entry.name);
|
|
1148
|
+
if (!isWithinRoot(fullPath, root))
|
|
1149
|
+
continue;
|
|
1150
|
+
if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
|
1151
|
+
result.push(fullPath);
|
|
1152
|
+
}
|
|
1153
|
+
else if (includeDirectChildDirs && entry.isDirectory()) {
|
|
1154
|
+
let children;
|
|
1155
|
+
try {
|
|
1156
|
+
children = readdirSync(fullPath, { withFileTypes: true });
|
|
1157
|
+
}
|
|
1158
|
+
catch {
|
|
1159
|
+
continue;
|
|
1160
|
+
}
|
|
1161
|
+
for (const child of children) {
|
|
1162
|
+
const childPath = join(fullPath, child.name);
|
|
1163
|
+
if (child.isFile()
|
|
1164
|
+
&& child.name.endsWith('.jsonl')
|
|
1165
|
+
&& isWithinRoot(childPath, root))
|
|
1166
|
+
result.push(childPath);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
return result;
|
|
1171
|
+
}
|
|
1172
|
+
function parseTimestamp(value) {
|
|
1173
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
1174
|
+
return value;
|
|
1175
|
+
if (typeof value !== 'string')
|
|
1176
|
+
return 0;
|
|
1177
|
+
const parsed = Date.parse(value);
|
|
1178
|
+
return Number.isNaN(parsed) ? 0 : parsed;
|
|
1179
|
+
}
|
|
1180
|
+
function contentText(content) {
|
|
1181
|
+
if (typeof content === 'string')
|
|
1182
|
+
return content;
|
|
1183
|
+
if (!Array.isArray(content))
|
|
1184
|
+
return '';
|
|
1185
|
+
return content
|
|
1186
|
+
.map((item) => {
|
|
1187
|
+
const block = asRecord(item);
|
|
1188
|
+
return block?.type === 'text' ? textValue(block.text) : '';
|
|
1189
|
+
})
|
|
1190
|
+
.filter(Boolean)
|
|
1191
|
+
.join('\n');
|
|
1192
|
+
}
|
|
1193
|
+
function buildActiveBranch(entries) {
|
|
1194
|
+
const treeEntries = entries.filter(entry => (entry.type !== 'session'
|
|
1195
|
+
&& typeof entry.id === 'string'));
|
|
1196
|
+
if (!treeEntries.length)
|
|
1197
|
+
return [];
|
|
1198
|
+
if (treeEntries.every(entry => entry.parentId === undefined))
|
|
1199
|
+
return treeEntries;
|
|
1200
|
+
const byId = new Map(treeEntries.map(entry => [entry.id, entry]));
|
|
1201
|
+
const branch = [];
|
|
1202
|
+
const visited = new Set();
|
|
1203
|
+
let current = treeEntries.at(-1);
|
|
1204
|
+
while (current) {
|
|
1205
|
+
const id = current.id;
|
|
1206
|
+
if (visited.has(id))
|
|
1207
|
+
return [];
|
|
1208
|
+
visited.add(id);
|
|
1209
|
+
branch.push(current);
|
|
1210
|
+
if (current.parentId === null)
|
|
1211
|
+
break;
|
|
1212
|
+
if (typeof current.parentId !== 'string')
|
|
1213
|
+
return [];
|
|
1214
|
+
current = byId.get(current.parentId);
|
|
1215
|
+
if (!current)
|
|
1216
|
+
return [];
|
|
1217
|
+
}
|
|
1218
|
+
return branch.reverse();
|
|
1219
|
+
}
|
|
1220
|
+
function parsePiSession(filePath, stats, root = getPiSessionRoot()) {
|
|
1221
|
+
if (!isWithinRoot(filePath, root))
|
|
1222
|
+
return null;
|
|
1223
|
+
let content;
|
|
1224
|
+
try {
|
|
1225
|
+
content = readFileSync(filePath, 'utf8');
|
|
1226
|
+
}
|
|
1227
|
+
catch {
|
|
1228
|
+
return null;
|
|
1229
|
+
}
|
|
1230
|
+
const entries = [];
|
|
1231
|
+
for (const line of content.split('\n')) {
|
|
1232
|
+
if (!line.trim())
|
|
1233
|
+
continue;
|
|
1234
|
+
try {
|
|
1235
|
+
const parsed = JSON.parse(line);
|
|
1236
|
+
const record = asRecord(parsed);
|
|
1237
|
+
if (record)
|
|
1238
|
+
entries.push(record);
|
|
1239
|
+
}
|
|
1240
|
+
catch {
|
|
1241
|
+
// PI itself tolerates malformed historical lines; the RPC stream does not.
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
const header = entries[0];
|
|
1245
|
+
if (!header
|
|
1246
|
+
|| header.type !== 'session'
|
|
1247
|
+
|| typeof header.id !== 'string'
|
|
1248
|
+
|| !SESSION_ID_PATTERN.test(header.id)
|
|
1249
|
+
|| typeof header.cwd !== 'string'
|
|
1250
|
+
|| !isAbsolute(header.cwd))
|
|
1251
|
+
return null;
|
|
1252
|
+
const activeBranch = buildActiveBranch(entries);
|
|
1253
|
+
let customTitle = '';
|
|
1254
|
+
for (const entry of entries) {
|
|
1255
|
+
if (entry.type === 'session_info')
|
|
1256
|
+
customTitle = textValue(entry.name).trim();
|
|
1257
|
+
}
|
|
1258
|
+
let firstPrompt = '';
|
|
1259
|
+
let lastText = '';
|
|
1260
|
+
let lastModified = parseTimestamp(header.timestamp);
|
|
1261
|
+
for (const entry of activeBranch) {
|
|
1262
|
+
if (entry.type !== 'message')
|
|
1263
|
+
continue;
|
|
1264
|
+
const message = asRecord(entry.message);
|
|
1265
|
+
if (!message)
|
|
1266
|
+
continue;
|
|
1267
|
+
const text = contentText(message.content).trim();
|
|
1268
|
+
if ((message.role === 'user' || message.role === 'assistant') && text) {
|
|
1269
|
+
if (message.role === 'user' && !firstPrompt)
|
|
1270
|
+
firstPrompt = text.slice(0, 100);
|
|
1271
|
+
lastText = text.slice(0, 120);
|
|
1272
|
+
lastModified = Math.max(lastModified, parseTimestamp(message.timestamp) || parseTimestamp(entry.timestamp));
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
const projectPath = resolve(header.cwd);
|
|
1276
|
+
const title = customTitle || firstPrompt || lastText || header.id.slice(0, 8);
|
|
1277
|
+
return {
|
|
1278
|
+
sessionId: header.id,
|
|
1279
|
+
title,
|
|
1280
|
+
...(customTitle ? { customTitle } : {}),
|
|
1281
|
+
preview: lastText || firstPrompt || title,
|
|
1282
|
+
firstPrompt: firstPrompt || title,
|
|
1283
|
+
lastModified: lastModified || stats?.mtimeMs || statSync(filePath).mtimeMs,
|
|
1284
|
+
projectPath,
|
|
1285
|
+
projectFolder: projectPath.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || projectPath,
|
|
1286
|
+
sourcePath: resolve(filePath),
|
|
1287
|
+
entries,
|
|
1288
|
+
activeBranch,
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
function piEntries(workDir, providerId = 'pi') {
|
|
1292
|
+
const requested = workDir ? normalizePathForCompare(workDir) : '';
|
|
1293
|
+
const layout = getPiSessionLayout(workDir, providerId);
|
|
1294
|
+
const root = workDir && !layout.custom
|
|
1295
|
+
? sessionDirForWorkDir(workDir, providerId)
|
|
1296
|
+
: layout.root;
|
|
1297
|
+
const result = [];
|
|
1298
|
+
for (const filePath of listJsonlFiles(root, !workDir && !layout.custom)) {
|
|
1299
|
+
let stats;
|
|
1300
|
+
try {
|
|
1301
|
+
stats = statSync(filePath);
|
|
1302
|
+
}
|
|
1303
|
+
catch {
|
|
1304
|
+
continue;
|
|
1305
|
+
}
|
|
1306
|
+
const session = parsePiSession(filePath, stats, root);
|
|
1307
|
+
if (!session)
|
|
1308
|
+
continue;
|
|
1309
|
+
if (requested && normalizePathForCompare(session.projectPath) !== requested)
|
|
1310
|
+
continue;
|
|
1311
|
+
result.push({
|
|
1312
|
+
session,
|
|
1313
|
+
sortKey: {
|
|
1314
|
+
lastModified: session.lastModified,
|
|
1315
|
+
sessionId: session.sessionId,
|
|
1316
|
+
tieBreaker: normalizePathForCompare(session.sourcePath),
|
|
1317
|
+
},
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
result.sort((a, b) => compareCursorSortKeys(a.sortKey, b.sortKey));
|
|
1321
|
+
return result;
|
|
1322
|
+
}
|
|
1323
|
+
function normalizeSessionLimit(limit) {
|
|
1324
|
+
if (limit === Number.POSITIVE_INFINITY)
|
|
1325
|
+
return limit;
|
|
1326
|
+
if (!Number.isFinite(limit) || !limit || limit <= 0)
|
|
1327
|
+
return PI_SESSION_LIST_LIMIT;
|
|
1328
|
+
return Math.min(Math.floor(limit), 500);
|
|
1329
|
+
}
|
|
1330
|
+
function compareCursorSortKeys(a, b) {
|
|
1331
|
+
const modified = b.lastModified - a.lastModified;
|
|
1332
|
+
if (modified)
|
|
1333
|
+
return modified;
|
|
1334
|
+
const id = a.sessionId.localeCompare(b.sessionId);
|
|
1335
|
+
return id || a.tieBreaker.localeCompare(b.tieBreaker);
|
|
1336
|
+
}
|
|
1337
|
+
function encodePiCursor(scope, key, workDir) {
|
|
1338
|
+
return Buffer.from(JSON.stringify({
|
|
1339
|
+
v: 1,
|
|
1340
|
+
provider: 'pi',
|
|
1341
|
+
scope,
|
|
1342
|
+
...(scope === 'workdir' && workDir ? { workDir: normalizePathForCompare(workDir) } : {}),
|
|
1343
|
+
...key,
|
|
1344
|
+
}), 'utf8').toString('base64url');
|
|
1345
|
+
}
|
|
1346
|
+
function decodePiCursor(cursor, scope, workDir) {
|
|
1347
|
+
let parsed;
|
|
1348
|
+
try {
|
|
1349
|
+
parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
|
1350
|
+
}
|
|
1351
|
+
catch {
|
|
1352
|
+
throw new Error('Invalid PI session list cursor');
|
|
1353
|
+
}
|
|
1354
|
+
const value = asRecord(parsed);
|
|
1355
|
+
if (!value
|
|
1356
|
+
|| value.v !== 1
|
|
1357
|
+
|| value.provider !== 'pi'
|
|
1358
|
+
|| value.scope !== scope
|
|
1359
|
+
|| typeof value.lastModified !== 'number'
|
|
1360
|
+
|| !Number.isFinite(value.lastModified)
|
|
1361
|
+
|| typeof value.sessionId !== 'string'
|
|
1362
|
+
|| typeof value.tieBreaker !== 'string')
|
|
1363
|
+
throw new Error('Invalid PI session list cursor');
|
|
1364
|
+
if (scope === 'workdir' && value.workDir !== normalizePathForCompare(workDir || '')) {
|
|
1365
|
+
throw new Error('PI session list cursor does not match requested workDir');
|
|
1366
|
+
}
|
|
1367
|
+
return {
|
|
1368
|
+
lastModified: value.lastModified,
|
|
1369
|
+
sessionId: value.sessionId,
|
|
1370
|
+
tieBreaker: value.tieBreaker,
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
function pageStart(entries, cursor, scope, workDir) {
|
|
1374
|
+
if (!cursor)
|
|
1375
|
+
return 0;
|
|
1376
|
+
const key = decodePiCursor(cursor, scope, workDir);
|
|
1377
|
+
const index = entries.findIndex(entry => compareCursorSortKeys(entry.sortKey, key) > 0);
|
|
1378
|
+
return index < 0 ? entries.length : index;
|
|
1379
|
+
}
|
|
1380
|
+
export function listPiSessions(workDir, providerId = 'pi') {
|
|
1381
|
+
return listPiSessionsPage(workDir, { limit: Number.POSITIVE_INFINITY, providerId }).sessions;
|
|
1382
|
+
}
|
|
1383
|
+
export function listPiSessionsPage(workDir, options = {}) {
|
|
1384
|
+
const providerId = options.providerId || 'pi';
|
|
1385
|
+
const entries = piEntries(workDir, providerId);
|
|
1386
|
+
const start = pageStart(entries, options.cursor, 'workdir', workDir);
|
|
1387
|
+
const limit = normalizeSessionLimit(options.limit);
|
|
1388
|
+
const page = limit === Number.POSITIVE_INFINITY
|
|
1389
|
+
? entries.slice(start)
|
|
1390
|
+
: entries.slice(start, start + limit);
|
|
1391
|
+
const hasMore = start + page.length < entries.length;
|
|
1392
|
+
return {
|
|
1393
|
+
sessions: page.map(({ session }) => ({
|
|
1394
|
+
sessionId: session.sessionId,
|
|
1395
|
+
title: session.title,
|
|
1396
|
+
...(session.customTitle ? { customTitle: session.customTitle } : {}),
|
|
1397
|
+
preview: session.preview,
|
|
1398
|
+
lastModified: session.lastModified,
|
|
1399
|
+
providerId,
|
|
1400
|
+
workDir: session.projectPath,
|
|
1401
|
+
})),
|
|
1402
|
+
hasMore,
|
|
1403
|
+
...(hasMore && page.length
|
|
1404
|
+
? { nextCursor: encodePiCursor('workdir', page.at(-1).sortKey, workDir) }
|
|
1405
|
+
: {}),
|
|
1406
|
+
};
|
|
1407
|
+
}
|
|
1408
|
+
export function listAllPiSessions(limit = 500, providerId = 'pi') {
|
|
1409
|
+
return listAllPiSessionsPage({ limit, providerId }).sessions;
|
|
1410
|
+
}
|
|
1411
|
+
export function listAllPiSessionsPage(options = {}) {
|
|
1412
|
+
const providerId = options.providerId || 'pi';
|
|
1413
|
+
const entries = piEntries(undefined, providerId);
|
|
1414
|
+
const start = pageStart(entries, options.cursor, 'global');
|
|
1415
|
+
const limit = normalizeSessionLimit(options.limit);
|
|
1416
|
+
const page = limit === Number.POSITIVE_INFINITY
|
|
1417
|
+
? entries.slice(start)
|
|
1418
|
+
: entries.slice(start, start + limit);
|
|
1419
|
+
const hasMore = start + page.length < entries.length;
|
|
1420
|
+
return {
|
|
1421
|
+
sessions: page.map(({ session }) => ({
|
|
1422
|
+
sessionId: session.sessionId,
|
|
1423
|
+
projectPath: session.projectPath,
|
|
1424
|
+
projectFolder: session.projectFolder,
|
|
1425
|
+
stableIdentity: normalizePathForCompare(session.sourcePath),
|
|
1426
|
+
title: session.title,
|
|
1427
|
+
firstPrompt: session.firstPrompt,
|
|
1428
|
+
lastModified: session.lastModified,
|
|
1429
|
+
providerId,
|
|
1430
|
+
workDir: session.projectPath,
|
|
1431
|
+
})),
|
|
1432
|
+
hasMore,
|
|
1433
|
+
...(hasMore && page.length
|
|
1434
|
+
? { nextCursor: encodePiCursor('global', page.at(-1).sortKey) }
|
|
1435
|
+
: {}),
|
|
1436
|
+
indexState: 'ready',
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
function findPiSessionFile(workDir, sessionId, providerId = 'pi') {
|
|
1440
|
+
if (!SESSION_ID_PATTERN.test(sessionId))
|
|
1441
|
+
return null;
|
|
1442
|
+
const requested = normalizePathForCompare(workDir);
|
|
1443
|
+
const root = sessionDirForWorkDir(workDir, providerId);
|
|
1444
|
+
for (const filePath of listJsonlFiles(root)) {
|
|
1445
|
+
const parsed = parsePiSession(filePath, undefined, root);
|
|
1446
|
+
if (parsed?.sessionId === sessionId
|
|
1447
|
+
&& normalizePathForCompare(parsed.projectPath) === requested)
|
|
1448
|
+
return parsed.sourcePath;
|
|
1449
|
+
}
|
|
1450
|
+
return null;
|
|
1451
|
+
}
|
|
1452
|
+
function findPiSessionGlobally(sessionId, providerId = 'pi') {
|
|
1453
|
+
if (!SESSION_ID_PATTERN.test(sessionId))
|
|
1454
|
+
return null;
|
|
1455
|
+
const layout = getPiSessionLayout(undefined, providerId);
|
|
1456
|
+
const root = layout.root;
|
|
1457
|
+
for (const filePath of listJsonlFiles(root, !layout.custom)) {
|
|
1458
|
+
const parsed = parsePiSession(filePath, undefined, root);
|
|
1459
|
+
if (parsed?.sessionId === sessionId)
|
|
1460
|
+
return parsed;
|
|
1461
|
+
}
|
|
1462
|
+
return null;
|
|
1463
|
+
}
|
|
1464
|
+
export function getPiSessionWorkDir(sessionId, providerId = 'pi', workDir) {
|
|
1465
|
+
if (workDir) {
|
|
1466
|
+
const root = sessionDirForWorkDir(workDir, providerId);
|
|
1467
|
+
const filePath = findPiSessionFile(workDir, sessionId, providerId);
|
|
1468
|
+
return filePath
|
|
1469
|
+
? parsePiSession(filePath, undefined, root)?.projectPath
|
|
1470
|
+
: undefined;
|
|
1471
|
+
}
|
|
1472
|
+
return findPiSessionGlobally(sessionId, providerId)?.projectPath;
|
|
1473
|
+
}
|
|
1474
|
+
function toIsoTimestamp(entry, message) {
|
|
1475
|
+
const raw = message?.timestamp ?? entry.timestamp;
|
|
1476
|
+
if (typeof raw === 'string' && Number.isFinite(Date.parse(raw)))
|
|
1477
|
+
return raw;
|
|
1478
|
+
if (typeof raw === 'number' && Number.isFinite(raw))
|
|
1479
|
+
return new Date(raw).toISOString();
|
|
1480
|
+
return undefined;
|
|
1481
|
+
}
|
|
1482
|
+
function toolOutputText(value) {
|
|
1483
|
+
const record = asRecord(value);
|
|
1484
|
+
if (record && Array.isArray(record.content)) {
|
|
1485
|
+
const text = contentText(record.content);
|
|
1486
|
+
if (text)
|
|
1487
|
+
return text;
|
|
1488
|
+
}
|
|
1489
|
+
if (typeof value === 'string')
|
|
1490
|
+
return value;
|
|
1491
|
+
try {
|
|
1492
|
+
return JSON.stringify(value, null, 2);
|
|
1493
|
+
}
|
|
1494
|
+
catch {
|
|
1495
|
+
return String(value);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
export function readPiSessionMessages(workDir, sessionId, providerId = 'pi') {
|
|
1499
|
+
const root = sessionDirForWorkDir(workDir, providerId);
|
|
1500
|
+
const filePath = findPiSessionFile(workDir, sessionId, providerId);
|
|
1501
|
+
if (!filePath)
|
|
1502
|
+
return [];
|
|
1503
|
+
const parsed = parsePiSession(filePath, undefined, root);
|
|
1504
|
+
if (!parsed)
|
|
1505
|
+
return [];
|
|
1506
|
+
const messages = [];
|
|
1507
|
+
const tools = new Map();
|
|
1508
|
+
for (const entry of parsed.activeBranch) {
|
|
1509
|
+
if (entry.type !== 'message')
|
|
1510
|
+
continue;
|
|
1511
|
+
const message = asRecord(entry.message);
|
|
1512
|
+
if (!message)
|
|
1513
|
+
continue;
|
|
1514
|
+
const timestamp = toIsoTimestamp(entry, message);
|
|
1515
|
+
if (message.role === 'user') {
|
|
1516
|
+
const text = contentText(message.content);
|
|
1517
|
+
if (text)
|
|
1518
|
+
messages.push({ role: 'user', content: text, timestamp });
|
|
1519
|
+
}
|
|
1520
|
+
else if (message.role === 'assistant' && Array.isArray(message.content)) {
|
|
1521
|
+
for (const item of message.content) {
|
|
1522
|
+
const block = asRecord(item);
|
|
1523
|
+
if (!block)
|
|
1524
|
+
continue;
|
|
1525
|
+
if (block.type === 'text' && typeof block.text === 'string' && block.text) {
|
|
1526
|
+
messages.push({ role: 'assistant', content: block.text, timestamp });
|
|
1527
|
+
}
|
|
1528
|
+
else if (block.type === 'thinking' && typeof block.thinking === 'string' && block.thinking) {
|
|
1529
|
+
messages.push({
|
|
1530
|
+
role: 'assistant',
|
|
1531
|
+
content: block.thinking,
|
|
1532
|
+
timestamp,
|
|
1533
|
+
isThinking: true,
|
|
1534
|
+
});
|
|
1535
|
+
}
|
|
1536
|
+
else if (block.type === 'toolCall'
|
|
1537
|
+
&& typeof block.id === 'string'
|
|
1538
|
+
&& typeof block.name === 'string') {
|
|
1539
|
+
const tool = {
|
|
1540
|
+
role: 'tool',
|
|
1541
|
+
content: '',
|
|
1542
|
+
toolName: block.name,
|
|
1543
|
+
toolId: block.id,
|
|
1544
|
+
toolInput: toolOutputText(block.arguments),
|
|
1545
|
+
timestamp,
|
|
1546
|
+
};
|
|
1547
|
+
tools.set(block.id, tool);
|
|
1548
|
+
messages.push(tool);
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
else if (message.role === 'toolResult') {
|
|
1553
|
+
const id = textValue(message.toolCallId);
|
|
1554
|
+
const output = contentText(message.content);
|
|
1555
|
+
const existing = tools.get(id);
|
|
1556
|
+
if (existing)
|
|
1557
|
+
existing.toolOutput = output;
|
|
1558
|
+
else {
|
|
1559
|
+
messages.push({
|
|
1560
|
+
role: 'tool',
|
|
1561
|
+
content: '',
|
|
1562
|
+
toolName: textValue(message.toolName) || 'tool',
|
|
1563
|
+
toolId: id,
|
|
1564
|
+
toolOutput: output,
|
|
1565
|
+
timestamp,
|
|
1566
|
+
});
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
else if (message.role === 'bashExecution') {
|
|
1570
|
+
messages.push({
|
|
1571
|
+
role: 'tool',
|
|
1572
|
+
content: '',
|
|
1573
|
+
toolName: 'bash',
|
|
1574
|
+
toolId: textValue(message.id) || textValue(entry.id),
|
|
1575
|
+
toolInput: toolOutputText({ command: message.command }),
|
|
1576
|
+
toolOutput: textValue(message.output),
|
|
1577
|
+
timestamp,
|
|
1578
|
+
});
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
return messages;
|
|
1582
|
+
}
|
|
1583
|
+
function activePiSession(sessionId, providerId, workDir) {
|
|
1584
|
+
for (const state of conversations.values()) {
|
|
1585
|
+
if ((state.piSessionId === sessionId || state.lastPiSessionId === sessionId)
|
|
1586
|
+
&& state.providerId === providerId
|
|
1587
|
+
&& normalizePathForCompare(state.workDir) === normalizePathForCompare(workDir)
|
|
1588
|
+
&& state.turnActive)
|
|
1589
|
+
return true;
|
|
1590
|
+
}
|
|
1591
|
+
return false;
|
|
1592
|
+
}
|
|
1593
|
+
function uniqueEntryId(entries) {
|
|
1594
|
+
const ids = new Set(entries.map(entry => textValue(entry.id)).filter(Boolean));
|
|
1595
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
1596
|
+
const id = randomUUID().slice(0, 8);
|
|
1597
|
+
if (!ids.has(id))
|
|
1598
|
+
return id;
|
|
1599
|
+
}
|
|
1600
|
+
return randomUUID();
|
|
1601
|
+
}
|
|
1602
|
+
export function renamePiSession(workDir, sessionId, newTitle, providerId = 'pi') {
|
|
1603
|
+
if (activePiSession(sessionId, providerId, workDir))
|
|
1604
|
+
return false;
|
|
1605
|
+
const root = sessionDirForWorkDir(workDir, providerId);
|
|
1606
|
+
const filePath = findPiSessionFile(workDir, sessionId, providerId);
|
|
1607
|
+
if (!filePath)
|
|
1608
|
+
return false;
|
|
1609
|
+
const parsed = parsePiSession(filePath, undefined, root);
|
|
1610
|
+
const title = newTitle.replace(/[\r\n]+/g, ' ').trim().slice(0, 200);
|
|
1611
|
+
if (!parsed || !title)
|
|
1612
|
+
return false;
|
|
1613
|
+
const leaf = parsed.entries.filter(entry => entry.type !== 'session').at(-1);
|
|
1614
|
+
const entry = {
|
|
1615
|
+
type: 'session_info',
|
|
1616
|
+
id: uniqueEntryId(parsed.entries),
|
|
1617
|
+
parentId: typeof leaf?.id === 'string' ? leaf.id : null,
|
|
1618
|
+
timestamp: new Date().toISOString(),
|
|
1619
|
+
name: title,
|
|
1620
|
+
};
|
|
1621
|
+
try {
|
|
1622
|
+
const needsNewline = !readFileSync(filePath, 'utf8').endsWith('\n');
|
|
1623
|
+
appendFileSync(filePath, `${needsNewline ? '\n' : ''}${JSON.stringify(entry)}\n`, 'utf8');
|
|
1624
|
+
return true;
|
|
1625
|
+
}
|
|
1626
|
+
catch {
|
|
1627
|
+
return false;
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
export function deletePiSession(workDir, sessionId, providerId = 'pi') {
|
|
1631
|
+
if (activePiSession(sessionId, providerId, workDir))
|
|
1632
|
+
return false;
|
|
1633
|
+
const root = sessionDirForWorkDir(workDir, providerId);
|
|
1634
|
+
const filePath = findPiSessionFile(workDir, sessionId, providerId);
|
|
1635
|
+
if (!filePath || !isWithinRoot(filePath, root))
|
|
1636
|
+
return false;
|
|
1637
|
+
try {
|
|
1638
|
+
unlinkSync(filePath);
|
|
1639
|
+
return true;
|
|
1640
|
+
}
|
|
1641
|
+
catch {
|
|
1642
|
+
try {
|
|
1643
|
+
rmSync(filePath, { force: true });
|
|
1644
|
+
return !existsSync(filePath);
|
|
1645
|
+
}
|
|
1646
|
+
catch {
|
|
1647
|
+
return false;
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
function rewritePiHeaderCwd(content, cwd) {
|
|
1652
|
+
const lines = content.split('\n');
|
|
1653
|
+
const headerIndex = lines.findIndex(line => line.trim());
|
|
1654
|
+
if (headerIndex < 0)
|
|
1655
|
+
return null;
|
|
1656
|
+
try {
|
|
1657
|
+
const header = JSON.parse(lines[headerIndex]);
|
|
1658
|
+
if (header?.type !== 'session' || typeof header.id !== 'string')
|
|
1659
|
+
return null;
|
|
1660
|
+
header.cwd = resolve(cwd);
|
|
1661
|
+
lines[headerIndex] = JSON.stringify(header);
|
|
1662
|
+
return lines.join('\n');
|
|
1663
|
+
}
|
|
1664
|
+
catch {
|
|
1665
|
+
return null;
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
function writeExclusiveAndSync(filePath, content) {
|
|
1669
|
+
const fd = openSync(filePath, 'wx', 0o600);
|
|
1670
|
+
try {
|
|
1671
|
+
writeFileSync(fd, content, 'utf8');
|
|
1672
|
+
fsyncSync(fd);
|
|
1673
|
+
}
|
|
1674
|
+
finally {
|
|
1675
|
+
closeSync(fd);
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
export function movePiSession(fromWorkDir, toWorkDir, sessionId, providerId = 'pi') {
|
|
1679
|
+
if (activePiSession(sessionId, providerId, fromWorkDir))
|
|
1680
|
+
return false;
|
|
1681
|
+
const sourceRoot = sessionDirForWorkDir(fromWorkDir, providerId);
|
|
1682
|
+
const sourcePath = findPiSessionFile(fromWorkDir, sessionId, providerId);
|
|
1683
|
+
if (!sourcePath || !isWithinRoot(sourcePath, sourceRoot))
|
|
1684
|
+
return false;
|
|
1685
|
+
const targetDir = sessionDirForWorkDir(toWorkDir, providerId);
|
|
1686
|
+
const targetRoot = getPiSessionRoot(toWorkDir, providerId);
|
|
1687
|
+
if (!isWithinRoot(targetDir, targetRoot))
|
|
1688
|
+
return false;
|
|
1689
|
+
try {
|
|
1690
|
+
mkdirSync(targetDir, { recursive: true });
|
|
1691
|
+
}
|
|
1692
|
+
catch {
|
|
1693
|
+
return false;
|
|
1694
|
+
}
|
|
1695
|
+
let targetPath = join(targetDir, basename(sourcePath));
|
|
1696
|
+
if (resolve(targetPath) === resolve(sourcePath)) {
|
|
1697
|
+
targetPath = join(targetDir, `${new Date().toISOString().replace(/[:.]/g, '-')}_${sessionId}.jsonl`);
|
|
1698
|
+
}
|
|
1699
|
+
if (!isWithinRoot(targetPath, targetRoot) || existsSync(targetPath))
|
|
1700
|
+
return false;
|
|
1701
|
+
let content;
|
|
1702
|
+
try {
|
|
1703
|
+
content = readFileSync(sourcePath, 'utf8');
|
|
1704
|
+
}
|
|
1705
|
+
catch {
|
|
1706
|
+
return false;
|
|
1707
|
+
}
|
|
1708
|
+
const rewritten = rewritePiHeaderCwd(content, toWorkDir);
|
|
1709
|
+
if (rewritten == null)
|
|
1710
|
+
return false;
|
|
1711
|
+
const temporaryPath = `${targetPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
1712
|
+
try {
|
|
1713
|
+
writeExclusiveAndSync(temporaryPath, rewritten);
|
|
1714
|
+
renameSync(temporaryPath, targetPath);
|
|
1715
|
+
unlinkSync(sourcePath);
|
|
1716
|
+
for (const state of conversations.values()) {
|
|
1717
|
+
if (state.piSessionId === sessionId || state.lastPiSessionId === sessionId) {
|
|
1718
|
+
if (state.providerId !== providerId
|
|
1719
|
+
|| normalizePathForCompare(state.workDir) !== normalizePathForCompare(fromWorkDir))
|
|
1720
|
+
continue;
|
|
1721
|
+
rejectPendingRpc(state, new Error('PI session was moved'));
|
|
1722
|
+
terminatePiChild(state);
|
|
1723
|
+
state.workDir = resolve(toWorkDir);
|
|
1724
|
+
state.sessionFile = targetPath;
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
return true;
|
|
1728
|
+
}
|
|
1729
|
+
catch {
|
|
1730
|
+
try {
|
|
1731
|
+
rmSync(temporaryPath, { force: true });
|
|
1732
|
+
}
|
|
1733
|
+
catch { /* best effort */ }
|
|
1734
|
+
try {
|
|
1735
|
+
rmSync(targetPath, { force: true });
|
|
1736
|
+
}
|
|
1737
|
+
catch { /* best effort */ }
|
|
1738
|
+
return false;
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
export { piCapabilities };
|
|
1742
|
+
//# sourceMappingURL=pi.js.map
|