@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/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