singleton-pipeline 0.4.0-beta.13 → 0.4.0-beta.14

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.
Files changed (77) hide show
  1. package/dist/packages/cli/src/assets/singleton-logo.txt +10 -0
  2. package/dist/packages/cli/src/commands/new.js +763 -0
  3. package/dist/packages/cli/src/commands/repl.js +557 -0
  4. package/dist/packages/cli/src/commands/usage.js +49 -0
  5. package/dist/packages/cli/src/executor/debug-loop.js +525 -0
  6. package/dist/packages/cli/src/executor/inputs.js +226 -0
  7. package/dist/packages/cli/src/executor/outputs.js +134 -0
  8. package/dist/packages/cli/src/executor/preflight.js +605 -0
  9. package/dist/packages/cli/src/executor/replay-loop.js +120 -0
  10. package/dist/packages/cli/src/executor/run-report.js +209 -0
  11. package/dist/packages/cli/src/executor/run-setup.js +114 -0
  12. package/dist/packages/cli/src/executor/security-review.js +97 -0
  13. package/dist/packages/cli/src/executor/snapshot-manager.js +349 -0
  14. package/dist/packages/cli/src/executor/step-runner.js +241 -0
  15. package/dist/packages/cli/src/executor.js +584 -0
  16. package/dist/packages/cli/src/index.js +107 -0
  17. package/dist/packages/cli/src/parser.js +89 -0
  18. package/dist/packages/cli/src/runners/_shared.js +96 -0
  19. package/dist/packages/cli/src/runners/claude.js +103 -0
  20. package/dist/packages/cli/src/runners/codex-instructions.js +69 -0
  21. package/dist/packages/cli/src/runners/codex.js +141 -0
  22. package/dist/packages/cli/src/runners/copilot.js +209 -0
  23. package/dist/packages/cli/src/runners/index.js +18 -0
  24. package/dist/packages/cli/src/runners/opencode.js +240 -0
  25. package/dist/packages/cli/src/scanner.js +43 -0
  26. package/dist/packages/cli/src/security/policy.js +115 -0
  27. package/dist/packages/cli/src/sentinels.js +1 -0
  28. package/dist/packages/cli/src/shell.js +753 -0
  29. package/dist/packages/cli/src/theme.js +39 -0
  30. package/dist/packages/cli/src/timeline.js +238 -0
  31. package/dist/packages/cli/src/types.js +1 -0
  32. package/dist/packages/cli/src/usage/aggregator.js +44 -0
  33. package/dist/packages/cli/src/usage/reader.js +30 -0
  34. package/dist/packages/cli/src/usage/types.js +1 -0
  35. package/dist/packages/server/src/index.js +36 -0
  36. package/dist/packages/server/src/routes/agents.js +31 -0
  37. package/dist/packages/server/src/routes/files.js +45 -0
  38. package/dist/packages/server/src/routes/pipelines.js +74 -0
  39. package/docs/reference.md +28 -0
  40. package/package.json +15 -14
  41. package/packages/web/dist/assets/{index-CnKytBly.js → index-9S0goZlQ.js} +1 -1
  42. package/packages/web/dist/assets/{index-CCFWfCA2.css → index-iV4UtXoN.css} +1 -1
  43. package/packages/web/dist/assets/logo-COSyZmgk.png +0 -0
  44. package/packages/web/dist/index.html +2 -2
  45. package/packages/cli/package.json +0 -18
  46. package/packages/cli/src/commands/new.js +0 -786
  47. package/packages/cli/src/commands/repl.js +0 -548
  48. package/packages/cli/src/executor/debug-loop.js +0 -587
  49. package/packages/cli/src/executor/inputs.js +0 -202
  50. package/packages/cli/src/executor/outputs.js +0 -140
  51. package/packages/cli/src/executor/preflight.js +0 -459
  52. package/packages/cli/src/executor/replay-loop.js +0 -172
  53. package/packages/cli/src/executor/run-report.js +0 -189
  54. package/packages/cli/src/executor/run-setup.js +0 -93
  55. package/packages/cli/src/executor/security-review.js +0 -108
  56. package/packages/cli/src/executor/snapshot-manager.js +0 -335
  57. package/packages/cli/src/executor/step-runner.js +0 -266
  58. package/packages/cli/src/executor.js +0 -652
  59. package/packages/cli/src/index.js +0 -107
  60. package/packages/cli/src/parser.js +0 -78
  61. package/packages/cli/src/runners/_shared.js +0 -83
  62. package/packages/cli/src/runners/claude.js +0 -122
  63. package/packages/cli/src/runners/codex-instructions.js +0 -75
  64. package/packages/cli/src/runners/codex.js +0 -165
  65. package/packages/cli/src/runners/copilot.js +0 -224
  66. package/packages/cli/src/runners/index.js +0 -20
  67. package/packages/cli/src/runners/opencode.js +0 -265
  68. package/packages/cli/src/scanner.js +0 -47
  69. package/packages/cli/src/security/policy.js +0 -126
  70. package/packages/cli/src/shell.js +0 -732
  71. package/packages/cli/src/theme.js +0 -46
  72. package/packages/cli/src/timeline.js +0 -180
  73. package/packages/server/package.json +0 -11
  74. package/packages/server/src/index.js +0 -43
  75. package/packages/server/src/routes/agents.js +0 -32
  76. package/packages/server/src/routes/files.js +0 -42
  77. package/packages/server/src/routes/pipelines.js +0 -74
