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.
- package/LICENSE +1 -1
- package/README.md +670 -97
- package/bin/loreli.js +89 -0
- package/package.json +74 -14
- package/packages/README.md +101 -0
- package/packages/action/README.md +98 -0
- package/packages/action/src/index.js +656 -0
- package/packages/agent/README.md +517 -0
- package/packages/agent/src/backends/claude.js +287 -0
- package/packages/agent/src/backends/codex.js +278 -0
- package/packages/agent/src/backends/cursor.js +294 -0
- package/packages/agent/src/backends/index.js +329 -0
- package/packages/agent/src/base.js +138 -0
- package/packages/agent/src/cli.js +198 -0
- package/packages/agent/src/factory.js +119 -0
- package/packages/agent/src/index.js +12 -0
- package/packages/agent/src/models.js +141 -0
- package/packages/agent/src/output.js +62 -0
- package/packages/agent/src/session.js +162 -0
- package/packages/agent/src/trace.js +186 -0
- package/packages/config/README.md +833 -0
- package/packages/config/src/defaults.js +134 -0
- package/packages/config/src/index.js +192 -0
- package/packages/config/src/schema.js +273 -0
- package/packages/config/src/validate.js +160 -0
- package/packages/context/README.md +165 -0
- package/packages/context/src/index.js +198 -0
- package/packages/hub/README.md +338 -0
- package/packages/hub/src/base.js +154 -0
- package/packages/hub/src/github.js +1558 -0
- package/packages/hub/src/index.js +79 -0
- package/packages/hub/src/labels.js +48 -0
- package/packages/identity/README.md +288 -0
- package/packages/identity/src/index.js +620 -0
- package/packages/identity/src/themes/avatar.js +217 -0
- package/packages/identity/src/themes/digimon.js +217 -0
- package/packages/identity/src/themes/dragonball.js +217 -0
- package/packages/identity/src/themes/lotr.js +217 -0
- package/packages/identity/src/themes/marvel.js +217 -0
- package/packages/identity/src/themes/pokemon.js +217 -0
- package/packages/identity/src/themes/starwars.js +217 -0
- package/packages/identity/src/themes/transformers.js +217 -0
- package/packages/identity/src/themes/zelda.js +217 -0
- package/packages/knowledge/README.md +237 -0
- package/packages/knowledge/src/index.js +412 -0
- package/packages/log/README.md +93 -0
- package/packages/log/src/index.js +252 -0
- package/packages/marker/README.md +200 -0
- package/packages/marker/src/index.js +184 -0
- package/packages/mcp/README.md +279 -0
- package/packages/mcp/instructions.md +121 -0
- package/packages/mcp/scaffolding/.agents/skills/loreli-context/SKILL.md +89 -0
- package/packages/mcp/scaffolding/ISSUE_TEMPLATE/config.yml +2 -0
- package/packages/mcp/scaffolding/ISSUE_TEMPLATE/loreli.yml +83 -0
- package/packages/mcp/scaffolding/loreli.yml +453 -0
- package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +3 -0
- package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +11 -0
- package/packages/mcp/scaffolding/mcp-configs/.mcp.json +11 -0
- package/packages/mcp/scaffolding/pull-request.md +23 -0
- package/packages/mcp/src/index.js +571 -0
- package/packages/mcp/src/tools/agents.js +429 -0
- package/packages/mcp/src/tools/context.js +199 -0
- package/packages/mcp/src/tools/github.js +1199 -0
- package/packages/mcp/src/tools/hitl.js +149 -0
- package/packages/mcp/src/tools/index.js +17 -0
- package/packages/mcp/src/tools/start.js +835 -0
- package/packages/mcp/src/tools/status.js +146 -0
- package/packages/mcp/src/tools/work.js +124 -0
- package/packages/orchestrator/README.md +192 -0
- package/packages/orchestrator/src/index.js +1226 -0
- package/packages/planner/README.md +168 -0
- package/packages/planner/src/index.js +1166 -0
- package/packages/review/README.md +129 -0
- package/packages/review/src/index.js +1283 -0
- package/packages/risk/README.md +119 -0
- package/packages/risk/src/index.js +428 -0
- package/packages/session/README.md +165 -0
- package/packages/session/src/index.js +215 -0
- package/packages/test-utils/README.md +96 -0
- package/packages/test-utils/src/index.js +354 -0
- package/packages/tmux/README.md +261 -0
- package/packages/tmux/src/index.js +452 -0
- package/packages/workflow/README.md +313 -0
- package/packages/workflow/src/index.js +481 -0
- package/packages/workflow/src/proof-of-life.js +74 -0
- package/packages/workspace/README.md +143 -0
- package/packages/workspace/src/index.js +1076 -0
- 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
|
+
}
|