loreli 1.0.0 → 2.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/README.md +66 -26
- package/package.json +17 -14
- package/packages/action/prompts/action.md +172 -0
- package/packages/action/src/index.js +33 -5
- package/packages/agent/README.md +107 -18
- package/packages/agent/src/backends/claude.js +111 -11
- package/packages/agent/src/backends/codex.js +78 -5
- package/packages/agent/src/backends/cursor.js +104 -27
- package/packages/agent/src/backends/index.js +162 -5
- package/packages/agent/src/cli.js +80 -3
- package/packages/agent/src/discover.js +396 -0
- package/packages/agent/src/factory.js +39 -34
- package/packages/agent/src/models.js +24 -6
- package/packages/classify/README.md +136 -0
- package/packages/classify/prompts/blocker.md +12 -0
- package/packages/classify/prompts/feedback.md +14 -0
- package/packages/classify/prompts/pane-state.md +20 -0
- package/packages/classify/src/index.js +81 -0
- package/packages/config/README.md +156 -91
- package/packages/config/src/defaults.js +32 -21
- package/packages/config/src/index.js +33 -2
- package/packages/config/src/schema.js +57 -39
- package/packages/hub/src/github.js +59 -20
- package/packages/identity/README.md +1 -1
- package/packages/identity/src/index.js +2 -2
- package/packages/knowledge/README.md +86 -106
- package/packages/knowledge/src/index.js +56 -225
- package/packages/mcp/README.md +51 -7
- package/packages/mcp/instructions.md +6 -1
- package/packages/mcp/scaffolding/loreli.yml +115 -77
- package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +1 -0
- package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +4 -1
- package/packages/mcp/scaffolding/mcp-configs/.mcp.json +4 -1
- package/packages/mcp/src/index.js +45 -16
- package/packages/mcp/src/tools/agent-context.js +44 -0
- package/packages/mcp/src/tools/agents.js +34 -13
- package/packages/mcp/src/tools/context.js +3 -2
- package/packages/mcp/src/tools/github.js +11 -47
- package/packages/mcp/src/tools/hitl.js +19 -6
- package/packages/mcp/src/tools/index.js +2 -1
- package/packages/mcp/src/tools/refactor.js +227 -0
- package/packages/mcp/src/tools/repo.js +44 -0
- package/packages/mcp/src/tools/start.js +159 -90
- package/packages/mcp/src/tools/status.js +5 -2
- package/packages/mcp/src/tools/work.js +18 -8
- package/packages/orchestrator/src/index.js +345 -79
- package/packages/planner/README.md +84 -1
- package/packages/planner/prompts/plan-reviewer.md +109 -0
- package/packages/planner/prompts/planner.md +191 -0
- package/packages/planner/prompts/tiebreaker-reviewer.md +71 -0
- package/packages/planner/src/index.js +326 -111
- package/packages/review/README.md +2 -2
- package/packages/review/prompts/reviewer.md +158 -0
- package/packages/review/src/index.js +196 -76
- package/packages/risk/README.md +81 -22
- package/packages/risk/prompts/risk.md +272 -0
- package/packages/risk/src/index.js +44 -33
- package/packages/tmux/src/index.js +61 -12
- package/packages/workflow/README.md +18 -14
- package/packages/workflow/prompts/preamble.md +14 -0
- package/packages/workflow/src/index.js +191 -12
- package/packages/workspace/README.md +2 -2
- package/packages/workspace/src/index.js +69 -18
|
@@ -29,6 +29,13 @@ const READY_TIMEOUT = 60000;
|
|
|
29
29
|
*/
|
|
30
30
|
const INPUT_READY_TIMEOUT = 15000;
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Settle delay (ms) after Phase 1 before sending the trust-approval
|
|
34
|
+
* keystroke. Gives cursor-agent time to render the trust dialog.
|
|
35
|
+
* @type {number}
|
|
36
|
+
*/
|
|
37
|
+
const TRUST_SETTLE = 2000;
|
|
38
|
+
|
|
32
39
|
/**
|
|
33
40
|
* Cursor Agent CLI backend. Interactive: stays running in a tmux pane.
|
|
34
41
|
*
|
|
@@ -51,12 +58,48 @@ const INPUT_READY_TIMEOUT = 15000;
|
|
|
51
58
|
* @extends CliAgent
|
|
52
59
|
*/
|
|
53
60
|
export class CursorBackend extends CliAgent {
|
|
61
|
+
/**
|
|
62
|
+
* Regex patterns that identify known Cursor Agent blocking dialogs.
|
|
63
|
+
*
|
|
64
|
+
* @type {Array<{pattern: RegExp, category: string, reasoning: string, remedy?: string[]}>}
|
|
65
|
+
*/
|
|
66
|
+
static patterns = [
|
|
67
|
+
{
|
|
68
|
+
pattern: /ready to build\?[\s\S]*no,\s*propose changes\s*\(p\s*or\s*esc\)/i,
|
|
69
|
+
category: 'option_dialog',
|
|
70
|
+
reasoning: 'Cursor plan-mode summary dialog blocks planner execution until a choice is made',
|
|
71
|
+
remedy: ['p']
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
pattern: /read the complete task instructions from[\s\S]*follow every instruction precisely\./i,
|
|
75
|
+
category: 'option_dialog',
|
|
76
|
+
reasoning: 'Cursor can remain on the initial injected prompt until an explicit continuation Enter key',
|
|
77
|
+
remedy: ['Enter']
|
|
78
|
+
}
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Detect known Cursor Agent CLI blocking patterns in pane output.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} output - Captured pane content.
|
|
85
|
+
* @returns {{category: string, reasoning: string, remedy?: string[]}|null} Diagnosis, or null if unrecognized.
|
|
86
|
+
*/
|
|
87
|
+
static diagnose(output) {
|
|
88
|
+
if (!output) return null;
|
|
89
|
+
for (const { pattern, category, reasoning, remedy } of CursorBackend.patterns) {
|
|
90
|
+
if (pattern.test(output)) return { category, reasoning, ...(remedy && { remedy }) };
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
54
95
|
/**
|
|
55
96
|
* Return the scaffold descriptor for Cursor Agent workspaces.
|
|
56
97
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
98
|
+
* Token propagation relies on process env inheritance and the
|
|
99
|
+
* envFile fallback. cursor-agent CLI does not expand
|
|
100
|
+
* `${env:NAME}` interpolation — it passes the literal string
|
|
101
|
+
* to MCP subprocesses, poisoning `_hydrateHub()`.
|
|
102
|
+
* Hooks use beforeShellExecution.
|
|
60
103
|
*
|
|
61
104
|
* @param {object} [context] - Agent context for env/token injection.
|
|
62
105
|
* @returns {object} Scaffold descriptor.
|
|
@@ -81,7 +124,9 @@ export class CursorBackend extends CliAgent {
|
|
|
81
124
|
},
|
|
82
125
|
{
|
|
83
126
|
path: '.cursor/mcp.json',
|
|
84
|
-
content: mcpJson(context, context ? {
|
|
127
|
+
content: mcpJson(context, context ? {
|
|
128
|
+
envFile: '.git/loreli.env'
|
|
129
|
+
} : {}),
|
|
85
130
|
marker: 'loreli',
|
|
86
131
|
format: 'json'
|
|
87
132
|
}
|
|
@@ -113,6 +158,26 @@ export class CursorBackend extends CliAgent {
|
|
|
113
158
|
|
|
114
159
|
return descriptor;
|
|
115
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* One-shot LLM call via `cursor-agent -p` (print mode).
|
|
163
|
+
*
|
|
164
|
+
* @param {string} prompt - Text prompt to send.
|
|
165
|
+
* @param {object} [opts] - Options.
|
|
166
|
+
* @param {string} [opts.model='fast'] - Model alias or exact string.
|
|
167
|
+
* @param {string} [opts.provider='anthropic'] - Provider for model resolution.
|
|
168
|
+
* @param {object} [opts.config] - Config instance for model resolution.
|
|
169
|
+
* @param {number} [opts.timeout=60000] - Max execution time in ms.
|
|
170
|
+
* @returns {Promise<string>} LLM response text.
|
|
171
|
+
*/
|
|
172
|
+
static async oneshot(prompt, { model, provider, config, timeout, discovered } = {}) {
|
|
173
|
+
const v = vendor(provider ?? 'anthropic');
|
|
174
|
+
const resolved = resolve(model ?? 'fast', 'cursor', v, config, discovered);
|
|
175
|
+
const vars = env('cursor', config) ?? {};
|
|
176
|
+
return CliAgent._exec('cursor-agent', [
|
|
177
|
+
'-p', '--trust', '--model', resolved, '--output-format', 'text'
|
|
178
|
+
], vars, timeout, prompt);
|
|
179
|
+
}
|
|
180
|
+
|
|
116
181
|
/**
|
|
117
182
|
* @param {object} opts
|
|
118
183
|
* @param {object} opts.identity - Agent identity.
|
|
@@ -125,11 +190,12 @@ export class CursorBackend extends CliAgent {
|
|
|
125
190
|
*/
|
|
126
191
|
constructor(opts) {
|
|
127
192
|
const v = vendor(opts.identity?.provider ?? 'anthropic');
|
|
128
|
-
const model = resolve(opts.model ?? 'balanced', 'cursor', v, opts.config);
|
|
193
|
+
const model = resolve(opts.model ?? 'balanced', 'cursor', v, opts.config, opts.discovered);
|
|
194
|
+
const mode = opts.role === 'planner' ? 'plan' : undefined;
|
|
129
195
|
|
|
130
196
|
super({
|
|
131
197
|
...opts,
|
|
132
|
-
command: buildCommand(model, opts.cwd)
|
|
198
|
+
command: buildCommand(model, opts.cwd, mode)
|
|
133
199
|
});
|
|
134
200
|
|
|
135
201
|
/** @type {string} Resolved model identifier. */
|
|
@@ -165,7 +231,7 @@ export class CursorBackend extends CliAgent {
|
|
|
165
231
|
/**
|
|
166
232
|
* Wait for cursor-agent to finish initializing.
|
|
167
233
|
*
|
|
168
|
-
* Readiness is detected reactively through
|
|
234
|
+
* Readiness is detected reactively through three phases:
|
|
169
235
|
*
|
|
170
236
|
* **Phase 1 — Process detection** (up to `readyTimeout`):
|
|
171
237
|
* Polls until the cursor-agent Node process is running. Two signals:
|
|
@@ -173,13 +239,20 @@ export class CursorBackend extends CliAgent {
|
|
|
173
239
|
* - Process: tmux reports `node` or a version string as the
|
|
174
240
|
* foreground command.
|
|
175
241
|
*
|
|
242
|
+
* **Phase 1.5 — Trust approval**:
|
|
243
|
+
* cursor-agent shows a "Workspace Trust" dialog for directories
|
|
244
|
+
* not previously trusted. The dialog blocks all TUI rendering.
|
|
245
|
+
* `--force` only auto-approves command execution, not workspace
|
|
246
|
+
* trust, and `--trust` only works in `--print` mode. Sending
|
|
247
|
+
* Enter dismisses the dialog (default action = approve). When
|
|
248
|
+
* no dialog is showing, the keystroke enters the empty input
|
|
249
|
+
* field harmlessly.
|
|
250
|
+
*
|
|
176
251
|
* **Phase 2 — Input readiness** (up to `INPUT_READY_TIMEOUT`):
|
|
177
252
|
* After the process starts, continues polling pane output for the
|
|
178
253
|
* TUI's workspace line (e.g. `~/path · branch`). This line renders
|
|
179
254
|
* only after MCP connections are established and the input field is
|
|
180
|
-
* active.
|
|
181
|
-
* with an event-driven check — the method returns as soon as the
|
|
182
|
-
* signal appears, not after a fixed sleep.
|
|
255
|
+
* active.
|
|
183
256
|
*
|
|
184
257
|
* @returns {Promise<void>}
|
|
185
258
|
*/
|
|
@@ -207,18 +280,18 @@ export class CursorBackend extends CliAgent {
|
|
|
207
280
|
await new Promise(function wait(r) { setTimeout(r, READY_POLL); });
|
|
208
281
|
}
|
|
209
282
|
|
|
283
|
+
// Phase 1.5: dismiss "Workspace Trust" dialog if present.
|
|
284
|
+
// cursor-agent blocks on an interactive trust prompt for
|
|
285
|
+
// programmatically-created workspaces. Enter approves it.
|
|
286
|
+
await new Promise(function settle(r) { setTimeout(r, TRUST_SETTLE); });
|
|
287
|
+
await this.tmux.keys(this.paneId, 'Enter');
|
|
288
|
+
log.info(`${name} phase 1.5 — sent trust approval (Enter)`);
|
|
289
|
+
|
|
210
290
|
// 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
291
|
const inputDeadline = Date.now() + INPUT_READY_TIMEOUT;
|
|
216
292
|
while (Date.now() < inputDeadline) {
|
|
217
293
|
const output = await this.tmux.capture(this.paneId);
|
|
218
294
|
|
|
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
295
|
if (output.includes('\u00b7')) {
|
|
223
296
|
log.info(`${name} phase 2 — TUI input ready (workspace line detected)`);
|
|
224
297
|
return;
|
|
@@ -270,23 +343,27 @@ export class CursorBackend extends CliAgent {
|
|
|
270
343
|
*
|
|
271
344
|
* Flags:
|
|
272
345
|
* - `--model`: resolved cursor-agent model name
|
|
273
|
-
* - `--
|
|
274
|
-
* - `--
|
|
346
|
+
* - `--plan`: read-only plan mode (planner)
|
|
347
|
+
* - `--force`: auto-approve tool prompts (all roles; required for unattended planner MCP calls)
|
|
348
|
+
* - `--sandbox disabled`: disable sandbox isolation prompts (action/reviewer)
|
|
275
349
|
* - `--approve-mcps`: auto-approve the scaffolded Loreli MCP server
|
|
276
350
|
* - `--workspace`: set the working directory
|
|
277
351
|
*
|
|
278
352
|
* @param {string} model - Resolved cursor-agent model name from config.
|
|
279
353
|
* @param {string} [cwd] - Working directory for --workspace.
|
|
354
|
+
* @param {string} [mode] - Execution mode ('plan' for planner read-only mode).
|
|
280
355
|
* @returns {string} CLI command string.
|
|
281
356
|
*/
|
|
282
|
-
function buildCommand(model, cwd) {
|
|
283
|
-
const parts = [
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
'--force'
|
|
287
|
-
|
|
288
|
-
'--
|
|
289
|
-
|
|
357
|
+
function buildCommand(model, cwd, mode) {
|
|
358
|
+
const parts = ['cursor-agent', `--model ${model}`];
|
|
359
|
+
|
|
360
|
+
if (mode === 'plan') {
|
|
361
|
+
parts.push('--plan', '--force');
|
|
362
|
+
} else {
|
|
363
|
+
parts.push('--force', '--sandbox disabled');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
parts.push('--approve-mcps');
|
|
290
367
|
|
|
291
368
|
if (cwd) parts.push(`--workspace '${cwd}'`);
|
|
292
369
|
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { discover as discoverModels } from '../discover.js';
|
|
3
|
+
import { logger } from 'loreli/log';
|
|
4
|
+
|
|
5
|
+
const log = logger('backends');
|
|
2
6
|
|
|
3
7
|
/**
|
|
4
8
|
* Map of built-in backends with their module paths and metadata.
|
|
@@ -26,6 +30,15 @@ export class BackendRegistry {
|
|
|
26
30
|
/** @type {Map<string, {name: string, provider: string, binary: string}>} */
|
|
27
31
|
this.discovered = new Map();
|
|
28
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Runtime-discovered models per backend. Populated by
|
|
35
|
+
* {@link discover} after binary detection. Keyed by backend
|
|
36
|
+
* name; each value contains a flat model list and a tier map.
|
|
37
|
+
*
|
|
38
|
+
* @type {Map<string, {models: object[], tiers: object}>}
|
|
39
|
+
*/
|
|
40
|
+
this.models = new Map();
|
|
41
|
+
|
|
29
42
|
/**
|
|
30
43
|
* Failure counts per backend. Used to detect degraded backends
|
|
31
44
|
* that repeatedly fail (e.g. budget exhaustion, rate limits).
|
|
@@ -33,6 +46,31 @@ export class BackendRegistry {
|
|
|
33
46
|
* @type {Map<string, {count: number, first: number, last: number}>}
|
|
34
47
|
*/
|
|
35
48
|
this._failures = new Map();
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Warning counts per backend. Tracks transient issues (dialogs,
|
|
52
|
+
* prompts) that were successfully remediated. Only promotes to a
|
|
53
|
+
* failure after reaching the warning threshold.
|
|
54
|
+
*
|
|
55
|
+
* @type {Map<string, {count: number, first: number, last: number}>}
|
|
56
|
+
*/
|
|
57
|
+
this._warnings = new Map();
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Tracks whether model/backends discovery has already been completed
|
|
61
|
+
* for oneshot calls in this registry instance.
|
|
62
|
+
*
|
|
63
|
+
* @type {boolean}
|
|
64
|
+
*/
|
|
65
|
+
this._discoveredOnce = false;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* In-flight discovery promise used to prevent concurrent oneshot
|
|
69
|
+
* calls from racing discovery and clearing shared state.
|
|
70
|
+
*
|
|
71
|
+
* @type {Promise<void>|null}
|
|
72
|
+
*/
|
|
73
|
+
this._discovering = null;
|
|
36
74
|
}
|
|
37
75
|
|
|
38
76
|
/**
|
|
@@ -49,13 +87,16 @@ export class BackendRegistry {
|
|
|
49
87
|
}
|
|
50
88
|
|
|
51
89
|
/**
|
|
52
|
-
* Discover available backends by checking for CLI binaries on PATH
|
|
53
|
-
*
|
|
54
|
-
* that create() can instantiate real agent instances.
|
|
90
|
+
* Discover available backends by checking for CLI binaries on PATH,
|
|
91
|
+
* then discover available models from backends that support it.
|
|
55
92
|
*
|
|
93
|
+
* Model discovery runs in parallel with backend class loading.
|
|
94
|
+
* Results are stored in {@link models} for use during resolution.
|
|
95
|
+
*
|
|
96
|
+
* @param {object} [config] - Optional config for discovery-time env overrides.
|
|
56
97
|
* @returns {Promise<void>}
|
|
57
98
|
*/
|
|
58
|
-
async discover() {
|
|
99
|
+
async discover(config) {
|
|
59
100
|
this.discovered.clear();
|
|
60
101
|
|
|
61
102
|
for (const [name, meta] of Object.entries(BUILTIN_BACKENDS)) {
|
|
@@ -67,6 +108,9 @@ export class BackendRegistry {
|
|
|
67
108
|
this.backends.set(name, { cls, provider: meta.provider, binary: meta.binary });
|
|
68
109
|
}
|
|
69
110
|
}
|
|
111
|
+
|
|
112
|
+
this.models = await discoverModels(this, { config });
|
|
113
|
+
this._discoveredOnce = true;
|
|
70
114
|
}
|
|
71
115
|
|
|
72
116
|
/**
|
|
@@ -150,6 +194,27 @@ export class BackendRegistry {
|
|
|
150
194
|
return entry.count >= 2 && (Date.now() - entry.last) < window;
|
|
151
195
|
}
|
|
152
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Record a transient warning for a backend. Warnings track
|
|
199
|
+
* recoverable issues (dialogs dismissed, prompts answered) that
|
|
200
|
+
* don't indicate a systemic failure. After 3 warnings within the
|
|
201
|
+
* degradation window, promotes to a full failure.
|
|
202
|
+
*
|
|
203
|
+
* @param {string} name - Backend name (e.g. 'codex', 'claude').
|
|
204
|
+
*/
|
|
205
|
+
recordWarning(name) {
|
|
206
|
+
const entry = this._warnings.get(name) ?? { count: 0, first: 0, last: 0 };
|
|
207
|
+
entry.count++;
|
|
208
|
+
if (!entry.first) entry.first = Date.now();
|
|
209
|
+
entry.last = Date.now();
|
|
210
|
+
this._warnings.set(name, entry);
|
|
211
|
+
|
|
212
|
+
if (entry.count >= 3) {
|
|
213
|
+
this.recordFailure(name);
|
|
214
|
+
this.recordFailure(name);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
153
218
|
/**
|
|
154
219
|
* Clear failure history for a backend. Called when a backend
|
|
155
220
|
* successfully completes work, proving it has recovered.
|
|
@@ -158,6 +223,26 @@ export class BackendRegistry {
|
|
|
158
223
|
*/
|
|
159
224
|
clearFailures(name) {
|
|
160
225
|
this._failures.delete(name);
|
|
226
|
+
this._warnings.delete(name);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Detect known blocking patterns in agent pane output using the
|
|
231
|
+
* backend's static diagnose() method.
|
|
232
|
+
*
|
|
233
|
+
* Each backend knows its own CLI's quirks — update dialogs, trust
|
|
234
|
+
* prompts, selection menus. This method delegates to the correct
|
|
235
|
+
* backend based on name, providing a regex-based fallback when LLM
|
|
236
|
+
* classification is unavailable.
|
|
237
|
+
*
|
|
238
|
+
* @param {string} name - Backend name (e.g. 'codex', 'claude').
|
|
239
|
+
* @param {string} output - Captured pane content.
|
|
240
|
+
* @returns {{category: string, reasoning: string}|null} Diagnosis, or null.
|
|
241
|
+
*/
|
|
242
|
+
diagnose(name, output) {
|
|
243
|
+
const info = this.backends.get(name);
|
|
244
|
+
if (!info?.cls?.diagnose) return null;
|
|
245
|
+
return info.cls.diagnose(output);
|
|
161
246
|
}
|
|
162
247
|
|
|
163
248
|
/**
|
|
@@ -295,6 +380,78 @@ export class BackendRegistry {
|
|
|
295
380
|
return paths;
|
|
296
381
|
}
|
|
297
382
|
|
|
383
|
+
/**
|
|
384
|
+
* One-shot LLM call using the best available backend.
|
|
385
|
+
*
|
|
386
|
+
* Prefers backends with discovery data and falls back across all
|
|
387
|
+
* discovered oneshot-capable backends on failure.
|
|
388
|
+
*
|
|
389
|
+
* @param {string} prompt - Text prompt to send.
|
|
390
|
+
* @param {object} [opts] - Options forwarded to the backend's oneshot().
|
|
391
|
+
* @param {string} [opts.model='fast'] - Model alias or exact string.
|
|
392
|
+
* @param {object} [opts.config] - Config instance for model resolution.
|
|
393
|
+
* @param {number} [opts.timeout=60000] - Max execution time in ms.
|
|
394
|
+
* @returns {Promise<string>} LLM response text.
|
|
395
|
+
*/
|
|
396
|
+
async oneshot(prompt, opts = {}) {
|
|
397
|
+
if (!this._discoveredOnce) {
|
|
398
|
+
if (!this._discovering) {
|
|
399
|
+
const self = this;
|
|
400
|
+
this._discovering = this.discover(opts.config).finally(function done() {
|
|
401
|
+
self._discovering = null;
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
await this._discovering;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const order = this._oneshotOrder();
|
|
408
|
+
if (!order.length) throw new Error('No backends available for oneshot');
|
|
409
|
+
|
|
410
|
+
let last;
|
|
411
|
+
for (const name of order) {
|
|
412
|
+
try {
|
|
413
|
+
const info = this.backends.get(name);
|
|
414
|
+
return await info.cls.oneshot(prompt, { ...opts, discovered: this.models });
|
|
415
|
+
} catch (err) {
|
|
416
|
+
last = err;
|
|
417
|
+
log.warn(`oneshot ${name} failed: ${err.message}`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
throw last;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Build backend order for oneshot calls.
|
|
426
|
+
*
|
|
427
|
+
* Backends with model discovery are tried first, followed by the
|
|
428
|
+
* remaining discovered backends that support static `oneshot()`.
|
|
429
|
+
*
|
|
430
|
+
* @returns {string[]} Ordered backend names.
|
|
431
|
+
*/
|
|
432
|
+
_oneshotOrder() {
|
|
433
|
+
const order = [];
|
|
434
|
+
const seen = new Set();
|
|
435
|
+
|
|
436
|
+
for (const [name] of this.models) {
|
|
437
|
+
if (!this.backends.has(name)) continue;
|
|
438
|
+
const info = this.backends.get(name);
|
|
439
|
+
if (typeof info?.cls?.oneshot !== 'function') continue;
|
|
440
|
+
order.push(name);
|
|
441
|
+
seen.add(name);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
for (const [name] of this.discovered) {
|
|
445
|
+
if (seen.has(name) || !this.backends.has(name)) continue;
|
|
446
|
+
const info = this.backends.get(name);
|
|
447
|
+
if (typeof info?.cls?.oneshot !== 'function') continue;
|
|
448
|
+
order.push(name);
|
|
449
|
+
seen.add(name);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return order;
|
|
453
|
+
}
|
|
454
|
+
|
|
298
455
|
/**
|
|
299
456
|
* Create a backend instance.
|
|
300
457
|
*
|
|
@@ -307,7 +464,7 @@ export class BackendRegistry {
|
|
|
307
464
|
const info = this.backends.get(name);
|
|
308
465
|
if (!info) throw new Error(`Unknown backend: "${name}"`);
|
|
309
466
|
if (!info.cls) throw new Error(`Backend "${name}" has no implementation class registered`);
|
|
310
|
-
const agent = new info.cls(opts);
|
|
467
|
+
const agent = new info.cls({ ...opts, discovered: this.models });
|
|
311
468
|
agent.backend = name;
|
|
312
469
|
return agent;
|
|
313
470
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { execFile as execFileCb } from 'node:child_process';
|
|
1
2
|
import { Tmux } from 'loreli/tmux';
|
|
2
3
|
import { Agent } from './base.js';
|
|
3
4
|
import { inline } from './models.js';
|
|
@@ -47,6 +48,74 @@ export class CliAgent extends Agent {
|
|
|
47
48
|
static scaffold() {
|
|
48
49
|
return null;
|
|
49
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Run a CLI binary and return its stdout.
|
|
53
|
+
*
|
|
54
|
+
* Shared subprocess execution for all backend `oneshot()` methods.
|
|
55
|
+
* Each backend calls this with its own binary, args, and env vars.
|
|
56
|
+
*
|
|
57
|
+
* When `input` is provided it is written to the child's stdin and
|
|
58
|
+
* the stream is closed. CLI tools like `claude -p` and
|
|
59
|
+
* `cursor-agent -p` expect their prompt on stdin — passing it as
|
|
60
|
+
* a positional argument causes the process to block indefinitely
|
|
61
|
+
* waiting for piped input that never arrives.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} binary - CLI binary name (e.g. 'claude', 'codex').
|
|
64
|
+
* @param {string[]} args - Command-line arguments.
|
|
65
|
+
* @param {object} [vars] - Additional environment variables.
|
|
66
|
+
* @param {number} [timeout=60000] - Max execution time in ms.
|
|
67
|
+
* @param {string} [input] - Text to write to stdin before closing.
|
|
68
|
+
* @returns {Promise<string>} Trimmed stdout.
|
|
69
|
+
*/
|
|
70
|
+
static _exec(binary, args, vars, timeout, input) {
|
|
71
|
+
return new Promise(function run(resolve, reject) {
|
|
72
|
+
const child = execFileCb(binary, args, {
|
|
73
|
+
env: { ...process.env, ...vars },
|
|
74
|
+
timeout: timeout ?? 60000,
|
|
75
|
+
maxBuffer: 1024 * 1024
|
|
76
|
+
}, function done(err, stdout, stderrOutput) {
|
|
77
|
+
if (err) {
|
|
78
|
+
const code = err.code ?? 'none';
|
|
79
|
+
const signal = err.signal ?? 'none';
|
|
80
|
+
const killed = Boolean(err.killed);
|
|
81
|
+
const stderr = String(err.stderr ?? stderrOutput ?? '').slice(0, 200);
|
|
82
|
+
const wrapped = Object.assign(
|
|
83
|
+
new Error(`${binary} failed (code=${code}, signal=${signal}, killed=${killed}): ${stderr}`),
|
|
84
|
+
{
|
|
85
|
+
cause: err,
|
|
86
|
+
code: err.code,
|
|
87
|
+
signal: err.signal,
|
|
88
|
+
killed: err.killed,
|
|
89
|
+
stderr: err.stderr
|
|
90
|
+
}
|
|
91
|
+
);
|
|
92
|
+
return reject(wrapped);
|
|
93
|
+
}
|
|
94
|
+
resolve(stdout.trim());
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (input != null) {
|
|
98
|
+
child.stdin.write(input);
|
|
99
|
+
child.stdin.end();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* One-shot LLM call via the CLI's non-interactive mode.
|
|
106
|
+
*
|
|
107
|
+
* Backends override this with their own binary, flags, model
|
|
108
|
+
* resolution, and env var collection. The base implementation
|
|
109
|
+
* throws to signal that the backend has not implemented it.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} _prompt - Text prompt to send.
|
|
112
|
+
* @param {object} [_opts] - Options (model, config, timeout).
|
|
113
|
+
* @returns {Promise<string>} LLM response text.
|
|
114
|
+
*/
|
|
115
|
+
static async oneshot(_prompt, _opts) {
|
|
116
|
+
throw new Error('Backend does not support oneshot — override static oneshot()');
|
|
117
|
+
}
|
|
118
|
+
|
|
50
119
|
/**
|
|
51
120
|
* @param {object} opts
|
|
52
121
|
* @param {object} opts.identity - Agent identity.
|
|
@@ -92,7 +161,6 @@ export class CliAgent extends Agent {
|
|
|
92
161
|
async spawn() {
|
|
93
162
|
const cmd = this._shell(`exec ${this.command}`);
|
|
94
163
|
this.paneId = await this._launch(cmd);
|
|
95
|
-
await this.tmux.set(this.paneId, 'remain-on-exit', 'on');
|
|
96
164
|
|
|
97
165
|
log.info(`spawning ${this.identity?.name ?? '?'} in pane ${this.paneId}: ${this.command}`);
|
|
98
166
|
this.transition('spawned');
|
|
@@ -123,14 +191,23 @@ export class CliAgent extends Agent {
|
|
|
123
191
|
*
|
|
124
192
|
* When the session already exists, a new window is added.
|
|
125
193
|
*
|
|
194
|
+
* Sets `remain-on-exit` on every pane so dead process output is
|
|
195
|
+
* preserved for diagnosis. Backend subclasses override spawn() but
|
|
196
|
+
* all call _launch(), so this is the single point of enforcement.
|
|
197
|
+
*
|
|
126
198
|
* @param {string} command - Shell command string.
|
|
127
199
|
* @returns {Promise<string>} The assigned pane ID.
|
|
128
200
|
*/
|
|
129
201
|
async _launch(command) {
|
|
202
|
+
let paneId;
|
|
130
203
|
if (!await this.tmux.has(this.session)) {
|
|
131
|
-
|
|
204
|
+
paneId = await this.tmux.create(this.session, { cwd: this.cwd, command });
|
|
205
|
+
} else {
|
|
206
|
+
paneId = await this.tmux.window(this.session, { cwd: this.cwd, command });
|
|
132
207
|
}
|
|
133
|
-
|
|
208
|
+
|
|
209
|
+
await this.tmux.set(paneId, 'remain-on-exit', 'on');
|
|
210
|
+
return paneId;
|
|
134
211
|
}
|
|
135
212
|
|
|
136
213
|
/**
|