@@ -0,0 +1,209 @@
1
+ import spawn from 'cross-spawn';
2
+ import path from 'node:path';
3
+ import { extractText, safeJsonParse } from './_shared.js';
4
+ const DEFAULT_TIMEOUT_MS = Number(process.env.SINGLETON_RUNNER_TIMEOUT_MS) || 10 * 60 * 1000;
5
+ function buildPrompt(systemPrompt, userPrompt) {
6
+ return [
7
+ '<system>',
8
+ systemPrompt,
9
+ '</system>',
10
+ '',
11
+ '<user>',
12
+ userPrompt,
13
+ '</user>',
14
+ '',
15
+ ].join('\n');
16
+ }
17
+ function normalizeToolPath(value) {
18
+ return String(value || '').replaceAll('\\', '/').replace(/^\/+/, '').replace(/\/+$/, '');
19
+ }
20
+ function hasGlob(value) {
21
+ return /[*?[\]{}]/.test(value);
22
+ }
23
+ function looksLikeFile(value) {
24
+ const base = path.posix.basename(value);
25
+ return Boolean(path.posix.extname(value)) || base === '.env';
26
+ }
27
+ function toWritePattern(entry) {
28
+ const normalized = normalizeToolPath(entry);
29
+ if (!normalized)
30
+ return null;
31
+ if (hasGlob(normalized) || looksLikeFile(normalized))
32
+ return normalized;
33
+ return `${normalized}/**`;
34
+ }
35
+ export function buildCopilotPermissionArgs(securityPolicy = {}) {
36
+ const profile = securityPolicy.profile || 'workspace-write';
37
+ const args = [];
38
+ if (profile === 'dangerous') {
39
+ args.push('--allow-all-tools');
40
+ }
41
+ else {
42
+ args.push('--allow-tool=read');
43
+ }
44
+ if (profile === 'restricted-write') {
45
+ for (const entry of securityPolicy.allowedPaths || []) {
46
+ const pattern = toWritePattern(entry);
47
+ if (pattern)
48
+ args.push(`--allow-tool=write(${pattern})`);
49
+ }
50
+ }
51
+ else if (profile === 'workspace-write') {
52
+ args.push('--allow-tool=write');
53
+ }
54
+ // Copilot CLI runs in deny-by-default mode as soon as any --allow-tool is
55
+ // present. Agents need shell access to list/grep the codebase even when their
56
+ // write surface is restricted - otherwise the scout can't discover anything.
57
+ // read-only stays shell-less; dangerous is already covered by --allow-all-tools.
58
+ if (profile === 'read-only') {
59
+ args.push('--deny-tool=write');
60
+ args.push('--deny-tool=shell');
61
+ }
62
+ else {
63
+ if (profile !== 'dangerous')
64
+ args.push('--allow-tool=shell');
65
+ args.push('--deny-tool=shell(git push)');
66
+ }
67
+ if (profile !== 'dangerous') {
68
+ args.push('--deny-tool=url');
69
+ }
70
+ for (const entry of securityPolicy.blockedPaths || []) {
71
+ const pattern = toWritePattern(entry);
72
+ if (pattern)
73
+ args.push(`--deny-tool=write(${pattern})`);
74
+ }
75
+ args.push('--deny-tool=memory');
76
+ return args;
77
+ }
78
+ export function buildCopilotArgs({ prompt, model, runnerAgent, securityPolicy = {} } = {}) {
79
+ // Copilot CLI expects the user prompt as `-p <text>` arg. Passing `-p -` is
80
+ // interpreted as the literal string "-", not as a stdin marker, so we always
81
+ // inline the prompt as an argument here. Callers must keep the prompt under
82
+ // ~32KB on Windows; large blobs should be referenced as files on disk rather
83
+ // than injected inline.
84
+ const args = [
85
+ '-p',
86
+ prompt ?? '',
87
+ '--output-format',
88
+ 'json',
89
+ ...buildCopilotPermissionArgs(securityPolicy),
90
+ ];
91
+ if (runnerAgent)
92
+ args.push('--agent', runnerAgent);
93
+ if (model)
94
+ args.push('--model', model);
95
+ return args;
96
+ }
97
+ export function summarizeCopilotEvents(events) {
98
+ // Copilot emits intermediate `assistant.message` events between tool calls
99
+ // (the model's "thinking out loud"). The final deliverable is the LAST
100
+ // assistant.message - concatenating them all would prepend narration noise
101
+ // to whatever the agent is supposed to produce as its output.
102
+ const assistantMessages = events.filter((event) => event.type === 'assistant.message');
103
+ const finalMessage = assistantMessages.at(-1);
104
+ const finalText = finalMessage ? extractText(finalMessage.data) : '';
105
+ const deltaText = events
106
+ .filter((event) => event.type === 'assistant.message_delta')
107
+ .map((event) => event.data?.deltaContent || event.data?.delta || '')
108
+ .filter(Boolean)
109
+ .join('');
110
+ const result = [...events].reverse().find((event) => event.type === 'result') || null;
111
+ const outputTokens = events.reduce((total, event) => {
112
+ if (event.type !== 'assistant.message')
113
+ return total;
114
+ return total + (Number(event.data?.outputTokens || 0) || 0);
115
+ }, 0);
116
+ return {
117
+ text: (finalText || deltaText).trim(),
118
+ turns: assistantMessages.length || null,
119
+ outputTokens: outputTokens || null,
120
+ premiumRequests: Number(result?.usage?.premiumRequests || 0) || null,
121
+ result,
122
+ };
123
+ }
124
+ export function extractCopilotErrorMessage(event) {
125
+ if (!event || typeof event !== 'object')
126
+ return '';
127
+ const item = event;
128
+ if (typeof item.error === 'string')
129
+ return item.error;
130
+ if (typeof item.message === 'string')
131
+ return item.message;
132
+ if (typeof item.error?.message === 'string')
133
+ return item.error.message;
134
+ if (typeof item.error?.data?.message === 'string')
135
+ return item.error.data.message;
136
+ if (typeof item.data?.message === 'string')
137
+ return item.data.message;
138
+ return extractText(item);
139
+ }
140
+ export const copilotRunner = {
141
+ id: 'copilot',
142
+ command: 'copilot',
143
+ async run({ cwd, systemPrompt, userPrompt, model, runnerAgent, securityPolicy, timeoutMs = DEFAULT_TIMEOUT_MS, }) {
144
+ // When --agent is used, Copilot loads the system prompt from .github/agents/<name>.md.
145
+ // We pass only the user prompt as `-p <text>`. Without --agent we inline the
146
+ // system prompt wrapped in <system>/<user> tags as the user prompt.
147
+ const prompt = runnerAgent ? userPrompt : buildPrompt(systemPrompt, userPrompt);
148
+ const args = buildCopilotArgs({ prompt, model, runnerAgent, securityPolicy });
149
+ const { events, stderr } = await new Promise((resolve, reject) => {
150
+ const child = spawn('copilot', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
151
+ const stdoutChunks = [];
152
+ let stderrText = '';
153
+ let timedOut = false;
154
+ const timer = setTimeout(() => {
155
+ timedOut = true;
156
+ child.kill('SIGTERM');
157
+ setTimeout(() => child.kill('SIGKILL'), 5000).unref();
158
+ }, timeoutMs);
159
+ child.stdout.on('data', (chunk) => stdoutChunks.push(chunk.toString()));
160
+ child.stderr.on('data', (chunk) => (stderrText += chunk.toString()));
161
+ child.on('error', (err) => {
162
+ clearTimeout(timer);
163
+ reject(err);
164
+ });
165
+ child.on('close', (code) => {
166
+ clearTimeout(timer);
167
+ const stdout = stdoutChunks.join('');
168
+ const events = stdout
169
+ .split('\n')
170
+ .map((line) => line.trim())
171
+ .filter(Boolean)
172
+ .map(safeJsonParse)
173
+ .filter(Boolean);
174
+ if (timedOut) {
175
+ reject(new Error(`copilot timed out after ${Math.round(timeoutMs / 1000)}s`));
176
+ return;
177
+ }
178
+ if (code !== 0) {
179
+ const result = [...events].reverse().find((event) => event.type === 'result');
180
+ const message = extractCopilotErrorMessage(result) || stderrText.trim() || stdout.trim() || 'unknown error';
181
+ reject(new Error(`copilot exited ${code}: ${message}`));
182
+ return;
183
+ }
184
+ resolve({ events, stderr: stderrText });
185
+ });
186
+ });
187
+ const summary = summarizeCopilotEvents(events);
188
+ return {
189
+ text: summary.text,
190
+ metadata: {
191
+ provider: 'copilot',
192
+ model: model || null,
193
+ runnerAgent: runnerAgent || null,
194
+ turns: summary.turns,
195
+ costUsd: null,
196
+ tokens: {
197
+ input: null,
198
+ output: summary.outputTokens,
199
+ },
200
+ premiumRequests: summary.premiumRequests,
201
+ raw: {
202
+ events,
203
+ stderr,
204
+ result: summary.result,
205
+ },
206
+ },
207
+ };
208
+ },
209
+ };
@@ -0,0 +1,18 @@
1
+ import { claudeRunner } from './claude.js';
2
+ import { codexRunner } from './codex.js';
3
+ import { copilotRunner } from './copilot.js';
4
+ import { opencodeRunner } from './opencode.js';
5
+ const RUNNERS = {
6
+ claude: claudeRunner,
7
+ codex: codexRunner,
8
+ copilot: copilotRunner,
9
+ opencode: opencodeRunner,
10
+ };
11
+ export function getRunner(provider = 'claude') {
12
+ const key = String(provider || 'claude').trim().toLowerCase();
13
+ const runner = RUNNERS[key];
14
+ if (!runner) {
15
+ throw new Error(`Unknown provider: ${provider}`);
16
+ }
17
+ return runner;
18
+ }
@@ -0,0 +1,240 @@
1
+ import spawn from 'cross-spawn';
2
+ import path from 'node:path';
3
+ import { extractText, findCostUsd, findUsage, safeJsonParse } from './_shared.js';
4
+ const DEFAULT_TIMEOUT_MS = Number(process.env.SINGLETON_RUNNER_TIMEOUT_MS) || 10 * 60 * 1000;
5
+ function buildPrompt(systemPrompt, userPrompt) {
6
+ return [
7
+ '<system>',
8
+ systemPrompt,
9
+ '</system>',
10
+ '',
11
+ '<user>',
12
+ userPrompt,
13
+ '</user>',
14
+ '',
15
+ ].join('\n');
16
+ }
17
+ function isAssistantEvent(event) {
18
+ const type = String(event?.type || '').toLowerCase();
19
+ const role = String(event?.role || event?.data?.role || event?.message?.role || '').toLowerCase();
20
+ return role === 'assistant' || type === 'text' || type.includes('assistant') || type.includes('message');
21
+ }
22
+ export function extractOpenCodeErrorMessage(event) {
23
+ if (!event || typeof event !== 'object')
24
+ return '';
25
+ const item = event;
26
+ if (typeof item.error === 'string')
27
+ return item.error;
28
+ if (typeof item.message === 'string')
29
+ return item.message;
30
+ if (typeof item.error?.message === 'string')
31
+ return item.error.message;
32
+ if (typeof item.error?.data?.message === 'string')
33
+ return item.error.data.message;
34
+ if (typeof item.data?.message === 'string')
35
+ return item.data.message;
36
+ return extractText(item);
37
+ }
38
+ function normalizePermissionPath(value) {
39
+ return String(value || '').replaceAll('\\', '/').replace(/^\/+/, '').replace(/\/+$/, '');
40
+ }
41
+ function hasGlob(value) {
42
+ return /[*?[\]{}]/.test(value);
43
+ }
44
+ function looksLikeFile(value) {
45
+ const base = path.posix.basename(value);
46
+ return Boolean(path.posix.extname(value)) || base === '.env';
47
+ }
48
+ function toOpenCodePathPattern(entry) {
49
+ const normalized = normalizePermissionPath(entry);
50
+ if (!normalized)
51
+ return null;
52
+ if (hasGlob(normalized) || looksLikeFile(normalized))
53
+ return normalized;
54
+ return `${normalized}/**`;
55
+ }
56
+ function buildEditPermission(securityPolicy) {
57
+ const profile = securityPolicy?.profile || 'workspace-write';
58
+ const blocked = (securityPolicy?.blockedPaths || [])
59
+ .map(toOpenCodePathPattern)
60
+ .filter((pattern) => typeof pattern === 'string');
61
+ if (profile === 'read-only')
62
+ return 'deny';
63
+ if (profile === 'dangerous')
64
+ return 'allow';
65
+ if (profile === 'restricted-write') {
66
+ const edit = { '*': 'deny' };
67
+ for (const entry of securityPolicy?.allowedPaths || []) {
68
+ const pattern = toOpenCodePathPattern(entry);
69
+ if (pattern)
70
+ edit[pattern] = 'allow';
71
+ }
72
+ for (const pattern of blocked)
73
+ edit[pattern] = 'deny';
74
+ return edit;
75
+ }
76
+ if (blocked.length) {
77
+ const edit = { '*': 'allow' };
78
+ for (const pattern of blocked)
79
+ edit[pattern] = 'deny';
80
+ return edit;
81
+ }
82
+ return 'allow';
83
+ }
84
+ export function buildOpenCodePermissionConfig(securityPolicy = {}) {
85
+ const profile = securityPolicy.profile || 'workspace-write';
86
+ if (profile === 'dangerous') {
87
+ return { permission: 'allow' };
88
+ }
89
+ const permission = {
90
+ read: 'allow',
91
+ glob: 'allow',
92
+ grep: 'allow',
93
+ edit: buildEditPermission(securityPolicy),
94
+ bash: profile === 'read-only' ? 'deny' : 'ask',
95
+ task: 'deny',
96
+ webfetch: 'deny',
97
+ websearch: 'deny',
98
+ external_directory: 'deny',
99
+ doom_loop: 'ask',
100
+ };
101
+ return { permission };
102
+ }
103
+ export function buildOpenCodeConfigContent({ securityPolicy = {}, runnerAgent = '', existingContent = '', } = {}) {
104
+ let config = {};
105
+ if (existingContent) {
106
+ try {
107
+ config = JSON.parse(existingContent);
108
+ }
109
+ catch {
110
+ config = {};
111
+ }
112
+ }
113
+ const permissionConfig = buildOpenCodePermissionConfig(securityPolicy);
114
+ config = {
115
+ ...config,
116
+ ...permissionConfig,
117
+ };
118
+ if (runnerAgent && permissionConfig.permission && typeof permissionConfig.permission === 'object') {
119
+ config.agent = {
120
+ ...(config.agent || {}),
121
+ [runnerAgent]: {
122
+ ...(config.agent?.[runnerAgent] || {}),
123
+ permission: permissionConfig.permission,
124
+ },
125
+ };
126
+ }
127
+ return JSON.stringify(config);
128
+ }
129
+ export function buildOpenCodeEnv({ securityPolicy = {}, runnerAgent = '', baseEnv = process.env, } = {}) {
130
+ // Note: we deliberately do NOT redirect XDG_DATA_HOME here. OpenCode stores
131
+ // provider auth under XDG_DATA_HOME, so isolating it strips API credentials
132
+ // from the spawned process. Security is enforced via OPENCODE_CONFIG_CONTENT
133
+ // and Singleton's post-run snapshot diff.
134
+ return {
135
+ ...baseEnv,
136
+ OPENCODE_CONFIG_CONTENT: buildOpenCodeConfigContent({
137
+ securityPolicy,
138
+ runnerAgent: runnerAgent || '',
139
+ existingContent: baseEnv.OPENCODE_CONFIG_CONTENT || '',
140
+ }),
141
+ };
142
+ }
143
+ export function buildOpenCodeArgs({ prompt, model, runnerAgent, securityPolicy = {} }) {
144
+ const args = ['run', '--format', 'json'];
145
+ if (model)
146
+ args.push('--model', model);
147
+ if (runnerAgent)
148
+ args.push('--agent', runnerAgent);
149
+ if (securityPolicy.profile === 'dangerous')
150
+ args.push('--dangerously-skip-permissions');
151
+ args.push(prompt);
152
+ return args;
153
+ }
154
+ export function summarizeOpenCodeEvents(events, stdout = '') {
155
+ const assistantText = events
156
+ .filter(isAssistantEvent)
157
+ .map((event) => extractText(event))
158
+ .filter(Boolean)
159
+ .join('\n')
160
+ .trim();
161
+ const usage = [...events].reverse().map(findUsage).find(Boolean) || null;
162
+ const costUsd = [...events].reverse().map(findCostUsd).find((value) => value !== null) ?? null;
163
+ const turns = events.filter(isAssistantEvent).length || null;
164
+ return {
165
+ text: assistantText || stdout.trim(),
166
+ turns,
167
+ costUsd,
168
+ tokens: usage,
169
+ };
170
+ }
171
+ export const opencodeRunner = {
172
+ id: 'opencode',
173
+ command: 'opencode',
174
+ async run({ cwd, systemPrompt, userPrompt, model, runnerAgent, securityPolicy, timeoutMs = DEFAULT_TIMEOUT_MS, }) {
175
+ const prompt = buildPrompt(systemPrompt, userPrompt);
176
+ const args = buildOpenCodeArgs({ prompt, model, runnerAgent, securityPolicy });
177
+ const env = buildOpenCodeEnv({
178
+ securityPolicy,
179
+ runnerAgent,
180
+ });
181
+ const runResult = await new Promise((resolve, reject) => {
182
+ const child = spawn('opencode', args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
183
+ const stdoutChunks = [];
184
+ let stderrText = '';
185
+ let timedOut = false;
186
+ const timer = setTimeout(() => {
187
+ timedOut = true;
188
+ child.kill('SIGTERM');
189
+ setTimeout(() => child.kill('SIGKILL'), 5000).unref();
190
+ }, timeoutMs);
191
+ child.stdout.on('data', (chunk) => stdoutChunks.push(chunk.toString()));
192
+ child.stderr.on('data', (chunk) => (stderrText += chunk.toString()));
193
+ child.on('error', (err) => {
194
+ clearTimeout(timer);
195
+ reject(err);
196
+ });
197
+ child.on('close', (code) => {
198
+ clearTimeout(timer);
199
+ const stdout = stdoutChunks.join('');
200
+ const events = stdout
201
+ .split('\n')
202
+ .map((line) => line.trim())
203
+ .filter(Boolean)
204
+ .map(safeJsonParse)
205
+ .filter(Boolean);
206
+ if (timedOut) {
207
+ reject(new Error(`opencode timed out after ${Math.round(timeoutMs / 1000)}s`));
208
+ return;
209
+ }
210
+ const errorEvent = [...events].reverse().find((event) => event.type === 'error' || event.error);
211
+ if (errorEvent) {
212
+ const message = extractOpenCodeErrorMessage(errorEvent) || stderrText.trim() || stdout.trim() || 'unknown error';
213
+ reject(new Error(`opencode error: ${message}`));
214
+ return;
215
+ }
216
+ if (code !== 0) {
217
+ const result = [...events].reverse().find((event) => event.type === 'result' || event.error || event.message);
218
+ const message = extractOpenCodeErrorMessage(result) || stderrText.trim() || stdout.trim() || 'unknown error';
219
+ reject(new Error(`opencode exited ${code}: ${message}`));
220
+ return;
221
+ }
222
+ resolve({ events, stdout, stderr: stderrText });
223
+ });
224
+ });
225
+ const { events, stdout, stderr } = runResult;
226
+ const summary = summarizeOpenCodeEvents(events, stdout);
227
+ return {
228
+ text: summary.text,
229
+ metadata: {
230
+ provider: 'opencode',
231
+ model: model || null,
232
+ runnerAgent: runnerAgent || null,
233
+ turns: summary.turns,
234
+ costUsd: summary.costUsd,
235
+ tokens: summary.tokens,
236
+ raw: { events, stdout, stderr },
237
+ },
238
+ };
239
+ },
240
+ };
@@ -0,0 +1,43 @@
1
+ import fg from 'fast-glob';
2
+ import fs from 'node:fs/promises';
3
+ import { parseAgentFile } from './parser.js';
4
+ const SOURCES = [
5
+ { kind: 'singleton', pattern: '.singleton/agents/*.md', priority: 3 },
6
+ { kind: 'claude', pattern: '.claude/agents/*.md', priority: 2 },
7
+ { kind: 'copilot', pattern: '.github/agents/*.md', priority: 2 },
8
+ { kind: 'opencode', pattern: '.opencode/agents/*.md', priority: 2 },
9
+ ];
10
+ function normalizeAgent(agent, source) {
11
+ const sourceProvider = ['claude', 'copilot', 'opencode'].includes(source)
12
+ ? source
13
+ : undefined;
14
+ return {
15
+ ...agent,
16
+ source,
17
+ provider: agent.provider || sourceProvider,
18
+ };
19
+ }
20
+ export async function scanAgents(root) {
21
+ const selected = new Map();
22
+ for (const source of SOURCES) {
23
+ const files = await fg([source.pattern], {
24
+ cwd: root,
25
+ absolute: true,
26
+ ignore: source.ignore || [],
27
+ });
28
+ for (const file of files) {
29
+ const content = await fs.readFile(file, 'utf8');
30
+ const parsed = parseAgentFile(content, file);
31
+ if (!parsed)
32
+ continue;
33
+ const agent = normalizeAgent(parsed, source.kind);
34
+ const existing = selected.get(agent.id);
35
+ if (!existing || source.priority > existing.priority) {
36
+ selected.set(agent.id, { ...agent, priority: source.priority });
37
+ }
38
+ }
39
+ }
40
+ return [...selected.values()]
41
+ .map(({ priority: _priority, ...agent }) => agent)
42
+ .sort((a, b) => a.id.localeCompare(b.id));
43
+ }
@@ -0,0 +1,115 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs/promises';
3
+ const VALID_PROFILES = new Set(['read-only', 'workspace-write', 'restricted-write', 'dangerous']);
4
+ const DEFAULT_PROFILE = 'workspace-write';
5
+ const DEFAULT_BLOCKED_PATHS = ['.git', 'node_modules', '.env', '.env.*', '.ssh'];
6
+ const DEFAULT_COMMIT_EXCLUDE_PATHS = ['.singleton'];
7
+ function asList(value) {
8
+ return Array.isArray(value) ? value.filter(Boolean).map(String) : [];
9
+ }
10
+ function isInside(parent, child) {
11
+ const rel = path.relative(parent, child);
12
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
13
+ }
14
+ function normalizeRel(value) {
15
+ return String(value || '').replaceAll('\\', '/').replace(/^\/+/, '').replace(/\/+$/, '');
16
+ }
17
+ function matchesPattern(relPath, pattern) {
18
+ const rel = normalizeRel(relPath);
19
+ const pat = normalizeRel(pattern);
20
+ if (!pat)
21
+ return false;
22
+ if (pat.endsWith('.*')) {
23
+ const prefix = pat.slice(0, -1);
24
+ return rel === pat.slice(0, -2) || rel.startsWith(prefix);
25
+ }
26
+ return rel === pat || rel.startsWith(`${pat}/`);
27
+ }
28
+ function resolvePath(root, value) {
29
+ return path.isAbsolute(value) ? path.resolve(value) : path.resolve(root, value);
30
+ }
31
+ export function resolveSecurityPolicy(step = {}, agent = {}) {
32
+ return resolveSecurityPolicyWithConfig(step, agent);
33
+ }
34
+ export async function loadProjectSecurityConfig(root) {
35
+ const file = path.join(root, '.singleton', 'security.json');
36
+ try {
37
+ const raw = await fs.readFile(file, 'utf8');
38
+ const config = JSON.parse(raw);
39
+ return {
40
+ file,
41
+ defaultProfile: config.default_profile || DEFAULT_PROFILE,
42
+ allowedPaths: asList(config.allowed_paths),
43
+ blockedPaths: asList(config.blocked_paths),
44
+ commit: {
45
+ excludePaths: [
46
+ ...DEFAULT_COMMIT_EXCLUDE_PATHS,
47
+ ...asList(config.commit?.exclude_paths),
48
+ ],
49
+ requireConfirmation: config.commit?.require_confirmation !== false,
50
+ },
51
+ };
52
+ }
53
+ catch (err) {
54
+ const code = err && typeof err === 'object' && 'code' in err ? err.code : null;
55
+ const message = err instanceof Error ? err.message : String(err);
56
+ if (code !== 'ENOENT') {
57
+ throw new Error(`Invalid project security config: ${file} (${message})`);
58
+ }
59
+ return {
60
+ file,
61
+ defaultProfile: DEFAULT_PROFILE,
62
+ allowedPaths: [],
63
+ blockedPaths: [],
64
+ commit: {
65
+ excludePaths: DEFAULT_COMMIT_EXCLUDE_PATHS,
66
+ requireConfirmation: true,
67
+ },
68
+ };
69
+ }
70
+ }
71
+ export function resolveSecurityPolicyWithConfig(step = {}, agent = {}, projectConfig = {}) {
72
+ const profile = (step.security_profile || agent.security_profile || projectConfig.defaultProfile || DEFAULT_PROFILE);
73
+ return {
74
+ profile,
75
+ allowedPaths: asList(step.allowed_paths ?? agent.allowed_paths ?? projectConfig.allowedPaths),
76
+ blockedPaths: [
77
+ ...DEFAULT_BLOCKED_PATHS,
78
+ ...asList(projectConfig.blockedPaths),
79
+ ...asList(step.blocked_paths ?? agent.blocked_paths),
80
+ ],
81
+ };
82
+ }
83
+ export function validateSecurityPolicy(policy) {
84
+ const errors = [];
85
+ if (!VALID_PROFILES.has(policy.profile)) {
86
+ errors.push(`unknown security_profile "${policy.profile}"`);
87
+ }
88
+ if (policy.profile === 'restricted-write' && policy.allowedPaths.length === 0) {
89
+ errors.push('restricted-write requires at least one allowed_paths entry');
90
+ }
91
+ return errors;
92
+ }
93
+ export function assertWriteAllowed(absTarget, { root, agentName, outputName, policy }) {
94
+ const absRoot = path.resolve(root);
95
+ const absPath = path.resolve(absTarget);
96
+ const rel = path.relative(absRoot, absPath);
97
+ if (!isInside(absRoot, absPath)) {
98
+ throw new Error(`Step "${agentName}" output "${outputName}" resolves outside the project root: ${absPath}`);
99
+ }
100
+ if (policy.profile === 'read-only') {
101
+ throw new Error(`Step "${agentName}" output "${outputName}" is blocked by read-only security_profile: ${rel}`);
102
+ }
103
+ if (policy.profile !== 'dangerous') {
104
+ const blocked = policy.blockedPaths.find((pattern) => matchesPattern(rel, pattern));
105
+ if (blocked) {
106
+ throw new Error(`Step "${agentName}" output "${outputName}" is blocked by security policy "${blocked}": ${rel}`);
107
+ }
108
+ }
109
+ if (policy.profile === 'restricted-write') {
110
+ const allowed = policy.allowedPaths.some((entry) => isInside(resolvePath(absRoot, entry), absPath));
111
+ if (!allowed) {
112
+ throw new Error(`Step "${agentName}" output "${outputName}" is outside allowed_paths: ${rel}`);
113
+ }
114
+ }
115
+ }
@@ -0,0 +1 @@
1
+ export const ESC_SENTINEL = '__SINGLETON_ESC__';