loreli 0.0.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +670 -97
  3. package/bin/loreli.js +89 -0
  4. package/package.json +74 -14
  5. package/packages/README.md +101 -0
  6. package/packages/action/README.md +98 -0
  7. package/packages/action/src/index.js +656 -0
  8. package/packages/agent/README.md +517 -0
  9. package/packages/agent/src/backends/claude.js +287 -0
  10. package/packages/agent/src/backends/codex.js +278 -0
  11. package/packages/agent/src/backends/cursor.js +294 -0
  12. package/packages/agent/src/backends/index.js +329 -0
  13. package/packages/agent/src/base.js +138 -0
  14. package/packages/agent/src/cli.js +198 -0
  15. package/packages/agent/src/factory.js +119 -0
  16. package/packages/agent/src/index.js +12 -0
  17. package/packages/agent/src/models.js +141 -0
  18. package/packages/agent/src/output.js +62 -0
  19. package/packages/agent/src/session.js +162 -0
  20. package/packages/agent/src/trace.js +186 -0
  21. package/packages/config/README.md +833 -0
  22. package/packages/config/src/defaults.js +134 -0
  23. package/packages/config/src/index.js +192 -0
  24. package/packages/config/src/schema.js +273 -0
  25. package/packages/config/src/validate.js +160 -0
  26. package/packages/context/README.md +165 -0
  27. package/packages/context/src/index.js +198 -0
  28. package/packages/hub/README.md +338 -0
  29. package/packages/hub/src/base.js +154 -0
  30. package/packages/hub/src/github.js +1558 -0
  31. package/packages/hub/src/index.js +79 -0
  32. package/packages/hub/src/labels.js +48 -0
  33. package/packages/identity/README.md +288 -0
  34. package/packages/identity/src/index.js +620 -0
  35. package/packages/identity/src/themes/avatar.js +217 -0
  36. package/packages/identity/src/themes/digimon.js +217 -0
  37. package/packages/identity/src/themes/dragonball.js +217 -0
  38. package/packages/identity/src/themes/lotr.js +217 -0
  39. package/packages/identity/src/themes/marvel.js +217 -0
  40. package/packages/identity/src/themes/pokemon.js +217 -0
  41. package/packages/identity/src/themes/starwars.js +217 -0
  42. package/packages/identity/src/themes/transformers.js +217 -0
  43. package/packages/identity/src/themes/zelda.js +217 -0
  44. package/packages/knowledge/README.md +237 -0
  45. package/packages/knowledge/src/index.js +412 -0
  46. package/packages/log/README.md +93 -0
  47. package/packages/log/src/index.js +252 -0
  48. package/packages/marker/README.md +200 -0
  49. package/packages/marker/src/index.js +184 -0
  50. package/packages/mcp/README.md +279 -0
  51. package/packages/mcp/instructions.md +121 -0
  52. package/packages/mcp/scaffolding/.agents/skills/loreli-context/SKILL.md +89 -0
  53. package/packages/mcp/scaffolding/ISSUE_TEMPLATE/config.yml +2 -0
  54. package/packages/mcp/scaffolding/ISSUE_TEMPLATE/loreli.yml +83 -0
  55. package/packages/mcp/scaffolding/loreli.yml +453 -0
  56. package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +3 -0
  57. package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +11 -0
  58. package/packages/mcp/scaffolding/mcp-configs/.mcp.json +11 -0
  59. package/packages/mcp/scaffolding/pull-request.md +23 -0
  60. package/packages/mcp/src/index.js +571 -0
  61. package/packages/mcp/src/tools/agents.js +429 -0
  62. package/packages/mcp/src/tools/context.js +199 -0
  63. package/packages/mcp/src/tools/github.js +1199 -0
  64. package/packages/mcp/src/tools/hitl.js +149 -0
  65. package/packages/mcp/src/tools/index.js +17 -0
  66. package/packages/mcp/src/tools/start.js +835 -0
  67. package/packages/mcp/src/tools/status.js +146 -0
  68. package/packages/mcp/src/tools/work.js +124 -0
  69. package/packages/orchestrator/README.md +192 -0
  70. package/packages/orchestrator/src/index.js +1226 -0
  71. package/packages/planner/README.md +168 -0
  72. package/packages/planner/src/index.js +1166 -0
  73. package/packages/review/README.md +129 -0
  74. package/packages/review/src/index.js +1283 -0
  75. package/packages/risk/README.md +119 -0
  76. package/packages/risk/src/index.js +428 -0
  77. package/packages/session/README.md +165 -0
  78. package/packages/session/src/index.js +215 -0
  79. package/packages/test-utils/README.md +96 -0
  80. package/packages/test-utils/src/index.js +354 -0
  81. package/packages/tmux/README.md +261 -0
  82. package/packages/tmux/src/index.js +452 -0
  83. package/packages/workflow/README.md +313 -0
  84. package/packages/workflow/src/index.js +481 -0
  85. package/packages/workflow/src/proof-of-life.js +74 -0
  86. package/packages/workspace/README.md +143 -0
  87. package/packages/workspace/src/index.js +1076 -0
  88. package/index.js +0 -8
