@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/index.js ADDED
@@ -0,0 +1,1834 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * executor-adapter MCP server
5
+ *
6
+ * Executor adapter (Pi CLI + Codex CLI backends) for Trellis Phase 2.1 and
7
+ * standalone use. Executor-specific behavior lives in executors.js; routing
8
+ * defaults to codex for implement/custom and pi for check.
9
+ *
10
+ * Postures:
11
+ * - With Trellis: reads task artifacts, assembles executor prompts. Never
12
+ * modifies workflow, hooks, task.py, or artifacts.
13
+ * - With `trellis channel`: emits message events into the channel so the
14
+ * audit trail belongs to Trellis core (worker_guard and event log are
15
+ * Trellis's, not ours).
16
+ * - Standalone: the main Agent invokes this MCP directly; runtime files
17
+ * live under `/tmp/executor-adapter/`.
18
+ *
19
+ * Forward compatibility:
20
+ * - `@mindfoldhq/trellis-core/channel` is loaded via dynamic import in a
21
+ * try/catch so an absent or breaking-changed package degrades the MCP
22
+ * to CLI fallback (or event-drop), never module-load failure.
23
+ * - The CLI fallback uses async spawn (fire-and-forget) so a missing
24
+ * `trellis` binary or a renamed subcommand also degrades gracefully.
25
+ * - Channel names are detected from a list of env var aliases so a future
26
+ * Trellis rename keeps working.
27
+ *
28
+ * Protocol: MCP over stdio.
29
+ */
30
+
31
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
32
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
33
+ import {
34
+ ListToolsRequestSchema,
35
+ CallToolRequestSchema,
36
+ } from '@modelcontextprotocol/sdk/types.js';
37
+ import { spawn, execSync, execFileSync } from 'node:child_process';
38
+ import * as fs from 'node:fs';
39
+ import * as path from 'node:path';
40
+ import * as os from 'node:os';
41
+ import * as crypto from 'node:crypto';
42
+ import { EXECUTORS, resolveExecutor } from './executors.js';
43
+
44
+ // ---- Constants ----
45
+
46
+ const SERVER_NAME = 'executor-adapter';
47
+ const LEGACY_SERVER_NAMES = ['pi-adapter'];
48
+ const SERVER_VERSION = '0.10.0'; // keep in sync with package.json
49
+ const TMP_RUNTIME_DIR = path.join(os.tmpdir(), SERVER_NAME);
50
+ const CHANNEL_ENV_ALIASES = ['TRELLIS_CHANNEL', 'TRELLIS_CHANNEL_NAME'];
51
+ const TRELLIS_BIN_ENV = 'TRELLIS_BINARY';
52
+ const DEFAULT_WRITE_TOOLS = 'read,bash,edit,write,grep,find,ls';
53
+ const DEFAULT_READ_TOOLS = 'read,grep,find,ls';
54
+ const DEFAULT_PATCH_TOOLS = 'read,bash,grep,find,ls';
55
+ const PROMPT_SIZE_WARN_BYTES = 80 * 1024;
56
+
57
+ // ---- Small utilities ----
58
+
59
+ function readFile(filePath) {
60
+ try {
61
+ if (fs.existsSync(filePath)) return fs.readFileSync(filePath, 'utf-8');
62
+ } catch {}
63
+ return null;
64
+ }
65
+
66
+ function resolvePath(p, cwd) {
67
+ if (path.isAbsolute(p)) return p;
68
+ return path.resolve(cwd || process.cwd(), p);
69
+ }
70
+
71
+ function logErr(msg) {
72
+ process.stderr.write(`[${SERVER_NAME}] ${msg}\n`);
73
+ }
74
+
75
+ function withTimeout(promise, ms, label) {
76
+ let timer;
77
+ return Promise.race([
78
+ promise,
79
+ new Promise((_, reject) => {
80
+ timer = setTimeout(() => reject(new Error(`${label || 'operation'} timed out after ${ms}ms`)), ms);
81
+ }),
82
+ ]).finally(() => clearTimeout(timer));
83
+ }
84
+
85
+ function resolveRuntimeDir(workDir) {
86
+ if (fs.existsSync(path.join(workDir, '.trellis'))) {
87
+ return path.join(workDir, '.trellis', '.runtime');
88
+ }
89
+ return TMP_RUNTIME_DIR;
90
+ }
91
+
92
+ function legacyRuntimeDirs(workDir) {
93
+ if (fs.existsSync(path.join(workDir, '.trellis'))) return [];
94
+ return LEGACY_SERVER_NAMES.map(name => path.join(os.tmpdir(), name));
95
+ }
96
+
97
+ function detectProjectMode(workDir, channelName, executionMode) {
98
+ if (fs.existsSync(path.join(workDir, '.trellis'))) {
99
+ return channelName ? 'trellis_channel_bridge' : 'trellis_local_worktree';
100
+ }
101
+ return executionMode === 'worktree' ? 'standalone_worktree' : 'standalone';
102
+ }
103
+
104
+ function isGitRepo(workDir) {
105
+ try {
106
+ execFileSync('git', ['rev-parse', '--show-toplevel'], {
107
+ cwd: workDir,
108
+ encoding: 'utf-8',
109
+ timeout: 10000,
110
+ stdio: ['ignore', 'pipe', 'pipe'],
111
+ });
112
+ return true;
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ // ---- Binary discovery (cached) ----
119
+
120
+ let _trellisBin = undefined;
121
+ function findTrellisBinary() {
122
+ if (_trellisBin !== undefined) return _trellisBin;
123
+ const fromEnv = process.env[TRELLIS_BIN_ENV];
124
+ if (fromEnv && fs.existsSync(fromEnv)) { _trellisBin = fromEnv; return _trellisBin; }
125
+ try { _trellisBin = execSync('which trellis', { encoding: 'utf-8' }).trim() || null; }
126
+ catch { _trellisBin = null; }
127
+ return _trellisBin;
128
+ }
129
+
130
+ // ---- trellis-core lazy loader (forward-compatible) ----
131
+
132
+ // undefined = unloaded, null = unavailable, fn = loaded
133
+ let _trellisCoreCache = undefined;
134
+ async function getTrellisCoreSendMessage() {
135
+ if (_trellisCoreCache !== undefined) return _trellisCoreCache;
136
+ try {
137
+ const mod = await import('@mindfoldhq/trellis-core/channel');
138
+ if (typeof mod.sendMessage === 'function') {
139
+ _trellisCoreCache = mod.sendMessage;
140
+ } else {
141
+ logErr('trellis-core/channel loaded but sendMessage missing — using CLI fallback');
142
+ _trellisCoreCache = null;
143
+ }
144
+ } catch (e) {
145
+ logErr(`trellis-core/channel unavailable (${e.message}) — using CLI fallback`);
146
+ _trellisCoreCache = null;
147
+ }
148
+ return _trellisCoreCache;
149
+ }
150
+
151
+ // ---- Channel detection + emission ----
152
+
153
+ function detectChannel(args, env) {
154
+ if (args && args.channel) return args.channel;
155
+ for (const key of CHANNEL_ENV_ALIASES) {
156
+ if (env[key]) return env[key];
157
+ }
158
+ return null;
159
+ }
160
+
161
+ // Emit a message event into the channel. Three-tier strategy:
162
+ // 1. Try trellis-core sendMessage (in-process, structured)
163
+ // 2. Fall back to `trellis channel send` (async spawn, fire-and-forget)
164
+ // 3. Drop with stderr note
165
+ // Channel emission is best-effort observability; it must never block dispatch.
166
+ async function emitChannelEvent(channelName, eventName, payload, workDir) {
167
+ if (!channelName) return { ok: true, reason: 'no-channel' };
168
+
169
+ const meta = { schema: 'executor-adapter.dispatch.v1', legacy_schema: 'pi-adapter.dispatch.v1', ...payload };
170
+
171
+ const sendMessage = await getTrellisCoreSendMessage();
172
+ if (sendMessage) {
173
+ try {
174
+ await withTimeout(sendMessage({
175
+ channel: channelName,
176
+ cwd: workDir,
177
+ by: SERVER_NAME,
178
+ text: `${SERVER_NAME}: ${eventName}`,
179
+ tag: `executor:${eventName}`,
180
+ meta,
181
+ }), 2000, 'trellis-core sendMessage');
182
+ return { ok: true, via: 'trellis-core' };
183
+ } catch (e) {
184
+ logErr(`trellis-core sendMessage failed (${e.message}) — falling back to CLI`);
185
+ }
186
+ }
187
+
188
+ const trellis = findTrellisBinary();
189
+ if (!trellis) {
190
+ logErr(`channel event "${eventName}" dropped: trellis CLI not found`);
191
+ return { ok: false, reason: 'no-fallback' };
192
+ }
193
+ try {
194
+ const child = spawn(trellis, [
195
+ 'channel', 'send', channelName,
196
+ '--as', SERVER_NAME,
197
+ '--stdin',
198
+ ], { cwd: workDir, stdio: ['pipe', 'ignore', 'ignore'], detached: true });
199
+ child.on('error', (e) => logErr(`trellis CLI fallback spawn failed: ${e.message}`));
200
+ child.stdin.on('error', (e) => logErr(`trellis CLI fallback stdin failed: ${e.message}`));
201
+ try {
202
+ child.stdin.end(`${SERVER_NAME}: ${eventName}\n\n${JSON.stringify(meta, null, 2)}\n`);
203
+ } catch (e) {
204
+ logErr(`trellis CLI fallback stdin write failed: ${e.message}`);
205
+ }
206
+ child.unref();
207
+ return { ok: true, via: 'cli' };
208
+ } catch (e) {
209
+ logErr(`channel event "${eventName}" CLI fallback errored: ${e.message}`);
210
+ return { ok: false, reason: 'cli-exception', error: e.message };
211
+ }
212
+ }
213
+
214
+ // ---- Subprocess hardening ----
215
+ // Env scrubbing lives in executors.js (per-executor keep-lists over a shared
216
+ // sensitive-pattern scrub).
217
+
218
+ // ---- Dispatch lock (always acquired; Trellis channel does not own executor lifecycle) ----
219
+
220
+ const _activeLocks = new Set();
221
+ const _activeChildren = new Set();
222
+
223
+ function dispatchLockPath(runtimeDir, taskDir, scope, extraInstructions) {
224
+ const fp = crypto
225
+ .createHash('sha256')
226
+ .update(`${taskDir || ''}|${scope || ''}|${extraInstructions || ''}`)
227
+ .digest('hex')
228
+ .slice(0, 12);
229
+ return path.join(runtimeDir, `${SERVER_NAME}.${fp}.lock`);
230
+ }
231
+
232
+ function acquireDispatchLock(lockPath) {
233
+ // Stale-lock detection: if a lock file exists, check if the holder is alive.
234
+ if (fs.existsSync(lockPath)) {
235
+ try {
236
+ const content = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
237
+ const pid = content.pid;
238
+ if (pid) {
239
+ try { process.kill(pid, 0); return { acquired: false, holder: content }; }
240
+ catch { /* stale — holder dead, fall through to overwrite */ }
241
+ }
242
+ } catch { /* malformed lock file, fall through */ }
243
+ }
244
+ // Atomic exclusive create to prevent TOCTOU races between the check above
245
+ // and the write. O_CREAT|O_EXCL (flag 'wx') fails if another process
246
+ // created the file in the window.
247
+ try {
248
+ const fd = fs.openSync(lockPath, 'wx');
249
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }));
250
+ fs.closeSync(fd);
251
+ } catch (e) {
252
+ if (e.code === 'EEXIST') {
253
+ // Race lost — re-read to report who holds it
254
+ try {
255
+ const content = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
256
+ return { acquired: false, holder: content };
257
+ } catch { /* give up, report generic */ }
258
+ return { acquired: false, holder: { pid: null, startedAt: null } };
259
+ }
260
+ throw e;
261
+ }
262
+ _activeLocks.add(lockPath);
263
+ return { acquired: true };
264
+ }
265
+
266
+ function releaseDispatchLock(lockPath) {
267
+ _activeLocks.delete(lockPath);
268
+ try { fs.unlinkSync(lockPath); } catch {}
269
+ }
270
+
271
+ function cleanupAllLocks() {
272
+ for (const p of _activeLocks) {
273
+ try { fs.unlinkSync(p); } catch {}
274
+ }
275
+ _activeLocks.clear();
276
+ }
277
+
278
+ function trackChild(proc) {
279
+ _activeChildren.add(proc);
280
+ const done = () => _activeChildren.delete(proc);
281
+ proc.once('close', done);
282
+ proc.once('error', done);
283
+ return proc;
284
+ }
285
+
286
+ function shutdownWithChildren(exitCode) {
287
+ for (const child of _activeChildren) {
288
+ try { child.kill('SIGTERM'); } catch {}
289
+ }
290
+ if (_activeChildren.size === 0) {
291
+ cleanupAllLocks();
292
+ process.exit(exitCode);
293
+ }
294
+ setTimeout(() => {
295
+ for (const child of _activeChildren) {
296
+ try { child.kill('SIGKILL'); } catch {}
297
+ }
298
+ cleanupAllLocks();
299
+ process.exit(exitCode);
300
+ }, 5000);
301
+ }
302
+
303
+ process.on('exit', cleanupAllLocks);
304
+ process.on('SIGINT', () => shutdownWithChildren(130));
305
+ process.on('SIGTERM', () => shutdownWithChildren(143));
306
+
307
+ // ---- Model resolution + executor routing live in executors.js ----
308
+
309
+ // ---- Trellis context assembly ----
310
+
311
+ function readJsonlManifest(repoRoot, jsonlPath) {
312
+ const result = { entries: [], files: [], missing: [] };
313
+ const full = path.join(repoRoot, jsonlPath);
314
+ if (!fs.existsSync(full)) return result;
315
+
316
+ const lines = readFile(full).split('\n').filter(l => l.trim());
317
+ for (const line of lines) {
318
+ try {
319
+ const item = JSON.parse(line);
320
+ const filePath = item.file || item.path;
321
+ if (!filePath) continue;
322
+ result.entries.push(item);
323
+
324
+ const resolved = path.join(repoRoot, filePath);
325
+ if (fs.existsSync(resolved)) {
326
+ const stat = fs.statSync(resolved);
327
+ if (stat.isDirectory()) {
328
+ const mds = fs.readdirSync(resolved)
329
+ .filter(f => f.endsWith('.md') && fs.statSync(path.join(resolved, f)).isFile())
330
+ .sort();
331
+ for (const md of mds) {
332
+ const content = readFile(path.join(resolved, md));
333
+ if (content) result.files.push({ path: path.join(filePath, md), content });
334
+ }
335
+ } else {
336
+ const content = readFile(resolved);
337
+ if (content) result.files.push({ path: filePath, content });
338
+ }
339
+ } else {
340
+ result.missing.push(filePath);
341
+ }
342
+ } catch {}
343
+ }
344
+ return result;
345
+ }
346
+
347
+ function resolveActiveTask(repoRoot, trellisContextId) {
348
+ if (!fs.existsSync(path.join(repoRoot, '.trellis'))) return null;
349
+ try {
350
+ const env = trellisContextId
351
+ ? { ...process.env, TRELLIS_CONTEXT_ID: trellisContextId }
352
+ : process.env;
353
+ const result = execSync(
354
+ `python3 ./.trellis/scripts/task.py current --source`,
355
+ { encoding: 'utf-8', cwd: repoRoot, timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'], env },
356
+ ).trim();
357
+ const match = result.match(/^(\S+)/);
358
+ return match ? match[1] : null;
359
+ } catch {
360
+ return null;
361
+ }
362
+ }
363
+
364
+ function assembleTrellisContext(repoRoot, taskDir, mode) {
365
+ const manifestFile = mode === 'check' ? 'check.jsonl' : 'implement.jsonl';
366
+ const manifest = readJsonlManifest(repoRoot, path.join(taskDir, manifestFile));
367
+ const artifacts = {};
368
+ for (const name of ['prd.md', 'design.md', 'implement.md']) {
369
+ const content = readFile(path.join(repoRoot, taskDir, name));
370
+ if (content) artifacts[name] = content;
371
+ }
372
+ return { taskDir, artifacts, manifest, manifestFile };
373
+ }
374
+
375
+ // ---- Prompt builders ----
376
+
377
+ function fencedContent(label, content) {
378
+ return `### ${label}\n\n~~~text\n${String(content || '')}\n~~~\n\n`;
379
+ }
380
+
381
+ function readContextFiles(repoRoot, contextFiles) {
382
+ const files = [];
383
+ if (!Array.isArray(contextFiles)) return files;
384
+ for (const filePath of contextFiles) {
385
+ if (typeof filePath !== 'string' || !filePath.trim()) continue;
386
+ const rel = filePath.trim();
387
+ const abs = path.isAbsolute(rel) ? rel : path.join(repoRoot, rel);
388
+ const content = readFile(abs);
389
+ if (content !== null) files.push({ path: rel, content });
390
+ }
391
+ return files;
392
+ }
393
+
394
+ function appendContextFilesPrompt(prompt, contextFiles) {
395
+ if (!Array.isArray(contextFiles) || contextFiles.length === 0) return prompt;
396
+ let p = prompt;
397
+ p += `\n## Additional Context Files\n\n`;
398
+ for (const f of contextFiles) p += fencedContent(f.path, f.content);
399
+ return p;
400
+ }
401
+
402
+ function buildDispatchPrompt(args) {
403
+ const { taskDir, artifacts, manifest, manifestFile, mode } = args;
404
+ const executionMode = args.execution_mode || defaultExecutionMode(mode);
405
+ const extraInstructions = args.extra_instructions || '';
406
+ const scopeConstraint = args.scope || '';
407
+ const validationCommands = args.validation_commands || [];
408
+ const embedContext = args.embed_context !== false;
409
+ const modeLabel = mode === 'check' ? 'Quality Check' : mode === 'implement' ? 'Implementation' : 'Custom';
410
+
411
+ let p = `# Executor Dispatch: ${modeLabel}\n\nActive task: \`${taskDir}\`\n\n`;
412
+
413
+ if (mode === 'implement') {
414
+ p += `You are the implementation executor for this Trellis task. The orchestrator (Claude Code / Codex / main Agent) will review your output.\n\n**Guards**:\n- Do NOT spawn other agents.\n- Do NOT modify task scope or mark the Trellis task complete.\n- Do NOT git commit.\n\n`;
415
+ } else if (mode === 'check') {
416
+ p += `You are the quality check executor for this Trellis task. Review all code changes against specs and task artifacts. Fix issues directly.\n\n**Guards**:\n- Do NOT spawn other agents.\n- Do NOT modify task scope or mark the Trellis task complete.\n- Do NOT git commit.\n\n`;
417
+ }
418
+
419
+ p += `## Read in this order before writing any code\n\n`;
420
+
421
+ let idx = 1;
422
+ if (manifest.files.length > 0) {
423
+ p += `### Context from ${manifestFile}\n\n`;
424
+ for (const f of manifest.files) { p += `${idx}. \`${f.path}\`\n`; idx++; }
425
+ p += '\n';
426
+ }
427
+ if (artifacts['prd.md']) p += `${idx++}. \`${taskDir}/prd.md\` — source of truth for scope and acceptance criteria.\n`;
428
+ if (artifacts['design.md']) p += `${idx++}. \`${taskDir}/design.md\` — technical design.\n`;
429
+ if (artifacts['implement.md']) p += `${idx++}. \`${taskDir}/implement.md\` — execution plan.\n`;
430
+
431
+ if (manifest.files.length > 0 || Object.keys(artifacts).length > 0) {
432
+ p += `\n## Embedded Trellis Context\n\n`;
433
+ if (embedContext) {
434
+ p += `These are embedded so isolated executor workers can run from a clean worktree without depending on uncommitted task files.\n\n`;
435
+ for (const f of manifest.files) p += fencedContent(f.path, f.content);
436
+ for (const [name, content] of Object.entries(artifacts)) p += fencedContent(`${taskDir}/${name}`, content);
437
+ } else {
438
+ p += `Context embedding is disabled. Read these paths on demand from the worktree before editing:\n\n`;
439
+ for (const f of manifest.files) p += `- \`${f.path}\`\n`;
440
+ for (const name of Object.keys(artifacts)) p += `- \`${taskDir}/${name}\`\n`;
441
+ p += '\n';
442
+ }
443
+ }
444
+
445
+ p = appendContextFilesPrompt(p, args.context_files);
446
+ if (scopeConstraint) p += `\n## Scope Constraint\n\n${scopeConstraint}\n\n`;
447
+ if (extraInstructions) p += `\n## Additional Instructions\n\n${extraInstructions}\n\n`;
448
+ if (executionMode === 'worktree') {
449
+ p += `\n## Execution Environment\n\nYou are running inside an isolated git worktree. Modify files there normally. The orchestrator will export your changes as a patch, review it, and decide whether to apply it to the main repository. Do not commit.\n\n`;
450
+ } else if (executionMode === 'patch') {
451
+ p += `\n## Execution Environment\n\nDo not edit files directly. Produce a unified diff in your final answer that the orchestrator can review and apply.\n\n`;
452
+ } else if (executionMode === 'review') {
453
+ p += `\n## Execution Environment\n\nRead-only review mode. Do not modify files. Report findings and recommended fixes only.\n\n`;
454
+ }
455
+ if (validationCommands.length > 0) {
456
+ p += `\n## Verification Commands\n\nRun these before reporting done:\n\n`;
457
+ for (const cmd of validationCommands) p += `\`\`\`bash\n${cmd}\n\`\`\`\n\n`;
458
+ }
459
+ p += `## Reporting\n\nWhen done, print:\n\n1. List of files created / modified (path only).\n2. Test/lint output (summary line per package).\n3. Any TODO comments left, with file:line.\n4. Any decisions made that weren't covered in spec or PRD.\n`;
460
+ return p;
461
+ }
462
+
463
+ function buildNoTrellisPrompt(mode, extraInstructions, scope, validationCommands, executionMode = defaultExecutionMode(mode), contextFiles = []) {
464
+ const modeLabel = mode === 'check' ? 'Quality Check' : mode === 'custom' ? 'Custom' : 'Implementation';
465
+ let p = `# Executor Dispatch: ${modeLabel} (no Trellis)\n\n`;
466
+ if (mode === 'implement') {
467
+ p += `You are the implementation executor. The main Agent will review your output.\n\n**Guards**:\n- Do NOT git commit.\n- Do NOT spawn other agents.\n\n`;
468
+ } else if (mode === 'check') {
469
+ p += `You are the quality check executor. Review all code changes. Fix issues directly.\n\n**Guards**:\n- Do NOT git commit.\n- Do NOT spawn other agents.\n\n`;
470
+ } else if (mode === 'custom') {
471
+ p += `You are an executor worker. The orchestrator will review your output.\n\n**Guards**:\n- Do NOT git commit.\n- Do NOT spawn other agents.\n\n`;
472
+ }
473
+ p += `## Task\n\n${extraInstructions}\n\n`;
474
+ p = appendContextFilesPrompt(p, contextFiles);
475
+ if (scope) p += `## Scope Constraint\n\n${scope}\n\n`;
476
+ if (executionMode === 'worktree') {
477
+ p += `## Execution Environment\n\nYou are running inside an isolated git worktree. Modify files there normally. The orchestrator will export your changes as a patch, review it, and decide whether to apply it to the main repository. Do not commit.\n\n`;
478
+ } else if (executionMode === 'patch') {
479
+ p += `## Execution Environment\n\nDo not edit files directly. Produce a unified diff in your final answer that the orchestrator can review and apply.\n\n`;
480
+ } else if (executionMode === 'review') {
481
+ p += `## Execution Environment\n\nRead-only review mode. Do not modify files. Report findings and recommended fixes only.\n\n`;
482
+ }
483
+ if (validationCommands && validationCommands.length > 0) {
484
+ p += `## Verification Commands\n\n`;
485
+ for (const cmd of validationCommands) p += `\`\`\`bash\n${cmd}\n\`\`\`\n\n`;
486
+ }
487
+ p += `## Reporting\n\nWhen done, print:\n\n1. List of files created / modified (path only).\n2. Test/lint output.\n3. Any TODO comments left, with file:line.\n4. Any decisions made that weren't in the task description.\n`;
488
+ return p;
489
+ }
490
+
491
+ // ---- Auto-validation (post-execution post-conditions) ----
492
+
493
+ function runPostValidation(workDir, params) {
494
+ const failures = [];
495
+ const {
496
+ min_files_changed,
497
+ required_paths_modified,
498
+ forbidden_paths,
499
+ min_diff_lines,
500
+ } = params;
501
+
502
+ const hasAnyCheck =
503
+ typeof min_files_changed === 'number' ||
504
+ (Array.isArray(required_paths_modified) && required_paths_modified.length > 0) ||
505
+ (Array.isArray(forbidden_paths) && forbidden_paths.length > 0) ||
506
+ typeof min_diff_lines === 'number';
507
+
508
+ if (!hasAnyCheck) return { passed: true, failures: [], skipped: true };
509
+
510
+ let changedFiles = [];
511
+ let shortstat = '';
512
+ try {
513
+ // Intent-to-add makes newly created files appear in `git diff` without
514
+ // staging content in the repository. This keeps validation consistent
515
+ // between direct/patch/review modes and worktree diff export.
516
+ try {
517
+ execFileSync('git', ['add', '-N', '.'], {
518
+ cwd: workDir,
519
+ encoding: 'utf-8',
520
+ timeout: 10000,
521
+ stdio: ['ignore', 'pipe', 'pipe'],
522
+ });
523
+ } catch {}
524
+ const out = execSync('git diff --name-only HEAD', { cwd: workDir, encoding: 'utf-8', timeout: 10000 }).trim();
525
+ changedFiles = out ? out.split('\n') : [];
526
+ shortstat = execSync('git diff --shortstat HEAD', { cwd: workDir, encoding: 'utf-8', timeout: 10000 }).trim();
527
+ } catch (e) {
528
+ return { passed: false, failures: [{ rule: 'git-state', detail: `git diff failed: ${e.message}` }], skipped: false };
529
+ }
530
+
531
+ if (typeof min_files_changed === 'number' && changedFiles.length < min_files_changed) {
532
+ failures.push({ rule: 'min_files_changed', detail: `expected ≥ ${min_files_changed}, got ${changedFiles.length}`, changedFiles });
533
+ }
534
+ if (Array.isArray(required_paths_modified)) {
535
+ const missing = required_paths_modified.filter(p => !changedFiles.includes(p));
536
+ if (missing.length > 0) failures.push({ rule: 'required_paths_modified', detail: `not touched: ${missing.join(', ')}`, changedFiles });
537
+ }
538
+ if (Array.isArray(forbidden_paths)) {
539
+ const violated = forbidden_paths.filter(p =>
540
+ changedFiles.some(c => c === p || c.startsWith(p.endsWith('/') ? p : p + '/')),
541
+ );
542
+ if (violated.length > 0) failures.push({ rule: 'forbidden_paths', detail: `modified despite ban: ${violated.join(', ')}`, changedFiles });
543
+ }
544
+ if (typeof min_diff_lines === 'number') {
545
+ const m = shortstat.match(/(\d+) insertion/);
546
+ const n = shortstat.match(/(\d+) deletion/);
547
+ const total = (m ? parseInt(m[1], 10) : 0) + (n ? parseInt(n[1], 10) : 0);
548
+ if (total < min_diff_lines) failures.push({ rule: 'min_diff_lines', detail: `expected ≥ ${min_diff_lines} lines (ins+del), got ${total}`, shortstat });
549
+ }
550
+
551
+ return { passed: failures.length === 0, failures, shortstat, changedFiles };
552
+ }
553
+
554
+ // ---- stdout head/tail buffer ----
555
+
556
+ function makeHeadTailBuffer(headCap = 10 * 1024, tailCap = 40 * 1024) {
557
+ let head = '';
558
+ let tail = '';
559
+ let droppedMiddle = false;
560
+ const render = () => {
561
+ if (!tail) return head;
562
+ return droppedMiddle
563
+ ? `${head}\n\n...[middle of output truncated]...\n\n${tail}`
564
+ : head + tail;
565
+ };
566
+ return {
567
+ push(s) {
568
+ if (head.length < headCap) {
569
+ const room = headCap - head.length;
570
+ head += s.slice(0, room);
571
+ if (s.length > room) tail += s.slice(room);
572
+ } else {
573
+ tail += s;
574
+ }
575
+ if (tail.length > tailCap * 2) {
576
+ tail = tail.slice(-tailCap);
577
+ droppedMiddle = true;
578
+ }
579
+ },
580
+ snapshot: render,
581
+ finalize: render,
582
+ };
583
+ }
584
+
585
+ function snapshotBuffer(buf) {
586
+ if (!buf) return '';
587
+ if (typeof buf.snapshot === 'function') return buf.snapshot();
588
+ if (typeof buf.finalize === 'function') return buf.finalize();
589
+ return '';
590
+ }
591
+
592
+ function promptSizeWarning(promptBytes) {
593
+ if (promptBytes <= PROMPT_SIZE_WARN_BYTES) return '';
594
+ const kb = Math.ceil(promptBytes / 1024);
595
+ return `WARN: Prompt is ${kb} KB; large prompts can destabilize the Pi SSE client in isolated mode. Consider trimming implement.jsonl or using \`embed_context=false\`.`;
596
+ }
597
+
598
+ function logFileSize(logPath) {
599
+ try {
600
+ return fs.statSync(logPath).size;
601
+ } catch {
602
+ return 0;
603
+ }
604
+ }
605
+
606
+ function formatTinyLogDiagnostics({ exec, exitCode, logPath, logBytes, stdout, stderr }) {
607
+ const size = typeof logBytes === 'number' ? logBytes : logFileSize(logPath);
608
+ if (exitCode === 0 || size >= 512) return '';
609
+
610
+ let text = `\n--- ${exec.name} failure hint ---\n`;
611
+ text += `${exec.failureHint(stderr, stdout) || `${exec.label} exited non-zero and output.log is tiny; in-memory stdout/stderr may contain the useful error.`}\n`;
612
+ text += `\n--- ${exec.name} stdout (in-memory, head+tail) ---\n${String(stdout || '').trim() || '(empty)'}\n`;
613
+ text += `\n--- ${exec.name} stderr (in-memory, head+tail) ---\n${String(stderr || '').trim() || '(empty)'}\n`;
614
+ return text;
615
+ }
616
+
617
+ function defaultExecutionMode(mode) {
618
+ return mode === 'check' ? 'review' : 'worktree';
619
+ }
620
+
621
+ function defaultToolsForExecution(executionMode) {
622
+ if (executionMode === 'review') return DEFAULT_READ_TOOLS;
623
+ if (executionMode === 'patch') return DEFAULT_PATCH_TOOLS;
624
+ return DEFAULT_WRITE_TOOLS;
625
+ }
626
+
627
+ function makeWorkerId(executor, mode, taskDir, scope, extraInstructions) {
628
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
629
+ const fp = crypto
630
+ .createHash('sha256')
631
+ .update(`${mode}|${taskDir || ''}|${scope || ''}|${extraInstructions || ''}|${process.pid}|${Date.now()}`)
632
+ .digest('hex')
633
+ .slice(0, 8);
634
+ return `${executor}-${mode}-${ts}-${fp}`;
635
+ }
636
+
637
+ function seedPiAgentConfig(targetPiHome) {
638
+ const srcDir = path.join(os.homedir(), '.pi', 'agent');
639
+ const required = ['models.json', 'settings.json'];
640
+ const copied = [];
641
+ for (const f of required) {
642
+ const src = path.join(srcDir, f);
643
+ if (fs.existsSync(src)) {
644
+ fs.mkdirSync(targetPiHome, { recursive: true });
645
+ const dest = path.join(targetPiHome, f);
646
+ fs.copyFileSync(src, dest);
647
+ copied.push({ source: src, destination: dest });
648
+ }
649
+ }
650
+ return { count: copied.length, files: copied };
651
+ }
652
+
653
+ function createWorkerRuntime(runtimeDir, workerId, executor) {
654
+ const workerDir = path.join(runtimeDir, 'pi-workers', workerId);
655
+ const repoDir = path.join(workerDir, 'repo');
656
+ fs.mkdirSync(workerDir, { recursive: true });
657
+ const worker = { workerId, workerDir, repoDir, configFiles: 0, configFilePaths: [] };
658
+ if (executor === 'pi') {
659
+ const piHome = path.join(workerDir, 'pi-home');
660
+ const sessionDir = path.join(workerDir, 'sessions');
661
+ fs.mkdirSync(piHome, { recursive: true });
662
+ fs.mkdirSync(sessionDir, { recursive: true });
663
+ const seed = seedPiAgentConfig(piHome);
664
+ if (seed.count === 0) {
665
+ console.error(`[${SERVER_NAME}] WARNING: no Pi agent config files found in ~/.pi/agent/; Pi subprocess may fail with "Model not found"`);
666
+ }
667
+ worker.piHome = piHome;
668
+ worker.sessionDir = sessionDir;
669
+ worker.configFiles = seed.count;
670
+ worker.configFilePaths = seed.files;
671
+ } else {
672
+ worker.lastMessagePath = path.join(workerDir, 'last-message.txt');
673
+ }
674
+ return worker;
675
+ }
676
+
677
+ function createWorktree(sourceDir, repoDir) {
678
+ fs.mkdirSync(path.dirname(repoDir), { recursive: true });
679
+ execFileSync('git', ['worktree', 'add', '--detach', repoDir, 'HEAD'], {
680
+ cwd: sourceDir,
681
+ encoding: 'utf-8',
682
+ timeout: 60000,
683
+ stdio: ['ignore', 'pipe', 'pipe'],
684
+ });
685
+ }
686
+
687
+ function prepareWorkerDiff(repoDir, patchFile) {
688
+ let changedFiles = [];
689
+ let shortstat = '';
690
+ let patch = '';
691
+ try {
692
+ // Intent-to-add makes newly created files appear in `git diff` without
693
+ // permanently staging content in the main repository.
694
+ execFileSync('git', ['add', '-N', '.'], {
695
+ cwd: repoDir,
696
+ encoding: 'utf-8',
697
+ timeout: 10000,
698
+ stdio: ['ignore', 'pipe', 'pipe'],
699
+ });
700
+ } catch {}
701
+
702
+ try {
703
+ const names = execFileSync('git', ['diff', '--name-only', 'HEAD'], {
704
+ cwd: repoDir,
705
+ encoding: 'utf-8',
706
+ timeout: 10000,
707
+ maxBuffer: 10 * 1024 * 1024,
708
+ }).trim();
709
+ changedFiles = names ? names.split('\n') : [];
710
+ shortstat = execFileSync('git', ['diff', '--shortstat', 'HEAD'], {
711
+ cwd: repoDir,
712
+ encoding: 'utf-8',
713
+ timeout: 10000,
714
+ maxBuffer: 10 * 1024 * 1024,
715
+ }).trim();
716
+ patch = execFileSync('git', ['diff', '--binary', 'HEAD'], {
717
+ cwd: repoDir,
718
+ encoding: 'utf-8',
719
+ timeout: 30000,
720
+ maxBuffer: 100 * 1024 * 1024,
721
+ });
722
+ fs.writeFileSync(patchFile, patch, 'utf-8');
723
+ } catch (e) {
724
+ return { ok: false, error: e.message, changedFiles, shortstat, patchFile };
725
+ }
726
+ return { ok: true, changedFiles, shortstat, patchFile, bytes: Buffer.byteLength(patch) };
727
+ }
728
+
729
+ function hasUsablePatch(executionMode, diffInfo, changedFiles) {
730
+ if (executionMode !== 'worktree') return false;
731
+ return Boolean(diffInfo && diffInfo.ok && Array.isArray(changedFiles) && changedFiles.length > 0);
732
+ }
733
+
734
+ function detectsDataValidationUnavailable(output) {
735
+ const text = output || '';
736
+ return [
737
+ /(derived|generated|sample|full|fixture|data).{0,100}(missing|unavailable|not available|not found|cannot|can't|unable|skipped|not attempted)/i,
738
+ /(cannot|can't|unable|skipped|not attempted).{0,100}(sample|full|data|validation|derived|generated)/i,
739
+ /data validation.{0,100}(must|needs?).{0,60}main repo/i,
740
+ ].some((re) => re.test(text));
741
+ }
742
+
743
+ function validationScopeText(validation, limitedDataValidation) {
744
+ if (!validation || validation.skipped) {
745
+ return limitedDataValidation
746
+ ? 'auto validation skipped; data validation must run in main repo'
747
+ : 'auto validation skipped; orchestrator validation required';
748
+ }
749
+ if (validation.passed) {
750
+ return limitedDataValidation
751
+ ? 'static/auto validation passed; data validation must run in main repo'
752
+ : 'static/auto validation passed; orchestrator validation required';
753
+ }
754
+ return 'auto validation failed';
755
+ }
756
+
757
+ function classifyResult({ executionMode, status, finalStatus, exitCode, validation, diffInfo, changedFiles, output }) {
758
+ const usablePatch = hasUsablePatch(executionMode, diffInfo, changedFiles);
759
+ const limitedDataValidation = usablePatch && validation?.passed && exitCode === 0 && detectsDataValidationUnavailable(output);
760
+
761
+ if (limitedDataValidation && (status === 'done' || status === 'blocked')) {
762
+ return {
763
+ status: 'patch_ready_limited_validation',
764
+ result_class: 'patch_ready_limited_validation',
765
+ status_reason: 'The executor produced a non-empty patch and static/auto validation passed, but data validation was not available in the isolated worktree.',
766
+ data_validation: 'not_attempted',
767
+ data_validation_reason: 'Derived/generated data was unavailable in the isolated worker; run data validation in the main repository after applying the patch.',
768
+ };
769
+ }
770
+
771
+ if (executionMode === 'worktree' && diffInfo?.ok && changedFiles.length === 0) {
772
+ return {
773
+ status: 'no_patch',
774
+ result_class: 'no_usable_patch',
775
+ status_reason: 'The executor exited without producing changes in diff.patch.',
776
+ data_validation: 'not_attempted',
777
+ data_validation_reason: 'No patch was produced, so main-repository data validation is not applicable yet.',
778
+ };
779
+ }
780
+
781
+ if (!validation?.passed) {
782
+ return {
783
+ status: 'validation_failed',
784
+ result_class: 'validation_failed',
785
+ status_reason: 'Post-execution validation failed.',
786
+ data_validation: 'not_attempted',
787
+ data_validation_reason: 'Main-repository data validation should wait until post-validation failures are resolved.',
788
+ };
789
+ }
790
+
791
+ if (executionMode === 'worktree' && usablePatch && finalStatus === 'done') {
792
+ return {
793
+ status: 'done',
794
+ result_class: 'patch_ready',
795
+ status_reason: 'The executor produced a non-empty patch and post-validation passed.',
796
+ data_validation: 'not_attempted',
797
+ data_validation_reason: 'Worktree dispatch does not prove main-repository data validation; run it after applying the patch.',
798
+ };
799
+ }
800
+
801
+ if (finalStatus === 'done') {
802
+ return {
803
+ status: 'done',
804
+ result_class: 'completed',
805
+ status_reason: 'The executor completed and post-validation passed.',
806
+ data_validation: 'not_attempted',
807
+ data_validation_reason: 'No adapter-level data validation result was captured.',
808
+ };
809
+ }
810
+
811
+ return {
812
+ status: finalStatus,
813
+ result_class: finalStatus === 'blocked' ? 'blocked' : 'failed',
814
+ status_reason: finalStatus === 'blocked'
815
+ ? 'The executor could not continue and did not produce an apply-ready patch.'
816
+ : 'The executor did not complete successfully.',
817
+ data_validation: 'not_attempted',
818
+ data_validation_reason: 'Main-repository data validation should wait until dispatch succeeds.',
819
+ };
820
+ }
821
+
822
+ function buildOrchestratorNextSteps({ resultClass, projectMode, applyCommand, validationCommands = [] }) {
823
+ if (resultClass === 'no_usable_patch') {
824
+ return ['Inspect report/log', 'Tighten scope or instructions', 'Re-dispatch or fix manually'];
825
+ }
826
+ if (resultClass === 'validation_failed') {
827
+ return ['Inspect report/diff', 'Resolve post-validation failures', 'Re-run cheap validation before apply'];
828
+ }
829
+ if (resultClass === 'blocked' || resultClass === 'failed') {
830
+ return ['Inspect report/log', 'Fix the blocker', 'Re-dispatch when the task can continue'];
831
+ }
832
+
833
+ const checkStep = projectMode.startsWith('trellis_')
834
+ ? 'Run independent check/trellis-check'
835
+ : 'Run independent review/check';
836
+ const steps = [
837
+ 'Inspect report.json and diff.patch',
838
+ applyCommand ? `Apply patch: ${applyCommand}` : 'Apply accepted changes',
839
+ 'Run cheap validation',
840
+ 'Run sample/small validation',
841
+ checkStep,
842
+ 'If check changes code, re-run sample/small validation',
843
+ 'Run expensive full validation',
844
+ 'Commit only from the orchestrator after validation passes',
845
+ ];
846
+ if (validationCommands.length > 0) {
847
+ steps.splice(3, 0, 'Run requested validation_commands in the main repository');
848
+ }
849
+ return steps;
850
+ }
851
+
852
+ function buildRecommendedCommands({ applyCommand, validationCommands = [] }) {
853
+ const commands = [];
854
+ if (applyCommand) commands.push(applyCommand);
855
+ commands.push('git status --short', 'git diff --stat');
856
+ for (const cmd of validationCommands) {
857
+ if (typeof cmd === 'string' && cmd.trim()) commands.push(cmd.trim());
858
+ }
859
+ return [...new Set(commands)];
860
+ }
861
+
862
+ function writeJsonFile(filePath, data) {
863
+ fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf-8');
864
+ }
865
+
866
+ function formatBlock(label, value, maxLen = 4000) {
867
+ const text = String(value || '').trim();
868
+ if (!text) return `${label}: (empty)`;
869
+ const truncated = text.length > maxLen ? `${text.slice(0, maxLen)}\n...[truncated ${text.length - maxLen} chars]` : text;
870
+ return `${label}:\n${truncated}`;
871
+ }
872
+
873
+ function formatSeededConfig(seed) {
874
+ if (!seed || !Array.isArray(seed.files) || seed.files.length === 0) return 'config_seeded: none';
875
+ const lines = ['config_seeded:'];
876
+ for (const file of seed.files) {
877
+ lines.push(` - ${file.destination} (from ${file.source})`);
878
+ }
879
+ return lines.join('\n');
880
+ }
881
+
882
+ function safeSmokeEnv(env) {
883
+ const keys = [
884
+ 'PI_CODING_AGENT_DIR',
885
+ 'PI_CODING_AGENT_SESSION_DIR',
886
+ 'PI_OFFLINE',
887
+ 'PI_SKIP_VERSION_CHECK',
888
+ 'PI_BINARY',
889
+ 'CODEX_BINARY',
890
+ ];
891
+ const out = {};
892
+ for (const key of keys) {
893
+ if (env[key] !== undefined) out[key] = env[key];
894
+ }
895
+ return out;
896
+ }
897
+
898
+ function formatSmokeEnv(env) {
899
+ const entries = Object.entries(env);
900
+ if (entries.length === 0) return 'env_passed: (none)';
901
+ return `env_passed:\n${entries.map(([k, v]) => ` ${k}=${v}`).join('\n')}`;
902
+ }
903
+
904
+ function smokeDiagnostic(stdout, stderr, seed) {
905
+ const combined = `${stdout || ''}\n${stderr || ''}`;
906
+ if (/model .*not found|unknown model|no model/i.test(combined)) {
907
+ if (!seed || seed.count === 0) {
908
+ return 'diagnostic: no Pi agent config files were seeded; check ~/.pi/agent/models.json and ~/.pi/agent/settings.json.';
909
+ }
910
+ return 'diagnostic: model routing failed despite seeded Pi agent config; check the configured model route and Pi model registry.';
911
+ }
912
+ if (/permission|denied|approval|tty|stdin/i.test(combined)) {
913
+ return 'diagnostic: Pi appears blocked by non-interactive execution or permissions.';
914
+ }
915
+ return 'diagnostic: inspect pi stderr/stdout above and run the same model with the pi CLI if needed.';
916
+ }
917
+
918
+ function dirSizeBytes(dir) {
919
+ let total = 0;
920
+ let entries = [];
921
+ try {
922
+ entries = fs.readdirSync(dir, { withFileTypes: true });
923
+ } catch {
924
+ return 0;
925
+ }
926
+ for (const entry of entries) {
927
+ const full = path.join(dir, entry.name);
928
+ try {
929
+ if (entry.isDirectory()) total += dirSizeBytes(full);
930
+ else if (entry.isFile() || entry.isSymbolicLink()) total += fs.lstatSync(full).size;
931
+ } catch {}
932
+ }
933
+ return total;
934
+ }
935
+
936
+ function runtimeWorkersDir(runtimeDir) {
937
+ const base = path.basename(runtimeDir);
938
+ if (/^(pi|codex)-/.test(base) && fs.existsSync(path.join(runtimeDir, 'report.json'))) return path.dirname(runtimeDir);
939
+ if (base === 'pi-workers') return runtimeDir;
940
+ return path.join(runtimeDir, 'pi-workers');
941
+ }
942
+
943
+ function existingRuntimeWorkersDirs(runtimeDir, workDir) {
944
+ const dirs = [runtimeWorkersDir(runtimeDir)];
945
+ for (const legacy of legacyRuntimeDirs(workDir)) {
946
+ dirs.push(runtimeWorkersDir(legacy));
947
+ }
948
+ return [...new Set(dirs)];
949
+ }
950
+
951
+ function removeWorkerRuntime(workerDir, workDir) {
952
+ const repoDir = path.join(workerDir, 'repo');
953
+ if (fs.existsSync(repoDir)) {
954
+ try {
955
+ execFileSync('git', ['worktree', 'remove', '--force', repoDir], {
956
+ cwd: workDir,
957
+ encoding: 'utf-8',
958
+ timeout: 30000,
959
+ stdio: ['ignore', 'pipe', 'pipe'],
960
+ });
961
+ } catch {}
962
+ }
963
+ fs.rmSync(workerDir, { recursive: true, force: true });
964
+ }
965
+
966
+ // ---- Tool implementations ----
967
+
968
+ async function dispatch(args) {
969
+ const {
970
+ mode = 'implement',
971
+ task_dir: explicitTaskDir,
972
+ working_directory: cwd,
973
+ executor: executorInput,
974
+ model: modelInput,
975
+ thinking = 'xhigh',
976
+ tools: toolsInput,
977
+ execution_mode: executionModeInput,
978
+ isolate_executor,
979
+ isolate_pi,
980
+ timeout_minutes: timeoutInput = 60,
981
+ dry_run = false,
982
+ extra_instructions,
983
+ scope,
984
+ context_files,
985
+ embed_context = true,
986
+ trellis_context_id,
987
+ validation_commands = [],
988
+ min_files_changed,
989
+ required_paths_modified,
990
+ forbidden_paths,
991
+ min_diff_lines,
992
+ } = args;
993
+
994
+ const isolateExecutor = isolate_executor ?? isolate_pi ?? true;
995
+ const timeout_minutes = Math.min(timeoutInput, 120);
996
+ const workDir = cwd || process.cwd();
997
+ const executionMode = executionModeInput || defaultExecutionMode(mode);
998
+ const tools = toolsInput || defaultToolsForExecution(executionMode);
999
+ if (!['review', 'patch', 'worktree', 'direct'].includes(executionMode)) {
1000
+ return { content: [{ type: 'text', text: `Error: unsupported execution_mode "${executionMode}". Use review, patch, worktree, or direct.` }], isError: true };
1001
+ }
1002
+ if (executionMode === 'worktree' && !isGitRepo(workDir)) {
1003
+ return { content: [{ type: 'text', text: `Error: execution_mode=worktree requires a git repository: ${workDir}` }], isError: true };
1004
+ }
1005
+ let executor;
1006
+ try {
1007
+ executor = resolveExecutor(executorInput, mode);
1008
+ } catch (e) {
1009
+ return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
1010
+ }
1011
+ const exec = EXECUTORS[executor];
1012
+ const channelName = detectChannel(args, process.env);
1013
+ const projectMode = detectProjectMode(workDir, channelName, executionMode);
1014
+ let model, modelFrom, modelKey;
1015
+ try {
1016
+ ({ resolved: model, from: modelFrom, key: modelKey } = exec.resolveModel(modelInput, mode));
1017
+ } catch (e) {
1018
+ return { content: [{ type: 'text', text: e.message }], isError: true };
1019
+ }
1020
+
1021
+ let taskDir = explicitTaskDir || null;
1022
+ if (taskDir) {
1023
+ // Explicit task_dir: validate existence so callers get a clear error
1024
+ // instead of a confusing missing-manifest report later.
1025
+ const taskAbs = path.isAbsolute(taskDir) ? taskDir : path.join(workDir, taskDir);
1026
+ if (!fs.existsSync(taskAbs)) {
1027
+ return {
1028
+ content: [{ type: 'text', text: `Error: task_dir "${taskDir}" does not exist (resolved: ${taskAbs}).` }],
1029
+ isError: true,
1030
+ };
1031
+ }
1032
+ } else {
1033
+ taskDir = resolveActiveTask(workDir, trellis_context_id);
1034
+ }
1035
+
1036
+ let context = null;
1037
+ let promptBody = '';
1038
+ if (taskDir) {
1039
+ context = assembleTrellisContext(workDir, taskDir, mode);
1040
+ promptBody = buildDispatchPrompt({ ...context, mode, execution_mode: executionMode, extra_instructions, scope, validation_commands, context_files: readContextFiles(workDir, context_files), embed_context });
1041
+ } else if (mode === 'custom') {
1042
+ if (!extra_instructions) return { content: [{ type: 'text', text: 'Error: custom mode requires extra_instructions.' }], isError: true };
1043
+ promptBody = buildNoTrellisPrompt(mode, extra_instructions, scope, validation_commands, executionMode, readContextFiles(workDir, context_files));
1044
+ } else {
1045
+ if (!extra_instructions) {
1046
+ return {
1047
+ content: [{ type: 'text', text: 'No active Trellis task found. Either:\n1. Use a Trellis project with an active task and inherited TRELLIS_CONTEXT_ID\n2. Provide trellis_context_id explicitly\n3. Provide task_dir explicitly\n4. Use mode="custom" with extra_instructions\n5. Provide extra_instructions to describe the task.' }],
1048
+ isError: true,
1049
+ };
1050
+ }
1051
+ promptBody = buildNoTrellisPrompt(mode, extra_instructions, scope, validation_commands, executionMode, readContextFiles(workDir, context_files));
1052
+ }
1053
+
1054
+ const runtimeDir = resolveRuntimeDir(workDir);
1055
+ fs.mkdirSync(runtimeDir, { recursive: true });
1056
+
1057
+ const workerId = makeWorkerId(executor, mode, taskDir, scope, extra_instructions);
1058
+ const worker = createWorkerRuntime(runtimeDir, workerId, executor);
1059
+ const promptPath = path.join(worker.workerDir, 'prompt.md');
1060
+ const logPath = path.join(worker.workerDir, 'output.log');
1061
+ const reportPath = path.join(worker.workerDir, 'report.json');
1062
+ const patchPath = path.join(worker.workerDir, 'diff.patch');
1063
+ fs.writeFileSync(promptPath, promptBody, 'utf-8');
1064
+ const promptBytes = fs.statSync(promptPath).size;
1065
+ const promptWarning = executor === 'pi' ? promptSizeWarning(promptBytes) : '';
1066
+ if (promptWarning) logErr(promptWarning);
1067
+
1068
+ let metaResponse = `Resolved task: ${taskDir || '(custom mode)'}\n`;
1069
+ if (context) {
1070
+ metaResponse += `Manifest: ${context.manifestFile} (${context.manifest.entries.length} entries, ${context.manifest.files.length} files resolved)\n`;
1071
+ metaResponse += `Artifacts: ${Object.keys(context.artifacts).join(', ') || 'none'}\n`;
1072
+ if (context.manifest.missing && context.manifest.missing.length > 0) {
1073
+ const m = context.manifest.missing;
1074
+ metaResponse += `Missing manifest files: ${m.length} (${m.slice(0, 5).join(', ')}${m.length > 5 ? `, +${m.length - 5} more` : ''})\n`;
1075
+ }
1076
+ }
1077
+ metaResponse += `Prompt: ${promptPath}\n`;
1078
+ metaResponse += `Log: ${logPath}\n`;
1079
+ metaResponse += `Report: ${reportPath}\n`;
1080
+ metaResponse += `Prompt size: ${Math.ceil(promptBytes / 1024)} KB\n`;
1081
+ if (promptWarning) metaResponse += `${promptWarning}\n`;
1082
+ metaResponse += `Worker: ${workerId} (${executionMode})\n`;
1083
+ metaResponse += `Project mode: ${projectMode}\n`;
1084
+ if (executionMode === 'worktree') {
1085
+ metaResponse += `Worker repo: ${worker.repoDir}\n`;
1086
+ metaResponse += `Patch: ${patchPath}\n`;
1087
+ }
1088
+ metaResponse += `Executor: ${executor}\n`;
1089
+ metaResponse += `Model: ${model || '(codex CLI default)'} (${modelFrom}${modelKey ? `:${modelKey}` : ''}, thinking: ${thinking})\n`;
1090
+ if (executor === 'codex') {
1091
+ metaResponse += `Sandbox: ${exec.sandboxFor(executionMode)} (codex; tools param not applicable)\n`;
1092
+ } else {
1093
+ metaResponse += `Tools: ${tools}\n`;
1094
+ }
1095
+ metaResponse += `Timeout: ${timeout_minutes} min\n`;
1096
+ metaResponse += `Channel: ${channelName || '(none, local mode)'}\n`;
1097
+ if (executor === 'pi') {
1098
+ metaResponse += `Pi config: seeded ${worker.configFiles || 0} agent file(s) into isolated piHome\n`;
1099
+ }
1100
+
1101
+ const { env: execEnv, stripped: strippedEnv } = exec.buildEnv(process.env);
1102
+ if (strippedEnv.length > 0) {
1103
+ metaResponse += `Env: scrubbed ${strippedEnv.length} sensitive var${strippedEnv.length === 1 ? '' : 's'} from ${exec.label} subprocess\n`;
1104
+ }
1105
+
1106
+ if (dry_run) {
1107
+ return {
1108
+ content: [{
1109
+ type: 'text',
1110
+ text: `[DRY RUN] No ${exec.label} process started.\n\n${metaResponse}\n--- Generated prompt (first 4000 chars) ---\n\n${promptBody.slice(0, 4000)}${promptBody.length > 4000 ? '\n... (truncated)' : ''}`,
1111
+ }],
1112
+ };
1113
+ }
1114
+
1115
+ const execBin = exec.findBinary();
1116
+ if (!execBin) {
1117
+ const other = executor === 'pi' ? 'codex' : 'pi';
1118
+ return { content: [{ type: 'text', text: `Error: ${executor} binary not found in PATH (or ${exec.binaryEnvVar} env). Install it, fix ${exec.binaryEnvVar}, or pass executor="${other}".\n\n${metaResponse}` }], isError: true };
1119
+ }
1120
+
1121
+ let lockPath = null;
1122
+ lockPath = dispatchLockPath(runtimeDir, taskDir, scope, extra_instructions);
1123
+ const lr = acquireDispatchLock(lockPath);
1124
+ if (!lr.acquired) {
1125
+ return {
1126
+ content: [{
1127
+ type: 'text',
1128
+ text: `Error: another dispatch with identical (task, scope, extra_instructions) is already running.\n Holder PID: ${lr.holder?.pid}\n Started: ${lr.holder?.startedAt}\n Lock file: ${lockPath}\n\nIf the holder is actually dead, remove the lock file manually.\n\n${metaResponse}`,
1129
+ }],
1130
+ isError: true,
1131
+ };
1132
+ }
1133
+
1134
+ let piWorkDir = workDir;
1135
+ if (executionMode === 'worktree') {
1136
+ try {
1137
+ createWorktree(workDir, worker.repoDir);
1138
+ piWorkDir = worker.repoDir;
1139
+ } catch (e) {
1140
+ if (lockPath) releaseDispatchLock(lockPath);
1141
+ const message = `${metaResponse}\nError creating isolated git worktree: ${e.message}`;
1142
+ writeJsonFile(reportPath, {
1143
+ worker_id: workerId,
1144
+ status: 'spawn_error',
1145
+ result_class: 'failed',
1146
+ status_reason: 'Could not create isolated git worktree.',
1147
+ validation_scope: 'not run',
1148
+ data_validation: 'not_attempted',
1149
+ data_validation_reason: 'Dispatch did not start.',
1150
+ orchestrator_next_steps: ['Inspect error', 'Fix worktree setup', 'Re-dispatch'],
1151
+ recommended_main_repo_commands: ['git status --short'],
1152
+ project_mode: projectMode,
1153
+ executor,
1154
+ error: e.message,
1155
+ execution_mode: executionMode,
1156
+ prompt_file: promptPath,
1157
+ log_file: logPath,
1158
+ report_file: reportPath,
1159
+ patch_file: null,
1160
+ finished_at: new Date().toISOString(),
1161
+ });
1162
+ return { content: [{ type: 'text', text: message }], isError: true };
1163
+ }
1164
+ }
1165
+
1166
+ await emitChannelEvent(channelName, 'dispatch_start', {
1167
+ mode, task: taskDir, scope: scope || null, model, executor,
1168
+ worker_id: workerId, execution_mode: executionMode,
1169
+ prompt_file: promptPath, log_file: logPath,
1170
+ report_file: reportPath, patch_file: executionMode === 'worktree' ? patchPath : null,
1171
+ started_at: new Date().toISOString(),
1172
+ }, workDir);
1173
+
1174
+ return new Promise((resolve) => {
1175
+ const spawnSpec = exec.buildSpawnSpec({
1176
+ model, tools, thinking,
1177
+ isolate: isolateExecutor,
1178
+ executionMode, promptPath, worker,
1179
+ });
1180
+
1181
+ const timeout = timeout_minutes * 60 * 1000;
1182
+ let killed = false;
1183
+ let logBytes = 0;
1184
+ const stdoutBuf = makeHeadTailBuffer();
1185
+ const stderrBuf = makeHeadTailBuffer(2 * 1024, 8 * 1024);
1186
+
1187
+ const proc = trackChild(spawn(execBin, spawnSpec.argv, {
1188
+ cwd: piWorkDir,
1189
+ env: {
1190
+ ...execEnv,
1191
+ ...spawnSpec.env,
1192
+ },
1193
+ stdio: [spawnSpec.stdinFd ?? 'ignore', 'pipe', 'pipe'],
1194
+ }));
1195
+ if (spawnSpec.stdinFd != null) {
1196
+ // spawn dups the fd; close our copy so codex sees EOF after the prompt.
1197
+ try { fs.closeSync(spawnSpec.stdinFd); } catch {}
1198
+ }
1199
+
1200
+ proc.stdout.on('data', (d) => {
1201
+ logBytes += d.length;
1202
+ stdoutBuf.push(d.toString());
1203
+ });
1204
+ proc.stderr.on('data', (d) => {
1205
+ logBytes += d.length;
1206
+ stderrBuf.push(d.toString());
1207
+ });
1208
+
1209
+ const logStream = fs.createWriteStream(logPath, { flags: 'w' });
1210
+ proc.stdout.pipe(logStream);
1211
+ proc.stderr.pipe(logStream);
1212
+
1213
+ let killTimer = null;
1214
+ let settled = false;
1215
+ let spawnErrored = false;
1216
+ const finish = (result) => {
1217
+ if (settled) return;
1218
+ settled = true;
1219
+ clearTimeout(timer);
1220
+ if (killTimer) clearTimeout(killTimer);
1221
+ if (lockPath) releaseDispatchLock(lockPath);
1222
+ resolve(result);
1223
+ };
1224
+
1225
+ const timer = setTimeout(() => {
1226
+ killed = true;
1227
+ proc.kill('SIGTERM');
1228
+ killTimer = setTimeout(() => proc.kill('SIGKILL'), 5000);
1229
+ }, timeout);
1230
+
1231
+ proc.on('close', async (code, signal) => {
1232
+ if (spawnErrored || settled) return;
1233
+ logStream.close();
1234
+
1235
+ const exitCode = code;
1236
+ const signaled = Boolean(signal);
1237
+ let diffInfo = null;
1238
+ if (executionMode === 'worktree') {
1239
+ diffInfo = prepareWorkerDiff(worker.repoDir, patchPath);
1240
+ }
1241
+
1242
+ const validation = runPostValidation(piWorkDir, {
1243
+ min_files_changed, required_paths_modified, forbidden_paths, min_diff_lines,
1244
+ });
1245
+
1246
+ const stdout = stdoutBuf.finalize();
1247
+ const stderr = stderrBuf.finalize();
1248
+ const interp = exec.interpretOutput({ exitCode, killed, stdout, stderr, worker });
1249
+ let output = interp.output;
1250
+ if (killed) output += `\n\n[PROCESS KILLED: exceeded ${timeout_minutes} minute timeout]`;
1251
+ if (signaled && !killed) output += `\n\n[PROCESS EXITED BY SIGNAL: ${signal}]`;
1252
+
1253
+ const runStatus = signaled ? 'killed' : interp.runStatus;
1254
+ const changedFiles = validation.changedFiles || diffInfo?.changedFiles || [];
1255
+ const ok = runStatus === 'done' && validation.passed && (!diffInfo || diffInfo.ok) && !(executionMode === 'worktree' && diffInfo?.ok && changedFiles.length === 0);
1256
+ const finalStatus = ok
1257
+ ? 'done'
1258
+ : (!validation.passed ? 'validation_failed' : (diffInfo && !diffInfo.ok ? 'diff_failed' : runStatus));
1259
+ const result = classifyResult({
1260
+ executionMode,
1261
+ status: runStatus,
1262
+ finalStatus,
1263
+ exitCode,
1264
+ validation,
1265
+ diffInfo,
1266
+ changedFiles,
1267
+ output,
1268
+ });
1269
+ const applyCommand = executionMode === 'worktree' ? `git apply "${patchPath}"` : null;
1270
+ const recommendedCommands = buildRecommendedCommands({ applyCommand, validationCommands: validation_commands });
1271
+ const orchestratorNextSteps = buildOrchestratorNextSteps({
1272
+ resultClass: result.result_class,
1273
+ projectMode,
1274
+ applyCommand,
1275
+ validationCommands: validation_commands,
1276
+ });
1277
+ const report = {
1278
+ worker_id: workerId,
1279
+ status: result.status,
1280
+ run_status: runStatus,
1281
+ result_class: result.result_class,
1282
+ status_reason: result.status_reason,
1283
+ validation_scope: validationScopeText(validation, result.result_class === 'patch_ready_limited_validation'),
1284
+ data_validation: result.data_validation,
1285
+ data_validation_reason: result.data_validation_reason,
1286
+ orchestrator_next_steps: orchestratorNextSteps,
1287
+ recommended_main_repo_commands: recommendedCommands,
1288
+ project_mode: projectMode,
1289
+ execution_mode: executionMode,
1290
+ task: taskDir || null,
1291
+ executor,
1292
+ model,
1293
+ model_source: modelFrom,
1294
+ model_key: modelKey,
1295
+ tools,
1296
+ isolate_executor: isolateExecutor,
1297
+ isolate_pi: isolateExecutor,
1298
+ usage: interp.usage || null,
1299
+ exit_code: exitCode,
1300
+ signal: signal || null,
1301
+ killed: killed || signaled,
1302
+ validation: validation.skipped ? 'skipped' : (validation.passed ? 'passed' : 'failed'),
1303
+ validation_failures: validation.failures || [],
1304
+ changed_files: changedFiles,
1305
+ shortstat: validation.shortstat || diffInfo?.shortstat || '',
1306
+ prompt_file: promptPath,
1307
+ log_file: logPath,
1308
+ report_file: reportPath,
1309
+ patch_file: executionMode === 'worktree' ? patchPath : null,
1310
+ apply_command: applyCommand,
1311
+ worker_repo: executionMode === 'worktree' ? worker.repoDir : null,
1312
+ diff: diffInfo,
1313
+ finished_at: new Date().toISOString(),
1314
+ };
1315
+ writeJsonFile(reportPath, report);
1316
+
1317
+ await emitChannelEvent(channelName, ok ? 'dispatch_done' : 'dispatch_failed', {
1318
+ mode, task: taskDir, executor,
1319
+ worker_id: workerId, execution_mode: executionMode,
1320
+ status: report.status,
1321
+ result_class: report.result_class,
1322
+ status_reason: report.status_reason,
1323
+ validation_scope: report.validation_scope,
1324
+ exit_code: exitCode, signal: signal || null, killed: killed || signaled,
1325
+ validation: report.validation,
1326
+ validation_failures: validation.failures || [],
1327
+ changed_files: changedFiles,
1328
+ report_file: reportPath,
1329
+ patch_file: report.patch_file,
1330
+ apply_command: report.apply_command,
1331
+ finished_at: report.finished_at,
1332
+ }, workDir);
1333
+
1334
+ let validationBlock = '';
1335
+ if (!validation.skipped) {
1336
+ validationBlock = `\n--- post-validation ---\nstatus: ${validation.passed ? 'passed' : 'FAILED'}\n`;
1337
+ if (validation.shortstat) validationBlock += `git: ${validation.shortstat}\n`;
1338
+ if (validation.changedFiles && validation.changedFiles.length > 0) {
1339
+ validationBlock += `changed files (${validation.changedFiles.length}):\n`;
1340
+ for (const f of validation.changedFiles.slice(0, 30)) validationBlock += ` ${f}\n`;
1341
+ if (validation.changedFiles.length > 30) validationBlock += ` ... +${validation.changedFiles.length - 30} more\n`;
1342
+ }
1343
+ if (validation.failures.length > 0) {
1344
+ validationBlock += `failures:\n`;
1345
+ for (const f of validation.failures) validationBlock += ` - ${f.rule}: ${f.detail}\n`;
1346
+ }
1347
+ validationBlock += '\n';
1348
+ }
1349
+ let artifactBlock = `\n--- artifacts ---\nstatus: ${report.status}\nresult_class: ${report.result_class}\nreason: ${report.status_reason}\nvalidation_scope: ${report.validation_scope}\nreport: ${reportPath}\n`;
1350
+ if (executionMode === 'worktree') {
1351
+ artifactBlock += `worker_repo: ${worker.repoDir}\npatch: ${patchPath}\napply: ${report.apply_command}\n`;
1352
+ if (diffInfo && !diffInfo.ok) artifactBlock += `diff_error: ${diffInfo.error}\n`;
1353
+ }
1354
+ if (orchestratorNextSteps.length > 0) {
1355
+ artifactBlock += `next_steps:\n`;
1356
+ for (const step of orchestratorNextSteps) artifactBlock += ` - ${step}\n`;
1357
+ }
1358
+ const tinyLogDiagnostics = formatTinyLogDiagnostics({
1359
+ exec,
1360
+ exitCode,
1361
+ logPath,
1362
+ logBytes,
1363
+ stdout: snapshotBuffer(stdoutBuf) || stdout,
1364
+ stderr: snapshotBuffer(stderrBuf) || stderr,
1365
+ });
1366
+
1367
+ const isError = !['done', 'patch_ready_limited_validation'].includes(report.status);
1368
+ finish({
1369
+ content: [{
1370
+ type: 'text',
1371
+ text: `${metaResponse}\n${exec.label} exited with code ${exitCode}${signal ? ` (signal ${signal})` : ''}.\n${artifactBlock}${validationBlock}${tinyLogDiagnostics}\nOutput captured in: ${logPath}\nUse read_report for the structured summary and optional log tail.`,
1372
+ }],
1373
+ isError,
1374
+ });
1375
+ });
1376
+
1377
+ proc.on('error', async (err) => {
1378
+ if (settled) return;
1379
+ spawnErrored = true;
1380
+ logStream.close();
1381
+ writeJsonFile(reportPath, {
1382
+ worker_id: workerId,
1383
+ status: 'spawn_error',
1384
+ result_class: 'failed',
1385
+ status_reason: `Could not spawn ${exec.label} subprocess.`,
1386
+ validation_scope: 'not run',
1387
+ data_validation: 'not_attempted',
1388
+ data_validation_reason: 'Dispatch did not start.',
1389
+ orchestrator_next_steps: ['Inspect error', `Fix ${exec.name} binary/config`, 'Re-dispatch'],
1390
+ recommended_main_repo_commands: ['git status --short'],
1391
+ project_mode: projectMode,
1392
+ execution_mode: executionMode,
1393
+ task: taskDir || null,
1394
+ executor,
1395
+ error: err.message,
1396
+ prompt_file: promptPath,
1397
+ log_file: logPath,
1398
+ report_file: reportPath,
1399
+ patch_file: executionMode === 'worktree' ? patchPath : null,
1400
+ worker_repo: executionMode === 'worktree' ? worker.repoDir : null,
1401
+ finished_at: new Date().toISOString(),
1402
+ });
1403
+ await emitChannelEvent(channelName, 'spawn_error', {
1404
+ mode, task: taskDir, executor,
1405
+ worker_id: workerId, execution_mode: executionMode,
1406
+ error: err.message, report_file: reportPath,
1407
+ finished_at: new Date().toISOString(),
1408
+ }, workDir);
1409
+ finish({
1410
+ content: [{ type: 'text', text: `${metaResponse}\nError spawning ${exec.name}: ${err.message}` }],
1411
+ isError: true,
1412
+ });
1413
+ });
1414
+ });
1415
+ }
1416
+
1417
+ function smoke(args) {
1418
+ const { model: modelInput, mode = 'implement', executor: executorInput, working_directory } = args;
1419
+ if (!['implement', 'check'].includes(mode)) {
1420
+ return { content: [{ type: 'text', text: `Error: unsupported smoke mode "${mode}". Use implement or check.` }], isError: true };
1421
+ }
1422
+ let executor;
1423
+ try {
1424
+ executor = resolveExecutor(executorInput, mode);
1425
+ } catch (e) {
1426
+ return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
1427
+ }
1428
+ const exec = EXECUTORS[executor];
1429
+ let model, modelFrom, modelKey;
1430
+ try {
1431
+ ({ resolved: model, from: modelFrom, key: modelKey } = exec.resolveModel(modelInput, mode));
1432
+ } catch (e) {
1433
+ return { content: [{ type: 'text', text: e.message }], isError: true };
1434
+ }
1435
+ const execBin = exec.findBinary();
1436
+ if (!execBin) return { content: [{ type: 'text', text: `Error: ${executor} binary not found in PATH (or ${exec.binaryEnvVar} env).` }], isError: true };
1437
+
1438
+ // Use the same env scrub + isolation as dispatch so smoke catches config issues.
1439
+ const { env: execEnv } = exec.buildEnv(process.env);
1440
+ let tmpDir = null;
1441
+ let seed = null;
1442
+ let childEnv = execEnv;
1443
+ if (executor === 'pi') {
1444
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-smoke-'));
1445
+ const isolatedHome = path.join(tmpDir, 'pi-home');
1446
+ const sessionDir = path.join(tmpDir, 'sessions');
1447
+ fs.mkdirSync(isolatedHome, { recursive: true });
1448
+ fs.mkdirSync(sessionDir, { recursive: true });
1449
+ seed = seedPiAgentConfig(isolatedHome);
1450
+ childEnv = {
1451
+ ...execEnv,
1452
+ PI_CODING_AGENT_DIR: isolatedHome,
1453
+ PI_CODING_AGENT_SESSION_DIR: sessionDir,
1454
+ PI_OFFLINE: '1',
1455
+ PI_SKIP_VERSION_CHECK: '1',
1456
+ };
1457
+ }
1458
+ const safeEnv = safeSmokeEnv(childEnv);
1459
+ const spec = exec.smokeSpec(model);
1460
+ const modelLabel = `${model || '(codex CLI default)'} (${modelFrom}${modelKey ? `:${modelKey}` : ''})`;
1461
+
1462
+ return new Promise((resolve) => {
1463
+ const proc = trackChild(spawn(execBin, spec.argv, {
1464
+ cwd: working_directory || process.cwd(),
1465
+ env: childEnv,
1466
+ // -p / exec modes block on stdin EOF if stdin is a live pipe.
1467
+ stdio: ['ignore', 'pipe', 'pipe'],
1468
+ }));
1469
+
1470
+ let stdout = '';
1471
+ let stderr = '';
1472
+ let killed = false;
1473
+ let settled = false;
1474
+ let killTimer = null;
1475
+ const finish = (result) => {
1476
+ if (settled) return;
1477
+ settled = true;
1478
+ clearTimeout(timer);
1479
+ if (killTimer) clearTimeout(killTimer);
1480
+ if (tmpDir) { try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {} }
1481
+ resolve(result);
1482
+ };
1483
+ const timer = setTimeout(() => {
1484
+ killed = true;
1485
+ proc.kill('SIGTERM');
1486
+ killTimer = setTimeout(() => proc.kill('SIGKILL'), 5000);
1487
+ }, 60000);
1488
+ proc.stdout.on('data', (d) => { stdout += d.toString(); });
1489
+ proc.stderr.on('data', (d) => { stderr += d.toString(); });
1490
+ proc.on('close', (code, signal) => {
1491
+ const interp = exec.interpretOutput({ exitCode: code, killed, stdout, stderr });
1492
+ const ready = interp.output.includes(spec.readyText);
1493
+ let text = `${exec.label} smoke: ${ready ? 'PASSED' : 'FAILED'} | model=${modelLabel} | exit=${code} | signal=${signal || 'none'} | killed=${killed}`;
1494
+ if (executor === 'pi') text += `\n${formatSeededConfig(seed)}`;
1495
+ text += `\n${formatSmokeEnv(safeEnv)}`;
1496
+ text += `\n${formatBlock(`${exec.name} stdout`, stdout)}`;
1497
+ text += `\n${formatBlock(`${exec.name} stderr`, stderr)}`;
1498
+ if (!ready) {
1499
+ const hint = executor === 'pi'
1500
+ ? smokeDiagnostic(stdout, stderr, seed)
1501
+ : (exec.failureHint(stderr, interp.output) || `diagnostic: inspect ${exec.name} stderr/stdout above and run the same command with the ${exec.name} CLI if needed.`);
1502
+ text += `\n${hint}`;
1503
+ }
1504
+ finish({
1505
+ content: [{ type: 'text', text }],
1506
+ isError: !ready,
1507
+ });
1508
+ });
1509
+ proc.on('error', (err) => {
1510
+ let text = `${exec.label} smoke: FAILED | model=${modelLabel} | spawn_error=${err.message}`;
1511
+ if (executor === 'pi') text += `\n${formatSeededConfig(seed)}`;
1512
+ text += `\n${formatSmokeEnv(safeEnv)}`;
1513
+ finish({ content: [{ type: 'text', text }], isError: true });
1514
+ });
1515
+ });
1516
+ }
1517
+
1518
+ function candidateWorkerDirs(args) {
1519
+ const { runtime_dir, worker_id, working_directory } = args;
1520
+ const dirs = [];
1521
+ if (runtime_dir) {
1522
+ const resolvedRuntime = resolvePath(runtime_dir, working_directory);
1523
+ if (worker_id) {
1524
+ dirs.push(path.join(resolvedRuntime, 'pi-workers', worker_id));
1525
+ dirs.push(path.join(resolvedRuntime, worker_id));
1526
+ }
1527
+ dirs.push(resolvedRuntime);
1528
+ }
1529
+ if (worker_id) {
1530
+ const base = working_directory ? resolveRuntimeDir(resolvePath(working_directory)) : TMP_RUNTIME_DIR;
1531
+ dirs.push(path.join(base, 'pi-workers', worker_id));
1532
+ const workDir = working_directory ? resolvePath(working_directory) : process.cwd();
1533
+ for (const legacy of legacyRuntimeDirs(workDir)) {
1534
+ dirs.push(path.join(legacy, 'pi-workers', worker_id));
1535
+ }
1536
+ }
1537
+ return [...new Set(dirs)];
1538
+ }
1539
+
1540
+ function resolveReportInputs(args) {
1541
+ const { log_file, report_file, working_directory } = args;
1542
+ if (report_file) {
1543
+ const reportPath = resolvePath(report_file, working_directory);
1544
+ return { reportPath, logPath: null };
1545
+ }
1546
+ if (log_file) {
1547
+ const logPath = resolvePath(log_file, working_directory);
1548
+ return { reportPath: path.join(path.dirname(logPath), 'report.json'), logPath };
1549
+ }
1550
+ for (const dir of candidateWorkerDirs(args)) {
1551
+ const reportPath = path.join(dir, 'report.json');
1552
+ if (fs.existsSync(reportPath)) return { reportPath, logPath: path.join(dir, 'output.log') };
1553
+ }
1554
+ return { reportPath: null, logPath: null };
1555
+ }
1556
+
1557
+ function summarizeReport(report, reportPath) {
1558
+ const changed = Array.isArray(report.changed_files) ? report.changed_files : [];
1559
+ const steps = Array.isArray(report.orchestrator_next_steps) ? report.orchestrator_next_steps : [];
1560
+ const commands = Array.isArray(report.recommended_main_repo_commands) ? report.recommended_main_repo_commands : [];
1561
+ let text = `--- report summary ---\n`;
1562
+ text += `report: ${reportPath}\n`;
1563
+ text += `status: ${report.status || '(unknown)'}\n`;
1564
+ text += `result_class: ${report.result_class || '(missing)'}\n`;
1565
+ text += `project_mode: ${report.project_mode || '(unknown)'}\n`;
1566
+ if (report.status_reason) text += `status_reason: ${report.status_reason}\n`;
1567
+ if (report.validation_scope) text += `validation_scope: ${report.validation_scope}\n`;
1568
+ if (report.data_validation) text += `data_validation: ${report.data_validation}\n`;
1569
+ if (report.data_validation_reason) text += `data_validation_reason: ${report.data_validation_reason}\n`;
1570
+ if (report.apply_command) text += `apply_command: ${report.apply_command}\n`;
1571
+ if (changed.length > 0) {
1572
+ text += `changed_files (${changed.length}):\n`;
1573
+ for (const f of changed.slice(0, 30)) text += ` ${f}\n`;
1574
+ if (changed.length > 30) text += ` ... +${changed.length - 30} more\n`;
1575
+ } else {
1576
+ text += `changed_files: none\n`;
1577
+ }
1578
+ if (steps.length > 0) {
1579
+ text += `orchestrator_next_steps:\n`;
1580
+ for (const step of steps) text += ` - ${step}\n`;
1581
+ }
1582
+ if (commands.length > 0) {
1583
+ text += `recommended_main_repo_commands:\n`;
1584
+ for (const cmd of commands) text += ` ${cmd}\n`;
1585
+ }
1586
+ return text;
1587
+ }
1588
+
1589
+ function readReport(args) {
1590
+ const { lines = 200 } = args;
1591
+ const { reportPath, logPath } = resolveReportInputs(args);
1592
+ if (!reportPath && !logPath) {
1593
+ return { content: [{ type: 'text', text: 'Error: provide log_file, report_file, or runtime_dir plus worker_id.' }], isError: true };
1594
+ }
1595
+ try {
1596
+ const n = Number.isFinite(Number(lines)) ? Math.max(1, Math.min(10000, Math.floor(Number(lines)))) : 200;
1597
+ let text = '';
1598
+ if (reportPath && fs.existsSync(reportPath)) {
1599
+ const report = JSON.parse(fs.readFileSync(reportPath, 'utf-8'));
1600
+ text += summarizeReport(report, reportPath);
1601
+ } else if (reportPath) {
1602
+ text += `report not found: ${reportPath}\n`;
1603
+ }
1604
+ const resolvedLog = logPath || (reportPath ? path.join(path.dirname(reportPath), 'output.log') : null);
1605
+ if (resolvedLog && fs.existsSync(resolvedLog)) {
1606
+ const raw = fs.readFileSync(resolvedLog, 'utf-8');
1607
+ const parts = raw.split('\n');
1608
+ const content = parts.slice(Math.max(0, parts.length - n - 1)).join('\n');
1609
+ text += `\n--- ${resolvedLog} (last ${n} lines) ---\n\n${content}`;
1610
+ }
1611
+ if (!text.trim()) return { content: [{ type: 'text', text: `No report/log found for provided arguments.` }], isError: true };
1612
+ return { content: [{ type: 'text', text }] };
1613
+ } catch (e) {
1614
+ return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
1615
+ }
1616
+ }
1617
+
1618
+ function cleanupRuntime(args) {
1619
+ const {
1620
+ working_directory,
1621
+ retain_days = 7,
1622
+ dry_run = false,
1623
+ } = args;
1624
+ const workDir = working_directory || process.cwd();
1625
+ const runtimeDir = resolveRuntimeDir(workDir);
1626
+ const workersDirs = existingRuntimeWorkersDirs(runtimeDir, workDir);
1627
+ const retainMs = Math.max(0, Number(retain_days) || 0) * 24 * 60 * 60 * 1000;
1628
+ const cutoff = Date.now() - retainMs;
1629
+ const existingWorkersDirs = workersDirs.filter(dir => fs.existsSync(dir));
1630
+
1631
+ if (existingWorkersDirs.length === 0) {
1632
+ return {
1633
+ content: [{
1634
+ type: 'text',
1635
+ text: JSON.stringify({ runtime_dir: runtimeDir, workers_dirs: workersDirs, dry_run, removed: [], retained: [], bytes_freed: 0 }, null, 2),
1636
+ }],
1637
+ };
1638
+ }
1639
+
1640
+ const removed = [];
1641
+ const retained = [];
1642
+ let bytesFreed = 0;
1643
+ for (const workersDir of existingWorkersDirs) {
1644
+ const entries = fs.readdirSync(workersDir, { withFileTypes: true })
1645
+ .filter(entry => entry.isDirectory() && /^(pi|codex)-/.test(entry.name));
1646
+
1647
+ for (const entry of entries) {
1648
+ const dir = path.join(workersDir, entry.name);
1649
+ let stat;
1650
+ try {
1651
+ stat = fs.statSync(dir);
1652
+ } catch {
1653
+ continue;
1654
+ }
1655
+ const bytes = dirSizeBytes(dir);
1656
+ const item = {
1657
+ worker_id: entry.name,
1658
+ path: dir,
1659
+ mtime: stat.mtime.toISOString(),
1660
+ bytes,
1661
+ };
1662
+ if (stat.mtimeMs < cutoff) {
1663
+ removed.push(item);
1664
+ bytesFreed += bytes;
1665
+ if (!dry_run) removeWorkerRuntime(dir, workDir);
1666
+ } else {
1667
+ retained.push(item);
1668
+ }
1669
+ }
1670
+ }
1671
+
1672
+ const result = {
1673
+ runtime_dir: runtimeDir,
1674
+ workers_dirs: existingWorkersDirs,
1675
+ retain_days: Number(retain_days),
1676
+ dry_run: Boolean(dry_run),
1677
+ removed,
1678
+ retained,
1679
+ bytes_freed: dry_run ? 0 : bytesFreed,
1680
+ bytes_would_free: dry_run ? bytesFreed : undefined,
1681
+ };
1682
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
1683
+ }
1684
+
1685
+ function previewPrompt(args) {
1686
+ const {
1687
+ mode = 'implement',
1688
+ task_dir: explicitTaskDir,
1689
+ working_directory: cwd,
1690
+ execution_mode: executionModeInput,
1691
+ extra_instructions,
1692
+ scope,
1693
+ context_files,
1694
+ embed_context = true,
1695
+ trellis_context_id,
1696
+ validation_commands = [],
1697
+ } = args;
1698
+ const workDir = cwd || process.cwd();
1699
+ const executionMode = executionModeInput || defaultExecutionMode(mode);
1700
+ let taskDir = explicitTaskDir || resolveActiveTask(workDir, trellis_context_id);
1701
+ if (!taskDir && mode !== 'custom' && !extra_instructions) {
1702
+ return { content: [{ type: 'text', text: 'No active Trellis task found. Provide task_dir, trellis_context_id, use custom mode, or provide extra_instructions for standalone preview.' }], isError: true };
1703
+ }
1704
+
1705
+ let body;
1706
+ if (taskDir) {
1707
+ const context = assembleTrellisContext(workDir, taskDir, mode);
1708
+ body = buildDispatchPrompt({ ...context, mode, execution_mode: executionMode, extra_instructions, scope, validation_commands, context_files: readContextFiles(workDir, context_files), embed_context });
1709
+ } else {
1710
+ body = buildNoTrellisPrompt(mode, extra_instructions, scope, validation_commands, executionMode, readContextFiles(workDir, context_files));
1711
+ }
1712
+ return { content: [{ type: 'text', text: body }] };
1713
+ }
1714
+
1715
+ // ---- Tool schemas ----
1716
+
1717
+ const TOOLS = [
1718
+ {
1719
+ name: 'dispatch',
1720
+ description: 'Dispatch an implementation or check task to an executor backend (Codex CLI for implement/custom by default, Pi for check/cross-model review). With an active Trellis task, reads implement.jsonl/check.jsonl + prd.md + design.md + implement.md to assemble the prompt. Defaults to isolated worktree execution for implement/custom and read-only review for check. Emits Trellis channel events when TRELLIS_CHANNEL (or channel param) is set. Optional post-validation params catch "exit 0 + no work" failures.',
1721
+ inputSchema: {
1722
+ type: 'object',
1723
+ properties: {
1724
+ mode: { type: 'string', enum: ['implement', 'check', 'custom'], default: 'implement' },
1725
+ task_dir: { type: 'string', description: 'Explicit Trellis task directory (relative to repo root). Omit to auto-resolve.' },
1726
+ working_directory: { type: 'string', description: 'Repo root. Defaults to cwd.' },
1727
+ executor: { type: 'string', enum: ['pi', 'codex'], description: 'Executor backend. Defaults: [executor_adapter] default_executor in ~/.pi/config.toml, else implement/custom→codex, check→pi.' },
1728
+ model: { type: 'string', description: 'Logical name (implementer / reviewer / custom key from ~/.pi/config.toml [executor_adapter], or [executor_adapter.codex] for codex) or a fully qualified route/model name. Omit to use the default for the mode (codex falls back to the codex CLI default model).' },
1729
+ thinking: { type: 'string', default: 'xhigh' },
1730
+ execution_mode: { type: 'string', enum: ['review', 'patch', 'worktree', 'direct'], description: 'review=read-only report, patch=final-answer diff, worktree=isolated git worktree + exported diff.patch, direct=legacy in-place execution. Defaults to worktree for implement/custom and review for check.' },
1731
+ isolate_executor: { type: 'boolean', default: true, description: 'Preferred isolation flag. When true, Pi gets --no-extensions/--no-skills/etc. plus a per-worker PI_CODING_AGENT_DIR; codex gets --ignore-rules --ephemeral while still loading user config for provider/auth settings.' },
1732
+ isolate_pi: { type: 'boolean', default: true, description: 'Compatibility name for executor isolation. When true, Pi gets --no-extensions/--no-skills/etc. plus a per-worker PI_CODING_AGENT_DIR; codex gets --ignore-rules --ephemeral while still loading user config for provider/auth settings.' },
1733
+ embed_context: { type: 'boolean', default: true, description: 'When false, Trellis manifest/task artifact contents are not inlined under Embedded Trellis Context; only paths are listed for the executor to read on demand.' },
1734
+ tools: { type: 'string', description: 'Comma-separated Pi tools (pi executor only; codex uses --sandbox mapped from execution_mode). Defaults by execution_mode: review=read,grep,find,ls; patch=read,bash,grep,find,ls; worktree/direct=read,bash,edit,write,grep,find,ls.' },
1735
+ timeout_minutes: { type: 'number', default: 60, description: 'Capped at 120.' },
1736
+ dry_run: { type: 'boolean', default: false, description: 'Build prompt without launching the executor.' },
1737
+ extra_instructions: { type: 'string' },
1738
+ scope: { type: 'string', description: 'File/path constraints stated to the executor.' },
1739
+ context_files: { type: 'array', items: { type: 'string' }, description: 'Optional additional files to embed into the prompt. Contents are included only when explicitly requested.' },
1740
+ trellis_context_id: { type: 'string', description: 'Optional Trellis session/context id. Passed as TRELLIS_CONTEXT_ID when auto-resolving the active task via task.py current.' },
1741
+ validation_commands: { type: 'array', items: { type: 'string' }, description: 'Commands the executor should run before reporting done.' },
1742
+ channel: { type: 'string', description: 'Trellis channel name. Overrides TRELLIS_CHANNEL / TRELLIS_CHANNEL_NAME env. When set, message events are emitted into the channel for audit.' },
1743
+ min_files_changed: { type: 'number', description: 'Fail if fewer files are modified after the executor exits.' },
1744
+ required_paths_modified: { type: 'array', items: { type: 'string' }, description: 'Fail if any path NOT in the diff.' },
1745
+ forbidden_paths: { type: 'array', items: { type: 'string' }, description: 'Fail if any path IS in the diff. Trailing / matches directory prefix.' },
1746
+ min_diff_lines: { type: 'number', description: 'Fail if total ins+del < N.' },
1747
+ },
1748
+ },
1749
+ },
1750
+ {
1751
+ name: 'preview_prompt',
1752
+ description: 'Preview the prompt that dispatch() would generate, without running an executor.',
1753
+ inputSchema: {
1754
+ type: 'object',
1755
+ properties: {
1756
+ mode: { type: 'string', enum: ['implement', 'check', 'custom'], default: 'implement' },
1757
+ task_dir: { type: 'string' },
1758
+ working_directory: { type: 'string' },
1759
+ execution_mode: { type: 'string', enum: ['review', 'patch', 'worktree', 'direct'] },
1760
+ extra_instructions: { type: 'string' },
1761
+ scope: { type: 'string' },
1762
+ context_files: { type: 'array', items: { type: 'string' } },
1763
+ embed_context: { type: 'boolean', default: true },
1764
+ trellis_context_id: { type: 'string' },
1765
+ validation_commands: { type: 'array', items: { type: 'string' } },
1766
+ },
1767
+ },
1768
+ },
1769
+ {
1770
+ name: 'smoke',
1771
+ description: 'Quick smoke test: verify the executor binary (pi or codex) and the resolved model are reachable.',
1772
+ inputSchema: {
1773
+ type: 'object',
1774
+ properties: {
1775
+ model: { type: 'string', description: 'Logical name or fully qualified route/model name.' },
1776
+ mode: { type: 'string', enum: ['implement', 'check'], default: 'implement', description: 'Default logical key to resolve when model is omitted.' },
1777
+ executor: { type: 'string', enum: ['pi', 'codex'], description: 'Executor backend. Defaults: [executor_adapter] default_executor, else implement→codex, check→pi.' },
1778
+ working_directory: { type: 'string' },
1779
+ },
1780
+ },
1781
+ },
1782
+ {
1783
+ name: 'read_report',
1784
+ description: 'Read an executor completion report from report.json/log files or a Trellis/standalone runtime directory.',
1785
+ inputSchema: {
1786
+ type: 'object',
1787
+ properties: {
1788
+ log_file: { type: 'string', description: 'Path to executor output log. report.json is read from the same directory when present.' },
1789
+ report_file: { type: 'string', description: 'Path to report.json.' },
1790
+ runtime_dir: { type: 'string', description: 'Runtime dir or worker dir. Supports .trellis/.runtime and standalone /tmp/executor-adapter layouts; legacy /tmp/pi-adapter is still readable.' },
1791
+ worker_id: { type: 'string', description: 'Worker id under <runtime_dir>/pi-workers/<worker-id>.' },
1792
+ lines: { type: 'number', default: 200 },
1793
+ working_directory: { type: 'string' },
1794
+ },
1795
+ },
1796
+ },
1797
+ {
1798
+ name: 'cleanup_runtime',
1799
+ description: 'Prune old executor worker runtime directories under .trellis/.runtime/pi-workers or /tmp/executor-adapter/pi-workers.',
1800
+ inputSchema: {
1801
+ type: 'object',
1802
+ properties: {
1803
+ working_directory: { type: 'string', description: 'Repo root. Defaults to cwd. Trellis repos use .trellis/.runtime; standalone repos use /tmp/executor-adapter.' },
1804
+ retain_days: { type: 'number', default: 7, description: 'Remove worker directories older than this many days. Default 7.' },
1805
+ dry_run: { type: 'boolean', default: false, description: 'When true, only reports what would be removed.' },
1806
+ },
1807
+ },
1808
+ },
1809
+ ];
1810
+
1811
+ // ---- MCP server ----
1812
+
1813
+ const server = new Server(
1814
+ { name: SERVER_NAME, version: SERVER_VERSION },
1815
+ { capabilities: { tools: {} } },
1816
+ );
1817
+
1818
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
1819
+
1820
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
1821
+ const name = req.params?.name;
1822
+ const args = req.params?.arguments || {};
1823
+ switch (name) {
1824
+ case 'dispatch': return await dispatch(args);
1825
+ case 'preview_prompt': return previewPrompt(args);
1826
+ case 'smoke': return await smoke(args);
1827
+ case 'read_report': return readReport(args);
1828
+ case 'cleanup_runtime': return cleanupRuntime(args);
1829
+ default: throw new Error(`Unknown tool: ${name}`);
1830
+ }
1831
+ });
1832
+
1833
+ const transport = new StdioServerTransport();
1834
+ await server.connect(transport);