loreli 0.0.0 → 1.0.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.
Files changed (88) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +670 -97
  3. package/bin/loreli.js +89 -0
  4. package/package.json +74 -14
  5. package/packages/README.md +101 -0
  6. package/packages/action/README.md +98 -0
  7. package/packages/action/src/index.js +656 -0
  8. package/packages/agent/README.md +517 -0
  9. package/packages/agent/src/backends/claude.js +287 -0
  10. package/packages/agent/src/backends/codex.js +278 -0
  11. package/packages/agent/src/backends/cursor.js +294 -0
  12. package/packages/agent/src/backends/index.js +329 -0
  13. package/packages/agent/src/base.js +138 -0
  14. package/packages/agent/src/cli.js +198 -0
  15. package/packages/agent/src/factory.js +119 -0
  16. package/packages/agent/src/index.js +12 -0
  17. package/packages/agent/src/models.js +141 -0
  18. package/packages/agent/src/output.js +62 -0
  19. package/packages/agent/src/session.js +162 -0
  20. package/packages/agent/src/trace.js +186 -0
  21. package/packages/config/README.md +833 -0
  22. package/packages/config/src/defaults.js +134 -0
  23. package/packages/config/src/index.js +192 -0
  24. package/packages/config/src/schema.js +273 -0
  25. package/packages/config/src/validate.js +160 -0
  26. package/packages/context/README.md +165 -0
  27. package/packages/context/src/index.js +198 -0
  28. package/packages/hub/README.md +338 -0
  29. package/packages/hub/src/base.js +154 -0
  30. package/packages/hub/src/github.js +1558 -0
  31. package/packages/hub/src/index.js +79 -0
  32. package/packages/hub/src/labels.js +48 -0
  33. package/packages/identity/README.md +288 -0
  34. package/packages/identity/src/index.js +620 -0
  35. package/packages/identity/src/themes/avatar.js +217 -0
  36. package/packages/identity/src/themes/digimon.js +217 -0
  37. package/packages/identity/src/themes/dragonball.js +217 -0
  38. package/packages/identity/src/themes/lotr.js +217 -0
  39. package/packages/identity/src/themes/marvel.js +217 -0
  40. package/packages/identity/src/themes/pokemon.js +217 -0
  41. package/packages/identity/src/themes/starwars.js +217 -0
  42. package/packages/identity/src/themes/transformers.js +217 -0
  43. package/packages/identity/src/themes/zelda.js +217 -0
  44. package/packages/knowledge/README.md +237 -0
  45. package/packages/knowledge/src/index.js +412 -0
  46. package/packages/log/README.md +93 -0
  47. package/packages/log/src/index.js +252 -0
  48. package/packages/marker/README.md +200 -0
  49. package/packages/marker/src/index.js +184 -0
  50. package/packages/mcp/README.md +279 -0
  51. package/packages/mcp/instructions.md +121 -0
  52. package/packages/mcp/scaffolding/.agents/skills/loreli-context/SKILL.md +89 -0
  53. package/packages/mcp/scaffolding/ISSUE_TEMPLATE/config.yml +2 -0
  54. package/packages/mcp/scaffolding/ISSUE_TEMPLATE/loreli.yml +83 -0
  55. package/packages/mcp/scaffolding/loreli.yml +453 -0
  56. package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +3 -0
  57. package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +11 -0
  58. package/packages/mcp/scaffolding/mcp-configs/.mcp.json +11 -0
  59. package/packages/mcp/scaffolding/pull-request.md +23 -0
  60. package/packages/mcp/src/index.js +571 -0
  61. package/packages/mcp/src/tools/agents.js +429 -0
  62. package/packages/mcp/src/tools/context.js +199 -0
  63. package/packages/mcp/src/tools/github.js +1199 -0
  64. package/packages/mcp/src/tools/hitl.js +149 -0
  65. package/packages/mcp/src/tools/index.js +17 -0
  66. package/packages/mcp/src/tools/start.js +835 -0
  67. package/packages/mcp/src/tools/status.js +146 -0
  68. package/packages/mcp/src/tools/work.js +124 -0
  69. package/packages/orchestrator/README.md +192 -0
  70. package/packages/orchestrator/src/index.js +1226 -0
  71. package/packages/planner/README.md +168 -0
  72. package/packages/planner/src/index.js +1166 -0
  73. package/packages/review/README.md +129 -0
  74. package/packages/review/src/index.js +1283 -0
  75. package/packages/risk/README.md +119 -0
  76. package/packages/risk/src/index.js +428 -0
  77. package/packages/session/README.md +165 -0
  78. package/packages/session/src/index.js +215 -0
  79. package/packages/test-utils/README.md +96 -0
  80. package/packages/test-utils/src/index.js +354 -0
  81. package/packages/tmux/README.md +261 -0
  82. package/packages/tmux/src/index.js +452 -0
  83. package/packages/workflow/README.md +313 -0
  84. package/packages/workflow/src/index.js +481 -0
  85. package/packages/workflow/src/proof-of-life.js +74 -0
  86. package/packages/workspace/README.md +143 -0
  87. package/packages/workspace/src/index.js +1076 -0
  88. package/index.js +0 -8