@@ -0,0 +1,287 @@
1
+ import { writeFile, unlink, mkdir } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { CliAgent } from '../cli.js';
5
+ import { resolve, env } from '../models.js';
6
+ import { mcpJson } from 'loreli/workspace';
7
+ import { logger } from 'loreli/log';
8
+
9
+ const log = logger('claude');
10
+
11
+ /**
12
+ * Interval for polling pane content during readiness detection.
13
+ * @type {number}
14
+ */
15
+ const READY_POLL = 1000;
16
+
17
+ /**
18
+ * Maximum time to wait for the Claude CLI to become ready.
19
+ * @type {number}
20
+ */
21
+ const READY_TIMEOUT = 60000;
22
+
23
+ /**
24
+ * Claude Code CLI backend. Interactive: stays running in a tmux pane.
25
+ *
26
+ * Uses `--dangerously-skip-permissions` to bypass ALL startup dialogs
27
+ * (workspace trust, permission bypass, "continue anyway"). This flag
28
+ * eliminates the need for fragile interactive dialog navigation.
29
+ *
30
+ * Uses `--mcp-config` to load the scaffolded `.mcp.json` that points
31
+ * back to the Loreli MCP server. The agent discovers Loreli tools
32
+ * automatically on startup.
33
+ *
34
+ * After the CLI is ready (welcome banner visible), prompts are
35
+ * delivered via file-based send (multi-line) or tmux send-keys
36
+ * (single-line).
37
+ *
38
+ * @extends CliAgent
39
+ */
40
+ export class ClaudeBackend extends CliAgent {
41
+ /**
42
+ * Return the scaffold descriptor for Claude Code workspaces.
43
+ *
44
+ * Claude uses `${GITHUB_TOKEN}` interpolation in `.mcp.json` —
45
+ * Claude Code resolves this from its parent process environment.
46
+ * Hooks use PreToolUse to delegate to deny.sh and protect.sh.
47
+ *
48
+ * @param {object} [context] - Agent context for env/token injection.
49
+ * @returns {object} Scaffold descriptor.
50
+ */
51
+ static scaffold(context) {
52
+ const denied = context?.denied ?? [];
53
+
54
+ return {
55
+ configs: [
56
+ {
57
+ path: '.mcp.json',
58
+ content: mcpJson(context, context ? { tokenRef: '${GITHUB_TOKEN}' } : {}),
59
+ marker: 'loreli',
60
+ format: 'json'
61
+ }
62
+ ],
63
+ hooks: [
64
+ {
65
+ path: '.claude/settings.local.json',
66
+ key: 'PreToolUse',
67
+ entry: {
68
+ matcher: 'Bash',
69
+ hooks: [{ type: 'command', command: 'bash "$CLAUDE_PROJECT_DIR/.loreli/deny.sh"' }]
70
+ },
71
+ marker: 'deny.sh'
72
+ },
73
+ {
74
+ path: '.claude/settings.local.json',
75
+ key: 'PreToolUse',
76
+ entry: {
77
+ matcher: 'Write|Edit|MultiEdit',
78
+ hooks: [{ type: 'command', command: 'bash "$CLAUDE_PROJECT_DIR/.loreli/protect.sh"' }]
79
+ },
80
+ marker: 'protect.sh'
81
+ }
82
+ ],
83
+ files: []
84
+ };
85
+ }
86
+ /**
87
+ * @param {object} opts
88
+ * @param {object} opts.identity - Agent identity.
89
+ * @param {string} opts.role - Agent role.
90
+ * @param {string} opts.cwd - Working directory.
91
+ * @param {string} [opts.model='balanced'] - Model alias or exact string.
92
+ * @param {object} [opts.config] - Config instance for model resolution.
93
+ * @param {string} [opts.session='loreli'] - Tmux session name.
94
+ * @param {number} [opts.readyTimeout=60000] - Max ms to wait for CLI readiness.
95
+ */
96
+ constructor(opts) {
97
+ const model = resolve(opts.model ?? 'balanced', 'claude', 'anthropic', opts.config);
98
+
99
+ const denied = opts.config?.get?.('agents.disallowedTools') ?? [];
100
+
101
+ super({
102
+ ...opts,
103
+ command: buildCommand(model, opts.cwd, denied)
104
+ });
105
+
106
+ /** @type {string} Resolved model identifier. */
107
+ this.model = model;
108
+
109
+ /** @type {object|undefined} Backend-specific environment variables. */
110
+ this._env = env('claude', opts.config) ?? {};
111
+
112
+ if (opts.context?.token) this._env.GITHUB_TOKEN = opts.context.token;
113
+
114
+ /** @type {number} Max ms to wait for CLI readiness. */
115
+ this.readyTimeout = opts.readyTimeout ?? READY_TIMEOUT;
116
+ }
117
+
118
+ /**
119
+ * Spawn the Claude CLI and wait for readiness.
120
+ *
121
+ * Builds an inline shell command with env exports and passes it
122
+ * directly to tmux — no launcher scripts on disk. The user's
123
+ * `.zshrc` is bypassed because tmux runs the command via `/bin/sh`.
124
+ *
125
+ * All trust and permission dialogs are skipped by
126
+ * `--dangerously-skip-permissions` in the command — no interactive
127
+ * navigation is needed.
128
+ *
129
+ * @returns {Promise<void>}
130
+ */
131
+ async spawn() {
132
+ const cmd = this._shell(`exec ${this.command}`, this._env);
133
+ this.paneId = await this._launch(cmd);
134
+
135
+ log.info(`spawning ${this.identity?.name ?? '?'} in pane ${this.paneId}: ${this.command}`);
136
+ this.transition('spawned');
137
+
138
+ await this._navigate();
139
+ }
140
+
141
+ /**
142
+ * Wait for Claude CLI to finish initializing, including MCP tool discovery.
143
+ *
144
+ * Two-phase readiness check:
145
+ * 1. Navigate the bypass-permissions dialog and wait for the welcome banner
146
+ * 2. Wait for the MCP server to write a `.loreli-mcp-ready` marker file
147
+ *
148
+ * Phase 2 prevents a race condition where the prompt is sent before
149
+ * Claude Code finishes its MCP handshake, causing the agent to work
150
+ * without access to MCP tools.
151
+ *
152
+ * @returns {Promise<void>}
153
+ */
154
+ async _navigate() {
155
+ if (this.readyTimeout <= 0) return;
156
+
157
+ const deadline = Date.now() + this.readyTimeout;
158
+ const name = this.identity?.name ?? '?';
159
+ let accepted = false;
160
+ let banner = false;
161
+
162
+ while (Date.now() < deadline) {
163
+ // Guard against pane death — if Claude Code crashes during
164
+ // startup, the pane disappears and capture throws. Fall through
165
+ // to the timeout warning rather than crashing the spawn pipeline.
166
+ let output;
167
+ try {
168
+ output = await this.tmux.capture(this.paneId);
169
+ } catch {
170
+ log.warn(`${name} pane died during readiness check — proceeding`);
171
+ return;
172
+ }
173
+
174
+ // Detect the bypass-permissions TUI confirmation dialog.
175
+ // The cursor defaults to "No, exit" — we need Down then Enter
176
+ // to select "Yes, I accept".
177
+ if (!accepted && output.includes('Yes, I accept')) {
178
+ log.info(`${name} accepting bypass-permissions dialog`);
179
+ await this.tmux.keys(this.paneId, 'Down');
180
+ await new Promise(function pause(r) { setTimeout(r, 300); });
181
+ await this.tmux.keys(this.paneId, 'Enter');
182
+ accepted = true;
183
+ await new Promise(function wait(r) { setTimeout(r, READY_POLL); });
184
+ continue;
185
+ }
186
+
187
+ // Phase 1: Welcome banner signals Claude CLI is initialized
188
+ if (!banner && output.includes('Claude Code')) {
189
+ if (accepted || !output.includes('Bypass Permissions')) {
190
+ log.info(`${name} ready — Claude Code welcome banner detected`);
191
+ banner = true;
192
+ // Fall through to Phase 2 — don't return yet
193
+ }
194
+ }
195
+
196
+ // Phase 2: Wait for MCP tools to be discoverable. The loreli MCP
197
+ // server writes this file in its oninitialized callback, which fires
198
+ // after the client (Claude Code) completes the MCP handshake.
199
+ if (banner) {
200
+ const ready = join(this.cwd, '.loreli', 'mcp-ready');
201
+ if (existsSync(ready)) {
202
+ log.info(`${name} MCP tools ready`);
203
+ try { await unlink(ready); } catch { /* already cleaned */ }
204
+ return;
205
+ }
206
+ }
207
+
208
+ await new Promise(function wait(r) { setTimeout(r, READY_POLL); });
209
+ }
210
+
211
+ log.warn(`${name} readiness timeout after ${this.readyTimeout}ms — proceeding anyway`);
212
+ }
213
+
214
+ /**
215
+ * Send a prompt to Claude's interactive session.
216
+ *
217
+ * Multi-line prompts are written to a Markdown file and a single-line
218
+ * reference is sent via tmux send-keys. This avoids newline bytes
219
+ * being interpreted as Enter and fragmenting the prompt.
220
+ *
221
+ * Single-line messages are sent directly via send-keys.
222
+ *
223
+ * @param {string} message - The prompt text.
224
+ * @returns {Promise<void>}
225
+ */
226
+ async send(message) {
227
+ if (!this.paneId) throw new Error('Agent not spawned — call spawn() first');
228
+ if (this.canTransition('working')) this.transition('working');
229
+
230
+ if (!message.includes('\n')) {
231
+ await this.tmux.send(this.paneId, message);
232
+ return;
233
+ }
234
+
235
+ const dir = join(this.cwd, '.loreli');
236
+ await mkdir(dir, { recursive: true });
237
+ const file = join(dir, `task-${Date.now()}.md`);
238
+ await writeFile(file, message, 'utf8');
239
+ log.info(`${this.identity?.name ?? '?'} prompt written to ${file}`);
240
+
241
+ await this.tmux.send(this.paneId,
242
+ `Read the complete task instructions from ${file} and execute them. Follow every instruction precisely.`
243
+ );
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Build the claude CLI command string.
249
+ *
250
+ * Flags:
251
+ * - `--dangerously-skip-permissions`: bypass all trust/permission dialogs
252
+ * - `--mcp-config .mcp.json`: load scaffolded Loreli MCP config
253
+ * - `--model`: resolved model identifier
254
+ *
255
+ * No `--prompt` flag — prompts are delivered via `send()` after spawn
256
+ * to avoid shell injection risks from unescaped special characters.
257
+ *
258
+ * @param {string} model - Resolved model identifier.
259
+ * @param {string} [cwd] - Working directory (for --mcp-config path).
260
+ * @param {string[]} [denied=[]] - Commands to block via --disallowedTools.
261
+ * @returns {string} CLI command string.
262
+ */
263
+ function buildCommand(model, cwd, denied = []) {
264
+ const parts = [
265
+ 'claude',
266
+ '--dangerously-skip-permissions',
267
+ `--model ${model}`
268
+ ];
269
+
270
+ // Defense-in-depth: --disallowedTools is a hard guardrail that cannot
271
+ // be bypassed by the agent. The hooks file (.claude/settings.local.json)
272
+ // provides richer feedback, but this flag is the safety net.
273
+ if (denied.length) {
274
+ const globs = denied.map(function toGlob(cmd) {
275
+ return `"Bash(${cmd}:*)"`;
276
+ });
277
+ parts.push(`--disallowedTools ${globs.join(' ')}`);
278
+ }
279
+
280
+ // Only add --mcp-config when the scaffolded file exists. A missing
281
+ // file causes Claude CLI to hang indefinitely with zero output,
282
+ // blocking _navigate() for the full READY_TIMEOUT.
283
+ const mcpPath = cwd ? join(cwd, '.mcp.json') : null;
284
+ if (mcpPath && existsSync(mcpPath)) parts.push(`--mcp-config ${mcpPath}`);
285
+
286
+ return parts.join(' ');
287
+ }
@@ -0,0 +1,278 @@
1
+ import { writeFile, unlink, mkdir } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { CliAgent } from '../cli.js';
5
+ import { resolve, env } from '../models.js';
6
+ import { codexToml, mcpJson } from 'loreli/workspace';
7
+ import { logger } from 'loreli/log';
8
+
9
+ const log = logger('codex');
10
+
11
+ /**
12
+ * Interval for polling pane content during readiness detection.
13
+ * @type {number}
14
+ */
15
+ const READY_POLL = 1000;
16
+
17
+ /**
18
+ * Maximum time to wait for the Codex CLI to become ready.
19
+ * @type {number}
20
+ */
21
+ const READY_TIMEOUT = 60000;
22
+
23
+ /**
24
+ * OpenAI Codex CLI backend. Interactive: stays running in a tmux pane.
25
+ *
26
+ * Uses `-a never -s workspace-write` for sandboxed unattended execution and
27
+ * `--no-alt-screen` so tmux can capture pane output (the TUI's
28
+ * alternate screen buffer is invisible to `capture-pane`).
29
+ *
30
+ * MCP servers are injected via `-c` flags at CLI level because
31
+ * Codex only reads `~/.codex/config.toml` (global), not the local
32
+ * `.codex/config.toml` in the working directory.
33
+ *
34
+ * After the CLI is ready (welcome banner visible), prompts are
35
+ * delivered via file-based send (multi-line) or tmux send-keys
36
+ * (single-line).
37
+ *
38
+ * @extends CliAgent
39
+ */
40
+ export class CodexBackend extends CliAgent {
41
+ /**
42
+ * Return the scaffold descriptor for Codex workspaces.
43
+ *
44
+ * Codex uses TOML config with `env_vars` forwarding for
45
+ * GITHUB_TOKEN — whitelists the parent process env var for
46
+ * forwarding to MCP subprocesses.
47
+ *
48
+ * @param {object} [context] - Agent context for env/token injection.
49
+ * @returns {object} Scaffold descriptor.
50
+ */
51
+ static scaffold(context) {
52
+ return {
53
+ configs: [
54
+ {
55
+ path: '.mcp.json',
56
+ content: mcpJson(context, context ? { tokenRef: '${GITHUB_TOKEN}' } : {}),
57
+ marker: 'loreli',
58
+ format: 'json'
59
+ },
60
+ {
61
+ path: '.codex/config.toml',
62
+ content: codexToml(context),
63
+ marker: 'mcp_servers.loreli',
64
+ format: 'toml'
65
+ }
66
+ ],
67
+ hooks: [],
68
+ files: []
69
+ };
70
+ }
71
+ /**
72
+ * @param {object} opts
73
+ * @param {object} opts.identity - Agent identity.
74
+ * @param {string} opts.role - Agent role.
75
+ * @param {string} opts.cwd - Working directory.
76
+ * @param {string} [opts.model='balanced'] - Model alias or exact string.
77
+ * @param {object} [opts.config] - Config instance for model resolution.
78
+ * @param {string} [opts.session='loreli'] - Tmux session name.
79
+ * @param {number} [opts.readyTimeout=60000] - Max ms to wait for CLI readiness.
80
+ */
81
+ constructor(opts) {
82
+ const model = resolve(opts.model ?? 'balanced', 'codex', 'openai', opts.config);
83
+
84
+ const denied = opts.config?.get?.('agents.disallowedTools') ?? [];
85
+
86
+ super({
87
+ ...opts,
88
+ command: buildCommand(model, opts.cwd, opts.context, denied)
89
+ });
90
+
91
+ /** @type {string} Resolved model identifier. */
92
+ this.model = model;
93
+
94
+ /** @type {object|undefined} Backend-specific environment variables. */
95
+ this._env = env('codex', opts.config) ?? {};
96
+
97
+ if (opts.context?.token) this._env.GITHUB_TOKEN = opts.context.token;
98
+
99
+ /** @type {number} Max ms to wait for CLI readiness. */
100
+ this.readyTimeout = opts.readyTimeout ?? READY_TIMEOUT;
101
+ }
102
+
103
+ /**
104
+ * Spawn the Codex CLI and wait for readiness.
105
+ *
106
+ * Builds an inline shell command with env exports and passes it
107
+ * directly to tmux — no launcher scripts on disk.
108
+ *
109
+ * @returns {Promise<void>}
110
+ */
111
+ async spawn() {
112
+ const cmd = this._shell(`exec ${this.command}`, this._env);
113
+ this.paneId = await this._launch(cmd);
114
+
115
+ log.info(`spawning ${this.identity?.name ?? '?'} in pane ${this.paneId}: ${this.command}`);
116
+ this.transition('spawned');
117
+
118
+ await this._navigate();
119
+ }
120
+
121
+ /**
122
+ * Wait for Codex CLI to finish initializing, including MCP tool discovery.
123
+ *
124
+ * Two-phase readiness check:
125
+ * 1. Wait for the welcome banner (`OpenAI Codex`) in pane output
126
+ * 2. Wait for the MCP server to write a `.loreli/mcp-ready` marker file
127
+ *
128
+ * Phase 2 prevents a race condition where the prompt is sent before
129
+ * Codex finishes its MCP handshake, causing the agent to work
130
+ * without access to MCP tools.
131
+ *
132
+ * @returns {Promise<void>}
133
+ */
134
+ async _navigate() {
135
+ if (this.readyTimeout <= 0) return;
136
+
137
+ const deadline = Date.now() + this.readyTimeout;
138
+ const name = this.identity?.name ?? '?';
139
+ let banner = false;
140
+
141
+ while (Date.now() < deadline) {
142
+ let output;
143
+ try {
144
+ output = await this.tmux.capture(this.paneId);
145
+ } catch {
146
+ log.warn(`${name} pane died during readiness check — proceeding`);
147
+ return;
148
+ }
149
+
150
+ if (!banner && output.includes('OpenAI Codex')) {
151
+ log.info(`${name} ready — OpenAI Codex welcome banner detected`);
152
+ banner = true;
153
+ }
154
+
155
+ if (banner) {
156
+ const ready = join(this.cwd, '.loreli', 'mcp-ready');
157
+ if (existsSync(ready)) {
158
+ log.info(`${name} MCP tools ready`);
159
+ try { await unlink(ready); } catch { /* already cleaned */ }
160
+ return;
161
+ }
162
+ }
163
+
164
+ await new Promise(function wait(r) { setTimeout(r, READY_POLL); });
165
+ }
166
+
167
+ log.warn(`${name} readiness timeout after ${this.readyTimeout}ms — proceeding anyway`);
168
+ }
169
+
170
+ /**
171
+ * Send a prompt to Codex's interactive session.
172
+ *
173
+ * Multi-line prompts are written to a Markdown file and a single-line
174
+ * reference is sent via tmux send-keys. This avoids newline bytes
175
+ * being interpreted as Enter and fragmenting the prompt.
176
+ *
177
+ * Single-line messages are sent directly via send-keys.
178
+ *
179
+ * @param {string} message - The prompt text.
180
+ * @returns {Promise<void>}
181
+ */
182
+ async send(message) {
183
+ if (!this.paneId) throw new Error('Agent not spawned — call spawn() first');
184
+ if (this.canTransition('working')) this.transition('working');
185
+
186
+ if (!message.includes('\n')) {
187
+ await this.tmux.send(this.paneId, message);
188
+ return;
189
+ }
190
+
191
+ const dir = join(this.cwd, '.loreli');
192
+ await mkdir(dir, { recursive: true });
193
+ const file = join(dir, `task-${Date.now()}.md`);
194
+ await writeFile(file, message, 'utf8');
195
+ log.info(`${this.identity?.name ?? '?'} prompt written to ${file}`);
196
+
197
+ await this.tmux.send(this.paneId,
198
+ `Read the complete task instructions from ${file} and execute them. Follow every instruction precisely.`
199
+ );
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Build `-c` flags for injecting loreli MCP server config into Codex CLI.
205
+ *
206
+ * Codex only reads `~/.codex/config.toml` (global), not the local
207
+ * `.codex/config.toml` in the working directory. The `-c` flag is
208
+ * the only reliable way to add MCP servers at runtime. When context
209
+ * is provided, LORELI_* env vars are also injected so the MCP server
210
+ * subprocess can hydrate session state on startup.
211
+ *
212
+ * @param {object} [context] - Agent context for env injection.
213
+ * @param {string} [context.session] - Session ID.
214
+ * @param {string} [context.agent] - Agent identity name.
215
+ * @param {string} [context.repo] - Target repository (owner/name).
216
+ * @param {string} [context.token] - GitHub token (forwarded via env_vars).
217
+ * @returns {string} `-c` flag string (with leading space) or empty string.
218
+ */
219
+ function mcpFlags(context) {
220
+ const flags = [
221
+ `-c 'mcp_servers.loreli.command="npx"'`,
222
+ `-c 'mcp_servers.loreli.args=["loreli", "mcp"]'`
223
+ ];
224
+
225
+ if (context?.session) {
226
+ flags.push(`-c 'mcp_servers.loreli.env.LORELI_SESSION="${context.session}"'`);
227
+ flags.push(`-c 'mcp_servers.loreli.env.LORELI_AGENT="${context.agent}"'`);
228
+ flags.push(`-c 'mcp_servers.loreli.env.LORELI_REPO="${context.repo}"'`);
229
+ if (context.home) flags.push(`-c 'mcp_servers.loreli.env.LORELI_HOME="${context.home}"'`);
230
+ // Forward token from parent process env without embedding the
231
+ // literal secret in CLI flags, logs, or launcher command strings.
232
+ if (context.token) flags.push(`-c 'mcp_servers.loreli.env_vars=["GITHUB_TOKEN"]'`);
233
+ }
234
+
235
+ return ' ' + flags.join(' ');
236
+ }
237
+
238
+ /**
239
+ * Build `-c` flags for injecting `rules.prefix_rules` into Codex CLI.
240
+ *
241
+ * Codex supports `rules.prefix_rules` in its `config.toml` to forbid
242
+ * commands by token pattern. Since Codex has no hooks system, this is
243
+ * the only way to block tool execution at the CLI level.
244
+ *
245
+ * @param {string[]} denied - Commands to block.
246
+ * @returns {string} `-c` flag string (with leading space) or empty string.
247
+ */
248
+ function rulesFlags(denied) {
249
+ if (!denied?.length) return '';
250
+ const rules = denied.map(function toRule(cmd) {
251
+ return `{pattern=[{token="${cmd}"}], decision="forbidden", justification="Use Loreli MCP tools instead of ${cmd}"}`;
252
+ });
253
+ return ` -c 'rules.prefix_rules=[${rules.join(', ')}]'`;
254
+ }
255
+
256
+ /**
257
+ * Build the codex CLI command string.
258
+ *
259
+ * Flags:
260
+ * - `-a never`: disable approval prompts for unattended operation.
261
+ * `--full-auto` maps to `-a on-request` which still allows the
262
+ * model to trigger interactive approval prompts, blocking the
263
+ * agent in tmux with no human to respond.
264
+ * - `-s workspace-write`: sandbox to workspace write access only
265
+ * - `--no-alt-screen`: inline TUI mode for tmux capture compatibility
266
+ * - `-C ${cwd}`: set the working directory for codex
267
+ * - `-c mcp_servers.loreli.*`: inject loreli MCP server config
268
+ * - `-c rules.prefix_rules`: inject command deny rules
269
+ *
270
+ * @param {string} model - Resolved model identifier.
271
+ * @param {string} cwd - Working directory.
272
+ * @param {object} [context] - Agent context for MCP env injection.
273
+ * @param {string[]} [denied=[]] - Commands to block via prefix rules.
274
+ * @returns {string} CLI command string.
275
+ */
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)}`;
278
+ }