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.
Files changed (63) hide show
  1. package/README.md +66 -26
  2. package/package.json +17 -14
  3. package/packages/action/prompts/action.md +172 -0
  4. package/packages/action/src/index.js +33 -5
  5. package/packages/agent/README.md +107 -18
  6. package/packages/agent/src/backends/claude.js +111 -11
  7. package/packages/agent/src/backends/codex.js +78 -5
  8. package/packages/agent/src/backends/cursor.js +104 -27
  9. package/packages/agent/src/backends/index.js +162 -5
  10. package/packages/agent/src/cli.js +80 -3
  11. package/packages/agent/src/discover.js +396 -0
  12. package/packages/agent/src/factory.js +39 -34
  13. package/packages/agent/src/models.js +24 -6
  14. package/packages/classify/README.md +136 -0
  15. package/packages/classify/prompts/blocker.md +12 -0
  16. package/packages/classify/prompts/feedback.md +14 -0
  17. package/packages/classify/prompts/pane-state.md +20 -0
  18. package/packages/classify/src/index.js +81 -0
  19. package/packages/config/README.md +156 -91
  20. package/packages/config/src/defaults.js +32 -21
  21. package/packages/config/src/index.js +33 -2
  22. package/packages/config/src/schema.js +57 -39
  23. package/packages/hub/src/github.js +59 -20
  24. package/packages/identity/README.md +1 -1
  25. package/packages/identity/src/index.js +2 -2
  26. package/packages/knowledge/README.md +86 -106
  27. package/packages/knowledge/src/index.js +56 -225
  28. package/packages/mcp/README.md +51 -7
  29. package/packages/mcp/instructions.md +6 -1
  30. package/packages/mcp/scaffolding/loreli.yml +115 -77
  31. package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +1 -0
  32. package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +4 -1
  33. package/packages/mcp/scaffolding/mcp-configs/.mcp.json +4 -1
  34. package/packages/mcp/src/index.js +45 -16
  35. package/packages/mcp/src/tools/agent-context.js +44 -0
  36. package/packages/mcp/src/tools/agents.js +34 -13
  37. package/packages/mcp/src/tools/context.js +3 -2
  38. package/packages/mcp/src/tools/github.js +11 -47
  39. package/packages/mcp/src/tools/hitl.js +19 -6
  40. package/packages/mcp/src/tools/index.js +2 -1
  41. package/packages/mcp/src/tools/refactor.js +227 -0
  42. package/packages/mcp/src/tools/repo.js +44 -0
  43. package/packages/mcp/src/tools/start.js +159 -90
  44. package/packages/mcp/src/tools/status.js +5 -2
  45. package/packages/mcp/src/tools/work.js +18 -8
  46. package/packages/orchestrator/src/index.js +345 -79
  47. package/packages/planner/README.md +84 -1
  48. package/packages/planner/prompts/plan-reviewer.md +109 -0
  49. package/packages/planner/prompts/planner.md +191 -0
  50. package/packages/planner/prompts/tiebreaker-reviewer.md +71 -0
  51. package/packages/planner/src/index.js +326 -111
  52. package/packages/review/README.md +2 -2
  53. package/packages/review/prompts/reviewer.md +158 -0
  54. package/packages/review/src/index.js +196 -76
  55. package/packages/risk/README.md +81 -22
  56. package/packages/risk/prompts/risk.md +272 -0
  57. package/packages/risk/src/index.js +44 -33
  58. package/packages/tmux/src/index.js +61 -12
  59. package/packages/workflow/README.md +18 -14
  60. package/packages/workflow/prompts/preamble.md +14 -0
  61. package/packages/workflow/src/index.js +191 -12
  62. package/packages/workspace/README.md +2 -2
  63. 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
- * 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.
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 ? { envFile: '.git/loreli.env' } : {}),
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 two phases:
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. 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.
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
- * - `--force`: auto-approve all tool usage
274
- * - `--sandbox disabled`: disable sandbox isolation prompts
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
- 'cursor-agent',
285
- `--model ${model}`,
286
- '--force',
287
- '--sandbox disabled',
288
- '--approve-mcps'
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
- * Lazy-loads the actual backend class for each discovered binary so
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
- return this.tmux.create(this.session, { cwd: this.cwd, command });
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
- return this.tmux.window(this.session, { cwd: this.cwd, command });
208
+
209
+ await this.tmux.set(paneId, 'remain-on-exit', 'on');
210
+ return paneId;
134
211
  }
135
212
 
136
213
  /**