@openagents-org/agent-launcher 0.2.161 → 0.2.163

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,922 @@
1
+ /**
2
+ * Pi CLI — RPC (JSONL) framing, event classification and helpers (pure, no I/O).
3
+ *
4
+ * This module turns the raw byte stream produced by
5
+ * `pi --mode rpc [...]`
6
+ * into normalized events the PiAdapter maps onto OpenAgents workspace
7
+ * messages. Like copilot-stream-parser.js it is split into layers so a Pi
8
+ * upgrade only ever touches one table:
9
+ *
10
+ * 1. FRAMING (schema-agnostic) — `PiJsonlFramer`
11
+ * Reassembles complete records from arbitrary chunk boundaries. Pi's RPC
12
+ * protocol is *strict* JSONL: LF (`\n`) is the ONLY record delimiter, a
13
+ * trailing `\r` may be stripped, and U+2028/U+2029 MUST NOT be treated as
14
+ * newlines (they are legal inside JSON strings). Node's `readline` splits
15
+ * on those separators and is therefore not protocol-compliant — hence the
16
+ * hand-rolled framer here. A `StringDecoder` does the byte→string step so
17
+ * a multi-byte UTF-8 sequence (CJK, emoji) split across two chunks is
18
+ * reassembled instead of being decoded into replacement characters.
19
+ *
20
+ * 2. CLASSIFICATION (schema-aware) — `classifyPiEvent()` + EVENT_KIND_BY_TYPE
21
+ * Maps one parsed JSON object onto `{ kind, ... }`. All Pi type strings
22
+ * live in ONE table so a schema drift is a single-point edit. Anything
23
+ * unrecognized degrades to a redacted `unknown` event — never a throw.
24
+ *
25
+ * 3. TURN ACCUMULATION — `PiAssistantAccumulator`
26
+ * Pi emits each assistant content block TWICE: incrementally through
27
+ * `message_update` (`text_delta` … `text_end`) and again in full on
28
+ * `message_end`. The accumulator hands each block to the caller exactly
29
+ * once, keyed by its `contentIndex`, so streaming output and the final
30
+ * message can never be posted twice.
31
+ *
32
+ * ── Verification status ──────────────────────────────────────────────────
33
+ * Verified against @earendil-works/pi-coding-agent v0.83.0 (bin `pi`,
34
+ * engines.node >= 22.19.0) using the RPC protocol spec shipped in the package
35
+ * (docs/rpc.md) plus live `pi --mode rpc` captures. Confirmed by capture:
36
+ * • command responses: {"id","type":"response","command","success",[data|error]}
37
+ * • event order for a turn: agent_start → turn_start → message_start/…_end
38
+ * (user) → message_start/…_end (assistant) → turn_end → agent_end →
39
+ * agent_settled
40
+ * • a provider/auth failure is NOT a failed `response`: the prompt is
41
+ * accepted (success:true) and the failure arrives as an assistant message
42
+ * with stopReason "error" + `errorMessage`
43
+ * • `--session-id <uuid>` creates the session when missing and resumes it on
44
+ * the next process, so no session-id correlation heuristics are needed
45
+ * Never infer an event's meaning from a field name alone.
46
+ *
47
+ * Normalized event kinds (the adapter's stable contract):
48
+ * { kind:'response', id, command, success, data, error }
49
+ * { kind:'agent_start' }
50
+ * { kind:'agent_end', willRetry }
51
+ * { kind:'agent_settled' }
52
+ * { kind:'message_start', role }
53
+ * { kind:'message_update', delta } raw assistantMessageEvent
54
+ * { kind:'message_end', message } full AgentMessage
55
+ * { kind:'tool_start', toolCallId, toolName, preview }
56
+ * { kind:'tool_update', toolCallId, toolName }
57
+ * { kind:'tool_end', toolCallId, toolName, isError, preview }
58
+ * { kind:'bash_output', id, delta }
59
+ * { kind:'queue_update', steering, followUp }
60
+ * { kind:'compaction_start',reason }
61
+ * { kind:'compaction_end', reason, aborted, willRetry, error }
62
+ * { kind:'retry_start', attempt, maxAttempts, delayMs, message }
63
+ * { kind:'retry_end', success, attempt, finalError }
64
+ * { kind:'extension_error', message }
65
+ * { kind:'ui_request', id, method, needsResponse, title }
66
+ * { kind:'oversize', bytes } a single record blew the line cap
67
+ * { kind:'unknown', raw } unrecognized / unparseable (redacted)
68
+ */
69
+
70
+ 'use strict';
71
+
72
+ const path = require('path');
73
+ const { StringDecoder } = require('string_decoder');
74
+
75
+ // ────────────────────────────────────────────────────────────────────────
76
+ // Constants
77
+ // ────────────────────────────────────────────────────────────────────────
78
+
79
+ /**
80
+ * FALLBACK floor only. The authoritative minimum lives in the registry
81
+ * (`install.min_version` in pi.yaml / registry.json) so the catalog and the
82
+ * runtime gate cannot drift; the adapter reads it from there and passes it to
83
+ * `classifyPiVersion`. This constant is used only when the registry entry
84
+ * cannot be read at all.
85
+ */
86
+ const MIN_PI_VERSION = '0.83.0';
87
+
88
+ /** Pi's own hard engine floor (package.json engines.node). */
89
+ const MIN_NODE_VERSION = '22.19.0';
90
+
91
+ /**
92
+ * Hard cap on a single un-terminated record we will buffer. Pi can emit very
93
+ * large single lines (an `agent_end` carries every message of the run), so this
94
+ * is generous — but unbounded buffering on a wedged stream is how a daemon
95
+ * OOMs. Mirrors goose-stream.js's MAX_LINE_BYTES rationale.
96
+ */
97
+ const MAX_LINE_BYTES = 16 * 1024 * 1024;
98
+
99
+ /** How much of a tool argument we surface as a workspace status line. */
100
+ const TOOL_PREVIEW_LIMIT = 160;
101
+
102
+ /**
103
+ * Extension UI methods that BLOCK the Pi process until the client answers.
104
+ * A headless run has no one to answer them, so the adapter must reply
105
+ * `{cancelled:true}` immediately or the turn hangs forever.
106
+ */
107
+ const BLOCKING_UI_METHODS = new Set(['select', 'confirm', 'input', 'editor']);
108
+
109
+ // ────────────────────────────────────────────────────────────────────────
110
+ // Redaction
111
+ // ────────────────────────────────────────────────────────────────────────
112
+
113
+ /**
114
+ * Mask credentials in arbitrary text before it reaches a log, a status file or
115
+ * the workspace. `secrets` are exact values (e.g. the configured API key) that
116
+ * must be masked even when they don't match any known shape. Never throws.
117
+ *
118
+ * @param {string} text
119
+ * @param {string[]} [secrets]
120
+ * @returns {string}
121
+ */
122
+ function redactSecrets(text, secrets) {
123
+ if (text == null) return '';
124
+ let out = typeof text === 'string' ? text : String(text);
125
+ for (const secret of secrets || []) {
126
+ if (secret && typeof secret === 'string' && secret.length >= 8) {
127
+ out = out.split(secret).join('***');
128
+ }
129
+ }
130
+ out = out.replace(
131
+ /\b(api[_-]?key|secret|token|authorization|auth|bearer|password)\b\s*[:=]?\s*['"]?([A-Za-z0-9._-]{8,})['"]?/gi,
132
+ (_m, label) => `${label}=***`,
133
+ );
134
+ out = out.replace(/\b(?:sk|pk|rk)-[A-Za-z0-9._-]{6,}\b/g, '***');
135
+ out = out.replace(/\bgh[pousr]_[A-Za-z0-9]{16,}\b/g, '***');
136
+ out = out.replace(/\bAKIA[0-9A-Z]{12,}\b/g, '***');
137
+ // scheme://user:pass@host
138
+ out = out.replace(/(\b[a-z][a-z0-9+.-]*:\/\/)[^/@\s:]+:[^/@\s]+@/gi, '$1***@');
139
+ return out;
140
+ }
141
+
142
+ /**
143
+ * Short, redacted, single-line diagnostic for an unrecognized record so a
144
+ * schema drift is observable in logs without dumping a transcript or a secret.
145
+ */
146
+ function diagnosticFor(value, maxLen = 300) {
147
+ let s;
148
+ if (typeof value === 'string') s = value;
149
+ else {
150
+ try { s = JSON.stringify(value); } catch { s = String(value); }
151
+ }
152
+ s = redactSecrets(s == null ? '' : s).replace(/\s+/g, ' ').trim();
153
+ return s.length > maxLen ? s.slice(0, maxLen) + '…' : s;
154
+ }
155
+
156
+ // ────────────────────────────────────────────────────────────────────────
157
+ // Framing — strict JSONL, LF only, UTF-8 safe across chunk boundaries
158
+ // ────────────────────────────────────────────────────────────────────────
159
+
160
+ /**
161
+ * Incremental JSONL framer for Pi's RPC stdout.
162
+ *
163
+ * Contract (from the Pi RPC spec):
164
+ * - split on `\n` and ONLY on `\n`
165
+ * - tolerate (strip) a single trailing `\r`
166
+ * - never treat U+2028 / U+2029 as a record separator
167
+ * - a record may span many chunks; a chunk may carry many records
168
+ */
169
+ class PiJsonlFramer {
170
+ constructor({ maxLineBytes = MAX_LINE_BYTES } = {}) {
171
+ this._decoder = new StringDecoder('utf8');
172
+ this._buf = '';
173
+ this._maxLineBytes = maxLineBytes;
174
+ /** Records dropped because they exceeded the cap (diagnostics only). */
175
+ this.dropped = 0;
176
+ /** Byte length of the last dropped record, for a redacted log line. */
177
+ this.lastDroppedBytes = 0;
178
+ this._skippingOversize = false;
179
+ }
180
+
181
+ /**
182
+ * Feed a stdout chunk. Returns the complete records it completed, in order.
183
+ * A record that exceeds the cap is dropped (and counted) rather than
184
+ * buffered forever; the framer then resynchronizes on the next `\n`.
185
+ *
186
+ * @param {Buffer|string} chunk
187
+ * @returns {string[]}
188
+ */
189
+ push(chunk) {
190
+ if (chunk == null) return [];
191
+ this._buf += typeof chunk === 'string' ? chunk : this._decoder.write(chunk);
192
+ return this._drain();
193
+ }
194
+
195
+ /**
196
+ * Flush at EOF: decodes any dangling multi-byte remainder and emits the
197
+ * trailing record when the stream ended without a final `\n`. Idempotent.
198
+ * @returns {string[]}
199
+ */
200
+ flush() {
201
+ this._buf += this._decoder.end();
202
+ const lines = this._drain();
203
+ const tail = this._buf;
204
+ this._buf = '';
205
+ if (this._skippingOversize) {
206
+ // The stream ended mid-oversize record — it was already counted.
207
+ this._skippingOversize = false;
208
+ return lines;
209
+ }
210
+ if (tail) lines.push(PiJsonlFramer._strip(tail));
211
+ return lines;
212
+ }
213
+
214
+ _drain() {
215
+ const lines = [];
216
+ while (true) {
217
+ const idx = this._buf.indexOf('\n');
218
+ if (idx === -1) break;
219
+ const raw = this._buf.slice(0, idx);
220
+ this._buf = this._buf.slice(idx + 1);
221
+ if (this._skippingOversize) {
222
+ // Tail of a record we already gave up on — resynchronized now.
223
+ this._skippingOversize = false;
224
+ continue;
225
+ }
226
+ lines.push(PiJsonlFramer._strip(raw));
227
+ }
228
+ if (this._buf.length > this._maxLineBytes) {
229
+ this.dropped++;
230
+ this.lastDroppedBytes = this._buf.length;
231
+ this._buf = '';
232
+ this._skippingOversize = true;
233
+ }
234
+ return lines;
235
+ }
236
+
237
+ /** Strip a single trailing CR, per the RPC spec's `\r\n` tolerance. */
238
+ static _strip(line) {
239
+ return line.endsWith('\r') ? line.slice(0, -1) : line;
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Parse one JSONL record. Returns null for a blank line or anything that isn't
245
+ * a JSON object — a corrupt record must degrade, never abort the turn.
246
+ * @param {string} line
247
+ * @returns {object|null}
248
+ */
249
+ function parseLine(line) {
250
+ if (line == null) return null;
251
+ const trimmed = String(line).trim();
252
+ if (!trimmed) return null;
253
+ if (trimmed[0] !== '{') return null; // Pi only ever emits objects
254
+ try {
255
+ const v = JSON.parse(trimmed);
256
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : null;
257
+ } catch {
258
+ return null;
259
+ }
260
+ }
261
+
262
+ // ────────────────────────────────────────────────────────────────────────
263
+ // Classification — the SINGLE place that knows Pi's type strings
264
+ // ────────────────────────────────────────────────────────────────────────
265
+
266
+ /**
267
+ * Pi RPC record `type` → normalized kind. Verified against v0.83.0 docs/rpc.md.
268
+ * Extend ONLY this table when Pi adds events; unlisted types become `unknown`.
269
+ */
270
+ const EVENT_KIND_BY_TYPE = {
271
+ // command responses
272
+ response: 'response',
273
+ // agent lifecycle
274
+ agent_start: 'agent_start',
275
+ agent_end: 'agent_end',
276
+ agent_settled: 'agent_settled',
277
+ turn_start: 'turn_start',
278
+ turn_end: 'turn_end',
279
+ // messages
280
+ message_start: 'message_start',
281
+ message_update: 'message_update',
282
+ message_end: 'message_end',
283
+ // tools
284
+ tool_execution_start: 'tool_start',
285
+ tool_execution_update: 'tool_update',
286
+ tool_execution_end: 'tool_end',
287
+ bash_execution_update: 'bash_output',
288
+ // queue / context management
289
+ queue_update: 'queue_update',
290
+ compaction_start: 'compaction_start',
291
+ compaction_end: 'compaction_end',
292
+ // retries
293
+ auto_retry_start: 'retry_start',
294
+ auto_retry_end: 'retry_end',
295
+ summarization_retry_scheduled: 'retry_start',
296
+ summarization_retry_attempt_start: 'turn_start',
297
+ summarization_retry_finished: 'retry_end',
298
+ // diagnostics + extension UI
299
+ extension_error: 'extension_error',
300
+ extension_ui_request: 'ui_request',
301
+ };
302
+
303
+ function _str(v) {
304
+ return typeof v === 'string' && v.length ? v : undefined;
305
+ }
306
+
307
+ /**
308
+ * Condense a tool's arguments into one short, redacted status preview.
309
+ * @param {string} toolName
310
+ * @param {*} args
311
+ * @returns {string}
312
+ */
313
+ function summarizeToolArgs(toolName, args) {
314
+ if (!args || typeof args !== 'object') return '';
315
+ const PREFERRED = [
316
+ 'command', 'file_path', 'path', 'filePath', 'file',
317
+ 'pattern', 'query', 'url', 'glob', 'old_string',
318
+ ];
319
+ let preview = '';
320
+ for (const key of PREFERRED) {
321
+ const val = args[key];
322
+ if (typeof val === 'string' && val) { preview = val; break; }
323
+ }
324
+ if (!preview) {
325
+ const keys = Object.keys(args).filter((k) => typeof k === 'string');
326
+ if (!keys.length) return '';
327
+ preview = keys.slice(0, 6).join(', ');
328
+ }
329
+ preview = redactSecrets(preview).replace(/\s+/g, ' ').trim();
330
+ if (preview.length > TOOL_PREVIEW_LIMIT) preview = preview.slice(0, TOOL_PREVIEW_LIMIT) + '…';
331
+ return preview;
332
+ }
333
+
334
+ /** Condense a tool result's content blocks into a short redacted preview. */
335
+ function summarizeToolResult(result) {
336
+ if (!result || typeof result !== 'object') return '';
337
+ const content = Array.isArray(result.content) ? result.content : [];
338
+ const text = content
339
+ .map((b) => (b && typeof b === 'object' && typeof b.text === 'string' ? b.text : ''))
340
+ .join(' ')
341
+ .trim();
342
+ if (!text) return '';
343
+ const preview = redactSecrets(text).replace(/\s+/g, ' ').trim();
344
+ return preview.length > TOOL_PREVIEW_LIMIT ? preview.slice(0, TOOL_PREVIEW_LIMIT) + '…' : preview;
345
+ }
346
+
347
+ /**
348
+ * Classify one parsed Pi RPC record. Never throws; an unrecognized shape
349
+ * becomes `{ kind:'unknown', raw }` carrying a redacted diagnostic.
350
+ * @param {object} obj
351
+ * @returns {{kind: string} & object}
352
+ */
353
+ function classifyPiEvent(obj) {
354
+ if (!obj || typeof obj !== 'object') return { kind: 'unknown', raw: diagnosticFor(obj) };
355
+ const type = _str(obj.type);
356
+ const kind = type ? EVENT_KIND_BY_TYPE[type] : undefined;
357
+
358
+ switch (kind) {
359
+ case 'response':
360
+ return {
361
+ kind: 'response',
362
+ id: obj.id == null ? null : String(obj.id),
363
+ command: _str(obj.command) || null,
364
+ success: obj.success === true,
365
+ data: obj.data == null ? null : obj.data,
366
+ error: _str(obj.error) || null,
367
+ };
368
+ case 'agent_start':
369
+ return { kind: 'agent_start' };
370
+ case 'agent_end':
371
+ return { kind: 'agent_end', willRetry: obj.willRetry === true };
372
+ case 'agent_settled':
373
+ return { kind: 'agent_settled' };
374
+ case 'turn_start':
375
+ return { kind: 'turn_start' };
376
+ case 'turn_end':
377
+ return { kind: 'turn_end' };
378
+ case 'message_start':
379
+ return {
380
+ kind: 'message_start',
381
+ role: (obj.message && _str(obj.message.role)) || null,
382
+ };
383
+ case 'message_update':
384
+ return {
385
+ kind: 'message_update',
386
+ delta: (obj.assistantMessageEvent && typeof obj.assistantMessageEvent === 'object')
387
+ ? obj.assistantMessageEvent
388
+ : null,
389
+ };
390
+ case 'message_end':
391
+ return {
392
+ kind: 'message_end',
393
+ message: (obj.message && typeof obj.message === 'object') ? obj.message : null,
394
+ };
395
+ case 'tool_start': {
396
+ const toolName = _str(obj.toolName) || 'tool';
397
+ return {
398
+ kind: 'tool_start',
399
+ toolCallId: _str(obj.toolCallId) || null,
400
+ toolName,
401
+ preview: summarizeToolArgs(toolName, obj.args),
402
+ };
403
+ }
404
+ case 'tool_update':
405
+ return {
406
+ kind: 'tool_update',
407
+ toolCallId: _str(obj.toolCallId) || null,
408
+ toolName: _str(obj.toolName) || 'tool',
409
+ };
410
+ case 'tool_end': {
411
+ const toolName = _str(obj.toolName) || 'tool';
412
+ return {
413
+ kind: 'tool_end',
414
+ toolCallId: _str(obj.toolCallId) || null,
415
+ toolName,
416
+ isError: obj.isError === true,
417
+ preview: summarizeToolResult(obj.result),
418
+ };
419
+ }
420
+ case 'bash_output':
421
+ return {
422
+ kind: 'bash_output',
423
+ id: obj.id == null ? null : String(obj.id),
424
+ delta: typeof obj.delta === 'string' ? obj.delta : '',
425
+ };
426
+ case 'queue_update':
427
+ return {
428
+ kind: 'queue_update',
429
+ steering: Array.isArray(obj.steering) ? obj.steering.length : 0,
430
+ followUp: Array.isArray(obj.followUp) ? obj.followUp.length : 0,
431
+ };
432
+ case 'compaction_start':
433
+ return { kind: 'compaction_start', reason: _str(obj.reason) || 'manual' };
434
+ case 'compaction_end':
435
+ return {
436
+ kind: 'compaction_end',
437
+ reason: _str(obj.reason) || 'manual',
438
+ aborted: obj.aborted === true,
439
+ willRetry: obj.willRetry === true,
440
+ error: _str(obj.errorMessage) ? redactSecrets(obj.errorMessage) : null,
441
+ };
442
+ case 'retry_start':
443
+ return {
444
+ kind: 'retry_start',
445
+ attempt: Number.isFinite(obj.attempt) ? obj.attempt : null,
446
+ maxAttempts: Number.isFinite(obj.maxAttempts) ? obj.maxAttempts : null,
447
+ delayMs: Number.isFinite(obj.delayMs) ? obj.delayMs : null,
448
+ message: _str(obj.errorMessage) ? redactSecrets(obj.errorMessage) : null,
449
+ };
450
+ case 'retry_end':
451
+ return {
452
+ kind: 'retry_end',
453
+ // `summarization_retry_finished` carries no outcome — treat as success.
454
+ success: obj.success !== false,
455
+ attempt: Number.isFinite(obj.attempt) ? obj.attempt : null,
456
+ finalError: _str(obj.finalError) ? redactSecrets(obj.finalError) : null,
457
+ };
458
+ case 'extension_error':
459
+ return {
460
+ kind: 'extension_error',
461
+ message: redactSecrets(_str(obj.error) || 'Pi extension reported an error'),
462
+ };
463
+ case 'ui_request': {
464
+ const method = _str(obj.method) || '';
465
+ return {
466
+ kind: 'ui_request',
467
+ id: obj.id == null ? null : String(obj.id),
468
+ method,
469
+ needsResponse: BLOCKING_UI_METHODS.has(method),
470
+ title: redactSecrets(_str(obj.title) || _str(obj.message) || ''),
471
+ };
472
+ }
473
+ default:
474
+ return { kind: 'unknown', raw: diagnosticFor(obj) };
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Stateful parser: bytes in, normalized events out. Thin wrapper over the
480
+ * framer + classifier so the adapter never touches either directly.
481
+ */
482
+ class PiStreamParser {
483
+ constructor(opts) {
484
+ this._framer = new PiJsonlFramer(opts);
485
+ }
486
+
487
+ /** @param {Buffer|string} chunk @returns {Array<object>} */
488
+ push(chunk) {
489
+ return this._classify(this._framer.push(chunk));
490
+ }
491
+
492
+ /** Flush at EOF. @returns {Array<object>} */
493
+ flush() {
494
+ return this._classify(this._framer.flush());
495
+ }
496
+
497
+ _classify(lines) {
498
+ const before = this._reportedDrops || 0;
499
+ const events = [];
500
+ for (const line of lines) {
501
+ const obj = parseLine(line);
502
+ if (obj === null) {
503
+ // Blank lines are silent; anything else is a corrupt/foreign record we
504
+ // surface as `unknown` so a schema drift shows up in the logs.
505
+ if (String(line).trim()) events.push({ kind: 'unknown', raw: diagnosticFor(line) });
506
+ continue;
507
+ }
508
+ events.push(classifyPiEvent(obj));
509
+ }
510
+ if (this._framer.dropped > before) {
511
+ this._reportedDrops = this._framer.dropped;
512
+ events.push({ kind: 'oversize', bytes: this._framer.lastDroppedBytes });
513
+ }
514
+ return events;
515
+ }
516
+ }
517
+
518
+ // ────────────────────────────────────────────────────────────────────────
519
+ // Turn accumulation — deduplicates message_update against message_end
520
+ // ────────────────────────────────────────────────────────────────────────
521
+
522
+ /**
523
+ * Pi delivers each assistant content block twice: incrementally via
524
+ * `message_update` (…`text_end` / `thinking_end` carry the COMPLETE block) and
525
+ * again inside the `message_end` payload. This accumulator releases each block
526
+ * exactly once, keyed by its `contentIndex`, so the adapter can stream blocks
527
+ * as they finish AND still finalize from `message_end` without duplicating a
528
+ * single character.
529
+ *
530
+ * Usage per assistant message:
531
+ * acc.startMessage() // on message_start (role 'assistant')
532
+ * acc.pushDelta(ev.delta) -> blocks[] // on message_update
533
+ * acc.endMessage(ev.message) -> {...} // on message_end
534
+ */
535
+ class PiAssistantAccumulator {
536
+ constructor() {
537
+ this.reset();
538
+ }
539
+
540
+ reset() {
541
+ this._released = new Set(); // contentIndex values already handed out
542
+ this._texts = []; // released text blocks, in order
543
+ this._thinking = []; // released thinking blocks, in order
544
+ this._active = false;
545
+ this._closed = false;
546
+ }
547
+
548
+ /**
549
+ * Begin a new assistant message. Block indices AND the released-block lists
550
+ * are per-message: in a tool loop Pi emits several assistant messages per
551
+ * turn, and only the LAST one's text is the user-facing answer (the earlier
552
+ * ones are narration around tool calls). Keeping the lists per-message is
553
+ * what lets the adapter take "the final message's text" as the reply.
554
+ */
555
+ startMessage() {
556
+ this._released = new Set();
557
+ this._texts = [];
558
+ this._thinking = [];
559
+ this._active = true;
560
+ this._closed = false;
561
+ }
562
+
563
+ /**
564
+ * Begin a new message implicitly when the previous one already ended.
565
+ * Pi always emits `message_start` first, but relying on that alone means a
566
+ * dropped or reordered start event would make the NEXT message's block look
567
+ * like a duplicate of the previous message's same-index block and silently
568
+ * swallow it — losing an answer. Reopening here removes that failure mode.
569
+ */
570
+ _ensureOpen() {
571
+ if (this._closed) this.startMessage();
572
+ }
573
+
574
+ /**
575
+ * Feed one `assistantMessageEvent`. Returns the blocks it completed —
576
+ * `[{ type:'text'|'thinking', text }]` — or [] for a partial delta.
577
+ * @param {object|null} delta
578
+ */
579
+ pushDelta(delta) {
580
+ if (!delta || typeof delta !== 'object') return [];
581
+ this._ensureOpen();
582
+ const out = [];
583
+ const idx = Number.isFinite(delta.contentIndex) ? delta.contentIndex : null;
584
+ if (delta.type === 'text_end') {
585
+ const text = typeof delta.content === 'string' ? delta.content : '';
586
+ if (this._release(idx, 'text', text)) out.push({ type: 'text', text });
587
+ } else if (delta.type === 'thinking_end') {
588
+ const text = typeof delta.content === 'string'
589
+ ? delta.content
590
+ : (typeof delta.thinking === 'string' ? delta.thinking : '');
591
+ if (this._release(idx, 'thinking', text)) out.push({ type: 'thinking', text });
592
+ }
593
+ return out;
594
+ }
595
+
596
+ /**
597
+ * Finalize from the `message_end` payload. Emits any block the streaming
598
+ * path never released (a non-streaming provider, or a block whose `*_end`
599
+ * delta was lost), and reports the message-level error.
600
+ *
601
+ * @param {object|null} message the AgentMessage from message_end
602
+ * @returns {{blocks: Array<{type:string,text:string}>, texts: string[],
603
+ * thinking: string[], errorMessage: string|null, stopReason: string|null}}
604
+ */
605
+ endMessage(message) {
606
+ this._ensureOpen();
607
+ const blocks = [];
608
+ const content = message && Array.isArray(message.content) ? message.content : [];
609
+ for (let i = 0; i < content.length; i++) {
610
+ const block = content[i];
611
+ if (!block || typeof block !== 'object') continue;
612
+ if (block.type === 'text') {
613
+ const text = typeof block.text === 'string' ? block.text : '';
614
+ if (this._release(i, 'text', text)) blocks.push({ type: 'text', text });
615
+ } else if (block.type === 'thinking') {
616
+ const text = typeof block.thinking === 'string' ? block.thinking : '';
617
+ if (this._release(i, 'thinking', text)) blocks.push({ type: 'thinking', text });
618
+ }
619
+ // toolCall blocks are surfaced through tool_execution_* events instead.
620
+ }
621
+ this._active = false;
622
+ this._closed = true;
623
+ const stopReason = message && _str(message.stopReason) ? message.stopReason : null;
624
+ const rawError = message && _str(message.errorMessage) ? message.errorMessage : null;
625
+ return {
626
+ blocks,
627
+ texts: this._texts.slice(),
628
+ thinking: this._thinking.slice(),
629
+ errorMessage: rawError ? redactSecrets(rawError) : null,
630
+ stopReason,
631
+ };
632
+ }
633
+
634
+ /** Text of the whole message, in block order, once it has ended. */
635
+ get text() {
636
+ return this._texts.join('\n').trim();
637
+ }
638
+
639
+ _release(idx, type, text) {
640
+ // A block with no usable index can't be deduped by index; fall back to
641
+ // value-identity so a repeated payload still can't be posted twice.
642
+ const key = idx == null ? `${type}:${text}` : `${idx}`;
643
+ if (this._released.has(key)) return false;
644
+ this._released.add(key);
645
+ if (!text || !text.trim()) return false;
646
+ if (type === 'text') this._texts.push(text);
647
+ else this._thinking.push(text);
648
+ return true;
649
+ }
650
+ }
651
+
652
+ // ────────────────────────────────────────────────────────────────────────
653
+ // Error classification → the SHARED health-status vocabulary
654
+ // ────────────────────────────────────────────────────────────────────────
655
+
656
+ /**
657
+ * Map a raw Pi failure string (an assistant `errorMessage`, a failed RPC
658
+ * `error`, or stderr) to `{ kind, userMessage }`. `kind` is one of
659
+ * 'auth' | 'rate_limit' | 'model' | 'provider' | 'network' | 'context' |
660
+ * 'filesystem' | null, and the caller decides which REASON (if any) it maps
661
+ * to. Always pass the result through redactSecrets before display.
662
+ *
663
+ * @param {string} text
664
+ * @returns {{kind: string|null, userMessage: string}}
665
+ */
666
+ function classifyPiError(text) {
667
+ const raw = redactSecrets(String(text == null ? '' : text)).trim();
668
+ if (!raw) return { kind: null, userMessage: 'Pi reported an error.' };
669
+ const low = raw.toLowerCase();
670
+ const has = (...needles) => needles.some((n) => low.includes(n));
671
+
672
+ if (has('401', '403', 'unauthorized', 'authentication_error', 'authentication failed',
673
+ 'invalid api key', 'invalid x-api-key', 'invalid_api_key', 'no api key',
674
+ 'missing api key', 'no credentials', 'not authenticated')) {
675
+ return {
676
+ kind: 'auth',
677
+ userMessage:
678
+ `Pi authentication failed: ${raw}\n\n` +
679
+ "Check this agent's PI_API_KEY in the launcher (OpenAgents maps it to the provider's " +
680
+ 'native env var, including DEEPSEEK_API_KEY / ANTHROPIC_API_KEY / OPENAI_API_KEY / ' +
681
+ 'GEMINI_API_KEY), or sign in once ' +
682
+ 'with `pi` → `/login` so the credential lands in ~/.pi/agent/auth.json.',
683
+ };
684
+ }
685
+ if (has('429', 'rate limit', 'rate_limit', 'too many requests', 'quota', 'overloaded',
686
+ 'insufficient_quota')) {
687
+ return {
688
+ kind: 'rate_limit',
689
+ userMessage: `Pi hit the provider's rate limit or quota: ${raw}\n\nWait and retry, or switch provider/model.`,
690
+ };
691
+ }
692
+ if (has('context', 'too long', 'maximum context', 'context_length_exceeded')) {
693
+ return {
694
+ kind: 'context',
695
+ userMessage: `Pi exceeded the model's context window: ${raw}\n\nUse /restart in this channel to start a fresh Pi session.`,
696
+ };
697
+ }
698
+ if (has('model not found', 'unknown model', 'no such model', 'no model matching',
699
+ 'model_not_found', 'does not support')) {
700
+ return {
701
+ kind: 'model',
702
+ userMessage: `Pi could not use the configured model: ${raw}\n\nCheck PI_MODEL (and PI_PROVIDER) for this agent.`,
703
+ };
704
+ }
705
+ if (has('unknown provider', 'no provider', 'provider not found', 'unsupported provider')) {
706
+ return {
707
+ kind: 'provider',
708
+ userMessage: `Pi could not use the configured provider: ${raw}\n\nCheck PI_PROVIDER for this agent (run \`pi --list-models\` to see what is configured).`,
709
+ };
710
+ }
711
+ if (has('econnrefused', 'enotfound', 'etimedout', 'network', 'socket hang up',
712
+ 'fetch failed', 'timed out', 'getaddrinfo', 'certificate')) {
713
+ return {
714
+ kind: 'network',
715
+ userMessage: `Pi could not reach the provider: ${raw}\n\nCheck network access and any HTTP_PROXY/HTTPS_PROXY setting.`,
716
+ };
717
+ }
718
+ if (has('enoent', 'eacces', 'eperm', 'no such file', 'permission denied', 'not a directory')) {
719
+ return {
720
+ kind: 'filesystem',
721
+ userMessage: `Pi hit a filesystem error: ${raw}\n\nCheck that the agent's working directory exists and is writable.`,
722
+ };
723
+ }
724
+ return { kind: null, userMessage: `Pi error: ${raw}` };
725
+ }
726
+
727
+ // ────────────────────────────────────────────────────────────────────────
728
+ // Version helpers + argv construction
729
+ // ────────────────────────────────────────────────────────────────────────
730
+
731
+ /** Extract a dotted version from arbitrary CLI output, or null. */
732
+ function parseVersion(raw) {
733
+ if (!raw) return null;
734
+ const m = String(raw).match(/(\d+)\.(\d+)\.(\d+)/);
735
+ return m ? `${m[1]}.${m[2]}.${m[3]}` : null;
736
+ }
737
+
738
+ /** -1 / 0 / 1 for semver-ish triples. Missing components count as 0. */
739
+ function compareVersions(a, b) {
740
+ const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
741
+ const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
742
+ for (let i = 0; i < 3; i++) {
743
+ const d = (pa[i] || 0) - (pb[i] || 0);
744
+ if (d !== 0) return d > 0 ? 1 : -1;
745
+ }
746
+ return 0;
747
+ }
748
+
749
+ /**
750
+ * Classify a `pi --version` output against the minimum supported version.
751
+ * @param {string} raw
752
+ * @param {string} [minVersion] the registry's `install.min_version`; falls back
753
+ * to MIN_PI_VERSION only when the registry entry is unreadable.
754
+ * @returns {{version: string|null, supported: boolean|null}}
755
+ * supported true → >= min; false → CONFIRMED older; null → undetermined.
756
+ */
757
+ function classifyPiVersion(raw, minVersion = MIN_PI_VERSION) {
758
+ const version = parseVersion(raw);
759
+ if (!version) return { version: null, supported: null };
760
+ const floor = parseVersion(minVersion) || MIN_PI_VERSION;
761
+ return { version, supported: compareVersions(version, floor) >= 0 };
762
+ }
763
+
764
+ /**
765
+ * Classify a Node runtime version against Pi's engine floor (>= 22.19.0).
766
+ * @returns {{version: string|null, supported: boolean|null}}
767
+ */
768
+ function classifyNodeVersion(raw) {
769
+ const version = parseVersion(raw);
770
+ if (!version) return { version: null, supported: null };
771
+ return { version, supported: compareVersions(version, MIN_NODE_VERSION) >= 0 };
772
+ }
773
+
774
+ /**
775
+ * Extract the real target out of a Windows `.cmd` shim's text.
776
+ *
777
+ * npm has shipped two shim dialects and both are in the wild:
778
+ * - modern cmd-shim: `SET dp0=%~dp0` … `"%dp0%\..\pkg\dist\cli.js"`
779
+ * - older / hand-written: `node "%~dp0\cli.js" %*` (or `"%~dp0cli.js"`,
780
+ * since `%~dp0` already ends in a backslash)
781
+ * Matching only `%dp0%` — as the older sibling adapters do — silently fails on
782
+ * the second dialect and falls back to `cmd.exe /c`, where the ~14 KB
783
+ * `--append-system-prompt` argument blows past cmd.exe's 8191-character command
784
+ * line and the agent hangs. So both dialects are accepted here.
785
+ *
786
+ * A `.js`/`.mjs` target wins over a `.exe` target: the modern shim names
787
+ * `node.exe` on the same line as the script, and the script is what we want to
788
+ * run under a Node WE choose.
789
+ *
790
+ * Pure (string + path math only) and resolved with win32 path semantics —
791
+ * `.cmd` shims are a Windows-only construct — so it is unit-testable on any OS.
792
+ *
793
+ * @param {string} content the .cmd file's text
794
+ * @param {string} cmdDir the directory the .cmd lives in
795
+ * @returns {{kind:'script'|'exe', target:string}|null}
796
+ */
797
+ function parseWindowsCmdShim(content, cmdDir) {
798
+ if (!content) return null;
799
+ // `%dp0%` | `%~dp0` | `%~dp0%`, then an OPTIONAL separator, then the path.
800
+ const jsMatch = String(content).match(/%~?dp0%?[\\/]?([^\s"*?]+\.m?js)/i);
801
+ if (jsMatch) return { kind: 'script', target: path.win32.resolve(cmdDir, jsMatch[1]) };
802
+ const exeMatch = String(content).match(/%~?dp0%?[\\/]?([^\s"*?]+\.exe)/i);
803
+ if (exeMatch) return { kind: 'exe', target: path.win32.resolve(cmdDir, exeMatch[1]) };
804
+ return null;
805
+ }
806
+
807
+ /** A canonical v4/v7 UUID, the only shape we accept for a stored session id. */
808
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
809
+
810
+ function isValidSessionId(id) {
811
+ return typeof id === 'string' && UUID_RE.test(id);
812
+ }
813
+
814
+ /**
815
+ * Build the argv (WITHOUT the executable) for one `pi --mode rpc` process.
816
+ *
817
+ * Secrets never appear here: Pi reads provider credentials from the
818
+ * environment (or ~/.pi/agent/auth.json), and `--api-key` is deliberately
819
+ * never emitted so a key cannot leak into `ps`, a crash dump or a log line.
820
+ *
821
+ * @param {object} o
822
+ * @param {string} o.sessionDir OpenAgents-managed session storage
823
+ * @param {string} o.sessionId stable per-channel session UUID
824
+ * @param {string} [o.appendSystemPrompt]
825
+ * @param {string} [o.provider]
826
+ * @param {string} [o.model]
827
+ * @param {string} [o.thinking] off|minimal|low|medium|high|xhigh|max
828
+ * @param {string} [o.sessionName]
829
+ * @param {string[]} [o.extensions] explicit OpenAgents-managed extensions
830
+ * @param {boolean} [o.trustProject=false] true → --approve, false → --no-approve
831
+ * @returns {string[]}
832
+ */
833
+ function buildPiArgs({
834
+ sessionDir,
835
+ sessionId,
836
+ appendSystemPrompt,
837
+ provider,
838
+ model,
839
+ thinking,
840
+ sessionName,
841
+ extensions = [],
842
+ trustProject = false,
843
+ } = {}) {
844
+ const args = ['--mode', 'rpc'];
845
+ if (sessionDir) args.push('--session-dir', sessionDir);
846
+ if (sessionId) args.push('--session-id', sessionId);
847
+ if (sessionName) args.push('--name', sessionName);
848
+ // Project trust: default DENY. `workingDir` is an arbitrary user repo, and a
849
+ // trusted project may load .pi/settings.json and project-local extensions —
850
+ // executable surface we must not opt into implicitly. See docs/agents/pi.md.
851
+ args.push(trustProject ? '--approve' : '--no-approve');
852
+ if (provider) args.push('--provider', provider);
853
+ if (model) args.push('--model', model);
854
+ if (thinking) args.push('--thinking', thinking);
855
+ for (const extension of extensions || []) {
856
+ if (extension) args.push('--extension', extension);
857
+ }
858
+ if (appendSystemPrompt) args.push('--append-system-prompt', appendSystemPrompt);
859
+ return args;
860
+ }
861
+
862
+ /** Valid `--thinking` levels (Pi v0.83.0). Anything else is dropped. */
863
+ const THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
864
+
865
+ /** Normalize a user-supplied thinking level, or null when unusable. */
866
+ function normalizeThinking(value) {
867
+ const v = String(value == null ? '' : value).trim().toLowerCase();
868
+ return THINKING_LEVELS.has(v) ? v : null;
869
+ }
870
+
871
+ /**
872
+ * Interpret the PI_TRUST_PROJECT env value. Default (unset/anything else) is
873
+ * FALSE — the safe, non-executing choice.
874
+ */
875
+ function parseTrustProject(value) {
876
+ const v = String(value == null ? '' : value).trim().toLowerCase();
877
+ return v === '1' || v === 'true' || v === 'yes' || v === 'on';
878
+ }
879
+
880
+ /** Redact an argv for logging (no secret is ever passed as an arg, but the
881
+ * system prompt embeds the workspace token, so the value is elided). */
882
+ function redactArgs(argv) {
883
+ const out = [];
884
+ const ELIDE = new Set(['--append-system-prompt', '--system-prompt']);
885
+ for (let i = 0; i < argv.length; i++) {
886
+ out.push(argv[i]);
887
+ if (ELIDE.has(argv[i]) && i + 1 < argv.length) {
888
+ out.push(`<${argv[i + 1].length} chars>`);
889
+ i++;
890
+ }
891
+ }
892
+ return out.map((a) => redactSecrets(a));
893
+ }
894
+
895
+ module.exports = {
896
+ MIN_PI_VERSION,
897
+ MIN_NODE_VERSION,
898
+ MAX_LINE_BYTES,
899
+ BLOCKING_UI_METHODS,
900
+ EVENT_KIND_BY_TYPE,
901
+ PiJsonlFramer,
902
+ PiStreamParser,
903
+ PiAssistantAccumulator,
904
+ parseLine,
905
+ classifyPiEvent,
906
+ summarizeToolArgs,
907
+ summarizeToolResult,
908
+ redactSecrets,
909
+ diagnosticFor,
910
+ classifyPiError,
911
+ parseVersion,
912
+ compareVersions,
913
+ classifyPiVersion,
914
+ classifyNodeVersion,
915
+ parseWindowsCmdShim,
916
+ isValidSessionId,
917
+ buildPiArgs,
918
+ normalizeThinking,
919
+ parseTrustProject,
920
+ redactArgs,
921
+ THINKING_LEVELS,
922
+ };