loreli 0.0.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +670 -97
- package/bin/loreli.js +89 -0
- package/package.json +74 -14
- package/packages/README.md +101 -0
- package/packages/action/README.md +98 -0
- package/packages/action/src/index.js +656 -0
- package/packages/agent/README.md +517 -0
- package/packages/agent/src/backends/claude.js +287 -0
- package/packages/agent/src/backends/codex.js +278 -0
- package/packages/agent/src/backends/cursor.js +294 -0
- package/packages/agent/src/backends/index.js +329 -0
- package/packages/agent/src/base.js +138 -0
- package/packages/agent/src/cli.js +198 -0
- package/packages/agent/src/factory.js +119 -0
- package/packages/agent/src/index.js +12 -0
- package/packages/agent/src/models.js +141 -0
- package/packages/agent/src/output.js +62 -0
- package/packages/agent/src/session.js +162 -0
- package/packages/agent/src/trace.js +186 -0
- package/packages/config/README.md +833 -0
- package/packages/config/src/defaults.js +134 -0
- package/packages/config/src/index.js +192 -0
- package/packages/config/src/schema.js +273 -0
- package/packages/config/src/validate.js +160 -0
- package/packages/context/README.md +165 -0
- package/packages/context/src/index.js +198 -0
- package/packages/hub/README.md +338 -0
- package/packages/hub/src/base.js +154 -0
- package/packages/hub/src/github.js +1558 -0
- package/packages/hub/src/index.js +79 -0
- package/packages/hub/src/labels.js +48 -0
- package/packages/identity/README.md +288 -0
- package/packages/identity/src/index.js +620 -0
- package/packages/identity/src/themes/avatar.js +217 -0
- package/packages/identity/src/themes/digimon.js +217 -0
- package/packages/identity/src/themes/dragonball.js +217 -0
- package/packages/identity/src/themes/lotr.js +217 -0
- package/packages/identity/src/themes/marvel.js +217 -0
- package/packages/identity/src/themes/pokemon.js +217 -0
- package/packages/identity/src/themes/starwars.js +217 -0
- package/packages/identity/src/themes/transformers.js +217 -0
- package/packages/identity/src/themes/zelda.js +217 -0
- package/packages/knowledge/README.md +237 -0
- package/packages/knowledge/src/index.js +412 -0
- package/packages/log/README.md +93 -0
- package/packages/log/src/index.js +252 -0
- package/packages/marker/README.md +200 -0
- package/packages/marker/src/index.js +184 -0
- package/packages/mcp/README.md +279 -0
- package/packages/mcp/instructions.md +121 -0
- package/packages/mcp/scaffolding/.agents/skills/loreli-context/SKILL.md +89 -0
- package/packages/mcp/scaffolding/ISSUE_TEMPLATE/config.yml +2 -0
- package/packages/mcp/scaffolding/ISSUE_TEMPLATE/loreli.yml +83 -0
- package/packages/mcp/scaffolding/loreli.yml +453 -0
- package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +3 -0
- package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +11 -0
- package/packages/mcp/scaffolding/mcp-configs/.mcp.json +11 -0
- package/packages/mcp/scaffolding/pull-request.md +23 -0
- package/packages/mcp/src/index.js +571 -0
- package/packages/mcp/src/tools/agents.js +429 -0
- package/packages/mcp/src/tools/context.js +199 -0
- package/packages/mcp/src/tools/github.js +1199 -0
- package/packages/mcp/src/tools/hitl.js +149 -0
- package/packages/mcp/src/tools/index.js +17 -0
- package/packages/mcp/src/tools/start.js +835 -0
- package/packages/mcp/src/tools/status.js +146 -0
- package/packages/mcp/src/tools/work.js +124 -0
- package/packages/orchestrator/README.md +192 -0
- package/packages/orchestrator/src/index.js +1226 -0
- package/packages/planner/README.md +168 -0
- package/packages/planner/src/index.js +1166 -0
- package/packages/review/README.md +129 -0
- package/packages/review/src/index.js +1283 -0
- package/packages/risk/README.md +119 -0
- package/packages/risk/src/index.js +428 -0
- package/packages/session/README.md +165 -0
- package/packages/session/src/index.js +215 -0
- package/packages/test-utils/README.md +96 -0
- package/packages/test-utils/src/index.js +354 -0
- package/packages/tmux/README.md +261 -0
- package/packages/tmux/src/index.js +452 -0
- package/packages/workflow/README.md +313 -0
- package/packages/workflow/src/index.js +481 -0
- package/packages/workflow/src/proof-of-life.js +74 -0
- package/packages/workspace/README.md +143 -0
- package/packages/workspace/src/index.js +1076 -0
- package/index.js +0 -8
|
@@ -0,0 +1,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
|
+
}
|