@openagents-org/agent-launcher 0.2.160 → 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 +73 -3
- package/src/installer.js +20 -4
- package/src/workspace-client.js +4 -4
|
@@ -0,0 +1,1492 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi CLI adapter for OpenAgents workspace.
|
|
3
|
+
*
|
|
4
|
+
* Bridges the Pi coding agent (https://pi.dev, npm `@earendil-works/pi-coding-agent`,
|
|
5
|
+
* executable `pi`) to an OpenAgents workspace:
|
|
6
|
+
* - polling loop + per-channel task dispatch (inherited from BaseAdapter)
|
|
7
|
+
* - ONE long-lived `pi --mode rpc` subprocess per active channel, driven over
|
|
8
|
+
* stdin/stdout JSONL — the same persistent-process model claude.js uses
|
|
9
|
+
* (_persistentProcs / _channelProcesses / idle timer / watchdog /
|
|
10
|
+
* _stopProcess), so process lifecycle behaviour is identical across the two
|
|
11
|
+
* - the RPC stream is framed + classified by pi-stream.js (pure, unit-tested)
|
|
12
|
+
* and mapped onto the standard OpenAgents events (thinking / status /
|
|
13
|
+
* response / error)
|
|
14
|
+
* - real session continuity via Pi's `--session-id <uuid>`, which CREATES the
|
|
15
|
+
* session when missing and RESUMES it when present, so a channel keeps its
|
|
16
|
+
* context across messages, process restarts and launcher restarts without
|
|
17
|
+
* any session-correlation guesswork
|
|
18
|
+
*
|
|
19
|
+
* Deliberately NOT used: `@earendil-works/pi-coding-agent`'s in-process
|
|
20
|
+
* `AgentSession` / Agent Core API. OpenAgents integrates every coding agent as
|
|
21
|
+
* a CLI subprocess (see the sibling adapters); embedding Pi's SDK would put a
|
|
22
|
+
* third-party agent runtime, its provider stack and its extension loader inside
|
|
23
|
+
* the daemon process. Nothing here imports any Pi module.
|
|
24
|
+
*
|
|
25
|
+
* Verified against pi 0.83.0 (engines.node >= 22.19.0). Because that engine
|
|
26
|
+
* floor is above what a user's ambient Node may satisfy, a resolved JS entry
|
|
27
|
+
* point is launched with the launcher's own portable Node (v22.22.3) and
|
|
28
|
+
* preflight refuses to start on anything older.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
'use strict';
|
|
32
|
+
|
|
33
|
+
const crypto = require('crypto');
|
|
34
|
+
const fs = require('fs');
|
|
35
|
+
const os = require('os');
|
|
36
|
+
const path = require('path');
|
|
37
|
+
const { execFileSync, execSync, spawn } = require('child_process');
|
|
38
|
+
|
|
39
|
+
const BaseAdapter = require('./base');
|
|
40
|
+
const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle } = require('./utils');
|
|
41
|
+
const { buildPiSystemPrompt } = require('./workspace-prompt');
|
|
42
|
+
const { REASON, classifySpawnError } = require('./health-status');
|
|
43
|
+
const { defaultAgentWorkdir, whichBinary, whereBinary } = require('../paths');
|
|
44
|
+
const {
|
|
45
|
+
PiStreamParser,
|
|
46
|
+
PiAssistantAccumulator,
|
|
47
|
+
buildPiArgs,
|
|
48
|
+
classifyNodeVersion,
|
|
49
|
+
classifyPiError,
|
|
50
|
+
classifyPiVersion,
|
|
51
|
+
isValidSessionId,
|
|
52
|
+
normalizeThinking,
|
|
53
|
+
parseTrustProject,
|
|
54
|
+
parseWindowsCmdShim,
|
|
55
|
+
redactArgs,
|
|
56
|
+
redactSecrets,
|
|
57
|
+
MIN_NODE_VERSION,
|
|
58
|
+
MIN_PI_VERSION,
|
|
59
|
+
} = require('./pi-stream');
|
|
60
|
+
|
|
61
|
+
const IS_WINDOWS = process.platform === 'win32';
|
|
62
|
+
const LAUNCHER_PROVIDER_EXTENSION = path.join(__dirname, 'pi-launcher-provider.mjs');
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Package identity — read from the registry, never hard-coded here
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The bundled registry's `pi` entry, memoized. The npm package name and the
|
|
70
|
+
* minimum supported version are CONFIGURATION: they live in
|
|
71
|
+
* sdk/src/openagents/registry/pi.yaml → packages/agent-connector/registry.json
|
|
72
|
+
* and are read from there, so bumping the catalog cannot leave this adapter
|
|
73
|
+
* probing a stale path or enforcing a stale version floor.
|
|
74
|
+
*/
|
|
75
|
+
let _piRegistryEntry;
|
|
76
|
+
function piRegistryEntry() {
|
|
77
|
+
if (_piRegistryEntry !== undefined) return _piRegistryEntry;
|
|
78
|
+
_piRegistryEntry = null;
|
|
79
|
+
try {
|
|
80
|
+
const catalog = require('../../registry.json');
|
|
81
|
+
if (Array.isArray(catalog)) {
|
|
82
|
+
_piRegistryEntry = catalog.find((e) => e && e.name === 'pi') || null;
|
|
83
|
+
}
|
|
84
|
+
} catch { /* bundle unreadable — fall back to the module defaults */ }
|
|
85
|
+
return _piRegistryEntry;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The npm package Pi installs as, per the registry. Null when undeclared. */
|
|
89
|
+
function piNpmPackage() {
|
|
90
|
+
const entry = piRegistryEntry();
|
|
91
|
+
return (entry && entry.install && entry.install.npm_package) || null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The registry's version floor, falling back to the module default. */
|
|
95
|
+
function piMinVersion() {
|
|
96
|
+
const entry = piRegistryEntry();
|
|
97
|
+
return (entry && entry.install && entry.install.min_version) || MIN_PI_VERSION;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Release an idle channel's Pi process after this long (matches claude.js). */
|
|
101
|
+
const IDLE_TIMEOUT_MS = 60 * 60 * 1000;
|
|
102
|
+
|
|
103
|
+
/** Watchdog cadence + how many consecutive silences kill a wedged process. */
|
|
104
|
+
const WATCHDOG_INTERVAL_MS = 15_000;
|
|
105
|
+
const WATCHDOG_MAX_TIMEOUTS = 20; // 20 × 15s = 5 min of silence
|
|
106
|
+
|
|
107
|
+
/** How long a control RPC (abort / get_state) may wait for its response. */
|
|
108
|
+
const RPC_TIMEOUT_MS = 30_000;
|
|
109
|
+
|
|
110
|
+
/** Grace given to an in-flight turn to settle after an `abort` before the kill. */
|
|
111
|
+
const ABORT_GRACE_MS = 3_000;
|
|
112
|
+
|
|
113
|
+
/** Version probes are cached per resolved binary so we never spawn per message. */
|
|
114
|
+
const VERSION_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
115
|
+
const _piVersionCache = new Map(); // binPath -> { version, supported, at }
|
|
116
|
+
const _nodeVersionCache = new Map(); // nodeBin -> { version, supported, at }
|
|
117
|
+
|
|
118
|
+
/** Thrown into pending RPCs when a channel is torn down. */
|
|
119
|
+
class PiCancelledError extends Error {
|
|
120
|
+
constructor(message) {
|
|
121
|
+
super(message);
|
|
122
|
+
this.name = 'PiCancelledError';
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
class PiAdapter extends BaseAdapter {
|
|
127
|
+
/**
|
|
128
|
+
* @param {object} opts - BaseAdapter opts plus:
|
|
129
|
+
* @param {Set} [opts.disabledModules]
|
|
130
|
+
* @param {string} [opts.workingDir]
|
|
131
|
+
*/
|
|
132
|
+
constructor(opts) {
|
|
133
|
+
super(opts);
|
|
134
|
+
this.disabledModules = opts.disabledModules || new Set();
|
|
135
|
+
/** Pi reaches workspace APIs via bash + curl; there is no MCP wiring. */
|
|
136
|
+
this.toolMode = 'skills';
|
|
137
|
+
|
|
138
|
+
// channel → { sessionId, workingDir } (Pi session UUIDs we own)
|
|
139
|
+
this._channelSessions = {};
|
|
140
|
+
// channel → persistent-process record
|
|
141
|
+
this._persistentProcs = {};
|
|
142
|
+
// channel → child process (BaseAdapter/stop paths read this)
|
|
143
|
+
this._channelProcesses = {};
|
|
144
|
+
this._stoppingChannels = new Set();
|
|
145
|
+
// Dedup for "Execution stopped by user." — the control handler and the
|
|
146
|
+
// in-flight message handler both race to announce it (see claude.js).
|
|
147
|
+
this._stopNoticeSent = new Set();
|
|
148
|
+
|
|
149
|
+
this._sessionsFile = path.join(
|
|
150
|
+
os.homedir(), '.openagents', 'sessions',
|
|
151
|
+
`${this.workspaceId}_${this.agentName}_pi.json`,
|
|
152
|
+
);
|
|
153
|
+
// Pi sessions live under OpenAgents' own data tree, NOT in the user's
|
|
154
|
+
// project and NOT in the shared ~/.pi/agent/sessions — one directory per
|
|
155
|
+
// (workspace, agent) so two agents can never read each other's transcripts.
|
|
156
|
+
this._sessionDir = path.join(
|
|
157
|
+
os.homedir(), '.openagents', 'pi-sessions',
|
|
158
|
+
`${PiAdapter._safe(this.workspaceId)}_${PiAdapter._safe(this.agentName)}`,
|
|
159
|
+
);
|
|
160
|
+
this._piBin = null;
|
|
161
|
+
this._loadSessions();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
static _safe(value) {
|
|
165
|
+
return String(value || 'default').replace(/[^A-Za-z0-9._-]/g, '_') || 'default';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Exact secret values that must never reach a log, status or chat message. */
|
|
169
|
+
_secrets() {
|
|
170
|
+
const env = this.agentEnv || process.env;
|
|
171
|
+
const out = [];
|
|
172
|
+
if (this.token) out.push(this.token);
|
|
173
|
+
for (const [k, v] of Object.entries(env)) {
|
|
174
|
+
if (!v || typeof v !== 'string' || v.length < 8) continue;
|
|
175
|
+
if (/(_API_KEY|_AUTH_TOKEN|_OAUTH_TOKEN|_TOKEN|_SECRET|_ACCESS_KEY)$/.test(k)) out.push(v);
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Redact with this agent's concrete secrets folded in. */
|
|
181
|
+
_redact(text) {
|
|
182
|
+
return redactSecrets(text, this._secrets());
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ------------------------------------------------------------------
|
|
186
|
+
// Session persistence (Pi session UUIDs, bound to the working dir)
|
|
187
|
+
// ------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
_loadSessions() {
|
|
190
|
+
try {
|
|
191
|
+
if (!fs.existsSync(this._sessionsFile)) return;
|
|
192
|
+
const data = JSON.parse(fs.readFileSync(this._sessionsFile, 'utf-8'));
|
|
193
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
194
|
+
this._log('Pi sessions file has an unexpected shape — starting fresh');
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
let kept = 0;
|
|
198
|
+
for (const [channel, entry] of Object.entries(data)) {
|
|
199
|
+
if (entry && typeof entry === 'object' && isValidSessionId(entry.sessionId)) {
|
|
200
|
+
this._channelSessions[channel] = { sessionId: entry.sessionId, workingDir: entry.workingDir || null };
|
|
201
|
+
kept++;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
this._log(`Loaded ${kept} Pi session(s)`);
|
|
205
|
+
} catch {
|
|
206
|
+
// A truncated/corrupt file must never wedge the agent: drop it and let
|
|
207
|
+
// each channel mint a fresh session id on its next message.
|
|
208
|
+
this._log('Could not read Pi sessions file (corrupt or unreadable) — starting fresh');
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
_saveSessions() {
|
|
213
|
+
try {
|
|
214
|
+
fs.mkdirSync(path.dirname(this._sessionsFile), { recursive: true });
|
|
215
|
+
fs.writeFileSync(this._sessionsFile, JSON.stringify(this._channelSessions));
|
|
216
|
+
} catch {}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The Pi session id for a channel, minting (and persisting) one when absent,
|
|
221
|
+
* corrupt, or bound to a different working directory. Pi's `--session-id`
|
|
222
|
+
* creates the session when it does not exist, so a lost/deleted session file
|
|
223
|
+
* degrades to "fresh context", never to an error.
|
|
224
|
+
*/
|
|
225
|
+
_sessionIdFor(channel, workingDir) {
|
|
226
|
+
const entry = this._channelSessions[channel];
|
|
227
|
+
if (entry && isValidSessionId(entry.sessionId)
|
|
228
|
+
&& (!entry.workingDir || !workingDir || entry.workingDir === workingDir)) {
|
|
229
|
+
return entry.sessionId;
|
|
230
|
+
}
|
|
231
|
+
const sessionId = crypto.randomUUID();
|
|
232
|
+
this._channelSessions[channel] = { sessionId, workingDir: workingDir || null };
|
|
233
|
+
this._saveSessions();
|
|
234
|
+
this._log(`Minted Pi session ${sessionId} for channel=${channel}`);
|
|
235
|
+
return sessionId;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
_clearSession(channel) {
|
|
239
|
+
if (this._channelSessions[channel]) {
|
|
240
|
+
delete this._channelSessions[channel];
|
|
241
|
+
this._saveSessions();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ------------------------------------------------------------------
|
|
246
|
+
// Binary resolution (mirrors the Claude/Cline adapters tier-for-tier)
|
|
247
|
+
// ------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The portable Node the launcher installs, else the Node running this daemon.
|
|
251
|
+
* Bare 'node' is never used: a packaged daemon's PATH may not have one.
|
|
252
|
+
*/
|
|
253
|
+
_findNodeBin() {
|
|
254
|
+
const home = os.homedir();
|
|
255
|
+
const candidates = IS_WINDOWS
|
|
256
|
+
? [path.join(home, '.openagents', 'nodejs', 'node.exe')]
|
|
257
|
+
: [path.join(home, '.openagents', 'nodejs', 'node'),
|
|
258
|
+
path.join(home, '.openagents', 'nodejs', 'bin', 'node')];
|
|
259
|
+
for (const c of candidates) if (fs.existsSync(c)) return c;
|
|
260
|
+
return process.execPath;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Resolve a shim/symlink to [nodeBin, jsEntry] so we spawn the JS entry point
|
|
265
|
+
* under a Node we control. On Windows this also avoids wrapping a `.cmd` in
|
|
266
|
+
* `cmd.exe /c`, whose 8191-char command-line cap would truncate the long
|
|
267
|
+
* --append-system-prompt. Returns null when the target is a native binary.
|
|
268
|
+
*/
|
|
269
|
+
_resolveToNodeCmd(binPath) {
|
|
270
|
+
const nodeBin = this._findNodeBin();
|
|
271
|
+
if (IS_WINDOWS && binPath.toLowerCase().endsWith('.cmd')) {
|
|
272
|
+
try {
|
|
273
|
+
const cmdDir = path.dirname(path.resolve(binPath));
|
|
274
|
+
// Both npm shim dialects (`%dp0%` and `%~dp0`) are parsed — see
|
|
275
|
+
// parseWindowsCmdShim. A shim we fail to parse falls back to
|
|
276
|
+
// `cmd.exe /c`, where the ~14 KB --append-system-prompt exceeds
|
|
277
|
+
// cmd.exe's 8191-character command line and the agent hangs.
|
|
278
|
+
const shim = parseWindowsCmdShim(fs.readFileSync(binPath, 'utf-8'), cmdDir);
|
|
279
|
+
if (shim && shim.kind === 'script') return [nodeBin, shim.target];
|
|
280
|
+
if (shim && shim.kind === 'exe') return [shim.target];
|
|
281
|
+
} catch {}
|
|
282
|
+
} else {
|
|
283
|
+
try {
|
|
284
|
+
let target = binPath;
|
|
285
|
+
if (fs.lstatSync(binPath).isSymbolicLink()) {
|
|
286
|
+
target = path.resolve(path.dirname(binPath), fs.readlinkSync(binPath));
|
|
287
|
+
}
|
|
288
|
+
if (target.endsWith('.js') || target.endsWith('.mjs')) return [nodeBin, target];
|
|
289
|
+
if (this._isNodeShebangScript(target)) return [nodeBin, target];
|
|
290
|
+
} catch {}
|
|
291
|
+
}
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** True when a file begins with a `#!...node` shebang. */
|
|
296
|
+
_isNodeShebangScript(filePath) {
|
|
297
|
+
try {
|
|
298
|
+
const fd = fs.openSync(filePath, 'r');
|
|
299
|
+
try {
|
|
300
|
+
const buf = Buffer.alloc(64);
|
|
301
|
+
const n = fs.readSync(fd, buf, 0, 64, 0);
|
|
302
|
+
const head = buf.slice(0, n).toString('utf-8');
|
|
303
|
+
return head.startsWith('#!') && /\bnode\b/.test(head.split('\n')[0]);
|
|
304
|
+
} finally {
|
|
305
|
+
fs.closeSync(fd);
|
|
306
|
+
}
|
|
307
|
+
} catch {
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Locate the `pi` executable. The isolated runtime prefix installed by
|
|
314
|
+
* `openagents install pi` wins over anything on the user's PATH, so a global
|
|
315
|
+
* Pi can never shadow the version OpenAgents manages.
|
|
316
|
+
*/
|
|
317
|
+
_findPiBinary() {
|
|
318
|
+
const home = os.homedir();
|
|
319
|
+
const ext = IS_WINDOWS ? '.cmd' : '';
|
|
320
|
+
|
|
321
|
+
// Tier 0: isolated runtime prefix (~/.openagents/runtimes/pi/)
|
|
322
|
+
const runtimeCandidate = path.join(home, '.openagents', 'runtimes', 'pi', 'node_modules', '.bin', `pi${ext}`);
|
|
323
|
+
if (fs.existsSync(runtimeCandidate)) return runtimeCandidate;
|
|
324
|
+
|
|
325
|
+
// Tier 0b: legacy shared portable prefix
|
|
326
|
+
const portable = path.join(home, '.openagents', 'nodejs', 'node_modules', '.bin', `pi${ext}`);
|
|
327
|
+
if (fs.existsSync(portable)) return portable;
|
|
328
|
+
|
|
329
|
+
// Tier 0c: the package's own entry point. npm normally creates the .bin
|
|
330
|
+
// shim above, but a partially-completed install can leave the package
|
|
331
|
+
// present without it — and the entry point is a Node script we can run.
|
|
332
|
+
// The package NAME comes from the registry and the entry path from the
|
|
333
|
+
// installed package's own `bin` field, so neither is written here.
|
|
334
|
+
const pkgBin = this._findPackageEntryPoint(home);
|
|
335
|
+
if (pkgBin) return pkgBin;
|
|
336
|
+
|
|
337
|
+
// Tier 1: PATH search via the codepage-safe lookup (forces UTF-8 output and
|
|
338
|
+
// verifies existence, so a non-ASCII username isn't mangled into ENOENT).
|
|
339
|
+
const viaWhere = whereBinary('pi');
|
|
340
|
+
if (viaWhere) return viaWhere;
|
|
341
|
+
|
|
342
|
+
// Tier 2: next to the Node interpreter running this daemon (npm global)
|
|
343
|
+
const nearNode = path.join(path.dirname(process.execPath), `pi${ext}`);
|
|
344
|
+
if (fs.existsSync(nearNode)) return nearNode;
|
|
345
|
+
|
|
346
|
+
// Tier 3: common install locations
|
|
347
|
+
const candidates = IS_WINDOWS ? [
|
|
348
|
+
path.join(process.env.APPDATA || '', 'npm', 'pi.cmd'),
|
|
349
|
+
] : [
|
|
350
|
+
path.join(home, '.local', 'bin', 'pi'),
|
|
351
|
+
path.join(home, '.npm-global', 'bin', 'pi'),
|
|
352
|
+
'/opt/homebrew/bin/pi',
|
|
353
|
+
'/usr/local/bin/pi',
|
|
354
|
+
];
|
|
355
|
+
for (const c of candidates) if (fs.existsSync(c)) return c;
|
|
356
|
+
|
|
357
|
+
// Tier 4: deep scan of every known bin dir (nvm/fnm/volta/homebrew/…)
|
|
358
|
+
return whichBinary('pi') || null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Locate the installed Pi package's own executable entry point, without
|
|
363
|
+
* naming either the package or its bin path in this file: the package name
|
|
364
|
+
* comes from the registry (`install.npm_package`) and the entry path from
|
|
365
|
+
* that package's `bin` field on disk. Returns null when it can't be resolved.
|
|
366
|
+
*/
|
|
367
|
+
_findPackageEntryPoint(home) {
|
|
368
|
+
const pkgName = piNpmPackage();
|
|
369
|
+
const binName = (piRegistryEntry() || {}).install?.binary || 'pi';
|
|
370
|
+
if (!pkgName) return null;
|
|
371
|
+
for (const root of [
|
|
372
|
+
path.join(home, '.openagents', 'runtimes', 'pi', 'node_modules'),
|
|
373
|
+
path.join(home, '.openagents', 'nodejs', 'node_modules'),
|
|
374
|
+
]) {
|
|
375
|
+
const pkgDir = path.join(root, ...pkgName.split('/'));
|
|
376
|
+
try {
|
|
377
|
+
const meta = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf-8'));
|
|
378
|
+
const bin = meta && meta.bin;
|
|
379
|
+
const rel = typeof bin === 'string' ? bin : (bin && (bin[binName] || bin[pkgName]));
|
|
380
|
+
if (!rel) continue;
|
|
381
|
+
const entry = path.join(pkgDir, rel);
|
|
382
|
+
if (fs.existsSync(entry)) return entry;
|
|
383
|
+
} catch { /* not installed here, or unreadable manifest */ }
|
|
384
|
+
}
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Resolve [cmd, ...args] for spawning, preferring the portable-Node path. */
|
|
389
|
+
_spawnableCmd(binPath, args) {
|
|
390
|
+
const resolved = this._resolveToNodeCmd(binPath);
|
|
391
|
+
if (resolved) return [...resolved, ...args];
|
|
392
|
+
if (IS_WINDOWS && binPath.toLowerCase().endsWith('.cmd')) return ['cmd.exe', '/c', binPath, ...args];
|
|
393
|
+
return [binPath, ...args];
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ------------------------------------------------------------------
|
|
397
|
+
// Version gates
|
|
398
|
+
// ------------------------------------------------------------------
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Run `pi --version`. Uses execFileSync (argument array, no shell) so a
|
|
402
|
+
* resolved path containing spaces or shell metacharacters can never be
|
|
403
|
+
* re-interpreted as a command. Isolated so tests can stub it.
|
|
404
|
+
*/
|
|
405
|
+
_readPiVersionRaw(piBin) {
|
|
406
|
+
const [cmd, ...args] = this._spawnableCmd(piBin, ['--version']);
|
|
407
|
+
return execFileSync(cmd, args, {
|
|
408
|
+
encoding: 'utf-8', timeout: 15000, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
|
|
409
|
+
}).trim();
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Run `node --version` for the interpreter we would launch Pi with. */
|
|
413
|
+
_readNodeVersionRaw(nodeBin) {
|
|
414
|
+
if (nodeBin === process.execPath) return process.version;
|
|
415
|
+
return execFileSync(nodeBin, ['--version'], {
|
|
416
|
+
encoding: 'utf-8', timeout: 8000, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
|
|
417
|
+
}).trim();
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* @returns {{version: string|null, compatible: boolean|null}}
|
|
422
|
+
* compatible true → >= MIN_PI_VERSION; false → CONFIRMED older;
|
|
423
|
+
* null → undetermined (we proceed leniently, like the Cline adapter).
|
|
424
|
+
*/
|
|
425
|
+
_checkPiVersion(piBin) {
|
|
426
|
+
const now = Date.now();
|
|
427
|
+
const cached = _piVersionCache.get(piBin);
|
|
428
|
+
if (cached && (now - cached.at) < VERSION_CACHE_TTL_MS) {
|
|
429
|
+
return { version: cached.version, compatible: cached.supported };
|
|
430
|
+
}
|
|
431
|
+
let version = null;
|
|
432
|
+
let supported = null;
|
|
433
|
+
try {
|
|
434
|
+
({ version, supported } = classifyPiVersion(this._readPiVersionRaw(piBin), piMinVersion()));
|
|
435
|
+
} catch {
|
|
436
|
+
version = null;
|
|
437
|
+
supported = null;
|
|
438
|
+
}
|
|
439
|
+
_piVersionCache.set(piBin, { version, supported, at: now });
|
|
440
|
+
return { version, compatible: supported };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Same shape for the Node that will host Pi (hard floor: 22.19.0). */
|
|
444
|
+
_checkNodeVersion(nodeBin) {
|
|
445
|
+
const now = Date.now();
|
|
446
|
+
const cached = _nodeVersionCache.get(nodeBin);
|
|
447
|
+
if (cached && (now - cached.at) < VERSION_CACHE_TTL_MS) {
|
|
448
|
+
return { version: cached.version, compatible: cached.supported };
|
|
449
|
+
}
|
|
450
|
+
let version = null;
|
|
451
|
+
let supported = null;
|
|
452
|
+
try {
|
|
453
|
+
({ version, supported } = classifyNodeVersion(this._readNodeVersionRaw(nodeBin)));
|
|
454
|
+
} catch {
|
|
455
|
+
version = null;
|
|
456
|
+
supported = null;
|
|
457
|
+
}
|
|
458
|
+
_nodeVersionCache.set(nodeBin, { version, supported, at: now });
|
|
459
|
+
return { version, compatible: supported };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Clear both version caches (test hook; also correct after an install). */
|
|
463
|
+
static _clearVersionCache() {
|
|
464
|
+
_piVersionCache.clear();
|
|
465
|
+
_nodeVersionCache.clear();
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Preflight gate, run by the daemon BEFORE the workspace join. Pi cannot do
|
|
470
|
+
* anything without a resolvable CLI and a Node new enough to run it, so both
|
|
471
|
+
* are checked here and reported with a precise REASON instead of failing
|
|
472
|
+
* every message after a pointless join.
|
|
473
|
+
*/
|
|
474
|
+
preflight() {
|
|
475
|
+
const piBin = this._piBin || this._findPiBinary();
|
|
476
|
+
if (!piBin) {
|
|
477
|
+
return {
|
|
478
|
+
ok: false,
|
|
479
|
+
reason: REASON.RUNTIME_MISSING,
|
|
480
|
+
message: 'Pi CLI not found — install it with: openagents install pi',
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
this._piBin = piBin;
|
|
484
|
+
|
|
485
|
+
// Only a JS entry point runs under a Node we choose; a native binary
|
|
486
|
+
// carries its own runtime and the engine floor does not apply.
|
|
487
|
+
const resolved = this._resolveToNodeCmd(piBin);
|
|
488
|
+
if (resolved && resolved.length === 2) {
|
|
489
|
+
const node = this._checkNodeVersion(resolved[0]);
|
|
490
|
+
if (node.compatible === false) {
|
|
491
|
+
return {
|
|
492
|
+
ok: false,
|
|
493
|
+
reason: REASON.VERSION_INCOMPATIBLE,
|
|
494
|
+
message:
|
|
495
|
+
`Pi requires Node.js >= ${MIN_NODE_VERSION} but the available runtime is ` +
|
|
496
|
+
`${node.version} (${resolved[0]}). Install the launcher's bundled Node ` +
|
|
497
|
+
'(reinstall Pi from the Install page) or upgrade the system Node.',
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const ver = this._checkPiVersion(piBin);
|
|
503
|
+
if (ver.compatible === false) {
|
|
504
|
+
return {
|
|
505
|
+
ok: false,
|
|
506
|
+
reason: REASON.VERSION_INCOMPATIBLE,
|
|
507
|
+
message:
|
|
508
|
+
`Pi CLI ${ver.version} is below the minimum supported version ${piMinVersion()}. ` +
|
|
509
|
+
'Update it from the Install page.',
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
this._log(`Pi CLI resolved: ${piBin}${ver.version ? ` (v${ver.version})` : ''}`);
|
|
514
|
+
return { ok: true };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// ------------------------------------------------------------------
|
|
518
|
+
// Control actions (stop / restart) + shutdown
|
|
519
|
+
// ------------------------------------------------------------------
|
|
520
|
+
|
|
521
|
+
async _onControlAction(action, payload) {
|
|
522
|
+
if (action === 'stop') {
|
|
523
|
+
const channel = (payload && typeof payload === 'object') ? payload.channel : null;
|
|
524
|
+
if (channel) {
|
|
525
|
+
const pp = this._persistentProcs[channel];
|
|
526
|
+
if (pp) pp.userStopped = true;
|
|
527
|
+
if (this._channelProcesses[channel]) {
|
|
528
|
+
this._log(`Stopping Pi for channel=${channel}`);
|
|
529
|
+
this._stoppingChannels.add(channel);
|
|
530
|
+
await this._abortChannel(channel);
|
|
531
|
+
delete this._channelQueues[channel];
|
|
532
|
+
await this._postStopNotice(channel);
|
|
533
|
+
}
|
|
534
|
+
} else {
|
|
535
|
+
for (const pp of Object.values(this._persistentProcs)) pp.userStopped = true;
|
|
536
|
+
await this._stopAllProcesses('Execution stopped by user.');
|
|
537
|
+
}
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
if (action === 'restart') {
|
|
541
|
+
const channel = (payload && typeof payload === 'object') ? payload.channel : null;
|
|
542
|
+
if (channel) {
|
|
543
|
+
await this._killPersistentProc(channel, 'restart requested');
|
|
544
|
+
this._clearSession(channel);
|
|
545
|
+
try {
|
|
546
|
+
await this.client.sendMessage(this.workspaceId, channel, this.token,
|
|
547
|
+
'Session restarted — the next message starts a fresh Pi session.',
|
|
548
|
+
{
|
|
549
|
+
senderType: 'agent',
|
|
550
|
+
senderName: this.agentName,
|
|
551
|
+
messageType: 'status',
|
|
552
|
+
metadata: { agent_mode: this._mode },
|
|
553
|
+
sessionId: this._sessionId,
|
|
554
|
+
});
|
|
555
|
+
} catch (e) {
|
|
556
|
+
this._log(`Restart: failed to post status: ${this._redact(e && e.message)}`);
|
|
557
|
+
}
|
|
558
|
+
} else {
|
|
559
|
+
this._channelSessions = {};
|
|
560
|
+
this._saveSessions();
|
|
561
|
+
await this._stopAllProcesses('Execution stopped.');
|
|
562
|
+
}
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
await super._onControlAction(action, payload);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Daemon shutdown — tear down every Pi child so no orphan survives the
|
|
570
|
+
* launcher and no channel is left showing a stale "running" status.
|
|
571
|
+
* Fire-and-forget: daemon._killAgent allows a short grace period.
|
|
572
|
+
*/
|
|
573
|
+
stop() {
|
|
574
|
+
this._stopAllProcesses(
|
|
575
|
+
'Task interrupted — daemon restarting. Send another message to continue.',
|
|
576
|
+
).catch(() => {});
|
|
577
|
+
super.stop();
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async _stopAllProcesses(message = 'Execution stopped.') {
|
|
581
|
+
const channels = Object.keys(this._persistentProcs);
|
|
582
|
+
if (!channels.length) return;
|
|
583
|
+
this._log(`Stopping ${channels.length} Pi process(es)...`);
|
|
584
|
+
for (const channel of channels) {
|
|
585
|
+
this._stoppingChannels.add(channel);
|
|
586
|
+
await this._killPersistentProc(channel, 'adapter stopping');
|
|
587
|
+
delete this._channelQueues[channel];
|
|
588
|
+
try { await this.sendResponse(channel, message); } catch {}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Post "Execution stopped by user." at most once per stop, per channel. */
|
|
593
|
+
async _postStopNotice(channel) {
|
|
594
|
+
if (!channel || this._stopNoticeSent.has(channel)) return;
|
|
595
|
+
this._stopNoticeSent.add(channel);
|
|
596
|
+
try { await this.sendResponse(channel, 'Execution stopped by user.'); } catch {}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Stop ONE channel: ask Pi to `abort` over RPC first (so it can unwind its
|
|
601
|
+
* tool work), give the turn a short grace period to settle, then terminate
|
|
602
|
+
* the process tree. Other channels and other agents are untouched.
|
|
603
|
+
*/
|
|
604
|
+
async _abortChannel(channel) {
|
|
605
|
+
const pp = this._persistentProcs[channel];
|
|
606
|
+
if (!pp) return;
|
|
607
|
+
try {
|
|
608
|
+
await this._sendRpc(pp, { type: 'abort' }, { timeoutMs: ABORT_GRACE_MS });
|
|
609
|
+
} catch (e) {
|
|
610
|
+
this._log(`Abort RPC did not complete for ${channel}: ${this._redact(e && e.message)}`);
|
|
611
|
+
}
|
|
612
|
+
// Give the in-flight turn a moment to settle on its own after the abort.
|
|
613
|
+
// Capture the record first: the exit/teardown path can null pp.turn between
|
|
614
|
+
// the check and the await.
|
|
615
|
+
const turn = pp.turn;
|
|
616
|
+
if (turn) {
|
|
617
|
+
const settled = await Promise.race([
|
|
618
|
+
turn.promise.then(() => true, () => true),
|
|
619
|
+
this._sleep(ABORT_GRACE_MS).then(() => false),
|
|
620
|
+
]);
|
|
621
|
+
if (settled && !pp.turnInFlight) {
|
|
622
|
+
// Clean abort: keep the process so the next message reuses its context.
|
|
623
|
+
this._log(`Pi aborted cleanly for ${channel} — process kept alive`);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
await this._killPersistentProc(channel, 'stop requested');
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Cross-platform process-tree termination. Same shape as claude.js: SIGINT/
|
|
632
|
+
* SIGTERM on the process GROUP first (Pi spawns bash tool children that must
|
|
633
|
+
* die with it), escalating to SIGKILL / `taskkill /F /T`.
|
|
634
|
+
*/
|
|
635
|
+
async _stopProcess(proc) {
|
|
636
|
+
if (!proc || proc.exitCode !== null) return;
|
|
637
|
+
try {
|
|
638
|
+
if (IS_WINDOWS) {
|
|
639
|
+
try { proc.kill('SIGINT'); } catch {}
|
|
640
|
+
const exited = await this._waitExit(proc, 1500);
|
|
641
|
+
if (!exited) {
|
|
642
|
+
try { execSync(`taskkill /F /T /PID ${proc.pid}`, { timeout: 5000, windowsHide: true }); } catch {}
|
|
643
|
+
}
|
|
644
|
+
} else {
|
|
645
|
+
try { process.kill(-proc.pid, 'SIGTERM'); } catch { try { proc.kill('SIGTERM'); } catch {} }
|
|
646
|
+
const exited = await this._waitExit(proc, 1500);
|
|
647
|
+
if (!exited) {
|
|
648
|
+
try { process.kill(-proc.pid, 'SIGKILL'); } catch { try { proc.kill('SIGKILL'); } catch {} }
|
|
649
|
+
await this._waitExit(proc, 1000);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
} catch {}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
_waitExit(proc, ms) {
|
|
656
|
+
return new Promise((resolve) => {
|
|
657
|
+
if (proc.exitCode !== null) { resolve(true); return; }
|
|
658
|
+
const t = setTimeout(() => resolve(false), ms);
|
|
659
|
+
proc.once('exit', () => { clearTimeout(t); resolve(true); });
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// ------------------------------------------------------------------
|
|
664
|
+
// Persistent process lifecycle
|
|
665
|
+
// ------------------------------------------------------------------
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Kill a channel's Pi process and clear every resource it owns: timers,
|
|
669
|
+
* pending RPCs (rejected with a clear cancellation), stream listeners and
|
|
670
|
+
* both channel registrations. Returns once the process has actually exited,
|
|
671
|
+
* so a replacement spawn can never race the old exit handler.
|
|
672
|
+
*/
|
|
673
|
+
_killPersistentProc(channel, why = 'released') {
|
|
674
|
+
const pp = this._persistentProcs[channel];
|
|
675
|
+
if (!pp) return Promise.resolve();
|
|
676
|
+
this._teardownProcState(pp, `Pi process ${why}`);
|
|
677
|
+
delete this._persistentProcs[channel];
|
|
678
|
+
if (this._channelProcesses[channel] === pp.proc) delete this._channelProcesses[channel];
|
|
679
|
+
return this._stopProcess(pp.proc).catch(() => {});
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** Clear timers/listeners and fail everything still waiting on this process. */
|
|
683
|
+
_teardownProcState(pp, reason) {
|
|
684
|
+
if (pp.idleTimer) { clearTimeout(pp.idleTimer); pp.idleTimer = null; }
|
|
685
|
+
this._stopWatchdog(pp);
|
|
686
|
+
pp.alive = false;
|
|
687
|
+
const err = new PiCancelledError(reason);
|
|
688
|
+
for (const [, entry] of pp.pending) {
|
|
689
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
690
|
+
try { entry.reject(err); } catch {}
|
|
691
|
+
}
|
|
692
|
+
pp.pending.clear();
|
|
693
|
+
if (pp.turn) {
|
|
694
|
+
const turn = pp.turn;
|
|
695
|
+
pp.turn = null;
|
|
696
|
+
pp.turnInFlight = false;
|
|
697
|
+
try { turn.resolve({ ended: true, reason }); } catch {}
|
|
698
|
+
}
|
|
699
|
+
for (const stream of [pp.proc && pp.proc.stdout, pp.proc && pp.proc.stderr]) {
|
|
700
|
+
if (!stream) continue;
|
|
701
|
+
stream.removeAllListeners('data');
|
|
702
|
+
stream.removeAllListeners('end');
|
|
703
|
+
// Keep an error guard: a SIGKILL'd child's pipe can still emit 'error'
|
|
704
|
+
// after teardown, and an unhandled stream error would crash the daemon.
|
|
705
|
+
stream.on('error', () => {});
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** Drop registrations only if they still point at THIS process. */
|
|
710
|
+
_unregisterProc(channel, pp, proc) {
|
|
711
|
+
if (this._persistentProcs[channel] === pp) delete this._persistentProcs[channel];
|
|
712
|
+
if (this._channelProcesses[channel] === proc) delete this._channelProcesses[channel];
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
_resetIdleTimer(channel) {
|
|
716
|
+
const pp = this._persistentProcs[channel];
|
|
717
|
+
if (!pp) return;
|
|
718
|
+
if (pp.idleTimer) clearTimeout(pp.idleTimer);
|
|
719
|
+
pp.idleTimer = setTimeout(() => {
|
|
720
|
+
this._log(`Pi process idle for ${IDLE_TIMEOUT_MS / 60000}min, releasing ${channel}`);
|
|
721
|
+
this._killPersistentProc(channel, 'idle timeout');
|
|
722
|
+
}, IDLE_TIMEOUT_MS);
|
|
723
|
+
// An idle timer must not hold the event loop open on shutdown.
|
|
724
|
+
if (typeof pp.idleTimer.unref === 'function') pp.idleTimer.unref();
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Kill the process when stdout falls silent while a turn is in flight.
|
|
729
|
+
* Paused during tool execution: a long build or test run legitimately
|
|
730
|
+
* produces no RPC events for minutes.
|
|
731
|
+
*/
|
|
732
|
+
_startWatchdog(pp) {
|
|
733
|
+
this._stopWatchdog(pp);
|
|
734
|
+
pp.lastStdoutTime = Date.now();
|
|
735
|
+
let consecutive = 0;
|
|
736
|
+
pp.watchdogTimer = setInterval(async () => {
|
|
737
|
+
if (!pp.turnInFlight) { consecutive = 0; return; }
|
|
738
|
+
if (pp.awaitingToolResult) { consecutive = 0; return; }
|
|
739
|
+
if (Date.now() - pp.lastStdoutTime < WATCHDOG_INTERVAL_MS) { consecutive = 0; return; }
|
|
740
|
+
consecutive++;
|
|
741
|
+
pp.lastStdoutTime = Date.now();
|
|
742
|
+
if (consecutive === 2) {
|
|
743
|
+
try { await this.sendStatus(pp.msgChannel, 'Still working...'); } catch {}
|
|
744
|
+
}
|
|
745
|
+
if (consecutive >= WATCHDOG_MAX_TIMEOUTS) {
|
|
746
|
+
this._log(`Watchdog: Pi silent ${consecutive * (WATCHDOG_INTERVAL_MS / 1000)}s on ${pp.msgChannel} — killing`);
|
|
747
|
+
pp.watchdogKilled = true;
|
|
748
|
+
try { await this.sendError(pp.msgChannel, 'The Pi process became unresponsive and was restarted.'); } catch {}
|
|
749
|
+
await this._killPersistentProc(pp.msgChannel, 'watchdog timeout');
|
|
750
|
+
}
|
|
751
|
+
}, WATCHDOG_INTERVAL_MS);
|
|
752
|
+
if (typeof pp.watchdogTimer.unref === 'function') pp.watchdogTimer.unref();
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
_stopWatchdog(pp) {
|
|
756
|
+
if (pp.watchdogTimer) { clearInterval(pp.watchdogTimer); pp.watchdogTimer = null; }
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Spawn one `pi --mode rpc` process for a channel and wire its streams.
|
|
761
|
+
* @returns {object} the persistent-process record
|
|
762
|
+
*/
|
|
763
|
+
_spawnPersistentProc(channel, { piBin, args, workingDir, env, sessionId }) {
|
|
764
|
+
const [cmd, ...spawnArgs] = this._spawnableCmd(piBin, args);
|
|
765
|
+
this._log(`Spawning pi in ${workingDir}: ${redactArgs([cmd, ...spawnArgs]).join(' ')}`);
|
|
766
|
+
|
|
767
|
+
const proc = spawn(cmd, spawnArgs, {
|
|
768
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
769
|
+
env,
|
|
770
|
+
cwd: workingDir,
|
|
771
|
+
detached: !IS_WINDOWS,
|
|
772
|
+
windowsHide: true,
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
const pp = {
|
|
776
|
+
proc,
|
|
777
|
+
parser: new PiStreamParser(),
|
|
778
|
+
pending: new Map(), // rpc id → { resolve, reject, timer, command }
|
|
779
|
+
queue: Promise.resolve(), // serializes async event handling
|
|
780
|
+
turn: null, // { promise, resolve } for the in-flight prompt
|
|
781
|
+
turnInFlight: false,
|
|
782
|
+
acc: new PiAssistantAccumulator(),
|
|
783
|
+
msgChannel: channel,
|
|
784
|
+
sessionId,
|
|
785
|
+
spawnMode: this._mode,
|
|
786
|
+
workingDir,
|
|
787
|
+
idleTimer: null,
|
|
788
|
+
watchdogTimer: null,
|
|
789
|
+
watchdogKilled: false,
|
|
790
|
+
awaitingToolResult: false,
|
|
791
|
+
lastStdoutTime: Date.now(),
|
|
792
|
+
alive: true,
|
|
793
|
+
stdinBroken: false,
|
|
794
|
+
userStopped: false,
|
|
795
|
+
rpcSeq: 0,
|
|
796
|
+
// per-turn accounting
|
|
797
|
+
turnTexts: [], // the answer candidate (last non-toolUse message)
|
|
798
|
+
heldTexts: [], // text blocks of the message currently streaming
|
|
799
|
+
turnError: null,
|
|
800
|
+
everPosted: false,
|
|
801
|
+
stderrBuf: '',
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
proc.stdout.on('data', (chunk) => {
|
|
805
|
+
pp.lastStdoutTime = Date.now();
|
|
806
|
+
let events;
|
|
807
|
+
try {
|
|
808
|
+
events = pp.parser.push(chunk);
|
|
809
|
+
} catch (e) {
|
|
810
|
+
// Framing must never take the adapter down.
|
|
811
|
+
this._log(`Pi stream framing error: ${this._redact(e && e.message)}`);
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
for (const ev of events) {
|
|
815
|
+
pp.queue = pp.queue.then(() => this._handleEvent(pp, ev)).catch(() => {});
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
// stderr is diagnostics ONLY — Pi's RPC protocol lives entirely on stdout,
|
|
820
|
+
// so a stderr line is never parsed as JSONL.
|
|
821
|
+
proc.stderr.on('data', (chunk) => {
|
|
822
|
+
const text = chunk.toString('utf-8');
|
|
823
|
+
pp.stderrBuf = (pp.stderrBuf + text).slice(-8192);
|
|
824
|
+
const line = text.trim();
|
|
825
|
+
if (line) this._log(`pi stderr [${channel}]: ${this._redact(line).slice(0, 500)}`);
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
proc.stdout.on('error', () => {});
|
|
829
|
+
proc.stderr.on('error', () => {});
|
|
830
|
+
// stdin errors arrive ASYNCHRONOUSLY: a Writable reports EPIPE / premature
|
|
831
|
+
// pipe close through an 'error' event, which the try/catch around
|
|
832
|
+
// stdin.write() cannot see. Without this listener a Pi process that exits
|
|
833
|
+
// mid-write raises an unhandled stream error and takes the daemon down.
|
|
834
|
+
if (proc.stdin) {
|
|
835
|
+
proc.stdin.on('error', (err) => {
|
|
836
|
+
pp.stdinBroken = true;
|
|
837
|
+
this._log(`pi stdin [${channel}] closed: ${this._redact(err && err.message)}`);
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
proc.stdout.on('end', () => {
|
|
842
|
+
let events = [];
|
|
843
|
+
try { events = pp.parser.flush(); } catch {}
|
|
844
|
+
for (const ev of events) {
|
|
845
|
+
pp.queue = pp.queue.then(() => this._handleEvent(pp, ev)).catch(() => {});
|
|
846
|
+
}
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
proc.on('exit', (code, signal) => {
|
|
850
|
+
this._log(`Pi process exited: channel=${channel} code=${code} signal=${signal || 'none'}`);
|
|
851
|
+
const why = signal
|
|
852
|
+
? `Pi process terminated by ${signal}`
|
|
853
|
+
: `Pi process exited with code ${code}`;
|
|
854
|
+
// A crash / non-zero exit mid-turn is a real failure the user must see.
|
|
855
|
+
// A user stop, a watchdog kill and a clean code-0 exit all have their own
|
|
856
|
+
// messaging, so none of them set a turn error here.
|
|
857
|
+
const abnormal = code !== 0 || (signal && !pp.userStopped);
|
|
858
|
+
if (pp.turnInFlight && abnormal && !pp.userStopped && !pp.watchdogKilled
|
|
859
|
+
&& !this._stoppingChannels.has(channel) && !pp.turnError) {
|
|
860
|
+
const stderr = this._redact((pp.stderrBuf || '').trim()).slice(-400);
|
|
861
|
+
pp.turnError = stderr ? `${why}. Last diagnostics: ${stderr}` : why;
|
|
862
|
+
}
|
|
863
|
+
pp.exitCode = code;
|
|
864
|
+
this._teardownProcState(pp, why);
|
|
865
|
+
this._unregisterProc(channel, pp, proc);
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
proc.on('error', (err) => {
|
|
869
|
+
const { message } = classifySpawnError(err, { label: 'Pi', bin: cmd });
|
|
870
|
+
this._log(message);
|
|
871
|
+
pp.spawnError = message;
|
|
872
|
+
this._reportStatus(REASON.SPAWN_FAILED, message);
|
|
873
|
+
this._teardownProcState(pp, message);
|
|
874
|
+
this._unregisterProc(channel, pp, proc);
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
this._persistentProcs[channel] = pp;
|
|
878
|
+
this._channelProcesses[channel] = proc;
|
|
879
|
+
this._resetIdleTimer(channel);
|
|
880
|
+
return pp;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// ------------------------------------------------------------------
|
|
884
|
+
// RPC plumbing
|
|
885
|
+
// ------------------------------------------------------------------
|
|
886
|
+
|
|
887
|
+
/** Write one JSONL command. Strict LF framing, exactly as Pi requires. */
|
|
888
|
+
_writeRpc(pp, payload) {
|
|
889
|
+
pp.proc.stdin.write(JSON.stringify(payload) + '\n');
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Send an RPC command and await its `response`. Every command carries a
|
|
894
|
+
* unique id so responses can be correlated; a timeout rejects rather than
|
|
895
|
+
* leaking a pending entry forever.
|
|
896
|
+
*/
|
|
897
|
+
_sendRpc(pp, command, { timeoutMs = RPC_TIMEOUT_MS } = {}) {
|
|
898
|
+
if (!pp.alive || pp.stdinBroken || !pp.proc.stdin || pp.proc.stdin.destroyed) {
|
|
899
|
+
return Promise.reject(new PiCancelledError('Pi process is not accepting commands'));
|
|
900
|
+
}
|
|
901
|
+
const id = `oa-${++pp.rpcSeq}`;
|
|
902
|
+
return new Promise((resolve, reject) => {
|
|
903
|
+
const timer = setTimeout(() => {
|
|
904
|
+
pp.pending.delete(id);
|
|
905
|
+
reject(new Error(`Pi RPC "${command.type}" timed out after ${timeoutMs}ms`));
|
|
906
|
+
}, timeoutMs);
|
|
907
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
908
|
+
pp.pending.set(id, { resolve, reject, timer, command: command.type });
|
|
909
|
+
try {
|
|
910
|
+
this._writeRpc(pp, { ...command, id });
|
|
911
|
+
} catch (e) {
|
|
912
|
+
clearTimeout(timer);
|
|
913
|
+
pp.pending.delete(id);
|
|
914
|
+
reject(new Error(`Pi stdin write failed: ${this._redact(e && e.message)}`));
|
|
915
|
+
}
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// ------------------------------------------------------------------
|
|
920
|
+
// Event → workspace mapping
|
|
921
|
+
// ------------------------------------------------------------------
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Re-redact a classified event's DIAGNOSTIC strings with this agent's
|
|
925
|
+
* concrete credential values before anything is logged or posted.
|
|
926
|
+
*
|
|
927
|
+
* pi-stream.js is pure and dependency-free, so it can only apply PATTERN
|
|
928
|
+
* redaction — it has no way to know that this agent's GEMINI_API_KEY is
|
|
929
|
+
* `AIza…`, a shape no generic rule matches. Doing the pass HERE, once, at the
|
|
930
|
+
* single boundary every event crosses, means a newly-added event kind cannot
|
|
931
|
+
* forget it: only fields listed below are ever surfaced.
|
|
932
|
+
*
|
|
933
|
+
* Assistant message text (`delta` / `message`) is deliberately NOT rewritten:
|
|
934
|
+
* it is the model's answer to the user, and mangling it would corrupt code
|
|
935
|
+
* and command output. That matches every other adapter in this package.
|
|
936
|
+
*/
|
|
937
|
+
_redactEvent(ev) {
|
|
938
|
+
const DIAGNOSTIC_FIELDS = ['preview', 'message', 'error', 'finalError', 'raw', 'title'];
|
|
939
|
+
let copy = null;
|
|
940
|
+
for (const field of DIAGNOSTIC_FIELDS) {
|
|
941
|
+
const value = ev[field];
|
|
942
|
+
if (typeof value !== 'string' || !value) continue;
|
|
943
|
+
const masked = this._redact(value);
|
|
944
|
+
if (masked === value) continue;
|
|
945
|
+
if (!copy) copy = { ...ev };
|
|
946
|
+
copy[field] = masked;
|
|
947
|
+
}
|
|
948
|
+
return copy || ev;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
async _handleEvent(pp, rawEvent) {
|
|
952
|
+
const ev = this._redactEvent(rawEvent);
|
|
953
|
+
const channel = pp.msgChannel;
|
|
954
|
+
switch (ev.kind) {
|
|
955
|
+
case 'response': {
|
|
956
|
+
const entry = ev.id ? pp.pending.get(ev.id) : null;
|
|
957
|
+
if (!entry) {
|
|
958
|
+
// Unknown/absent id: log and ignore. Pi answers `parse` errors with
|
|
959
|
+
// no id, and a late response after a timeout lands here too.
|
|
960
|
+
if (!ev.success) {
|
|
961
|
+
this._log(`Pi RPC failed (${ev.command || 'unknown'}, unmatched id): ${this._redact(ev.error)}`);
|
|
962
|
+
}
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
pp.pending.delete(ev.id);
|
|
966
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
967
|
+
entry.resolve(ev);
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
case 'agent_start':
|
|
972
|
+
pp.awaitingToolResult = false;
|
|
973
|
+
await this._status(pp, 'Pi is working...');
|
|
974
|
+
return;
|
|
975
|
+
|
|
976
|
+
case 'message_start':
|
|
977
|
+
if (ev.role === 'assistant') pp.acc.startMessage();
|
|
978
|
+
return;
|
|
979
|
+
|
|
980
|
+
case 'message_update': {
|
|
981
|
+
// Each COMPLETED content block, released exactly once by the
|
|
982
|
+
// accumulator, so the matching message_end can never re-post it.
|
|
983
|
+
//
|
|
984
|
+
// `thinking` goes out immediately — hidden reasoning is never the
|
|
985
|
+
// answer, so it cannot collide with the final reply. `text` is HELD
|
|
986
|
+
// until message_end, where stopReason says whether this message is
|
|
987
|
+
// interim narration (post it as thinking) or the turn's answer (post
|
|
988
|
+
// it once, as a chat message, at settlement). Posting text here as
|
|
989
|
+
// well would make the final answer appear twice: once as thinking and
|
|
990
|
+
// once as the reply.
|
|
991
|
+
for (const block of pp.acc.pushDelta(ev.delta)) {
|
|
992
|
+
if (block.type === 'thinking') await this._emitThinking(pp, block.text);
|
|
993
|
+
else pp.heldTexts.push(block.text);
|
|
994
|
+
}
|
|
995
|
+
// A streaming error delta is the provider aborting mid-message.
|
|
996
|
+
if (ev.delta && ev.delta.type === 'error' && ev.delta.reason === 'error') {
|
|
997
|
+
pp.turnError = pp.turnError || 'The model stream ended with an error.';
|
|
998
|
+
}
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
case 'message_end': {
|
|
1003
|
+
const msg = ev.message;
|
|
1004
|
+
if (!msg || msg.role !== 'assistant') return;
|
|
1005
|
+
const result = pp.acc.endMessage(msg);
|
|
1006
|
+
// Blocks whose streaming *_end never arrived (a non-streaming provider,
|
|
1007
|
+
// or a lost delta) surface here for the first time.
|
|
1008
|
+
for (const block of result.blocks) {
|
|
1009
|
+
if (block.type === 'thinking') await this._emitThinking(pp, block.text);
|
|
1010
|
+
else pp.heldTexts.push(block.text);
|
|
1011
|
+
}
|
|
1012
|
+
if (result.errorMessage) {
|
|
1013
|
+
pp.turnError = result.errorMessage;
|
|
1014
|
+
} else if (result.stopReason === 'error') {
|
|
1015
|
+
pp.turnError = pp.turnError || 'Pi ended the turn with an error.';
|
|
1016
|
+
}
|
|
1017
|
+
// stopReason 'toolUse' means the model is calling a tool and will speak
|
|
1018
|
+
// again — this message's text is narration around the call, so it goes
|
|
1019
|
+
// out now as `thinking`. Any other stop reason means the message stands
|
|
1020
|
+
// on its own: hold it as the answer candidate. The LAST such candidate
|
|
1021
|
+
// of the turn is what _postTurnOutcome posts, exactly once.
|
|
1022
|
+
const held = pp.heldTexts;
|
|
1023
|
+
pp.heldTexts = [];
|
|
1024
|
+
if (!held.length) return;
|
|
1025
|
+
if (result.stopReason === 'toolUse') {
|
|
1026
|
+
for (const text of held) await this._emitThinking(pp, text);
|
|
1027
|
+
} else {
|
|
1028
|
+
// A previous candidate is superseded — flush it as thinking rather
|
|
1029
|
+
// than dropping it, so nothing the model said is ever lost.
|
|
1030
|
+
for (const text of pp.turnTexts) await this._emitThinking(pp, text);
|
|
1031
|
+
pp.turnTexts = held;
|
|
1032
|
+
pp.everPosted = true;
|
|
1033
|
+
}
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
case 'tool_start':
|
|
1038
|
+
pp.awaitingToolResult = true;
|
|
1039
|
+
pp.everPosted = true;
|
|
1040
|
+
await this._status(pp, ev.preview ? `${ev.toolName} › ${ev.preview}` : `${ev.toolName} running`);
|
|
1041
|
+
return;
|
|
1042
|
+
|
|
1043
|
+
case 'tool_update':
|
|
1044
|
+
// Progress only — keeps the watchdog quiet without spamming the channel.
|
|
1045
|
+
pp.lastStdoutTime = Date.now();
|
|
1046
|
+
return;
|
|
1047
|
+
|
|
1048
|
+
case 'tool_end':
|
|
1049
|
+
// Always report completion, not just failure: without a terminal status
|
|
1050
|
+
// the thread stays visually parked on the tool's "running" line.
|
|
1051
|
+
pp.awaitingToolResult = false;
|
|
1052
|
+
await this._status(pp, ev.isError
|
|
1053
|
+
? `${ev.toolName} failed${ev.preview ? `: ${ev.preview}` : ''}`
|
|
1054
|
+
: `${ev.toolName} ✓${ev.preview ? ` ${ev.preview}` : ''}`);
|
|
1055
|
+
return;
|
|
1056
|
+
|
|
1057
|
+
case 'bash_output':
|
|
1058
|
+
// Direct RPC bash output. We never issue `bash` commands ourselves, so
|
|
1059
|
+
// this only appears if an extension does; treat it as tool activity.
|
|
1060
|
+
pp.awaitingToolResult = true;
|
|
1061
|
+
return;
|
|
1062
|
+
|
|
1063
|
+
case 'queue_update':
|
|
1064
|
+
return;
|
|
1065
|
+
|
|
1066
|
+
case 'compaction_start':
|
|
1067
|
+
await this._status(pp, `Compacting conversation context (${ev.reason})...`);
|
|
1068
|
+
return;
|
|
1069
|
+
|
|
1070
|
+
case 'compaction_end':
|
|
1071
|
+
if (ev.error) {
|
|
1072
|
+
await this._status(pp, `Context compaction failed: ${ev.error}`);
|
|
1073
|
+
} else if (!ev.aborted) {
|
|
1074
|
+
await this._status(pp, 'Context compaction complete.');
|
|
1075
|
+
}
|
|
1076
|
+
return;
|
|
1077
|
+
|
|
1078
|
+
case 'retry_start':
|
|
1079
|
+
await this._status(
|
|
1080
|
+
pp,
|
|
1081
|
+
`Transient provider error — retrying (attempt ${ev.attempt ?? '?'}` +
|
|
1082
|
+
`${ev.maxAttempts ? `/${ev.maxAttempts}` : ''})...`,
|
|
1083
|
+
);
|
|
1084
|
+
return;
|
|
1085
|
+
|
|
1086
|
+
case 'retry_end':
|
|
1087
|
+
if (!ev.success) {
|
|
1088
|
+
pp.turnError = pp.turnError || (ev.finalError || 'Pi exhausted its automatic retries.');
|
|
1089
|
+
}
|
|
1090
|
+
return;
|
|
1091
|
+
|
|
1092
|
+
case 'extension_error':
|
|
1093
|
+
// The pure classifier can only apply PATTERN redaction — it does not
|
|
1094
|
+
// know this agent's concrete credential values. Re-redact here, at the
|
|
1095
|
+
// boundary, or a key that matches no known shape (e.g. a Google
|
|
1096
|
+
// AIza... key) reaches the log and the workspace verbatim.
|
|
1097
|
+
this._log(`Pi extension error on ${channel}: ${ev.message}`);
|
|
1098
|
+
await this._status(pp, `A Pi extension reported an error: ${ev.message}`);
|
|
1099
|
+
return;
|
|
1100
|
+
|
|
1101
|
+
case 'ui_request':
|
|
1102
|
+
await this._handleUiRequest(pp, ev);
|
|
1103
|
+
return;
|
|
1104
|
+
|
|
1105
|
+
case 'agent_end':
|
|
1106
|
+
// A low-level run finished. `willRetry` means Pi continues on its own —
|
|
1107
|
+
// the turn is only over at agent_settled.
|
|
1108
|
+
pp.awaitingToolResult = false;
|
|
1109
|
+
return;
|
|
1110
|
+
|
|
1111
|
+
case 'agent_settled':
|
|
1112
|
+
pp.awaitingToolResult = false;
|
|
1113
|
+
if (pp.turn) {
|
|
1114
|
+
const turn = pp.turn;
|
|
1115
|
+
pp.turn = null;
|
|
1116
|
+
pp.turnInFlight = false;
|
|
1117
|
+
this._stopWatchdog(pp);
|
|
1118
|
+
turn.resolve({ settled: true });
|
|
1119
|
+
}
|
|
1120
|
+
return;
|
|
1121
|
+
|
|
1122
|
+
case 'oversize':
|
|
1123
|
+
this._log(`Pi emitted a record over the ${ev.bytes}-byte line cap — dropped`);
|
|
1124
|
+
return;
|
|
1125
|
+
|
|
1126
|
+
case 'unknown':
|
|
1127
|
+
this._log(`Unrecognized Pi RPC record: ${ev.raw}`);
|
|
1128
|
+
return;
|
|
1129
|
+
|
|
1130
|
+
default:
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* Answer an extension UI request. Headless runs must NEVER block on terminal
|
|
1137
|
+
* interaction, so every dialog method is immediately cancelled — the
|
|
1138
|
+
* extension sees "user dismissed" and Pi keeps going. Fire-and-forget
|
|
1139
|
+
* methods (notify/setStatus/…) expect no reply.
|
|
1140
|
+
*/
|
|
1141
|
+
async _handleUiRequest(pp, ev) {
|
|
1142
|
+
if (!ev.needsResponse) {
|
|
1143
|
+
if (ev.method === 'notify' && ev.title) await this._status(pp, ev.title);
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
this._log(`Pi extension asked for "${ev.method}" input — auto-cancelling (headless)`);
|
|
1147
|
+
try {
|
|
1148
|
+
this._writeRpc(pp, { type: 'extension_ui_response', id: ev.id, cancelled: true });
|
|
1149
|
+
} catch (e) {
|
|
1150
|
+
this._log(`Could not answer extension UI request: ${this._redact(e && e.message)}`);
|
|
1151
|
+
}
|
|
1152
|
+
await this._status(
|
|
1153
|
+
pp,
|
|
1154
|
+
`A Pi extension requested interactive input (${ev.method}) and was dismissed — ` +
|
|
1155
|
+
'the workspace runs Pi headless.',
|
|
1156
|
+
);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* Post interim content as a `thinking` event: hidden model reasoning (which
|
|
1161
|
+
* must never become an assistant reply) and narration around tool calls.
|
|
1162
|
+
* The turn's ANSWER never comes through here — it is posted exactly once by
|
|
1163
|
+
* _postTurnOutcome, so no text can appear both as thinking and as the reply.
|
|
1164
|
+
*/
|
|
1165
|
+
async _emitThinking(pp, text) {
|
|
1166
|
+
if (!text || !text.trim()) return;
|
|
1167
|
+
pp.everPosted = true;
|
|
1168
|
+
try { await this.sendThinking(pp.msgChannel, text); } catch {}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* Post a status line. Deliberately does NOT set `everPosted`: that flag
|
|
1173
|
+
* gates the "Pi finished without producing a response" diagnostic, and a
|
|
1174
|
+
* generic progress line must not suppress it.
|
|
1175
|
+
*/
|
|
1176
|
+
async _status(pp, text) {
|
|
1177
|
+
try { await this.sendStatus(pp.msgChannel, text); } catch {}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// ------------------------------------------------------------------
|
|
1181
|
+
// Message handling
|
|
1182
|
+
// ------------------------------------------------------------------
|
|
1183
|
+
|
|
1184
|
+
/** Environment for the Pi child: agent env plus Pi's own hygiene settings. */
|
|
1185
|
+
_buildEnv() {
|
|
1186
|
+
const env = { ...(this.agentEnv || process.env) };
|
|
1187
|
+
// The Launcher exposes one provider-agnostic secret field. Pi's native
|
|
1188
|
+
// providers still expect their conventional env var, so mirror the value
|
|
1189
|
+
// in memory for the child only. Existing provider-specific env remains
|
|
1190
|
+
// authoritative for backwards compatibility.
|
|
1191
|
+
const provider = String(env.PI_PROVIDER || '').trim().toLowerCase();
|
|
1192
|
+
const key = String(env.PI_API_KEY || '').trim();
|
|
1193
|
+
if (key) {
|
|
1194
|
+
const target = {
|
|
1195
|
+
anthropic: 'ANTHROPIC_API_KEY',
|
|
1196
|
+
openai: 'OPENAI_API_KEY',
|
|
1197
|
+
deepseek: 'DEEPSEEK_API_KEY',
|
|
1198
|
+
google: 'GEMINI_API_KEY',
|
|
1199
|
+
openrouter: 'OPENROUTER_API_KEY',
|
|
1200
|
+
}[provider];
|
|
1201
|
+
if (target && !String(env[target] || '').trim()) env[target] = key;
|
|
1202
|
+
}
|
|
1203
|
+
// Keep the child off the network at startup for update/telemetry checks:
|
|
1204
|
+
// a launcher-managed install must not silently self-update mid-session.
|
|
1205
|
+
env.PI_SKIP_VERSION_CHECK = env.PI_SKIP_VERSION_CHECK || '1';
|
|
1206
|
+
// Pi writes config/auth under this dir; leave the user's own ~/.pi/agent in
|
|
1207
|
+
// place so an existing `pi` login is reused, but never write trust.json.
|
|
1208
|
+
return env;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/** Provider/model/thinking/trust configuration, read from the agent env. */
|
|
1212
|
+
_config() {
|
|
1213
|
+
const env = this.agentEnv || process.env;
|
|
1214
|
+
const val = (k) => String(env[k] == null ? '' : env[k]).trim();
|
|
1215
|
+
return {
|
|
1216
|
+
provider: val('PI_PROVIDER') || null,
|
|
1217
|
+
model: val('PI_MODEL') || null,
|
|
1218
|
+
baseUrl: val('PI_BASE_URL') || null,
|
|
1219
|
+
apiFormat: val('PI_API_FORMAT') || 'auto',
|
|
1220
|
+
thinking: normalizeThinking(val('PI_THINKING')),
|
|
1221
|
+
trustProject: parseTrustProject(val('PI_TRUST_PROJECT')),
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
_dirExists(p) {
|
|
1226
|
+
try { return fs.statSync(p).isDirectory(); } catch { return false; }
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* Split attachments into images Pi can take inline over RPC and everything
|
|
1231
|
+
* else, which is described in the prompt (Pi fetches those with bash+curl).
|
|
1232
|
+
* A download failure demotes the image to the curl path rather than failing
|
|
1233
|
+
* the message.
|
|
1234
|
+
*/
|
|
1235
|
+
async _prepareAttachments(channel, attachments) {
|
|
1236
|
+
const images = [];
|
|
1237
|
+
const rest = [];
|
|
1238
|
+
for (const att of attachments || []) {
|
|
1239
|
+
const contentType = String((att && att.contentType) || '');
|
|
1240
|
+
const fileId = att && att.fileId;
|
|
1241
|
+
if (!contentType.startsWith('image/') || !fileId) { rest.push(att); continue; }
|
|
1242
|
+
try {
|
|
1243
|
+
const buffer = await this.client.readFile(this.workspaceId, this.token, fileId);
|
|
1244
|
+
if (!buffer || !buffer.length) throw new Error('empty file');
|
|
1245
|
+
images.push({ type: 'image', data: Buffer.from(buffer).toString('base64'), mimeType: contentType });
|
|
1246
|
+
} catch (e) {
|
|
1247
|
+
this._log(`Could not inline image ${att.filename || fileId}: ${this._redact(e && e.message)}`);
|
|
1248
|
+
try {
|
|
1249
|
+
await this.sendStatus(
|
|
1250
|
+
channel,
|
|
1251
|
+
`Could not attach "${att.filename || 'image'}" directly — Pi will download it from the workspace instead.`,
|
|
1252
|
+
);
|
|
1253
|
+
} catch {}
|
|
1254
|
+
rest.push(att);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
return { images, rest };
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/**
|
|
1261
|
+
* Reuse the channel's live Pi process when it is still valid, otherwise spawn
|
|
1262
|
+
* a fresh one. The mode (execute/plan) is baked into the system prompt at
|
|
1263
|
+
* spawn time, so a mode switch always forces a respawn — the resumed session
|
|
1264
|
+
* keeps the conversation, exactly like claude.js's --resume respawn.
|
|
1265
|
+
*/
|
|
1266
|
+
async _ensureProc(channel, workingDir, systemPrompt) {
|
|
1267
|
+
const existing = this._persistentProcs[channel];
|
|
1268
|
+
if (existing && existing.alive) {
|
|
1269
|
+
if (existing.spawnMode === this._mode && existing.workingDir === workingDir) {
|
|
1270
|
+
this._resetIdleTimer(channel);
|
|
1271
|
+
return existing;
|
|
1272
|
+
}
|
|
1273
|
+
this._log(`Pi process for ${channel} is stale (mode/workdir changed) — respawning`);
|
|
1274
|
+
await this._killPersistentProc(channel, 'configuration changed');
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
const piBin = this._piBin || this._findPiBinary();
|
|
1278
|
+
if (!piBin) {
|
|
1279
|
+
const message = 'Pi CLI not found — install it with: openagents install pi';
|
|
1280
|
+
this._reportStatus(REASON.RUNTIME_MISSING, message);
|
|
1281
|
+
throw new Error(message);
|
|
1282
|
+
}
|
|
1283
|
+
this._piBin = piBin;
|
|
1284
|
+
|
|
1285
|
+
const cfg = this._config();
|
|
1286
|
+
const launcherKey = String((this.agentEnv || process.env).PI_API_KEY || '').trim();
|
|
1287
|
+
if (cfg.baseUrl && (!cfg.provider || !cfg.model)) {
|
|
1288
|
+
throw new Error('PI_PROVIDER and PI_MODEL are required when PI_BASE_URL is set.');
|
|
1289
|
+
}
|
|
1290
|
+
const sessionId = this._sessionIdFor(channel, workingDir);
|
|
1291
|
+
try { fs.mkdirSync(this._sessionDir, { recursive: true }); } catch {}
|
|
1292
|
+
|
|
1293
|
+
const args = buildPiArgs({
|
|
1294
|
+
sessionDir: this._sessionDir,
|
|
1295
|
+
sessionId,
|
|
1296
|
+
appendSystemPrompt: systemPrompt,
|
|
1297
|
+
provider: cfg.provider,
|
|
1298
|
+
model: cfg.model,
|
|
1299
|
+
thinking: cfg.thinking,
|
|
1300
|
+
sessionName: `${this.agentName}/${channel}`,
|
|
1301
|
+
// A Launcher-managed key is supplied through the same process-local
|
|
1302
|
+
// provider extension as relays. Besides keeping credentials out of argv
|
|
1303
|
+
// and ~/.pi, this bypasses Pi's native auth resolver on installations
|
|
1304
|
+
// where it incorrectly reports a present provider env key as missing.
|
|
1305
|
+
extensions: cfg.baseUrl || launcherKey ? [LAUNCHER_PROVIDER_EXTENSION] : [],
|
|
1306
|
+
trustProject: cfg.trustProject,
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
const pp = this._spawnPersistentProc(channel, {
|
|
1310
|
+
piBin,
|
|
1311
|
+
args,
|
|
1312
|
+
workingDir,
|
|
1313
|
+
env: this._buildEnv(),
|
|
1314
|
+
sessionId,
|
|
1315
|
+
});
|
|
1316
|
+
if (pp.spawnError) throw new Error(pp.spawnError);
|
|
1317
|
+
return pp;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
async _handleMessage(msg) {
|
|
1321
|
+
let content = (msg.content || '').trim();
|
|
1322
|
+
const attachments = msg.attachments || [];
|
|
1323
|
+
const channel = msg.sessionId || this.channelName || 'general';
|
|
1324
|
+
|
|
1325
|
+
this._stoppingChannels.delete(channel);
|
|
1326
|
+
this._stopNoticeSent.delete(channel);
|
|
1327
|
+
|
|
1328
|
+
if (!content && !attachments.length) return;
|
|
1329
|
+
|
|
1330
|
+
// Validate the working directory BEFORE any expensive work (attachment
|
|
1331
|
+
// downloads, prompt assembly) — never silently fall back to another dir.
|
|
1332
|
+
const workingDir = this.workingDir || defaultAgentWorkdir(this.agentName);
|
|
1333
|
+
if (this.workingDir && !this._dirExists(this.workingDir)) {
|
|
1334
|
+
await this.sendError(channel, `Working directory does not exist: ${this.workingDir}`);
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
const { images, rest } = await this._prepareAttachments(channel, attachments);
|
|
1339
|
+
const attText = formatAttachmentsForPrompt(rest, 'skills');
|
|
1340
|
+
if (attText) content = content ? content + attText : attText.trim();
|
|
1341
|
+
if (!content && !images.length) return;
|
|
1342
|
+
if (!content) content = 'Please look at the attached image(s).';
|
|
1343
|
+
|
|
1344
|
+
const sender = msg.senderName || msg.senderType || 'user';
|
|
1345
|
+
this._log(`Processing message from ${sender} in ${channel}: ${this._redact(content.slice(0, 80))}...`);
|
|
1346
|
+
|
|
1347
|
+
// Auto-title + resume-from on first encounter (parity with other adapters).
|
|
1348
|
+
if (!this._titledSessions.has(channel)) {
|
|
1349
|
+
this._titledSessions.add(channel);
|
|
1350
|
+
try {
|
|
1351
|
+
const info = await this.client.getSession(this.workspaceId, channel, this.token);
|
|
1352
|
+
const resumeFrom = info.resumeFrom;
|
|
1353
|
+
if (resumeFrom && !this._channelSessions[channel] && this._channelSessions[resumeFrom]) {
|
|
1354
|
+
this._channelSessions[channel] = { ...this._channelSessions[resumeFrom] };
|
|
1355
|
+
this._saveSessions();
|
|
1356
|
+
}
|
|
1357
|
+
const title = generateSessionTitle(content);
|
|
1358
|
+
if (title && !info.titleManuallySet && SESSION_DEFAULT_RE.test(info.title || '')) {
|
|
1359
|
+
await this.client.updateSession(this.workspaceId, channel, this.token, { title, autoTitle: true });
|
|
1360
|
+
}
|
|
1361
|
+
} catch {}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
await this.sendStatus(channel, 'thinking...');
|
|
1365
|
+
|
|
1366
|
+
let browserEnabled = false;
|
|
1367
|
+
try { browserEnabled = await this.getBrowserEnabled(); } catch {}
|
|
1368
|
+
const systemPrompt = '\n' + buildPiSystemPrompt({
|
|
1369
|
+
agentName: this.agentName,
|
|
1370
|
+
workspaceId: this.workspaceId,
|
|
1371
|
+
channelName: channel,
|
|
1372
|
+
endpoint: this.endpoint,
|
|
1373
|
+
token: this.token,
|
|
1374
|
+
mode: this._mode,
|
|
1375
|
+
disabledModules: this.disabledModules,
|
|
1376
|
+
browserEnabled,
|
|
1377
|
+
});
|
|
1378
|
+
|
|
1379
|
+
let pp;
|
|
1380
|
+
try {
|
|
1381
|
+
pp = await this._ensureProc(channel, workingDir, systemPrompt);
|
|
1382
|
+
} catch (e) {
|
|
1383
|
+
await this.sendError(channel, this._redact(e && e.message) || 'Could not start the Pi CLI.');
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
// Fresh per-turn accounting.
|
|
1388
|
+
pp.msgChannel = channel;
|
|
1389
|
+
pp.turnTexts = [];
|
|
1390
|
+
pp.heldTexts = [];
|
|
1391
|
+
pp.turnError = null;
|
|
1392
|
+
pp.everPosted = false;
|
|
1393
|
+
pp.awaitingToolResult = false;
|
|
1394
|
+
pp.acc.reset();
|
|
1395
|
+
|
|
1396
|
+
const turn = {};
|
|
1397
|
+
turn.promise = new Promise((resolve) => { turn.resolve = resolve; });
|
|
1398
|
+
pp.turn = turn;
|
|
1399
|
+
pp.turnInFlight = true;
|
|
1400
|
+
this._startWatchdog(pp);
|
|
1401
|
+
|
|
1402
|
+
const command = { type: 'prompt', message: content };
|
|
1403
|
+
if (images.length) command.images = images;
|
|
1404
|
+
|
|
1405
|
+
let accepted;
|
|
1406
|
+
try {
|
|
1407
|
+
accepted = await this._sendRpc(pp, command);
|
|
1408
|
+
} catch (e) {
|
|
1409
|
+
pp.turnInFlight = false;
|
|
1410
|
+
pp.turn = null;
|
|
1411
|
+
this._stopWatchdog(pp);
|
|
1412
|
+
await this._reportTurnFailure(pp, channel, e);
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
if (!accepted.success) {
|
|
1417
|
+
pp.turnInFlight = false;
|
|
1418
|
+
pp.turn = null;
|
|
1419
|
+
this._stopWatchdog(pp);
|
|
1420
|
+
const { userMessage } = classifyPiError(accepted.error || 'Pi rejected the prompt.');
|
|
1421
|
+
await this.sendError(channel, this._redact(userMessage));
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// Pi acknowledges acceptance immediately; the turn ends at `agent_settled`
|
|
1426
|
+
// (or when the process dies / the user stops it).
|
|
1427
|
+
await turn.promise;
|
|
1428
|
+
this._stopWatchdog(pp);
|
|
1429
|
+
pp.turnInFlight = false;
|
|
1430
|
+
|
|
1431
|
+
if (pp.userStopped || this._stoppingChannels.has(channel)) {
|
|
1432
|
+
if (!pp.everPosted) await this._postStopNotice(channel);
|
|
1433
|
+
pp.userStopped = false;
|
|
1434
|
+
return;
|
|
1435
|
+
}
|
|
1436
|
+
if (pp.watchdogKilled) {
|
|
1437
|
+
pp.watchdogKilled = false;
|
|
1438
|
+
return; // the watchdog already told the user
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
await this._postTurnOutcome(pp, channel);
|
|
1442
|
+
if (this._persistentProcs[channel] === pp) this._resetIdleTimer(channel);
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
/** Classify + surface a failure that happened before the turn even started. */
|
|
1446
|
+
async _reportTurnFailure(pp, channel, error) {
|
|
1447
|
+
const raw = (error && error.message) || String(error);
|
|
1448
|
+
if (error instanceof PiCancelledError && (pp.userStopped || this._stoppingChannels.has(channel))) {
|
|
1449
|
+
await this._postStopNotice(channel);
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
const { kind, userMessage } = classifyPiError(raw);
|
|
1453
|
+
if (kind === 'auth') this._reportStatus(REASON.LOGIN_REQUIRED, 'Pi is not authenticated');
|
|
1454
|
+
await this.sendError(channel, this._redact(userMessage));
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/**
|
|
1458
|
+
* Post the outcome of a settled turn: the final answer, or a classified
|
|
1459
|
+
* error, or — when Pi produced neither — an explicit notice so the thread
|
|
1460
|
+
* never hangs on "thinking…".
|
|
1461
|
+
*/
|
|
1462
|
+
async _postTurnOutcome(pp, channel) {
|
|
1463
|
+
const finalText = (pp.turnTexts || []).join('\n').trim();
|
|
1464
|
+
if (pp.turnError) {
|
|
1465
|
+
const { kind, userMessage } = classifyPiError(pp.turnError);
|
|
1466
|
+
if (kind === 'auth') {
|
|
1467
|
+
this._reportStatus(REASON.LOGIN_REQUIRED, 'Pi provider credentials were rejected');
|
|
1468
|
+
} else {
|
|
1469
|
+
this._reportStatus(null);
|
|
1470
|
+
}
|
|
1471
|
+
try { await this.sendError(channel, this._redact(userMessage)); } catch {}
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
this._reportStatus(null);
|
|
1475
|
+
if (finalText) {
|
|
1476
|
+
try { await this.sendResponse(channel, finalText); } catch {}
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
if (!pp.everPosted) {
|
|
1480
|
+
const stderr = this._redact((pp.stderrBuf || '').trim()).slice(-400);
|
|
1481
|
+
try {
|
|
1482
|
+
await this.sendError(
|
|
1483
|
+
channel,
|
|
1484
|
+
'Pi finished without producing a response.' + (stderr ? `\n\nLast diagnostics:\n${stderr}` : ''),
|
|
1485
|
+
);
|
|
1486
|
+
} catch {}
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
module.exports = PiAdapter;
|
|
1492
|
+
module.exports.PiCancelledError = PiCancelledError;
|