@@ -0,0 +1,294 @@
1
+ import { writeFile, mkdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { CliAgent } from '../cli.js';
4
+ import { resolve, env } from '../models.js';
5
+ import { mcpJson, cursorMatcher } from 'loreli/workspace';
6
+ import { vendor } from 'loreli/identity';
7
+ import { logger } from 'loreli/log';
8
+
9
+ const log = logger('cursor');
10
+
11
+ /**
12
+ * Interval for polling pane content during startup.
13
+ * @type {number}
14
+ */
15
+ const READY_POLL = 1000;
16
+
17
+ /**
18
+ * Maximum time to wait for cursor-agent to become ready.
19
+ * @type {number}
20
+ */
21
+ const READY_TIMEOUT = 60000;
22
+
23
+ /**
24
+ * Maximum time (ms) to wait for the TUI input prompt after the banner
25
+ * appears. This is NOT a fixed sleep — the readiness loop polls for
26
+ * a concrete signal (workspace path line rendered in the pane).
27
+ *
28
+ * @type {number}
29
+ */
30
+ const INPUT_READY_TIMEOUT = 15000;
31
+
32
+ /**
33
+ * Cursor Agent CLI backend. Interactive: stays running in a tmux pane.
34
+ *
35
+ * Cursor Agent is the multi-provider workhorse — it can run models from
36
+ * Anthropic, OpenAI, Google, and others through a single CLI. This makes
37
+ * it the natural fallback when provider-specific CLIs (claude, codex) are
38
+ * unavailable or their API endpoints are unreachable.
39
+ *
40
+ * The yin/yang adversarial pairing is preserved because each agent's
41
+ * identity carries its provider. The model resolver maps `balanced` to
42
+ * `claude-sonnet-4-20250514` for anthropic and `gpt-4o` for openai.
43
+ * The CURSOR_MODELS table then translates those API identifiers into
44
+ * cursor-agent short names.
45
+ *
46
+ * Flags:
47
+ * - `--force`: auto-approve tool usage (file writes, bash commands)
48
+ * - `--sandbox disabled`: no sandbox isolation prompts
49
+ * - `--approve-mcps`: auto-approve scaffolded Loreli MCP server
50
+ *
51
+ * @extends CliAgent
52
+ */
53
+ export class CursorBackend extends CliAgent {
54
+ /**
55
+ * Return the scaffold descriptor for Cursor Agent workspaces.
56
+ *
57
+ * cursor-agent CLI does not support `${env:NAME}` interpolation,
58
+ * so token propagation uses `envFile` pointing to `.git/loreli.env`
59
+ * (inherently unstageable). Hooks use beforeShellExecution.
60
+ *
61
+ * @param {object} [context] - Agent context for env/token injection.
62
+ * @returns {object} Scaffold descriptor.
63
+ */
64
+ static scaffold(context) {
65
+ const denied = context?.denied ?? [];
66
+
67
+ const dangerousCommands = [
68
+ ...denied,
69
+ 'git', 'rm', 'mv', 'chmod', 'sed', 'perl', 'tee', 'truncate',
70
+ 'node', 'python', 'python3', 'ruby'
71
+ ];
72
+ const unique = [...new Set(dangerousCommands)];
73
+
74
+ const descriptor = {
75
+ configs: [
76
+ {
77
+ path: '.mcp.json',
78
+ content: mcpJson(context, context ? { tokenRef: '${GITHUB_TOKEN}' } : {}),
79
+ marker: 'loreli',
80
+ format: 'json'
81
+ },
82
+ {
83
+ path: '.cursor/mcp.json',
84
+ content: mcpJson(context, context ? { envFile: '.git/loreli.env' } : {}),
85
+ marker: 'loreli',
86
+ format: 'json'
87
+ }
88
+ ],
89
+ hooks: [
90
+ {
91
+ path: '.cursor/hooks.json',
92
+ key: 'beforeShellExecution',
93
+ entry: {
94
+ command: '.loreli/deny.sh',
95
+ matcher: cursorMatcher(unique)
96
+ },
97
+ marker: 'deny.sh',
98
+ defaults: { version: 1 }
99
+ }
100
+ ],
101
+ files: []
102
+ };
103
+
104
+ // Declare .git/loreli.env for token propagation. Written by
105
+ // workspace.create() after clone when .git/ exists.
106
+ if (context?.token) {
107
+ descriptor.files.push({
108
+ path: '.git/loreli.env',
109
+ content: `GITHUB_TOKEN=${context.token}\n`,
110
+ mode: 0o600
111
+ });
112
+ }
113
+
114
+ return descriptor;
115
+ }
116
+ /**
117
+ * @param {object} opts
118
+ * @param {object} opts.identity - Agent identity.
119
+ * @param {string} opts.role - Agent role.
120
+ * @param {string} opts.cwd - Working directory.
121
+ * @param {string} [opts.model='balanced'] - Model alias or exact string.
122
+ * @param {object} [opts.config] - Config instance for model resolution.
123
+ * @param {string} [opts.session='loreli'] - Tmux session name.
124
+ * @param {number} [opts.readyTimeout=60000] - Max ms to wait for CLI readiness.
125
+ */
126
+ constructor(opts) {
127
+ const v = vendor(opts.identity?.provider ?? 'anthropic');
128
+ const model = resolve(opts.model ?? 'balanced', 'cursor', v, opts.config);
129
+
130
+ super({
131
+ ...opts,
132
+ command: buildCommand(model, opts.cwd)
133
+ });
134
+
135
+ /** @type {string} Resolved model identifier. */
136
+ this.model = model;
137
+
138
+ /** @type {object|undefined} Backend-specific environment variables. */
139
+ this._env = env('cursor', opts.config) ?? {};
140
+
141
+ if (opts.context?.token) this._env.GITHUB_TOKEN = opts.context.token;
142
+
143
+ /** @type {number} Max ms to wait for CLI readiness. */
144
+ this.readyTimeout = opts.readyTimeout ?? READY_TIMEOUT;
145
+ }
146
+
147
+ /**
148
+ * Spawn cursor-agent and wait for readiness.
149
+ *
150
+ * Builds an inline shell command with env exports and passes it
151
+ * directly to tmux — no launcher scripts on disk.
152
+ *
153
+ * @returns {Promise<void>}
154
+ */
155
+ async spawn() {
156
+ const cmd = this._shell(`exec ${this.command}`, this._env);
157
+ this.paneId = await this._launch(cmd);
158
+
159
+ log.info(`spawning ${this.identity?.name ?? '?'} in pane ${this.paneId}: ${this.command}`);
160
+ this.transition('spawned');
161
+
162
+ await this._navigate();
163
+ }
164
+
165
+ /**
166
+ * Wait for cursor-agent to finish initializing.
167
+ *
168
+ * Readiness is detected reactively through two phases:
169
+ *
170
+ * **Phase 1 — Process detection** (up to `readyTimeout`):
171
+ * Polls until the cursor-agent Node process is running. Two signals:
172
+ * - Banner: `Cursor Agent v<version>` appears in pane output.
173
+ * - Process: tmux reports `node` or a version string as the
174
+ * foreground command.
175
+ *
176
+ * **Phase 2 — Input readiness** (up to `INPUT_READY_TIMEOUT`):
177
+ * After the process starts, continues polling pane output for the
178
+ * TUI's workspace line (e.g. `~/path · branch`). This line renders
179
+ * only after MCP connections are established and the input field is
180
+ * active. This replaces the previous fixed-delay STABILIZE_MS timer
181
+ * with an event-driven check — the method returns as soon as the
182
+ * signal appears, not after a fixed sleep.
183
+ *
184
+ * @returns {Promise<void>}
185
+ */
186
+ async _navigate() {
187
+ if (this.readyTimeout <= 0) return;
188
+
189
+ const deadline = Date.now() + this.readyTimeout;
190
+ const name = this.identity?.name ?? '?';
191
+
192
+ // Phase 1: detect cursor-agent process startup
193
+ while (Date.now() < deadline) {
194
+ const output = await this.tmux.capture(this.paneId);
195
+
196
+ if (output.includes('Cursor Agent v')) {
197
+ log.info(`${name} phase 1 — cursor-agent banner detected`);
198
+ break;
199
+ }
200
+
201
+ const cmd = await this.tmux.command(this.paneId);
202
+ if (cmd === 'node' || /^\d+\.\d+\.\d+/.test(cmd)) {
203
+ log.info(`${name} phase 1 — cursor-agent process detected (${cmd})`);
204
+ break;
205
+ }
206
+
207
+ await new Promise(function wait(r) { setTimeout(r, READY_POLL); });
208
+ }
209
+
210
+ // Phase 2: wait for TUI input readiness (reactive, not fixed delay).
211
+ // cursor-agent renders a workspace line containing a middle dot (·)
212
+ // and/or the prompt indicator once MCP servers are connected and the
213
+ // input field is ready. Polling pane output for this is faster than
214
+ // a fixed timer and adapts to varying startup speeds.
215
+ const inputDeadline = Date.now() + INPUT_READY_TIMEOUT;
216
+ while (Date.now() < inputDeadline) {
217
+ const output = await this.tmux.capture(this.paneId);
218
+
219
+ // The workspace line (`~/path · branch`) appears after MCP init.
220
+ // The middle dot (·) is a reliable delimiter cursor-agent uses
221
+ // between the path and branch name in its TUI header.
222
+ if (output.includes('\u00b7')) {
223
+ log.info(`${name} phase 2 — TUI input ready (workspace line detected)`);
224
+ return;
225
+ }
226
+
227
+ await new Promise(function wait(r) { setTimeout(r, READY_POLL); });
228
+ }
229
+
230
+ log.warn(`${name} input readiness timeout after ${INPUT_READY_TIMEOUT}ms — proceeding anyway`);
231
+ }
232
+
233
+ /**
234
+ * Send a prompt to cursor-agent's interactive session.
235
+ *
236
+ * Multi-line prompts are written to a Markdown file and a single-line
237
+ * reference is sent, matching the ClaudeBackend pattern. This avoids
238
+ * tmux send-keys fragmenting the message at newlines.
239
+ *
240
+ * @param {string} message - The prompt text.
241
+ * @returns {Promise<void>}
242
+ */
243
+ async send(message) {
244
+ if (!this.paneId) throw new Error('Agent not spawned — call spawn() first');
245
+ if (this.canTransition('working')) this.transition('working');
246
+
247
+ if (!message.includes('\n')) {
248
+ await this.tmux.send(this.paneId, message);
249
+ return;
250
+ }
251
+
252
+ const dir = join(this.cwd, '.loreli');
253
+ await mkdir(dir, { recursive: true });
254
+ const file = join(dir, `task-${Date.now()}.md`);
255
+ await writeFile(file, message, 'utf8');
256
+ log.info(`${this.identity?.name ?? '?'} prompt written to ${file}`);
257
+
258
+ await this.tmux.send(this.paneId,
259
+ `Read the complete task instructions from ${file} and execute them. Follow every instruction precisely.`
260
+ );
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Build the cursor-agent CLI command string.
266
+ *
267
+ * Model names are resolved directly from config — no translation table.
268
+ * The `backends.cursor.models` config maps tiers to cursor-agent native
269
+ * names per provider (e.g. `sonnet-4.5-thinking`, `gpt-5.3-codex`).
270
+ *
271
+ * Flags:
272
+ * - `--model`: resolved cursor-agent model name
273
+ * - `--force`: auto-approve all tool usage
274
+ * - `--sandbox disabled`: disable sandbox isolation prompts
275
+ * - `--approve-mcps`: auto-approve the scaffolded Loreli MCP server
276
+ * - `--workspace`: set the working directory
277
+ *
278
+ * @param {string} model - Resolved cursor-agent model name from config.
279
+ * @param {string} [cwd] - Working directory for --workspace.
280
+ * @returns {string} CLI command string.
281
+ */
282
+ function buildCommand(model, cwd) {
283
+ const parts = [
284
+ 'cursor-agent',
285
+ `--model ${model}`,
286
+ '--force',
287
+ '--sandbox disabled',
288
+ '--approve-mcps'
289
+ ];
290
+
291
+ if (cwd) parts.push(`--workspace '${cwd}'`);
292
+
293
+ return parts.join(' ');
294
+ }
@@ -0,0 +1,329 @@
1
+ import { execFileSync } from 'node:child_process';
2
+
3
+ /**
4
+ * Map of built-in backends with their module paths and metadata.
5
+ *
6
+ * @type {Record<string, {path: string, cls: string, binary: string, provider: string}>}
7
+ */
8
+ const BUILTIN_BACKENDS = {
9
+ claude: { path: './claude.js', cls: 'ClaudeBackend', binary: 'claude', provider: 'anthropic' },
10
+ codex: { path: './codex.js', cls: 'CodexBackend', binary: 'codex', provider: 'openai' },
11
+ cursor: { path: './cursor.js', cls: 'CursorBackend', binary: 'cursor-agent', provider: 'multi' }
12
+ };
13
+
14
+ /**
15
+ * Discovers and manages available agent backends.
16
+ *
17
+ * Checks for CLI binaries on PATH and lazy-loads backend classes at
18
+ * discovery time. All backends are interactive CLI agents running in
19
+ * tmux panes.
20
+ */
21
+ export class BackendRegistry {
22
+ constructor() {
23
+ /** @type {Map<string, {cls: Function|null, provider: string, binary?: string}>} */
24
+ this.backends = new Map();
25
+
26
+ /** @type {Map<string, {name: string, provider: string, binary: string}>} */
27
+ this.discovered = new Map();
28
+
29
+ /**
30
+ * Failure counts per backend. Used to detect degraded backends
31
+ * that repeatedly fail (e.g. budget exhaustion, rate limits).
32
+ *
33
+ * @type {Map<string, {count: number, first: number, last: number}>}
34
+ */
35
+ this._failures = new Map();
36
+ }
37
+
38
+ /**
39
+ * Register a backend class.
40
+ *
41
+ * @param {string} name - Backend name (e.g. 'claude', 'codex').
42
+ * @param {Function} cls - Backend class constructor.
43
+ * @param {object} meta - Backend metadata.
44
+ * @param {string} meta.provider - AI provider ('anthropic', 'openai').
45
+ * @param {string} [meta.binary] - CLI binary name to check on PATH.
46
+ */
47
+ register(name, cls, meta) {
48
+ this.backends.set(name, { cls, ...meta });
49
+ }
50
+
51
+ /**
52
+ * Discover available backends by checking for CLI binaries on PATH.
53
+ * Lazy-loads the actual backend class for each discovered binary so
54
+ * that create() can instantiate real agent instances.
55
+ *
56
+ * @returns {Promise<void>}
57
+ */
58
+ async discover() {
59
+ this.discovered.clear();
60
+
61
+ for (const [name, meta] of Object.entries(BUILTIN_BACKENDS)) {
62
+ if (which(meta.binary)) {
63
+ const mod = await import(meta.path);
64
+ const cls = mod[meta.cls];
65
+
66
+ this.discovered.set(name, { name, provider: meta.provider, binary: meta.binary });
67
+ this.backends.set(name, { cls, provider: meta.provider, binary: meta.binary });
68
+ }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * List all discovered (available) backends.
74
+ *
75
+ * @returns {Array<{name: string, provider: string, binary: string}>}
76
+ */
77
+ available() {
78
+ return [...this.discovered.values()];
79
+ }
80
+
81
+ /**
82
+ * List unique providers from discovered backends.
83
+ *
84
+ * When cursor-agent is discovered (provider `multi`), it is expanded
85
+ * into `cursor-openai` and `cursor-anthropic` virtual providers.
86
+ * This makes every provider in the returned list directly usable for
87
+ * identity acquisition and yin/yang pairing.
88
+ *
89
+ * @returns {string[]} Array of provider names.
90
+ */
91
+ providers() {
92
+ const providerSet = new Set();
93
+ for (const info of this.discovered.values()) {
94
+ if (info.provider === 'multi') {
95
+ providerSet.add('cursor-openai');
96
+ providerSet.add('cursor-anthropic');
97
+ } else if (info.provider !== 'unknown') {
98
+ providerSet.add(info.provider);
99
+ }
100
+ }
101
+ return [...providerSet];
102
+ }
103
+
104
+ /**
105
+ * Check if a specific backend is available.
106
+ *
107
+ * @param {string} name - Backend name.
108
+ * @returns {boolean}
109
+ */
110
+ has(name) {
111
+ return this.backends.has(name);
112
+ }
113
+
114
+ /**
115
+ * Get a backend entry by name.
116
+ *
117
+ * @param {string} name - Backend name.
118
+ * @returns {{cls: Function|null, provider: string, binary?: string}|undefined}
119
+ */
120
+ get(name) {
121
+ return this.backends.get(name);
122
+ }
123
+
124
+ /**
125
+ * Record a backend failure. Repeated failures within a time window
126
+ * mark the backend as degraded, triggering fallback to cursor-agent.
127
+ *
128
+ * @param {string} name - Backend name (e.g. 'codex', 'claude').
129
+ */
130
+ recordFailure(name) {
131
+ const entry = this._failures.get(name) ?? { count: 0, first: 0, last: 0 };
132
+ entry.count++;
133
+ if (!entry.first) entry.first = Date.now();
134
+ entry.last = Date.now();
135
+ this._failures.set(name, entry);
136
+ }
137
+
138
+ /**
139
+ * Check if a backend is degraded. A backend is degraded when it has
140
+ * 2+ failures within the last 30 minutes — indicating a systemic
141
+ * issue like budget exhaustion or API outage.
142
+ *
143
+ * @param {string} name - Backend name.
144
+ * @returns {boolean} True if the backend is degraded.
145
+ */
146
+ degraded(name) {
147
+ const entry = this._failures.get(name);
148
+ if (!entry) return false;
149
+ const window = 30 * 60 * 1000;
150
+ return entry.count >= 2 && (Date.now() - entry.last) < window;
151
+ }
152
+
153
+ /**
154
+ * Clear failure history for a backend. Called when a backend
155
+ * successfully completes work, proving it has recovered.
156
+ *
157
+ * @param {string} name - Backend name.
158
+ */
159
+ clearFailures(name) {
160
+ this._failures.delete(name);
161
+ }
162
+
163
+ /**
164
+ * Select the best backend for a given AI provider.
165
+ *
166
+ * Resolution order:
167
+ * 1. Virtual cursor providers (`cursor-openai`, `cursor-anthropic`)
168
+ * route directly to the cursor backend
169
+ * 2. Exact provider match (claude for anthropic, codex for openai)
170
+ * — skipped when the matched backend is degraded and a multi-
171
+ * provider fallback exists
172
+ * 3. Multi-provider backend (cursor-agent — runs any provider)
173
+ * 4. Generic default fallback via {@link defaultBackend}
174
+ *
175
+ * @param {string} provider - AI provider ('anthropic', 'openai', 'cursor-openai', 'cursor-anthropic').
176
+ * @returns {string} Backend name.
177
+ * @throws {Error} When no suitable backend is available.
178
+ */
179
+ forProvider(provider) {
180
+ if (provider?.startsWith('cursor-')) {
181
+ for (const info of this.discovered.values()) {
182
+ if (info.provider === 'multi') return info.name;
183
+ }
184
+ }
185
+
186
+ for (const info of this.discovered.values()) {
187
+ if (info.provider === provider) {
188
+ if (this.degraded(info.name)) {
189
+ const cursor = this._cursorFallback();
190
+ if (cursor) return cursor;
191
+ }
192
+ return info.name;
193
+ }
194
+ }
195
+
196
+ for (const info of this.discovered.values()) {
197
+ if (info.provider === 'multi') return info.name;
198
+ }
199
+
200
+ return this.defaultBackend();
201
+ }
202
+
203
+ /**
204
+ * Find the cursor (multi-provider) backend among discovered backends.
205
+ *
206
+ * @returns {string|null} Backend name, or null if not available.
207
+ */
208
+ _cursorFallback() {
209
+ for (const info of this.discovered.values()) {
210
+ if (info.provider === 'multi') return info.name;
211
+ }
212
+ return null;
213
+ }
214
+
215
+ /**
216
+ * Return the default backend name using priority:
217
+ * 1. claude (Anthropic)
218
+ * 2. cursor (multi-provider)
219
+ * 3. first discovered backend
220
+ *
221
+ * @returns {string} Backend name.
222
+ * @throws {Error} When no backends are available.
223
+ */
224
+ defaultBackend() {
225
+ if (this.discovered.has('claude')) return 'claude';
226
+ if (this.discovered.has('cursor')) return 'cursor';
227
+
228
+ const first = this.discovered.keys().next();
229
+ if (!first.done) return first.value;
230
+
231
+ throw new Error('No backends available — install claude, cursor-agent, or codex CLI');
232
+ }
233
+
234
+ /**
235
+ * Resolve the best backend for a provider with optional explicit override.
236
+ *
237
+ * Resolution order:
238
+ * 1. Explicit override: if provided and registered, use it directly
239
+ * 2. Provider-native match via {@link forProvider}
240
+ *
241
+ * @param {string} provider - AI provider ('anthropic', 'openai', 'cursor-openai', 'cursor-anthropic').
242
+ * @param {string} [explicit] - Explicitly requested backend name.
243
+ * @returns {string} Backend name.
244
+ * @throws {Error} When no suitable backend is available.
245
+ */
246
+ resolve(provider, explicit) {
247
+ if (explicit && this.has(explicit)) return explicit;
248
+ return this.forProvider(provider);
249
+ }
250
+
251
+ /**
252
+ * Collect scaffold descriptors from all registered backends.
253
+ *
254
+ * Calls the static `scaffold(context)` method on every backend
255
+ * class that defines one. Returns a flat array of descriptors
256
+ * that `workspace.prepare()` can write generically.
257
+ *
258
+ * @param {object} [context] - Agent context for env/token injection.
259
+ * @returns {object[]} Array of scaffold descriptors.
260
+ */
261
+ scaffoldAll(context) {
262
+ const descriptors = [];
263
+ for (const [, info] of this.backends) {
264
+ if (typeof info.cls?.scaffold === 'function') {
265
+ const descriptor = info.cls.scaffold(context);
266
+ if (descriptor) descriptors.push(descriptor);
267
+ }
268
+ }
269
+ return descriptors;
270
+ }
271
+
272
+ /**
273
+ * Collect config path metadata from all registered backends.
274
+ *
275
+ * Returns the union of `configs` entries from all backend scaffold
276
+ * descriptors — used by the start tool to know which files to
277
+ * ensure in the remote repository.
278
+ *
279
+ * @returns {Array<{path: string, marker: string, format: string}>}
280
+ */
281
+ configPaths() {
282
+ const seen = new Set();
283
+ const paths = [];
284
+ for (const [, info] of this.backends) {
285
+ if (typeof info.cls?.scaffold !== 'function') continue;
286
+ const descriptor = info.cls.scaffold();
287
+ if (!descriptor?.configs) continue;
288
+ for (const cfg of descriptor.configs) {
289
+ if (!seen.has(cfg.path)) {
290
+ seen.add(cfg.path);
291
+ paths.push({ path: cfg.path, marker: cfg.marker, format: cfg.format });
292
+ }
293
+ }
294
+ }
295
+ return paths;
296
+ }
297
+
298
+ /**
299
+ * Create a backend instance.
300
+ *
301
+ * @param {string} name - Backend name.
302
+ * @param {object} opts - Options passed to the backend constructor.
303
+ * @returns {object} Backend instance.
304
+ * @throws {Error} When the backend is not registered or has no class.
305
+ */
306
+ create(name, opts) {
307
+ const info = this.backends.get(name);
308
+ if (!info) throw new Error(`Unknown backend: "${name}"`);
309
+ if (!info.cls) throw new Error(`Backend "${name}" has no implementation class registered`);
310
+ const agent = new info.cls(opts);
311
+ agent.backend = name;
312
+ return agent;
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Check if a binary exists on PATH.
318
+ *
319
+ * @param {string} binary - Binary name to check.
320
+ * @returns {boolean} True if the binary is on PATH.
321
+ */
322
+ function which(binary) {
323
+ try {
324
+ execFileSync('which', [binary], { stdio: 'ignore' });
325
+ return true;
326
+ } catch {
327
+ return false;
328
+ }
329
+ }