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
@@ -323,9 +323,9 @@ await prepare('~/.loreli/workspaces/loreli-optimus-0', {
323
323
 
324
324
  ### Model Aliases
325
325
 
326
- Loreli provides human-friendly model aliases that resolve to backend-specific and provider-specific identifiers. Resolution is config-driven aliases map to concrete model IDs through the standard config resolution chain (overrides > loreli.yml > defaults).
326
+ Loreli provides human-friendly model aliases (`fast`, `balanced`, `powerful`) that resolve to backend-specific and provider-specific model identifiers. Resolution combines config overrides, runtime discovery, and static fallbacks to always produce a valid model ID.
327
327
 
328
- The `resolve()` function takes an alias, backend name, provider, and optional `config` parameter. It looks up `config.get('backends.{backend}.models.{alias}.{provider}')` first, then falls through to built-in defaults from `defaults.js`. Exact model strings (not matching any alias) are returned unchanged.
328
+ The `resolve()` function takes an alias, backend name, provider, optional config, and optional discovery cache. Exact model strings (not matching any alias) are returned unchanged.
329
329
 
330
330
  The following example demonstrates basic alias resolution using built-in defaults. Each backend has its own model mappings — the claude backend resolves to provider-specific model IDs, while the cursor backend resolves to cursor-agent short names:
331
331
 
@@ -338,7 +338,7 @@ models.resolve('fast', 'cursor', 'anthropic'); // 'sonnet-4.5'
338
338
  models.resolve('gpt-custom', 'codex', 'openai'); // 'gpt-custom' (passthrough)
339
339
  ```
340
340
 
341
- The following example demonstrates overriding model IDs via config. This is useful when your environment routes through a different proxy or you have access to newer model versions:
341
+ The following example demonstrates overriding model IDs via config. This is useful when your environment routes through a LiteLLM proxy or you have access to newer model versions:
342
342
 
343
343
  ```js
344
344
  import { models } from 'loreli/agent';
@@ -359,7 +359,50 @@ models.resolve('fast', 'codex', 'openai', config); // 'my-custom-gpt'
359
359
  models.resolve('balanced', 'codex', 'openai', config); // 'gpt-5.1-codex' (falls to defaults)
360
360
  ```
361
361
 
362
- #### Default Model Mappings
362
+ #### Resolution Chain
363
+
364
+ Model resolution follows a four-layer priority chain:
365
+
366
+ 1. **Config override** — `backends.{name}.models.{alias}.{provider}` from `loreli.yml` or `config.merge()`. Always wins. This is the escape hatch for LiteLLM proxies, private deployments, and custom model names.
367
+ 2. **Runtime discovery** — models discovered at startup from backend CLIs and configured proxy endpoints. `cursor-agent` uses `--list-models`. `claude`/`codex` use OpenAI-compatible model listing (`/v1/models` with `/models` fallback) when `ANTHROPIC_BASE_URL`/`OPENAI_BASE_URL` is configured. Discovered models are classified into tiers by name-pattern heuristics and cached on the `BackendRegistry`.
368
+ 3. **Static fallbacks** — built-in defaults from `defaults.js`. When discovery data is available, static fallbacks are validated against the discovered model list. Invalid models trigger a warning and fall back to the backend's default discovered model.
369
+ 4. **Pass-through** — exact model strings (those not matching any alias) bypass resolution entirely.
370
+
371
+ #### Auto Model Discovery
372
+
373
+ At startup, `BackendRegistry.discover()` probes available backends for their supported models:
374
+
375
+ | Backend | Discovery Method | Behavior |
376
+ |---------|-----------------|----------|
377
+ | `cursor-agent` | `--list-models` CLI flag | Parses structured output, classifies into tiers per provider |
378
+ | `claude` | Proxy model listing (`/v1/models` / `/models`) when `ANTHROPIC_BASE_URL` is configured | Auth uses `ANTHROPIC_API_KEY` first, then `OPENAI_API_KEY`; if discovery is unavailable, static defaults are used |
379
+ | `codex` | Proxy model listing (`/v1/models` / `/models`) when `OPENAI_BASE_URL` is configured | Auth uses `OPENAI_API_KEY` first, then `ANTHROPIC_API_KEY`; if discovery is unavailable, static defaults are used |
380
+
381
+ Proxy requests use `timeouts.proxyDiscovery` from config (default `5000` ms). This controls the per-request HTTP timeout for `/v1/models` / `/models` discovery calls.
382
+
383
+ For all runtime-discovered models, tiers are inferred from name-pattern heuristics:
384
+
385
+ | Tier | Patterns |
386
+ |------|----------|
387
+ | `powerful` | `-xhigh`, `-max`, `-high`, `opus-*`, `o3` |
388
+ | `balanced` | No tier suffix, `-thinking` (non-opus), bare codex |
389
+ | `fast` | `-low`, `-mini`, `haiku-*`, `o4-mini` |
390
+
391
+ Discovery results override static defaults but are themselves overridden by explicit config. When discovery fails or is unavailable (including direct non-proxy setups), static fallbacks are used unchanged.
392
+
393
+ #### Validation
394
+
395
+ When discovery data is available for a backend, resolved model IDs are validated against the discovered model list. If a resolved model (from static defaults or config) is not found in the discovered list, a warning is logged and the backend's default model is used instead. This catches:
396
+
397
+ - Stale static defaults (model IDs removed by providers)
398
+ - Typos in `loreli.yml` backend model overrides
399
+ - Models no longer available in the current subscription/plan
400
+
401
+ When no discovery data is available (discovery failed, backend doesn't support it, or backend not installed), validation is skipped and models pass through as before.
402
+
403
+ #### Static Default Mappings
404
+
405
+ These are the built-in fallback model IDs used when neither config nor discovery provides a value:
363
406
 
364
407
  **Claude backend** (Anthropic model IDs):
365
408
 
@@ -385,33 +428,31 @@ models.resolve('balanced', 'codex', 'openai', config); // 'gpt-5.1-codex' (fall
385
428
  | `balanced` | `sonnet-4.5-thinking` | `gpt-5.3-codex` |
386
429
  | `powerful` | `opus-4.6-thinking` | `gpt-5.1-codex-max` |
387
430
 
388
- #### Overriding via `loreli.yml`
431
+ #### LiteLLM / Proxy Override
389
432
 
390
- Add a `backends` section to the target repo's `loreli.yml` to override the defaults. Unknown backends, tiers, and providers are passed through, so custom configurations work out of the box:
433
+ When backends are behind a LiteLLM proxy or custom gateway, model discovery will query the configured base URL and validate resolved IDs against the returned model list. If your proxy uses custom aliases, override via `loreli.yml`:
391
434
 
392
435
  ```yaml
393
436
  backends:
394
437
  claude:
438
+ env:
439
+ ANTHROPIC_BASE_URL: https://your-litellm.example.com/v1
395
440
  models:
396
441
  fast:
397
- anthropic: my-proxy-haiku
442
+ anthropic: litellm/haiku
398
443
  balanced:
399
- anthropic: my-proxy-sonnet
444
+ anthropic: litellm/sonnet
445
+ powerful:
446
+ anthropic: litellm/opus
400
447
  codex:
448
+ env:
449
+ OPENAI_BASE_URL: https://your-litellm.example.com/v1
401
450
  models:
402
451
  fast:
403
- openai: my-proxy-gpt-mini
452
+ openai: litellm/gpt-mini
404
453
  ```
405
454
 
406
- #### Resolution Chain
407
-
408
- Model resolution follows the same priority as all config values:
409
-
410
- 1. **Start params** — `config.merge({ backends: { claude: { models: { ... } } } })`
411
- 2. **`loreli.yml`** — `backends.{name}.models` section in the target repo
412
- 3. **Built-in defaults** — hardcoded in `defaults.js`
413
-
414
- Exact model strings (those not matching any alias) bypass resolution entirely.
455
+ Config overrides always take precedence — discovery and static defaults are bypassed entirely when `backends.{name}.models` is configured.
415
456
 
416
457
  ### Backend Environment Variables
417
458
 
@@ -501,6 +542,54 @@ Backend selection is handled entirely by `BackendRegistry.forProvider()`. The or
501
542
  | Mixed (e.g. claude + cursor-agent) | Exact match first, cursor fills the gap |
502
543
  | Nothing available | Error with installation guidance |
503
544
 
545
+ ## One-Shot LLM Calls (`oneshot`)
546
+
547
+ Each backend provides a static `oneshot(prompt, opts)` method for non-interactive LLM calls. This uses the CLI's print/exec mode (`child_process.execFile`) — no tmux, no workspace, no agent lifecycle. It reuses the same model resolution and environment variable collection as interactive agents.
548
+
549
+ The `BackendRegistry` also exposes a convenience `oneshot()` that discovers once, orders backends with discovery-backed entries first, and falls back across remaining oneshot-capable backends when one fails.
550
+
551
+ ### Usage
552
+
553
+ ```js
554
+ import { BackendRegistry, ClaudeBackend } from 'loreli/agent';
555
+
556
+ // Via specific backend
557
+ const answer = await ClaudeBackend.oneshot('Summarize this text...', {
558
+ model: 'fast',
559
+ timeout: 30000
560
+ });
561
+
562
+ // Via registry (discovery-first ordering + fallback)
563
+ const backends = new BackendRegistry();
564
+ await backends.discover();
565
+ const result = await backends.oneshot('Classify this output...', { model: 'fast' });
566
+ ```
567
+
568
+ ### Backend CLI Flags
569
+
570
+ | Backend | Binary | Print Mode |
571
+ |---------|--------|------------|
572
+ | Claude | `claude` | `-p <prompt> --model <model> --output-format text` |
573
+ | Codex | `codex` | `exec --ephemeral --skip-git-repo-check <prompt> -m <model>` |
574
+ | Cursor | `cursor-agent` | `-p <prompt> --model <model> --output-format text` |
575
+
576
+ ### API
577
+
578
+ #### `Backend.oneshot(prompt, opts)` (static)
579
+
580
+ | Parameter | Type | Default | Description |
581
+ |-----------|------|---------|-------------|
582
+ | `prompt` | `string` | — | Text prompt to send. |
583
+ | `opts.model` | `string` | `'fast'` | Model alias or exact string. |
584
+ | `opts.config` | `Config` | `undefined` | Config instance for model resolution. |
585
+ | `opts.timeout` | `number` | `30000` | Max execution time in ms. |
586
+
587
+ **Returns:** `Promise<string>` — LLM response text (trimmed).
588
+
589
+ #### `BackendRegistry.oneshot(prompt, opts)`
590
+
591
+ Same parameters as above. Runs discovery once (with optional `opts.config`), then tries backends in order until one succeeds.
592
+
504
593
  ## Session Persistence
505
594
 
506
595
  Agents are spawned detached — they survive orchestrator shutdown. State is persisted to `~/.loreli/sessions/<id>/`:
@@ -38,6 +38,40 @@ const READY_TIMEOUT = 60000;
38
38
  * @extends CliAgent
39
39
  */
40
40
  export class ClaudeBackend extends CliAgent {
41
+ /**
42
+ * Regex patterns that identify known Claude CLI blocking dialogs.
43
+ *
44
+ * @type {Array<{pattern: RegExp, category: string, reasoning: string, remedy?: string[]}>}
45
+ */
46
+ static patterns = [
47
+ {
48
+ pattern: /yes,\s*i\s+accept/i,
49
+ category: 'option_dialog',
50
+ reasoning: 'Claude CLI showing bypass-permissions dialog requiring selection',
51
+ remedy: ['Enter']
52
+ },
53
+ {
54
+ pattern: /do you want to proceed\?[\s\S]*yes,\s*and don't ask again(?:\s+for loreli\s*-\s*[a-z-]+\s*commands)?/i,
55
+ category: 'option_dialog',
56
+ reasoning: 'Claude CLI asking MCP tool approval for a Loreli command',
57
+ remedy: ['2', 'Enter']
58
+ }
59
+ ];
60
+
61
+ /**
62
+ * Detect known Claude CLI blocking patterns in pane output.
63
+ *
64
+ * @param {string} output - Captured pane content.
65
+ * @returns {{category: string, reasoning: string, remedy?: string[]}|null} Diagnosis, or null if unrecognized.
66
+ */
67
+ static diagnose(output) {
68
+ if (!output) return null;
69
+ for (const { pattern, category, reasoning, remedy } of ClaudeBackend.patterns) {
70
+ if (pattern.test(output)) return { category, reasoning, ...(remedy && { remedy }) };
71
+ }
72
+ return null;
73
+ }
74
+
41
75
  /**
42
76
  * Return the scaffold descriptor for Claude Code workspaces.
43
77
  *
@@ -83,6 +117,28 @@ export class ClaudeBackend extends CliAgent {
83
117
  files: []
84
118
  };
85
119
  }
120
+ /**
121
+ * One-shot LLM call via `claude -p` (print mode).
122
+ *
123
+ * No tmux, no workspace, no identity — just a subprocess that
124
+ * prints the response and exits. Uses the same model resolution
125
+ * and env var collection as interactive agents.
126
+ *
127
+ * @param {string} prompt - Text prompt to send.
128
+ * @param {object} [opts] - Options.
129
+ * @param {string} [opts.model='fast'] - Model alias or exact string.
130
+ * @param {object} [opts.config] - Config instance for model resolution.
131
+ * @param {number} [opts.timeout=60000] - Max execution time in ms.
132
+ * @returns {Promise<string>} LLM response text.
133
+ */
134
+ static async oneshot(prompt, { model, config, timeout, discovered } = {}) {
135
+ const resolved = resolve(model ?? 'fast', 'claude', 'anthropic', config, discovered);
136
+ const vars = env('claude', config) ?? {};
137
+ return CliAgent._exec('claude', [
138
+ '-p', '--model', resolved, '--output-format', 'text'
139
+ ], vars, timeout, prompt);
140
+ }
141
+
86
142
  /**
87
143
  * @param {object} opts
88
144
  * @param {object} opts.identity - Agent identity.
@@ -94,13 +150,15 @@ export class ClaudeBackend extends CliAgent {
94
150
  * @param {number} [opts.readyTimeout=60000] - Max ms to wait for CLI readiness.
95
151
  */
96
152
  constructor(opts) {
97
- const model = resolve(opts.model ?? 'balanced', 'claude', 'anthropic', opts.config);
153
+ const model = resolve(opts.model ?? 'balanced', 'claude', 'anthropic', opts.config, opts.discovered);
98
154
 
99
155
  const denied = opts.config?.get?.('agents.disallowedTools') ?? [];
100
156
 
157
+ const mode = opts.role === 'planner' ? 'plan' : undefined;
158
+
101
159
  super({
102
160
  ...opts,
103
- command: buildCommand(model, opts.cwd, denied)
161
+ command: buildCommand(model, opts.cwd, denied, mode)
104
162
  });
105
163
 
106
164
  /** @type {string} Resolved model identifier. */
@@ -113,6 +171,9 @@ export class ClaudeBackend extends CliAgent {
113
171
 
114
172
  /** @type {number} Max ms to wait for CLI readiness. */
115
173
  this.readyTimeout = opts.readyTimeout ?? READY_TIMEOUT;
174
+
175
+ /** @type {boolean} Whether MCP readiness (Phase 2) was confirmed. */
176
+ this._mcpReady = false;
116
177
  }
117
178
 
118
179
  /**
@@ -167,8 +228,7 @@ export class ClaudeBackend extends CliAgent {
167
228
  try {
168
229
  output = await this.tmux.capture(this.paneId);
169
230
  } catch {
170
- log.warn(`${name} pane died during readiness check — proceeding`);
171
- return;
231
+ throw new Error(`${name} pane died during readiness check`);
172
232
  }
173
233
 
174
234
  // Detect the bypass-permissions TUI confirmation dialog.
@@ -200,6 +260,7 @@ export class ClaudeBackend extends CliAgent {
200
260
  const ready = join(this.cwd, '.loreli', 'mcp-ready');
201
261
  if (existsSync(ready)) {
202
262
  log.info(`${name} MCP tools ready`);
263
+ this._mcpReady = true;
203
264
  try { await unlink(ready); } catch { /* already cleaned */ }
204
265
  return;
205
266
  }
@@ -211,6 +272,37 @@ export class ClaudeBackend extends CliAgent {
211
272
  log.warn(`${name} readiness timeout after ${this.readyTimeout}ms — proceeding anyway`);
212
273
  }
213
274
 
275
+ /**
276
+ * Wait for MCP readiness when `_navigate()` Phase 2 timed out.
277
+ *
278
+ * Polls for the `.loreli/mcp-ready` marker file that the MCP server
279
+ * writes after the handshake completes. Without this, prompts sent
280
+ * immediately after a readiness timeout are dropped because Claude
281
+ * Code is still in its MCP initialization state.
282
+ *
283
+ * @returns {Promise<void>}
284
+ */
285
+ async _awaitMcp() {
286
+ if (this._mcpReady) return;
287
+
288
+ const ready = join(this.cwd, '.loreli', 'mcp-ready');
289
+ const deadline = Date.now() + 30000;
290
+ const name = this.identity?.name ?? '?';
291
+
292
+ while (Date.now() < deadline) {
293
+ if (existsSync(ready)) {
294
+ log.info(`${name} MCP tools ready (late)`);
295
+ this._mcpReady = true;
296
+ try { await unlink(ready); } catch { /* already cleaned */ }
297
+ return;
298
+ }
299
+ await new Promise(function wait(r) { setTimeout(r, READY_POLL); });
300
+ }
301
+
302
+ log.warn(`${name} MCP readiness not confirmed — sending prompt anyway`);
303
+ this._mcpReady = true;
304
+ }
305
+
214
306
  /**
215
307
  * Send a prompt to Claude's interactive session.
216
308
  *
@@ -227,6 +319,8 @@ export class ClaudeBackend extends CliAgent {
227
319
  if (!this.paneId) throw new Error('Agent not spawned — call spawn() first');
228
320
  if (this.canTransition('working')) this.transition('working');
229
321
 
322
+ await this._awaitMcp();
323
+
230
324
  if (!message.includes('\n')) {
231
325
  await this.tmux.send(this.paneId, message);
232
326
  return;
@@ -248,7 +342,8 @@ export class ClaudeBackend extends CliAgent {
248
342
  * Build the claude CLI command string.
249
343
  *
250
344
  * Flags:
251
- * - `--dangerously-skip-permissions`: bypass all trust/permission dialogs
345
+ * - `--dangerously-skip-permissions`: bypass all trust/permission dialogs (action/reviewer)
346
+ * - `--permission-mode plan`: read-only plan mode (planner)
252
347
  * - `--mcp-config .mcp.json`: load scaffolded Loreli MCP config
253
348
  * - `--model`: resolved model identifier
254
349
  *
@@ -258,14 +353,19 @@ export class ClaudeBackend extends CliAgent {
258
353
  * @param {string} model - Resolved model identifier.
259
354
  * @param {string} [cwd] - Working directory (for --mcp-config path).
260
355
  * @param {string[]} [denied=[]] - Commands to block via --disallowedTools.
356
+ * @param {string} [mode] - Permission mode ('plan' for read-only).
261
357
  * @returns {string} CLI command string.
262
358
  */
263
- function buildCommand(model, cwd, denied = []) {
264
- const parts = [
265
- 'claude',
266
- '--dangerously-skip-permissions',
267
- `--model ${model}`
268
- ];
359
+ function buildCommand(model, cwd, denied = [], mode) {
360
+ const parts = ['claude'];
361
+
362
+ if (mode === 'plan') {
363
+ parts.push('--permission-mode plan');
364
+ } else {
365
+ parts.push('--dangerously-skip-permissions');
366
+ }
367
+
368
+ parts.push(`--model ${model}`);
269
369
 
270
370
  // Defense-in-depth: --disallowedTools is a hard guardrail that cannot
271
371
  // be bypassed by the agent. The hooks file (.claude/settings.local.json)
@@ -38,6 +38,57 @@ const READY_TIMEOUT = 60000;
38
38
  * @extends CliAgent
39
39
  */
40
40
  export class CodexBackend extends CliAgent {
41
+ /**
42
+ * Regex patterns that identify known Codex CLI blocking dialogs.
43
+ * Matched against pane output by diagnose() for rapid-death
44
+ * remediation and stall monitor fallback.
45
+ *
46
+ * Each entry carries a `remedy` — an array of tmux key names that
47
+ * the orchestrator sends to dismiss the dialog. Patterns are
48
+ * checked in order; first match wins.
49
+ *
50
+ * @type {Array<{pattern: RegExp, category: string, reasoning: string, remedy?: string[]}>}
51
+ */
52
+ static patterns = [
53
+ {
54
+ pattern: /choose how you'd like.*to proceed/is,
55
+ category: 'option_dialog',
56
+ reasoning: 'Codex CLI showing model upgrade selection — selecting "Use existing model"',
57
+ remedy: ['Down', 'Enter']
58
+ },
59
+ {
60
+ pattern: /invalid model name/i,
61
+ category: 'fatal',
62
+ reasoning: 'Codex CLI received invalid model name — API key may not support the requested model'
63
+ },
64
+ {
65
+ pattern: /update available!.*\n.*press enter to continue/is,
66
+ category: 'option_dialog',
67
+ reasoning: 'Codex CLI showing update-available dialog requiring Enter to dismiss',
68
+ remedy: ['Enter']
69
+ },
70
+ {
71
+ pattern: /press enter to continue/i,
72
+ category: 'option_dialog',
73
+ reasoning: 'Codex CLI waiting for Enter keypress to continue',
74
+ remedy: ['Enter']
75
+ }
76
+ ];
77
+
78
+ /**
79
+ * Detect known Codex CLI blocking patterns in pane output.
80
+ *
81
+ * @param {string} output - Captured pane content.
82
+ * @returns {{category: string, reasoning: string, remedy?: string[]}|null} Diagnosis, or null if unrecognized.
83
+ */
84
+ static diagnose(output) {
85
+ if (!output) return null;
86
+ for (const { pattern, category, reasoning, remedy } of CodexBackend.patterns) {
87
+ if (pattern.test(output)) return { category, reasoning, ...(remedy && { remedy }) };
88
+ }
89
+ return null;
90
+ }
91
+
41
92
  /**
42
93
  * Return the scaffold descriptor for Codex workspaces.
43
94
  *
@@ -68,6 +119,24 @@ export class CodexBackend extends CliAgent {
68
119
  files: []
69
120
  };
70
121
  }
122
+ /**
123
+ * One-shot LLM call via `codex exec` (non-interactive mode).
124
+ *
125
+ * @param {string} prompt - Text prompt to send.
126
+ * @param {object} [opts] - Options.
127
+ * @param {string} [opts.model='fast'] - Model alias or exact string.
128
+ * @param {object} [opts.config] - Config instance for model resolution.
129
+ * @param {number} [opts.timeout=60000] - Max execution time in ms.
130
+ * @returns {Promise<string>} LLM response text.
131
+ */
132
+ static async oneshot(prompt, { model, config, timeout, discovered } = {}) {
133
+ const resolved = resolve(model ?? 'fast', 'codex', 'openai', config, discovered);
134
+ const vars = env('codex', config) ?? {};
135
+ return CliAgent._exec('codex', [
136
+ 'exec', '--ephemeral', '--skip-git-repo-check', prompt, '-m', resolved
137
+ ], vars, timeout);
138
+ }
139
+
71
140
  /**
72
141
  * @param {object} opts
73
142
  * @param {object} opts.identity - Agent identity.
@@ -79,13 +148,14 @@ export class CodexBackend extends CliAgent {
79
148
  * @param {number} [opts.readyTimeout=60000] - Max ms to wait for CLI readiness.
80
149
  */
81
150
  constructor(opts) {
82
- const model = resolve(opts.model ?? 'balanced', 'codex', 'openai', opts.config);
151
+ const model = resolve(opts.model ?? 'balanced', 'codex', 'openai', opts.config, opts.discovered);
83
152
 
84
153
  const denied = opts.config?.get?.('agents.disallowedTools') ?? [];
154
+ const mode = opts.role === 'planner' ? 'plan' : undefined;
85
155
 
86
156
  super({
87
157
  ...opts,
88
- command: buildCommand(model, opts.cwd, opts.context, denied)
158
+ command: buildCommand(model, opts.cwd, opts.context, denied, mode)
89
159
  });
90
160
 
91
161
  /** @type {string} Resolved model identifier. */
@@ -261,7 +331,8 @@ function rulesFlags(denied) {
261
331
  * `--full-auto` maps to `-a on-request` which still allows the
262
332
  * model to trigger interactive approval prompts, blocking the
263
333
  * agent in tmux with no human to respond.
264
- * - `-s workspace-write`: sandbox to workspace write access only
334
+ * - `-s read-only`: read-only sandbox (planner)
335
+ * - `-s workspace-write`: sandbox to workspace write access (action/reviewer)
265
336
  * - `--no-alt-screen`: inline TUI mode for tmux capture compatibility
266
337
  * - `-C ${cwd}`: set the working directory for codex
267
338
  * - `-c mcp_servers.loreli.*`: inject loreli MCP server config
@@ -271,8 +342,10 @@ function rulesFlags(denied) {
271
342
  * @param {string} cwd - Working directory.
272
343
  * @param {object} [context] - Agent context for MCP env injection.
273
344
  * @param {string[]} [denied=[]] - Commands to block via prefix rules.
345
+ * @param {string} [mode] - Execution mode ('plan' for planner read-only mode).
274
346
  * @returns {string} CLI command string.
275
347
  */
276
- function buildCommand(model, cwd, context, denied = []) {
277
- return `codex --model ${model} -a never -s workspace-write --no-alt-screen -C '${cwd}'${mcpFlags(context)}${rulesFlags(denied)}`;
348
+ function buildCommand(model, cwd, context, denied = [], mode) {
349
+ const sandbox = mode === 'plan' ? 'read-only' : 'workspace-write';
350
+ return `codex --model ${model} -a never -s ${sandbox} --no-alt-screen -C '${cwd}'${mcpFlags(context)}${rulesFlags(denied)}`;
278
351
  }