@galaxy9day/executor-adapter 0.10.0
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/LICENSE +235 -0
- package/README.md +310 -0
- package/executors.js +354 -0
- package/index.js +1834 -0
- package/package.json +57 -0
- package/templates/claude/agents/trellis-codex-implement.md +76 -0
- package/templates/claude/agents/trellis-pi-check.md +42 -0
- package/templates/claude/agents/trellis-pi-implement.md +75 -0
package/executors.js
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executor backends for executor-adapter.
|
|
3
|
+
*
|
|
4
|
+
* Each executor describes how to discover its binary, scrub the child
|
|
5
|
+
* environment, resolve models from ~/.pi/config.toml, build spawn argv,
|
|
6
|
+
* and interpret subprocess output into the adapter's shared result model.
|
|
7
|
+
* Everything downstream (worktree, diff export, post-validation,
|
|
8
|
+
* result_class, channel events) is executor-agnostic and lives in index.js.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import TOML from '@iarna/toml';
|
|
12
|
+
import { execSync } from 'node:child_process';
|
|
13
|
+
import * as fs from 'node:fs';
|
|
14
|
+
import * as path from 'node:path';
|
|
15
|
+
import * as os from 'node:os';
|
|
16
|
+
|
|
17
|
+
const SERVER_NAME = 'executor-adapter';
|
|
18
|
+
|
|
19
|
+
function logErr(msg) {
|
|
20
|
+
process.stderr.write(`[${SERVER_NAME}] ${msg}\n`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ---- Adapter config (single source of truth for models + executor routing) ----
|
|
24
|
+
|
|
25
|
+
let _configCache = null;
|
|
26
|
+
export function loadAdapterConfig() {
|
|
27
|
+
const cfgPath = path.join(os.homedir(), '.pi', 'config.toml');
|
|
28
|
+
let mtimeMs = null;
|
|
29
|
+
try {
|
|
30
|
+
mtimeMs = fs.statSync(cfgPath).mtimeMs;
|
|
31
|
+
} catch {}
|
|
32
|
+
if (
|
|
33
|
+
_configCache &&
|
|
34
|
+
_configCache.cfgPath === cfgPath &&
|
|
35
|
+
_configCache.mtimeMs === mtimeMs
|
|
36
|
+
) {
|
|
37
|
+
return _configCache.config;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let raw = null;
|
|
41
|
+
try {
|
|
42
|
+
if (mtimeMs !== null) raw = fs.readFileSync(cfgPath, 'utf-8');
|
|
43
|
+
} catch {}
|
|
44
|
+
const config = { pi: {}, codex: {}, defaultExecutor: null };
|
|
45
|
+
if (raw) {
|
|
46
|
+
try {
|
|
47
|
+
const parsed = TOML.parse(raw);
|
|
48
|
+
// Preferred section name + legacy aliases. New keys win when multiple
|
|
49
|
+
// sections exist.
|
|
50
|
+
const sections = [parsed.executor_adapter, parsed.pi_adapter, parsed.trellis_pi_adapter];
|
|
51
|
+
let legacyUsed = false;
|
|
52
|
+
for (const section of sections) {
|
|
53
|
+
if (!section || typeof section !== 'object' || Array.isArray(section)) continue;
|
|
54
|
+
for (const [k, v] of Object.entries(section)) {
|
|
55
|
+
if (typeof v !== 'string') continue;
|
|
56
|
+
if (k === 'default_executor') {
|
|
57
|
+
if (config.defaultExecutor === null) config.defaultExecutor = v;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (!(k in config.pi)) {
|
|
61
|
+
config.pi[k] = v;
|
|
62
|
+
if (section !== parsed.executor_adapter) legacyUsed = true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const codexSection = section.codex;
|
|
66
|
+
if (codexSection && typeof codexSection === 'object' && !Array.isArray(codexSection)) {
|
|
67
|
+
for (const [k, v] of Object.entries(codexSection)) {
|
|
68
|
+
if (typeof v !== 'string') continue;
|
|
69
|
+
if (!(k in config.codex)) config.codex[k] = v;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (legacyUsed) {
|
|
74
|
+
logErr('reading legacy [pi_adapter]/[trellis_pi_adapter] TOML section — rename to [executor_adapter] when convenient.');
|
|
75
|
+
}
|
|
76
|
+
} catch (e) {
|
|
77
|
+
logErr(`TOML parse error in ${cfgPath}: ${e.message}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
logErr(`reloaded model map from ${cfgPath} mtime=${mtimeMs === null ? 'missing' : mtimeMs}`);
|
|
81
|
+
_configCache = { cfgPath, mtimeMs, config };
|
|
82
|
+
return config;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class ModelResolutionError extends Error {
|
|
86
|
+
constructor(logicalKey) {
|
|
87
|
+
super(`Cannot resolve model "${logicalKey}". Configure ~/.pi/config.toml:\n\n [executor_adapter]\n implementer = "<your-pi-routable-model>" # required for mode=implement|custom\n reviewer = "<your-pi-routable-model>" # required for mode=check / cross-model review\n\nOr pass a fully qualified route directly, e.g. model="anthropic/claude-opus-4-7".`);
|
|
88
|
+
this.code = 'MODEL_NOT_RESOLVED';
|
|
89
|
+
this.logicalKey = logicalKey;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---- Shared subprocess helpers ----
|
|
94
|
+
|
|
95
|
+
// Explicit env override is authoritative: a configured-but-missing path is an
|
|
96
|
+
// error, not a trigger for silent PATH fallback.
|
|
97
|
+
function makeBinaryFinder(envVar, command) {
|
|
98
|
+
let cached;
|
|
99
|
+
return function findBinary() {
|
|
100
|
+
if (cached !== undefined) return cached;
|
|
101
|
+
const fromEnv = process.env[envVar];
|
|
102
|
+
if (fromEnv) {
|
|
103
|
+
cached = fs.existsSync(fromEnv) ? fromEnv : null;
|
|
104
|
+
return cached;
|
|
105
|
+
}
|
|
106
|
+
try { cached = execSync(`which ${command}`, { encoding: 'utf-8' }).trim() || null; }
|
|
107
|
+
catch { cached = null; }
|
|
108
|
+
return cached;
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const SENSITIVE = /TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|PRIVATE.?KEY|API.?KEY|_KEY$|_AUTH$|_BEARER$|_COOKIE$|^ANTHROPIC_|^OPENAI_|^CLAUDE_|^CCG_|^AWS_(ACCESS|SECRET)|^GH_TOKEN$|^GITHUB_TOKEN$|^OP_|^DOCKER_PASS/i;
|
|
113
|
+
|
|
114
|
+
function scrubEnv(parentEnv, keepPattern) {
|
|
115
|
+
const out = {};
|
|
116
|
+
const stripped = [];
|
|
117
|
+
for (const [k, v] of Object.entries(parentEnv)) {
|
|
118
|
+
if (keepPattern.test(k)) { out[k] = v; continue; }
|
|
119
|
+
if (SENSITIVE.test(k)) { stripped.push(k); continue; }
|
|
120
|
+
out[k] = v;
|
|
121
|
+
}
|
|
122
|
+
return { env: out, stripped };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function approvalBlocked(text) {
|
|
126
|
+
const t = text || '';
|
|
127
|
+
return /approval|approve|permission|confirm|non[- ]interactive|no ui|stdin|tty/i.test(t) &&
|
|
128
|
+
/blocked|required|waiting|denied|refused|unavailable|cannot|can't|failed/i.test(t);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function codexSandbox(executionMode) {
|
|
132
|
+
return (executionMode === 'review' || executionMode === 'patch') ? 'read-only' : 'workspace-write';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---- Executor: pi ----
|
|
136
|
+
|
|
137
|
+
const pi = {
|
|
138
|
+
name: 'pi',
|
|
139
|
+
label: 'Pi',
|
|
140
|
+
binaryEnvVar: 'PI_BINARY',
|
|
141
|
+
findBinary: makeBinaryFinder('PI_BINARY', 'pi'),
|
|
142
|
+
|
|
143
|
+
buildEnv(parentEnv) {
|
|
144
|
+
return scrubEnv(parentEnv, /^(PI_|NEWAPI_)/);
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
resolveModel(input, mode) {
|
|
148
|
+
const defaultKey = mode === 'check' ? 'reviewer' : 'implementer';
|
|
149
|
+
const logicalKey = input || defaultKey;
|
|
150
|
+
const map = loadAdapterConfig().pi;
|
|
151
|
+
if (map[logicalKey]) return { resolved: map[logicalKey], from: 'config', key: logicalKey };
|
|
152
|
+
// Fully qualified route (contains '/') — pass through.
|
|
153
|
+
if (logicalKey.includes('/')) return { resolved: logicalKey, from: 'direct', key: null };
|
|
154
|
+
// Unresolved: throw. No silent fallback — that would leak whichever
|
|
155
|
+
// model name was vendored into the source onto users of the package.
|
|
156
|
+
throw new ModelResolutionError(logicalKey);
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
buildSpawnSpec({ model, tools, thinking, isolate, promptPath, worker }) {
|
|
160
|
+
return {
|
|
161
|
+
argv: [
|
|
162
|
+
'--model', model,
|
|
163
|
+
'--tools', tools,
|
|
164
|
+
'--thinking', thinking,
|
|
165
|
+
...(isolate ? [
|
|
166
|
+
'--no-extensions',
|
|
167
|
+
'--no-skills',
|
|
168
|
+
'--no-prompt-templates',
|
|
169
|
+
'--no-context-files',
|
|
170
|
+
'--no-session',
|
|
171
|
+
] : []),
|
|
172
|
+
`@${promptPath}`,
|
|
173
|
+
'-p', 'Follow the instructions in the attached file. Read the listed files in order before writing any code.',
|
|
174
|
+
],
|
|
175
|
+
env: {
|
|
176
|
+
PI_CODING_AGENT_DIR: worker.piHome,
|
|
177
|
+
PI_CODING_AGENT_SESSION_DIR: worker.sessionDir,
|
|
178
|
+
PI_OFFLINE: '1',
|
|
179
|
+
PI_SKIP_VERSION_CHECK: '1',
|
|
180
|
+
},
|
|
181
|
+
// Pi's -p mode blocks on stdin EOF if stdin is a live pipe.
|
|
182
|
+
stdinFd: null,
|
|
183
|
+
};
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
interpretOutput({ exitCode, killed, stdout, stderr }) {
|
|
187
|
+
let output = '';
|
|
188
|
+
if (String(stdout || '').trim()) output += stdout.trim();
|
|
189
|
+
if (String(stderr || '').trim()) output += (output ? '\n\n--- stderr ---\n' : '') + stderr.trim();
|
|
190
|
+
let runStatus;
|
|
191
|
+
if (killed) runStatus = 'timeout';
|
|
192
|
+
else if (approvalBlocked(output)) runStatus = 'blocked';
|
|
193
|
+
else runStatus = exitCode === 0 ? 'done' : 'failed';
|
|
194
|
+
return { runStatus, output, usage: null };
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
failureHint(stderr, stdout) {
|
|
198
|
+
const combined = `${stderr || ''}\n${stdout || ''}`;
|
|
199
|
+
const noKey = combined.match(/No API key found for\s+([^\s"'`.,;]+)/i);
|
|
200
|
+
if (noKey) {
|
|
201
|
+
const provider = noKey[1];
|
|
202
|
+
return `Pi resolved to built-in provider '${provider}'. If you intended a custom provider, pass \`model="gpt/gpt-5.5"\` (fully qualified) or restart the MCP after editing ~/.pi/config.toml.`;
|
|
203
|
+
}
|
|
204
|
+
if (/stream_read_error|Stream ended/i.test(combined)) {
|
|
205
|
+
return 'Upstream SSE stream failed. Common causes: very large prompt, network reset, provider rate limit. Try reducing implement.jsonl manifest size or switching to openai-completions-compatible provider.';
|
|
206
|
+
}
|
|
207
|
+
return '';
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
smokeSpec(model) {
|
|
211
|
+
return {
|
|
212
|
+
argv: [
|
|
213
|
+
'--model', model,
|
|
214
|
+
'--tools', 'read',
|
|
215
|
+
'-p', 'Respond with exactly the string: PI READY. No other words.',
|
|
216
|
+
],
|
|
217
|
+
readyText: 'PI READY',
|
|
218
|
+
};
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// ---- Executor: codex ----
|
|
223
|
+
|
|
224
|
+
const codex = {
|
|
225
|
+
name: 'codex',
|
|
226
|
+
label: 'Codex',
|
|
227
|
+
binaryEnvVar: 'CODEX_BINARY',
|
|
228
|
+
findBinary: makeBinaryFinder('CODEX_BINARY', 'codex'),
|
|
229
|
+
sandboxFor: codexSandbox,
|
|
230
|
+
|
|
231
|
+
buildEnv(parentEnv) {
|
|
232
|
+
// Codex uses the current machine's CODEX_HOME for config.toml + auth.json.
|
|
233
|
+
// API keys are deliberately not forwarded; each host should provide its
|
|
234
|
+
// own local Codex credentials/provider config.
|
|
235
|
+
return scrubEnv(parentEnv, /^CODEX_(HOME|SQLITE_HOME)$/);
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
resolveModel(input, mode) {
|
|
239
|
+
const defaultKey = mode === 'check' ? 'reviewer' : 'implementer';
|
|
240
|
+
const map = loadAdapterConfig().codex;
|
|
241
|
+
const logicalKey = input || defaultKey;
|
|
242
|
+
if (map[logicalKey]) return { resolved: map[logicalKey], from: 'config', key: logicalKey };
|
|
243
|
+
if (input) return { resolved: input, from: 'direct', key: null };
|
|
244
|
+
// No config and no explicit model: defer to the codex CLI's own
|
|
245
|
+
// configured default model. Not an error.
|
|
246
|
+
return { resolved: null, from: 'codex-default', key: null };
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
buildSpawnSpec({ model, thinking, isolate, executionMode, promptPath, worker }) {
|
|
250
|
+
const sandbox = codexSandbox(executionMode);
|
|
251
|
+
return {
|
|
252
|
+
argv: [
|
|
253
|
+
'exec', '--json',
|
|
254
|
+
'-c', 'approval_policy="never"',
|
|
255
|
+
'--sandbox', sandbox,
|
|
256
|
+
...(model ? ['-m', model] : []),
|
|
257
|
+
...(thinking ? ['-c', `model_reasoning_effort="${thinking}"`] : []),
|
|
258
|
+
// Keep the user's Codex config so custom providers/base_url/auth mode
|
|
259
|
+
// match `codex exec` and smoke. `--ignore-user-config` would silently
|
|
260
|
+
// fall back to the built-in OpenAI provider while still reading auth.
|
|
261
|
+
...(isolate ? ['--ignore-rules', '--ephemeral'] : []),
|
|
262
|
+
'-o', worker.lastMessagePath,
|
|
263
|
+
'-',
|
|
264
|
+
],
|
|
265
|
+
env: {},
|
|
266
|
+
// '-' makes codex read the prompt from stdin; the caller wires this fd
|
|
267
|
+
// as stdio[0] and closes it after spawn.
|
|
268
|
+
stdinFd: fs.openSync(promptPath, 'r'),
|
|
269
|
+
sandbox,
|
|
270
|
+
};
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
interpretOutput({ exitCode, killed, stdout, stderr, worker }) {
|
|
274
|
+
let lastMessage = '';
|
|
275
|
+
let usage = null;
|
|
276
|
+
const errors = [];
|
|
277
|
+
for (const line of String(stdout || '').split('\n')) {
|
|
278
|
+
const trimmed = line.trim();
|
|
279
|
+
if (!trimmed) continue;
|
|
280
|
+
let event;
|
|
281
|
+
try { event = JSON.parse(trimmed); } catch { continue; }
|
|
282
|
+
if (!event || typeof event !== 'object') continue;
|
|
283
|
+
if (event.type === 'item.completed' && event.item?.type === 'agent_message' && typeof event.item.text === 'string') {
|
|
284
|
+
lastMessage = event.item.text;
|
|
285
|
+
} else if (event.type === 'turn.completed' && event.usage && typeof event.usage === 'object') {
|
|
286
|
+
usage = event.usage;
|
|
287
|
+
} else if (event.type === 'turn.failed' || event.type === 'error') {
|
|
288
|
+
const msg = event.message || event.error?.message || JSON.stringify(event);
|
|
289
|
+
errors.push(`${event.type}: ${msg}`);
|
|
290
|
+
}
|
|
291
|
+
// Unknown event types are ignored on purpose (forward compatibility).
|
|
292
|
+
}
|
|
293
|
+
if (worker?.lastMessagePath) {
|
|
294
|
+
try {
|
|
295
|
+
const fromFile = fs.readFileSync(worker.lastMessagePath, 'utf-8').trim();
|
|
296
|
+
if (fromFile) lastMessage = fromFile;
|
|
297
|
+
} catch {}
|
|
298
|
+
}
|
|
299
|
+
let output = lastMessage;
|
|
300
|
+
if (errors.length > 0) output += (output ? '\n\n' : '') + errors.join('\n');
|
|
301
|
+
if (String(stderr || '').trim()) output += (output ? '\n\n--- stderr ---\n' : '') + stderr.trim();
|
|
302
|
+
let runStatus;
|
|
303
|
+
if (killed) runStatus = 'timeout';
|
|
304
|
+
else if (approvalBlocked(output)) runStatus = 'blocked';
|
|
305
|
+
else if (errors.length > 0 || exitCode !== 0) runStatus = 'failed';
|
|
306
|
+
else runStatus = 'done';
|
|
307
|
+
return { runStatus, output, usage };
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
failureHint(stderr, stdout) {
|
|
311
|
+
const combined = `${stderr || ''}\n${stdout || ''}`;
|
|
312
|
+
if (/not logged in|login required|run codex login/i.test(combined)) {
|
|
313
|
+
return 'Codex is not authenticated on this machine. Run `codex login` once or fix `$CODEX_HOME/config.toml`/`auth.json`; API keys are not forwarded to the subprocess.';
|
|
314
|
+
}
|
|
315
|
+
if (/model .*(not found|not supported)|unknown model/i.test(combined)) {
|
|
316
|
+
return 'Codex rejected the model name. Check the `-m` value or the [executor_adapter.codex] section in ~/.pi/config.toml; omit model to use the codex CLI default.';
|
|
317
|
+
}
|
|
318
|
+
if (/sandbox|seatbelt|landlock|permission denied/i.test(combined)) {
|
|
319
|
+
return 'The codex OS sandbox blocked an operation. Check whether the execution_mode→sandbox mapping (review/patch=read-only, worktree/direct=workspace-write) matches what the task needs.';
|
|
320
|
+
}
|
|
321
|
+
return '';
|
|
322
|
+
},
|
|
323
|
+
|
|
324
|
+
smokeSpec(model) {
|
|
325
|
+
return {
|
|
326
|
+
argv: [
|
|
327
|
+
'exec', '--json',
|
|
328
|
+
'--sandbox', 'read-only',
|
|
329
|
+
'-c', 'approval_policy="never"',
|
|
330
|
+
'--ignore-rules',
|
|
331
|
+
'--ephemeral',
|
|
332
|
+
...(model ? ['-m', model] : []),
|
|
333
|
+
'Respond with exactly the string: CODEX READY. No other words.',
|
|
334
|
+
],
|
|
335
|
+
readyText: 'CODEX READY',
|
|
336
|
+
};
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
export const EXECUTORS = { pi, codex };
|
|
341
|
+
|
|
342
|
+
// Routing: explicit param > config default_executor > mode-based default.
|
|
343
|
+
// implement/custom default to codex (native GPT harness + OS sandbox);
|
|
344
|
+
// check defaults to pi (cross-model review needs Pi's provider routing).
|
|
345
|
+
export function resolveExecutor(explicit, mode) {
|
|
346
|
+
const pick = explicit || loadAdapterConfig().defaultExecutor;
|
|
347
|
+
if (pick) {
|
|
348
|
+
if (!EXECUTORS[pick]) {
|
|
349
|
+
throw new Error(`Unknown executor "${pick}". Use "pi" or "codex" (dispatch param or [executor_adapter] default_executor in ~/.pi/config.toml).`);
|
|
350
|
+
}
|
|
351
|
+
return pick;
|
|
352
|
+
}
|
|
353
|
+
return mode === 'check' ? 'pi' : 'codex';
|
|
354
|
+
}
|