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.
- package/LICENSE +21 -0
- package/README.md +200 -0
- package/docs/reference.md +1038 -0
- package/package.json +75 -0
- package/packages/cli/package.json +18 -0
- package/packages/cli/src/commands/new.js +440 -0
- package/packages/cli/src/commands/repl.js +551 -0
- package/packages/cli/src/executor.js +2646 -0
- package/packages/cli/src/index.js +107 -0
- package/packages/cli/src/parser.js +78 -0
- package/packages/cli/src/runners/_shared.js +83 -0
- package/packages/cli/src/runners/claude.js +119 -0
- package/packages/cli/src/runners/codex-instructions.js +75 -0
- package/packages/cli/src/runners/codex.js +162 -0
- package/packages/cli/src/runners/copilot.js +208 -0
- package/packages/cli/src/runners/index.js +20 -0
- package/packages/cli/src/runners/opencode.js +265 -0
- package/packages/cli/src/scanner.js +47 -0
- package/packages/cli/src/security/policy.js +126 -0
- package/packages/cli/src/shell.js +542 -0
- package/packages/cli/src/theme.js +46 -0
- package/packages/cli/src/timeline.js +146 -0
- package/packages/server/package.json +11 -0
- package/packages/server/src/index.js +43 -0
- package/packages/server/src/routes/agents.js +32 -0
- package/packages/server/src/routes/files.js +42 -0
- package/packages/server/src/routes/pipelines.js +74 -0
- package/packages/web/dist/assets/index-CCFWfCA2.css +1 -0
- package/packages/web/dist/assets/index-CnKytBly.js +55 -0
- package/packages/web/dist/index.html +13 -0
- package/packages/web/package.json +23 -0
|
@@ -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
|
+
};
|