singleton-pipeline 0.4.0-beta.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.
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import path from 'node:path';
4
+ import fs from 'node:fs/promises';
5
+ import { style, line } from './theme.js';
6
+ import { scanAgents } from './scanner.js';
7
+ import { runPipeline } from './executor.js';
8
+ import { newAgentCommand } from './commands/new.js';
9
+ import { replCommand } from './commands/repl.js';
10
+
11
+ const program = new Command();
12
+
13
+ function groupAgentsByProvider(agents) {
14
+ return agents.reduce((groups, agent) => {
15
+ const provider = agent.provider || 'unknown';
16
+ if (!groups.has(provider)) groups.set(provider, []);
17
+ groups.get(provider).push(agent);
18
+ return groups;
19
+ }, new Map());
20
+ }
21
+
22
+ program
23
+ .name('singleton')
24
+ .description('Singleton Pipeline Builder — scan agents and build pipelines')
25
+ .version('0.4.0-beta.0');
26
+
27
+ program
28
+ .command('scan')
29
+ .description('Scan a repo for agent .md files')
30
+ .argument('<path>', 'Path to repo to scan')
31
+ .option('-o, --output <file>', 'Output JSON file', '.singleton/agents.json')
32
+ .action(async (repoPath, opts) => {
33
+ const absPath = path.resolve(repoPath);
34
+ console.log(style.info(`Scanning ${absPath}...`));
35
+
36
+ const agents = await scanAgents(absPath);
37
+
38
+ if (agents.length === 0) {
39
+ console.log(style.warn('No agents found (no .md files with ## Config section).'));
40
+ return;
41
+ }
42
+
43
+ console.log(style.success(`\nFound ${agents.length} agent(s):\n`));
44
+ const groups = groupAgentsByProvider(agents);
45
+ [...groups.entries()].forEach(([provider, providerAgents]) => {
46
+ console.log(style.muted(' ════════════════════════════════════════'));
47
+ console.log(style.heading(` ${provider}`) + style.muted(` (${providerAgents.length})`));
48
+ console.log(style.muted(' ════════════════════════════════════════'));
49
+ console.log();
50
+
51
+ providerAgents.forEach((a, index) => {
52
+ console.log(style.id(` ${a.id}`) + style.muted(` — ${a.description || '(no description)'}`));
53
+ console.log(style.muted(` file: ${path.relative(absPath, a.file)}`));
54
+ console.log(style.info(` source:`) + style.muted(` ${a.source || 'repo'}`));
55
+ if (a.permission_mode) console.log(style.warn(` permission:`) + style.muted(` ${a.permission_mode}`));
56
+ console.log(style.success(` in:`) + style.muted(` ${a.inputs.join(', ') || '(none)'}`));
57
+ console.log(style.id(` out:`) + style.muted(` ${a.outputs.join(', ') || '(none)'}`));
58
+ if (a.tags?.length) console.log(style.muted(` tags: ${a.tags.join(', ')}`));
59
+ if (index < providerAgents.length - 1) console.log(style.muted(' ──────────────────────────────────────'));
60
+ });
61
+ console.log();
62
+ });
63
+
64
+ const outPath = path.resolve(absPath, opts.output);
65
+ await fs.mkdir(path.dirname(outPath), { recursive: true });
66
+ await fs.writeFile(outPath, JSON.stringify({ scannedAt: new Date().toISOString(), root: absPath, agents }, null, 2));
67
+ console.log(line.success(`Saved to ${path.relative(process.cwd(), outPath)}`));
68
+ });
69
+
70
+ program
71
+ .command('serve')
72
+ .description('Start the pipeline builder server + web UI')
73
+ .option('-p, --port <port>', 'Server port', '4317')
74
+ .option('-r, --root <path>', 'Project root to scan', process.cwd())
75
+ .action(async (opts) => {
76
+ const { startServer } = await import('../../server/src/index.js');
77
+ await startServer({ port: Number(opts.port), root: path.resolve(opts.root) });
78
+ });
79
+
80
+ program
81
+ .command('new')
82
+ .description('Create a new agent .md file interactively')
83
+ .option('-r, --root <path>', 'Project root to scan', process.cwd())
84
+ .action(async (opts) => {
85
+ await newAgentCommand(opts);
86
+ });
87
+
88
+ program
89
+ .command('run')
90
+ .description('Run (dry-run) a pipeline JSON')
91
+ .requiredOption('--pipeline <file>', 'Pipeline JSON file')
92
+ .option('--dry-run', 'Skip API calls, just show resolved plan')
93
+ .option('--verbose', 'Show prompts and outputs in the right panel')
94
+ .option('--debug', 'Pause before each step for manual review')
95
+ .action(async (opts) => {
96
+ await runPipeline(opts.pipeline, { dryRun: opts.dryRun, verbose: opts.verbose, debug: opts.debug });
97
+ });
98
+
99
+ program
100
+ .command('repl', { isDefault: true })
101
+ .description('Interactive Singleton shell (default)')
102
+ .option('-r, --root <path>', 'Project root', process.cwd())
103
+ .action(async (opts) => {
104
+ await replCommand(opts);
105
+ });
106
+
107
+ program.parseAsync(process.argv);
@@ -0,0 +1,78 @@
1
+ const CONFIG_HEADER = /^##\s+Config\s*$/m;
2
+ const PROMPT_HEADER = /^##\s+(Prompt|System|Instructions)\s*$/m;
3
+ const HR = /^---\s*$/m;
4
+ const KV_LINE = /^\s*-\s+\*\*([^*]+)\*\*\s*:\s*(.+?)\s*$/;
5
+ const REQUIRED = ['id', 'description', 'inputs', 'outputs'];
6
+ const LIST_KEYS = new Set(['inputs', 'outputs', 'tags', 'allowed_paths', 'blocked_paths']);
7
+
8
+ export function parseAgentFileDetailed(content, file) {
9
+ // Strip YAML frontmatter if present
10
+ const stripped = content.startsWith('---')
11
+ ? content.replace(/^---[\s\S]*?---\n?/, '')
12
+ : content;
13
+ const configMatch = stripped.match(CONFIG_HEADER);
14
+ if (!configMatch) {
15
+ return { agent: null, error: 'missing "## Config" section' };
16
+ }
17
+
18
+ const afterConfig = stripped.slice(configMatch.index + configMatch[0].length);
19
+
20
+ // Find end of config block: next ## header, ---, or EOF
21
+ const nextHeader = afterConfig.match(/^##\s+/m);
22
+ const hr = afterConfig.match(HR);
23
+ const ends = [nextHeader?.index, hr?.index].filter((i) => typeof i === 'number');
24
+ const endIdx = ends.length ? Math.min(...ends) : afterConfig.length;
25
+
26
+ const configBlock = afterConfig.slice(0, endIdx);
27
+ const config = {};
28
+ for (const line of configBlock.split('\n')) {
29
+ const m = line.match(KV_LINE);
30
+ if (!m) continue;
31
+ const key = m[1].trim();
32
+ const raw = m[2].trim();
33
+ config[key] = LIST_KEYS.has(key)
34
+ ? raw.split(',').map((s) => s.trim()).filter(Boolean)
35
+ : raw;
36
+ }
37
+
38
+ for (const k of REQUIRED) {
39
+ if (config[k] === undefined || (LIST_KEYS.has(k) && config[k].length === 0 && k !== 'tags')) {
40
+ return { agent: null, error: `missing required field: ${k}` };
41
+ }
42
+ }
43
+
44
+ // Extract prompt body
45
+ const promptMatch = stripped.match(PROMPT_HEADER);
46
+ let prompt = '';
47
+ if (promptMatch) {
48
+ prompt = stripped.slice(promptMatch.index + promptMatch[0].length).trim();
49
+ } else {
50
+ const hrAll = stripped.match(HR);
51
+ if (hrAll) prompt = stripped.slice(hrAll.index + hrAll[0].length).trim();
52
+ }
53
+
54
+ const agent = {
55
+ id: config.id,
56
+ description: config.description || '',
57
+ inputs: config.inputs || [],
58
+ outputs: config.outputs || [],
59
+ tags: config.tags || [],
60
+ provider: config.provider,
61
+ model: config.model,
62
+ runner_agent: config.runner_agent || config.opencode_agent,
63
+ opencode_agent: config.opencode_agent,
64
+ permission_mode: config.permission_mode,
65
+ security_profile: config.security_profile,
66
+ allowed_paths: config.allowed_paths || [],
67
+ blocked_paths: config.blocked_paths || [],
68
+ estimated_tokens: config.estimated_tokens ? Number(config.estimated_tokens) : undefined,
69
+ file,
70
+ prompt
71
+ };
72
+
73
+ return { agent, error: null };
74
+ }
75
+
76
+ export function parseAgentFile(content, file) {
77
+ return parseAgentFileDetailed(content, file).agent;
78
+ }
@@ -0,0 +1,83 @@
1
+ // Shared helpers used by multiple runners (opencode, codex, …).
2
+ // Each runner emits JSONL events with slightly different shapes; these helpers
3
+ // normalize the common patterns: parsing lines, walking nested events to find
4
+ // usage/cost, and extracting assistant text from messages with varying schemas.
5
+
6
+ export function safeJsonParse(line) {
7
+ try {
8
+ return JSON.parse(line);
9
+ } catch {
10
+ return null;
11
+ }
12
+ }
13
+
14
+ export function extractText(value) {
15
+ if (typeof value === 'string') return value;
16
+ if (!value || typeof value !== 'object') return '';
17
+
18
+ if (typeof value.text === 'string') return value.text;
19
+ if (typeof value.content === 'string') return value.content;
20
+ if (typeof value.delta === 'string') return value.delta;
21
+ if (typeof value.message === 'string') return value.message;
22
+
23
+ if (Array.isArray(value.content)) {
24
+ return value.content
25
+ .map((item) => extractText(item))
26
+ .filter(Boolean)
27
+ .join('\n');
28
+ }
29
+
30
+ if (Array.isArray(value.parts)) {
31
+ return value.parts
32
+ .map((item) => extractText(item))
33
+ .filter(Boolean)
34
+ .join('\n');
35
+ }
36
+
37
+ if (value.message && typeof value.message === 'object') return extractText(value.message);
38
+ if (value.part && typeof value.part === 'object') return extractText(value.part);
39
+ if (value.data && typeof value.data === 'object') return extractText(value.data);
40
+ if (value.result && typeof value.result === 'object') return extractText(value.result);
41
+
42
+ return '';
43
+ }
44
+
45
+ export function findUsage(value) {
46
+ if (!value || typeof value !== 'object') return null;
47
+
48
+ const input = value.input_tokens
49
+ ?? value.inputTokens
50
+ ?? value.tokens?.input
51
+ ?? value.usage?.input_tokens
52
+ ?? value.usage?.inputTokens
53
+ ?? (Object.prototype.hasOwnProperty.call(value, 'input') ? value.input : undefined);
54
+ const output = value.output_tokens
55
+ ?? value.outputTokens
56
+ ?? value.tokens?.output
57
+ ?? value.usage?.output_tokens
58
+ ?? value.usage?.outputTokens
59
+ ?? (Object.prototype.hasOwnProperty.call(value, 'output') ? value.output : undefined);
60
+ if (input !== undefined || output !== undefined) {
61
+ return { input: input ?? null, output: output ?? null };
62
+ }
63
+
64
+ for (const nested of Object.values(value)) {
65
+ const usage = findUsage(nested);
66
+ if (usage) return usage;
67
+ }
68
+
69
+ return null;
70
+ }
71
+
72
+ export function findCostUsd(value) {
73
+ if (!value || typeof value !== 'object') return null;
74
+ const direct = value.costUsd ?? value.cost_usd ?? value.total_cost_usd ?? value.usage?.costUsd ?? value.usage?.cost_usd;
75
+ if (direct !== undefined && direct !== null && !Number.isNaN(Number(direct))) return Number(direct);
76
+
77
+ for (const nested of Object.values(value)) {
78
+ const cost = findCostUsd(nested);
79
+ if (cost !== null) return cost;
80
+ }
81
+
82
+ return null;
83
+ }
@@ -0,0 +1,119 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ const DEFAULT_TIMEOUT_MS = Number(process.env.SINGLETON_RUNNER_TIMEOUT_MS) || 10 * 60 * 1000;
4
+ const ALLOWED_PERMISSION_MODES = new Set(['bypassPermissions']);
5
+ const READ_ONLY_DENY_TOOLS = ['Write', 'Edit', 'Bash', 'NotebookEdit'];
6
+
7
+ export function buildClaudePermissionArgs(securityPolicy = {}, permissionMode = '') {
8
+ // Legacy escape hatch: when permission_mode is explicitly set on the agent or
9
+ // step, honor it as-is and skip the security_policy mapping. This preserves
10
+ // backward compatibility with pipelines authored before security_policy
11
+ // support landed.
12
+ if (permissionMode) {
13
+ if (!ALLOWED_PERMISSION_MODES.has(permissionMode)) {
14
+ throw new Error(`unsupported Claude permission_mode: ${permissionMode}`);
15
+ }
16
+ return ['--permission-mode', permissionMode];
17
+ }
18
+
19
+ const profile = securityPolicy.profile || 'workspace-write';
20
+ const args = [];
21
+
22
+ if (profile === 'dangerous') {
23
+ args.push('--permission-mode', 'bypassPermissions');
24
+ return args;
25
+ }
26
+
27
+ if (profile === 'read-only') {
28
+ args.push('--disallowedTools', READ_ONLY_DENY_TOOLS.join(','));
29
+ return args;
30
+ }
31
+
32
+ // restricted-write & workspace-write: Claude Code does not support per-path
33
+ // tool filtering, so we let it edit and rely on Singleton's post-run snapshot
34
+ // diff to reject writes outside allowed_paths. acceptEdits avoids interactive
35
+ // prompts in -p mode.
36
+ args.push('--permission-mode', 'acceptEdits');
37
+ return args;
38
+ }
39
+
40
+ export const claudeRunner = {
41
+ id: 'claude',
42
+ command: 'claude',
43
+
44
+ async run({
45
+ cwd,
46
+ systemPrompt,
47
+ userPrompt,
48
+ model,
49
+ permissionMode = '',
50
+ securityPolicy,
51
+ timeoutMs = DEFAULT_TIMEOUT_MS,
52
+ }) {
53
+ const args = [
54
+ '-p',
55
+ '--output-format',
56
+ 'json',
57
+ '--system-prompt',
58
+ systemPrompt,
59
+ ...buildClaudePermissionArgs(securityPolicy, permissionMode),
60
+ ];
61
+
62
+ if (model) args.push('--model', model);
63
+
64
+ const raw = await new Promise((resolve, reject) => {
65
+ const child = spawn('claude', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
66
+ let stdout = '';
67
+ let stderr = '';
68
+ let timedOut = false;
69
+
70
+ const timer = setTimeout(() => {
71
+ timedOut = true;
72
+ child.kill('SIGTERM');
73
+ setTimeout(() => child.kill('SIGKILL'), 5000).unref();
74
+ }, timeoutMs);
75
+
76
+ child.stdout.on('data', (d) => (stdout += d.toString()));
77
+ child.stderr.on('data', (d) => (stderr += d.toString()));
78
+ child.on('error', (err) => {
79
+ clearTimeout(timer);
80
+ reject(err);
81
+ });
82
+ child.on('close', (code) => {
83
+ clearTimeout(timer);
84
+ if (timedOut) {
85
+ reject(new Error(`claude timed out after ${Math.round(timeoutMs / 1000)}s`));
86
+ return;
87
+ }
88
+ if (code !== 0) {
89
+ reject(new Error(`claude exited ${code}: ${stderr.trim() || stdout.trim()}`));
90
+ return;
91
+ }
92
+
93
+ try {
94
+ resolve(JSON.parse(stdout));
95
+ } catch (err) {
96
+ reject(new Error(`failed to parse claude output: ${err.message}\n${stdout.slice(0, 500)}`));
97
+ }
98
+ });
99
+
100
+ child.stdin.write(userPrompt);
101
+ child.stdin.end();
102
+ });
103
+
104
+ return {
105
+ text: typeof raw.result === 'string' ? raw.result : JSON.stringify(raw),
106
+ metadata: {
107
+ provider: 'claude',
108
+ model: raw.model ?? model ?? null,
109
+ turns: Number(raw.num_turns || 0) || null,
110
+ costUsd: Number(raw.total_cost_usd || 0) || null,
111
+ tokens: {
112
+ input: raw.usage?.input_tokens ?? null,
113
+ output: raw.usage?.output_tokens ?? null,
114
+ },
115
+ raw,
116
+ },
117
+ };
118
+ },
119
+ };
@@ -0,0 +1,75 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ const PROJECT_DOC_FILENAMES = ['AGENTS.override.md', 'AGENTS.md'];
5
+ const SKIP_DIRS = new Set(['.git', '.singleton', 'node_modules', 'dist', 'build', '.next', '.cache', 'coverage']);
6
+
7
+ function isSubdir(parent, child) {
8
+ const rel = path.relative(parent, child);
9
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
10
+ }
11
+
12
+ async function readFirstNonEmptyFile(dir, names) {
13
+ for (const name of names) {
14
+ const filePath = path.join(dir, name);
15
+ try {
16
+ const content = (await fs.readFile(filePath, 'utf8')).trim();
17
+ if (!content) continue;
18
+ return { filePath, content };
19
+ } catch {
20
+ // ignore missing files
21
+ }
22
+ }
23
+ return null;
24
+ }
25
+
26
+ async function collectInstructionFiles(rootDir, rel = '', out = []) {
27
+ const abs = rel ? path.join(rootDir, rel) : rootDir;
28
+ const entries = await fs.readdir(abs, { withFileTypes: true });
29
+
30
+ const found = await readFirstNonEmptyFile(abs, PROJECT_DOC_FILENAMES);
31
+ if (found) out.push(found);
32
+
33
+ for (const entry of entries) {
34
+ if (!entry.isDirectory()) continue;
35
+ if (SKIP_DIRS.has(entry.name)) continue;
36
+ await collectInstructionFiles(rootDir, rel ? path.join(rel, entry.name) : entry.name, out);
37
+ }
38
+
39
+ return out;
40
+ }
41
+
42
+ export async function discoverCodexProjectInstructions(projectRoot, currentDir) {
43
+ const root = projectRoot || currentDir;
44
+ if (!root) return { text: '', files: [] };
45
+
46
+ if (!projectRoot || !currentDir || !isSubdir(projectRoot, currentDir)) {
47
+ const single = await readFirstNonEmptyFile(currentDir || projectRoot, PROJECT_DOC_FILENAMES);
48
+ return single
49
+ ? { text: `<!-- ${path.basename(single.filePath)} -->\n${single.content}`, files: [single.filePath] }
50
+ : { text: '', files: [] };
51
+ }
52
+
53
+ const discovered = await collectInstructionFiles(root);
54
+ discovered.sort((a, b) => {
55
+ const relA = path.relative(root, a.filePath);
56
+ const relB = path.relative(root, b.filePath);
57
+ const depthA = relA.split(path.sep).length;
58
+ const depthB = relB.split(path.sep).length;
59
+ if (depthA !== depthB) return depthA - depthB;
60
+ return relA.localeCompare(relB);
61
+ });
62
+
63
+ const chunks = [];
64
+ const files = [];
65
+
66
+ for (const found of discovered) {
67
+ files.push(found.filePath);
68
+ chunks.push(`<!-- ${path.relative(root, found.filePath)} -->\n${found.content}`);
69
+ }
70
+
71
+ return {
72
+ text: chunks.join('\n\n').trim(),
73
+ files,
74
+ };
75
+ }
@@ -0,0 +1,162 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { spawn } from 'node:child_process';
5
+ import { discoverCodexProjectInstructions } from './codex-instructions.js';
6
+ import { findUsage, safeJsonParse } from './_shared.js';
7
+
8
+ const DEFAULT_TIMEOUT_MS = Number(process.env.SINGLETON_RUNNER_TIMEOUT_MS) || 10 * 60 * 1000;
9
+
10
+ function buildPrompt(systemPrompt, userPrompt, projectInstructions = '') {
11
+ const parts = ['Follow the system instructions exactly.', ''];
12
+
13
+ if (projectInstructions) {
14
+ parts.push('<codex_project_instructions>');
15
+ parts.push(projectInstructions);
16
+ parts.push('</codex_project_instructions>');
17
+ parts.push('');
18
+ }
19
+
20
+ parts.push('<system>');
21
+ parts.push(systemPrompt);
22
+ parts.push('</system>');
23
+ parts.push('');
24
+ parts.push('<user>');
25
+ parts.push(userPrompt);
26
+ parts.push('</user>');
27
+ parts.push('');
28
+
29
+ return parts.join('\n');
30
+ }
31
+
32
+ export function buildCodexSandboxArgs(securityPolicy = {}) {
33
+ const profile = securityPolicy.profile || 'workspace-write';
34
+
35
+ // Codex CLI sandbox modes: read-only, workspace-write,
36
+ // workspace-write-with-network, danger-full-access. Codex has no per-path
37
+ // filter, so restricted-write maps to workspace-write at the runner level
38
+ // and Singleton's post-run snapshot diff enforces the allowed_paths.
39
+ if (profile === 'read-only') return ['--sandbox', 'read-only'];
40
+ if (profile === 'dangerous') return ['--sandbox', 'danger-full-access'];
41
+ return ['--sandbox', 'workspace-write'];
42
+ }
43
+
44
+ export function buildCodexArgs({ prompt, model, outputFile, securityPolicy } = {}) {
45
+ const args = [
46
+ 'exec',
47
+ '--json',
48
+ '--ephemeral',
49
+ '--skip-git-repo-check',
50
+ ...buildCodexSandboxArgs(securityPolicy),
51
+ '--output-last-message',
52
+ outputFile,
53
+ ];
54
+
55
+ if (model) args.push('--model', model);
56
+ args.push('-');
57
+ return args;
58
+ }
59
+
60
+ export const codexRunner = {
61
+ id: 'codex',
62
+ command: 'codex',
63
+
64
+ async run({
65
+ cwd,
66
+ projectRoot = cwd,
67
+ currentDir = cwd,
68
+ systemPrompt,
69
+ userPrompt,
70
+ model,
71
+ securityPolicy,
72
+ timeoutMs = DEFAULT_TIMEOUT_MS,
73
+ }) {
74
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'singleton-codex-'));
75
+ const outputFile = path.join(tempDir, 'last-message.txt');
76
+ const projectInstructions = await discoverCodexProjectInstructions(projectRoot, currentDir);
77
+ const prompt = buildPrompt(systemPrompt, userPrompt, projectInstructions.text);
78
+
79
+ const args = buildCodexArgs({ prompt, model, outputFile, securityPolicy });
80
+
81
+ const { events, stderr } = await new Promise((resolve, reject) => {
82
+ const child = spawn('codex', args, {
83
+ cwd,
84
+ stdio: ['pipe', 'pipe', 'pipe'],
85
+ env: {
86
+ ...process.env,
87
+ CODEX_HOME: process.env.CODEX_HOME || path.join(os.homedir(), '.codex'),
88
+ },
89
+ });
90
+
91
+ const stdoutChunks = [];
92
+ let stderrText = '';
93
+ let timedOut = false;
94
+
95
+ const timer = setTimeout(() => {
96
+ timedOut = true;
97
+ child.kill('SIGTERM');
98
+ setTimeout(() => child.kill('SIGKILL'), 5000).unref();
99
+ }, timeoutMs);
100
+
101
+ child.stdout.on('data', (d) => stdoutChunks.push(d.toString()));
102
+ child.stderr.on('data', (d) => (stderrText += d.toString()));
103
+ child.on('error', (err) => {
104
+ clearTimeout(timer);
105
+ reject(err);
106
+ });
107
+ child.on('close', (code) => {
108
+ clearTimeout(timer);
109
+ const stdout = stdoutChunks.join('');
110
+ const events = stdout
111
+ .split('\n')
112
+ .map((line) => line.trim())
113
+ .filter(Boolean)
114
+ .map(safeJsonParse)
115
+ .filter(Boolean);
116
+
117
+ if (timedOut) {
118
+ reject(new Error(`codex timed out after ${Math.round(timeoutMs / 1000)}s`));
119
+ return;
120
+ }
121
+ if (code !== 0) {
122
+ const eventError = events.findLast?.((event) => event.type === 'error')?.message
123
+ || [...events].reverse().find((event) => event.type === 'error')?.message;
124
+ reject(new Error(`codex exited ${code}: ${eventError || stderrText.trim() || 'unknown error'}`));
125
+ return;
126
+ }
127
+
128
+ resolve({ events, stderr: stderrText });
129
+ });
130
+
131
+ child.stdin.write(prompt);
132
+ child.stdin.end();
133
+ });
134
+
135
+ let text = '';
136
+ try {
137
+ text = (await fs.readFile(outputFile, 'utf8')).trim();
138
+ } catch {
139
+ text = '';
140
+ }
141
+
142
+ const turns = events.filter((event) => event.type === 'turn.started').length || null;
143
+ const usage = [...events]
144
+ .reverse()
145
+ .map(findUsage)
146
+ .find(Boolean) || null;
147
+
148
+ try { await fs.rm(tempDir, { recursive: true, force: true }); } catch { /* ignore */ }
149
+
150
+ return {
151
+ text,
152
+ metadata: {
153
+ provider: 'codex',
154
+ model: model || null,
155
+ turns,
156
+ costUsd: null,
157
+ tokens: usage,
158
+ raw: { events, stderr, projectInstructionFiles: projectInstructions.files },
159
+ },
160
+ };
161
+ },
162
+ };