@openagents-org/agent-launcher 0.2.161 → 0.2.162
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/icons/pi.svg +1 -0
- package/package.json +1 -1
- package/registry.json +144 -0
- package/src/adapters/index.js +4 -1
- package/src/adapters/pi-launcher-provider.mjs +116 -0
- package/src/adapters/pi-stream.js +922 -0
- package/src/adapters/pi.js +1492 -0
- package/src/adapters/workspace-prompt.js +21 -0
- package/src/daemon.js +37 -2
- package/src/installer.js +20 -4
- package/src/workspace-client.js +4 -4
|
@@ -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
|
+
};
|