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
@@ -1,8 +1,19 @@
1
1
  import { execFile as execFileCb, execFileSync } from 'node:child_process';
2
+ import { writeFileSync, unlinkSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { randomUUID } from 'node:crypto';
2
6
  import { promisify } from 'node:util';
3
7
 
4
8
  const execFile = promisify(execFileCb);
5
9
 
10
+ /**
11
+ * Pattern for safe tmux session names — alphanumeric, hyphens, and underscores only.
12
+ * Prevents shell injection and tmux command-line metacharacter issues.
13
+ * @type {RegExp}
14
+ */
15
+ const SAFE_SESSION_NAME = /^[a-zA-Z0-9_-]+$/;
16
+
6
17
  /**
7
18
  * Clean, modern, ESM Node.js wrapper for tmux with pane-level control.
8
19
  *
@@ -85,6 +96,9 @@ export class Tmux {
85
96
  * @throws {Error} When a session with the same name already exists.
86
97
  */
87
98
  async create(name, opts = {}) {
99
+ if (!SAFE_SESSION_NAME.test(name))
100
+ throw new Error(`Invalid session name "${name}": must match ${SAFE_SESSION_NAME}`);
101
+
88
102
  if (await this.has(name)) {
89
103
  throw new Error(`Session "${name}" already exists`);
90
104
  }
@@ -98,7 +112,13 @@ export class Tmux {
98
112
  args.push('-P', '-F', '#{pane_id}');
99
113
  if (opts.cwd) args.push('-c', opts.cwd);
100
114
  args.push(opts.command);
101
- const paneId = await this.#exec(args);
115
+ let paneId;
116
+ try {
117
+ paneId = await this.#exec(args);
118
+ } catch (err) {
119
+ try { await this.#exec(['kill-session', '-t', name]); } catch { /* best-effort */ }
120
+ throw err;
121
+ }
102
122
  this.#sessions.add(name);
103
123
  return paneId;
104
124
  }
@@ -295,24 +315,49 @@ export class Tmux {
295
315
  return this.#exec(args);
296
316
  }
297
317
 
318
+ /**
319
+ * Maximum length for direct send-keys. Longer text uses load-buffer
320
+ * to avoid terminal truncation.
321
+ * @type {number}
322
+ */
323
+ static SEND_MAX = 200;
324
+
298
325
  /**
299
326
  * Send text to a pane followed by Enter.
300
327
  *
328
+ * Clears any partial input first with C-u, then delivers the text.
329
+ * For short single-line text, uses `send-keys -l` (literal mode) so
330
+ * strings like "Enter" or "Space" are not interpreted as key names.
331
+ * For long or multiline text, writes to a temp file and uses
332
+ * `load-buffer` / `paste-buffer` to avoid terminal truncation.
333
+ *
301
334
  * Text and Enter are sent as separate operations with a brief delay.
302
- * TUI applications (Claude Code, Cursor Agent) run in raw TTY mode
303
- * and process keystrokes via an async event loop. When send-keys
304
- * delivers text + Enter in a single call, both arrive in one PTY
305
- * read buffer. The TUI's event loop processes them in a single tick,
306
- * causing Enter to be consumed by the text rendering pipeline instead
307
- * of triggering "submit". Splitting into two calls guarantees Enter
308
- * arrives in a separate stdin data event.
335
+ * TUI applications process keystrokes asynchronously splitting
336
+ * guarantees Enter arrives in a separate stdin data event.
309
337
  *
310
338
  * @param {string} target - Pane ID or session:window.pane target.
311
339
  * @param {string} text - The text to send.
312
340
  * @returns {Promise<void>}
313
341
  */
314
342
  async send(target, text) {
315
- await this.#exec(['send-keys', '-t', target, text]);
343
+ await this.#exec(['send-keys', '-t', target, 'C-u']);
344
+
345
+ if (text.includes('\n') || text.length > Tmux.SEND_MAX) {
346
+ const id = randomUUID().slice(0, 8);
347
+ const bufName = `loreli-${id}`;
348
+ const tmpPath = join(tmpdir(), `loreli-send-${id}.txt`);
349
+ writeFileSync(tmpPath, text, { encoding: 'utf-8', mode: 0o600 });
350
+ try {
351
+ await this.#exec(['load-buffer', '-b', bufName, tmpPath]);
352
+ await this.#exec(['paste-buffer', '-b', bufName, '-t', target, '-d']);
353
+ } finally {
354
+ try { unlinkSync(tmpPath); } catch { /* may already be removed */ }
355
+ try { await this.#exec(['delete-buffer', '-b', bufName]); } catch { /* may already be deleted */ }
356
+ }
357
+ } else {
358
+ await this.#exec(['send-keys', '-l', '-t', target, text]);
359
+ }
360
+
316
361
  await new Promise(function settle(r) { setTimeout(r, 200); });
317
362
  await this.#exec(['send-keys', '-t', target, 'Enter']);
318
363
  }
@@ -342,9 +387,13 @@ export class Tmux {
342
387
  * @returns {Promise<string>} The captured pane content.
343
388
  */
344
389
  async capture(target, lines = 500) {
345
- return this.#exec([
346
- 'capture-pane', '-p', '-J', '-t', target, '-S', `-${lines}`
347
- ]);
390
+ try {
391
+ return await this.#exec([
392
+ 'capture-pane', '-p', '-J', '-t', target, '-S', `-${lines}`
393
+ ]);
394
+ } catch {
395
+ return '';
396
+ }
348
397
  }
349
398
 
350
399
  /**
@@ -104,9 +104,9 @@ Create and spawn a new agent via the orchestrator's factory. Convenience wrapper
104
104
 
105
105
  #### `workflow.custom(opts?)` → Promise\<string\>
106
106
 
107
- Load the project-specific custom prompt from the `prompts.{role}` config key (e.g. `prompts.action`, `prompts.reviewer`, `prompts.planner`, `prompts.risk`). By default, resolves this workflow's `static role`; pass `opts.role` to resolve another role during cross-role rendering. Reads each role-specific file once from the target repo via the hub and caches it on the instance. Returns an empty string when:
107
+ Load the project-specific custom prompt from the `workflows.{role}.prompt` config key (e.g. `workflows.action.prompt`, `workflows.reviewer.prompt`, `workflows.planner.prompt`, `workflows.risk.prompt`). By default, resolves this workflow's `static role`; pass `opts.role` to resolve another role during cross-role rendering. Reads each role-specific file once from the target repo via the hub and caches it on the instance. Returns an empty string when:
108
108
 
109
- - `prompts.{role}` is not configured for this workflow's role
109
+ - `workflows.{role}.prompt` is not configured for this workflow's role
110
110
  - The hub is unavailable (e.g. unit tests)
111
111
  - The repo is not set on the orchestrator
112
112
  - The file cannot be read (missing or inaccessible)
@@ -115,13 +115,13 @@ Rendering never fails due to a missing custom prompt — this method degrades si
115
115
 
116
116
  | Parameter | Type | Description |
117
117
  |-----------|------|-------------|
118
- | `opts.role` | `string` | Optional role override for `prompts.{role}` lookup |
118
+ | `opts.role` | `string` | Optional role override for `workflows.{role}.prompt` lookup |
119
119
 
120
120
  **Returns**: `Promise<string>` — Custom prompt text, or empty string.
121
121
 
122
122
  #### `workflow.render(vars)` → Promise\<string\>
123
123
 
124
- Load and render this workflow's prompt template with Mustache variables. Automatically prepends the shared autonomous-mode preamble and any per-role custom prompt from `prompts.{role}` (see [Autonomous Preamble](#autonomous-preamble) and [Custom Prompt Extensions](#custom-prompt-extensions) below).
124
+ Load and render this workflow's prompt template with Mustache variables. Automatically prepends the shared autonomous-mode preamble and any per-role custom prompt from `workflows.{role}.prompt` (see [Autonomous Preamble](#autonomous-preamble) and [Custom Prompt Extensions](#custom-prompt-extensions) below).
125
125
 
126
126
  | Parameter | Type | Description |
127
127
  |-----------|------|-------------|
@@ -137,7 +137,7 @@ Load and render an arbitrary Mustache template. Used for cross-role rendering (e
137
137
  |-----------|------|-------------|
138
138
  | `path` | `string` | Absolute path to a `.md` template file |
139
139
  | `vars` | `object` | Template variables |
140
- | `opts.role` | `string` | Optional role override for custom prompt lookup. When set, resolves `prompts.{opts.role}` instead of this workflow's `static role` |
140
+ | `opts.role` | `string` | Optional role override for custom prompt lookup. When set, resolves `workflows.{opts.role}.prompt` instead of this workflow's `static role` |
141
141
 
142
142
  **Returns**: `Promise<string>` — Rendered prompt text with preamble and custom prompt prepended.
143
143
 
@@ -274,24 +274,28 @@ The preamble is loaded once and cached for the lifetime of the process.
274
274
 
275
275
  ## Custom Prompt Extensions
276
276
 
277
- Projects can inject per-role custom instructions into agent prompts by setting `prompts.{role}` in `loreli.yml`. Each key maps to a workflow role and the value is a file path relative to the repository root.
277
+ Projects can inject per-role custom instructions into agent prompts by setting `workflows.{role}.prompt` in `loreli.yml`. Each role maps to a workflow and the value is a file path relative to the repository root.
278
278
 
279
279
  ```yaml
280
280
  # loreli.yml
281
- prompts:
282
- action: .loreli/action.md
283
- reviewer: .loreli/review.md
284
- planner: .loreli/planner.md
285
- risk: .loreli/risk.md
281
+ workflows:
282
+ action:
283
+ prompt: .loreli/action.md
284
+ reviewer:
285
+ prompt: .loreli/review.md
286
+ planner:
287
+ prompt: .loreli/planner.md
288
+ risk:
289
+ prompt: .loreli/risk.md
286
290
  ```
287
291
 
288
- When configured, `render()` and `renderFrom()` resolve `prompts.{this.constructor.role}` from config, read the file from the target repo via the hub, and insert its content between the autonomous preamble and the role-specific template:
292
+ When configured, `render()` and `renderFrom()` resolve `workflows.{role}.prompt` from config, read the file from the target repo via the hub, and insert its content between the autonomous preamble and the role-specific template:
289
293
 
290
294
  1. **Autonomous preamble** (`prompts/preamble.md`) — always present
291
- 2. **Custom prompt** (`prompts.{role}`) — when configured for the current role
295
+ 2. **Custom prompt** (`workflows.{role}.prompt`) — when configured for the current role
292
296
  3. **Role template** (action/planner/reviewer `.md` file)
293
297
 
294
- Each role resolves independently — an action workflow only reads `prompts.action`, a reviewer only reads `prompts.reviewer`. Roles without a configured prompt file are unaffected.
298
+ Each role resolves independently — an action workflow only reads `workflows.action.prompt`, a reviewer only reads `workflows.reviewer.prompt`. Roles without a configured prompt file are unaffected.
295
299
 
296
300
  The file is read once on the first `render()` call and cached on the `Workflow` instance for the remainder of the session. If the file is missing or unreadable, rendering continues normally — agents are never blocked by a missing custom prompt.
297
301
 
@@ -0,0 +1,14 @@
1
+ <autonomous>
2
+
3
+ You are running autonomously in a headless tmux pane with no human operator. There is no user to respond to questions.
4
+
5
+ - **Never ask** clarifying questions, present multiple-choice options, or wait for user input.
6
+ - **Never invoke** interactive skills (brainstorming, design reviews, or anything that requires human approval gates).
7
+ - **Always proceed** with your best judgment. When multiple approaches exist, pick the most reasonable default and document your reasoning.
8
+ - If a skill or workflow would normally require user confirmation, skip it entirely and proceed directly to your task.
9
+ - Before planning or coding, explicitly check whether `AGENTS.md` exists at the repository root. If it exists, read it and treat it as normative repository policy for this task.
10
+ - `AGENTS.md` policy takes precedence over generic defaults for repository-specific behavior. Follow it without waiting for confirmation.
11
+
12
+ Violations of these rules will permanently stall your session.
13
+
14
+ </autonomous>
@@ -3,6 +3,9 @@ import { join, dirname } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import Mustache from 'mustache';
5
5
  import { mark, has, parse } from 'loreli/marker';
6
+ import { Tmux } from 'loreli/tmux';
7
+ import { classify } from 'loreli/classify';
8
+ import { logger } from 'loreli/log';
6
9
 
7
10
  export { responder } from './proof-of-life.js';
8
11
 
@@ -16,10 +19,25 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
16
19
  * @type {string}
17
20
  */
18
21
  const PREAMBLE = join(__dirname, '..', 'prompts', 'preamble.md');
22
+ const log = logger('workflow');
19
23
 
20
24
  /** @type {string|null} Cached preamble text — loaded once, reused. */
21
25
  let _preamble = null;
22
26
 
27
+ /**
28
+ * Maximum backend-diagnose checks after prompt dispatch.
29
+ *
30
+ * @type {number}
31
+ */
32
+ const DISPATCH_NUDGE_CHECKS = 48;
33
+
34
+ /**
35
+ * Delay between post-dispatch diagnose checks.
36
+ *
37
+ * @type {number}
38
+ */
39
+ const DISPATCH_NUDGE_INTERVAL = 5000;
40
+
23
41
  /**
24
42
  * Load the shared preamble, caching after the first read.
25
43
  *
@@ -30,6 +48,100 @@ async function preamble() {
30
48
  return _preamble;
31
49
  }
32
50
 
51
+ /**
52
+ * Wait for the specified duration.
53
+ *
54
+ * @param {number} ms - Milliseconds to sleep.
55
+ * @returns {Promise<void>}
56
+ */
57
+ async function sleep(ms) {
58
+ await new Promise(function onTimer(resolve) {
59
+ const handle = setTimeout(resolve, ms);
60
+ handle?.unref?.();
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Normalize remedy values into tmux key sequences.
66
+ *
67
+ * Classifier output may provide remedy as a single space-delimited string,
68
+ * while backend fallback diagnosers usually return string arrays.
69
+ *
70
+ * @param {string|string[]|null|undefined} remedy - Remedy value from diagnosis.
71
+ * @returns {string[]} Keys to send through tmux.
72
+ */
73
+ function keys(remedy) {
74
+ if (Array.isArray(remedy)) {
75
+ const list = remedy.filter(Boolean);
76
+ if (list.length > 0) return list;
77
+ return ['Enter'];
78
+ }
79
+
80
+ if (typeof remedy === 'string') {
81
+ const list = remedy.split(/\s+/).filter(Boolean);
82
+ if (list.length > 0) return list;
83
+ return ['Enter'];
84
+ }
85
+
86
+ return ['Enter'];
87
+ }
88
+
89
+ /**
90
+ * Return whether a diagnosis category requires immediate remediation.
91
+ *
92
+ * @param {string|undefined} category - Diagnosis category.
93
+ * @returns {boolean} True when remediation should be applied immediately.
94
+ */
95
+ function actionable(category) {
96
+ return category === 'option_dialog'
97
+ || category === 'waiting_for_input'
98
+ || category === 'fatal'
99
+ || category === 'dead';
100
+ }
101
+
102
+ /**
103
+ * Run pane classification through loreli/classify.
104
+ *
105
+ * This is best-effort: classification errors should not block dispatch
106
+ * nudging because backend regex diagnosis can still recover option dialogs.
107
+ *
108
+ * @param {object} orchestrator - Orchestrator instance containing config/backends.
109
+ * @param {object} agent - Agent metadata.
110
+ * @param {string} pane - Captured pane output.
111
+ * @returns {Promise<object|null>} Classifier diagnosis, or null when unavailable/failed.
112
+ */
113
+ async function diagnose(orchestrator, agent, pane) {
114
+ const backends = orchestrator?.backendRegistry;
115
+ if (!backends?.oneshot) return null;
116
+
117
+ try {
118
+ const result = await classify('pane-state', pane, {
119
+ backends,
120
+ config: orchestrator?.cfg,
121
+ vars: { model: agent.model, backend: agent.backend, role: agent.role }
122
+ });
123
+
124
+ if (result?.category === 'option_dialog') {
125
+ log.info(
126
+ `dispatch nudge classify: ${agent.identity?.name ?? 'unknown'} ${agent.backend} `
127
+ + `${result.category} — ${result.reasoning}`
128
+ );
129
+ }
130
+
131
+ return result;
132
+ } catch (err) {
133
+ log.debug(`dispatch nudge classify failed for ${agent.identity?.name ?? 'unknown'}: ${err.message}`);
134
+ return null;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Swallow background nudge errors.
140
+ *
141
+ * @returns {void}
142
+ */
143
+ function ignoreNudgeError() {}
144
+
33
145
  /**
34
146
  * Abstract base class for role-specific workflow packages.
35
147
  *
@@ -138,22 +250,21 @@ export class Workflow {
138
250
  /**
139
251
  * Load the project-specific custom prompt for this workflow's role.
140
252
  *
141
- * Resolves `prompts.{role}` from config (e.g. `prompts.action`,
142
- * `prompts.reviewer`, `prompts.planner`), reads the file once from
143
- * the target repo via the hub, and caches the result on this
144
- * instance. Returns an empty string when the config key is unset,
145
- * the hub is unavailable, or the file cannot be read — prompt
146
- * rendering never fails due to a missing custom prompt.
253
+ * Resolves `workflows.{role}.prompt` from config, reads the file
254
+ * once from the target repo via the hub, and caches the result on
255
+ * this instance. Returns an empty string when the config key is
256
+ * unset, the hub is unavailable, or the file cannot be read —
257
+ * prompt rendering never fails due to a missing custom prompt.
147
258
  *
148
259
  * @param {object} [opts] - Options.
149
- * @param {string} [opts.role] - Role to resolve `prompts.{role}` for.
260
+ * @param {string} [opts.role] - Role to resolve prompt for.
150
261
  * @returns {Promise<string>} Custom prompt text, or empty string.
151
262
  */
152
263
  async custom(opts = {}) {
153
264
  const role = opts.role ?? this.constructor.role;
154
265
  if (this._custom.has(role)) return this._custom.get(role) ?? '';
155
266
 
156
- const path = this.orchestrator.cfg?.get?.(`prompts.${role}`);
267
+ const path = this.orchestrator.cfg?.get?.(`workflows.${role}.prompt`);
157
268
  if (!path || !this.hub?.read || !this.orchestrator.repo) {
158
269
  this._custom.set(role, null);
159
270
  return '';
@@ -175,8 +286,8 @@ export class Workflow {
175
286
  * Automatically injects the workflow's `role` into the template
176
287
  * variables so subclasses don't need to pass it explicitly.
177
288
  * Prepends the shared autonomous-mode preamble and any project-
178
- * specific custom prompt from `prompts.{role}` so every agent
179
- * prompt carries the headless operation directive and project rules.
289
+ * specific custom prompt from `workflows.{role}.prompt` so every
290
+ * agent prompt carries the headless operation directive and project rules.
180
291
  *
181
292
  * @param {object} vars - Mustache template variables.
182
293
  * @returns {Promise<string>} Rendered prompt text.
@@ -198,12 +309,12 @@ export class Workflow {
198
309
  * Used for cross-role rendering where one workflow needs another
199
310
  * role's prompt (e.g. review rendering the action prompt for forward).
200
311
  * Prepends the shared autonomous-mode preamble and any project-
201
- * specific custom prompt from `prompts.{role}`.
312
+ * specific custom prompt from `workflows.{role}.prompt`.
202
313
  *
203
314
  * @param {string} path - Absolute path to a .md template file.
204
315
  * @param {object} vars - Mustache template variables.
205
316
  * @param {object} [opts] - Options.
206
- * @param {string} [opts.role] - Role to resolve `prompts.{role}` for.
317
+ * @param {string} [opts.role] - Role to resolve prompt for.
207
318
  * @returns {Promise<string>} Rendered prompt text.
208
319
  */
209
320
  async renderFrom(path, vars, opts = {}) {
@@ -253,6 +364,74 @@ export class Workflow {
253
364
  async dispatch(agent, vars) {
254
365
  const prompt = await this.render(vars);
255
366
  await agent.send(prompt);
367
+ this.nudge(agent).catch(ignoreNudgeError);
368
+ }
369
+
370
+ /**
371
+ * Nudge known CLI option dialogs after prompt dispatch.
372
+ *
373
+ * Some backends surface interactive confirmation dialogs seconds
374
+ * after a prompt is submitted (for example MCP tool approvals).
375
+ * This helper polls pane output and applies backend-provided
376
+ * dialog remedies so workflows remain unattended.
377
+ *
378
+ * @param {object} agent - Target agent with backend/pane metadata.
379
+ * @returns {Promise<void>}
380
+ */
381
+ async nudge(agent) {
382
+ if (!agent?.backend || !agent?.paneId || !agent?.capture) return;
383
+
384
+ const registry = this.orchestrator?.backendRegistry;
385
+ if (!registry?.diagnose) return;
386
+
387
+ for (let i = 0; i < DISPATCH_NUDGE_CHECKS; i++) {
388
+ if (agent.state === 'dormant') return;
389
+
390
+ let pane;
391
+ try {
392
+ pane = await agent.capture(this.orchestrator?.cfg?.get?.('classify.maxLines') ?? 100);
393
+ } catch {
394
+ return;
395
+ }
396
+
397
+ const llm = await diagnose(this.orchestrator, agent, pane);
398
+ const fallback = registry.diagnose(agent.backend, pane);
399
+
400
+ let result = llm;
401
+ if (!result?.category && fallback?.category) {
402
+ result = fallback;
403
+ log.info(
404
+ `dispatch nudge fallback diagnose: ${agent.identity?.name ?? 'unknown'} ${agent.backend} `
405
+ + `${fallback.category} — ${fallback.reasoning}`
406
+ );
407
+ } else if (actionable(fallback?.category) && !actionable(result?.category)) {
408
+ result = fallback;
409
+ log.info(
410
+ `dispatch nudge fallback override: ${agent.identity?.name ?? 'unknown'} ${agent.backend} `
411
+ + `${fallback.category} over ${llm?.category ?? 'unknown'} — ${fallback.reasoning}`
412
+ );
413
+ }
414
+
415
+ if (result?.category === 'option_dialog') {
416
+ const seq = keys(result.remedy);
417
+ log.info(`dispatch nudge: ${agent.identity?.name ?? 'unknown'} ${agent.backend} option_dialog — sending ${seq.join('+')}`);
418
+ try {
419
+ const tmux = new Tmux();
420
+ await tmux.keys(agent.paneId, ...seq);
421
+ } catch {
422
+ log.debug(`dispatch nudge: keys failed for ${agent.identity?.name ?? 'unknown'}`);
423
+ return;
424
+ }
425
+ this.orchestrator.activity?.(agent.identity?.name);
426
+
427
+ if (i < DISPATCH_NUDGE_CHECKS - 1)
428
+ await sleep(DISPATCH_NUDGE_INTERVAL);
429
+ continue;
430
+ }
431
+
432
+ if (i < DISPATCH_NUDGE_CHECKS - 1)
433
+ await sleep(DISPATCH_NUDGE_INTERVAL);
434
+ }
256
435
  }
257
436
 
258
437
  // ── Proof of Life ───────────────────────────────────
@@ -18,11 +18,11 @@ This package prepares agent directories, scaffolds local MCP configs and safety
18
18
 
19
19
  #### `codexToml(context?)` → `string`
20
20
 
21
- Generate Codex TOML config content with optional agent context env keys.
21
+ Generate Codex TOML config content with `env_vars = ["GITHUB_TOKEN"]` forwarding and optional agent context env keys.
22
22
 
23
23
  #### `mcpJson(context?, opts?)` → `string`
24
24
 
25
- Generate JSON MCP config content with optional agent context, token reference (`tokenRef`), and env file path (`envFile`).
25
+ Generate JSON MCP config content with optional agent context, token reference (`tokenRef`), and env file path (`envFile`). Cursor scaffolding keeps both interpolation and envFile fallback for token propagation.
26
26
 
27
27
  ### Security/Hook Generators
28
28
 
@@ -4,7 +4,45 @@ import { homedir } from 'node:os';
4
4
  import { execFile } from 'node:child_process';
5
5
  import { promisify } from 'node:util';
6
6
 
7
- const run = promisify(execFile);
7
+ const exec = promisify(execFile);
8
+
9
+ /**
10
+ * Timeout for git network operations (fetch, clone, push) in ms.
11
+ * Prevents hung network connections from blocking the orchestrator.
12
+ * @type {number}
13
+ */
14
+ const GIT_TIMEOUT = 30_000;
15
+
16
+ /**
17
+ * Run a child process with an optional timeout.
18
+ *
19
+ * @param {string} cmd - Command to execute.
20
+ * @param {string[]} args - Arguments.
21
+ * @param {object} [opts] - execFile options.
22
+ * @returns {Promise<{stdout: string, stderr: string}>}
23
+ */
24
+ function run(cmd, args, opts = {}) {
25
+ return exec(cmd, args, opts);
26
+ }
27
+
28
+ /**
29
+ * Pattern for safe path segments — alphanumeric, hyphens, and underscores only.
30
+ * Prevents path traversal, tmux injection, and shell metacharacter issues.
31
+ * @type {RegExp}
32
+ */
33
+ const SAFE_NAME = /^[a-zA-Z0-9_-]+$/;
34
+
35
+ /**
36
+ * Validate that a name is safe for use in file paths and tmux session IDs.
37
+ *
38
+ * @param {string} value - The name to validate.
39
+ * @param {string} label - Human-readable label for error messages.
40
+ * @throws {Error} When the value contains unsafe characters.
41
+ */
42
+ function assertSafe(value, label) {
43
+ if (!SAFE_NAME.test(value))
44
+ throw new Error(`Invalid ${label} "${value}": must match ${SAFE_NAME}`);
45
+ }
8
46
 
9
47
  /**
10
48
  * Default root directory for agent workspaces.
@@ -38,7 +76,13 @@ export const MCP_JSON = JSON.stringify({
38
76
  * MCP config for TOML-based CLI tools (Codex).
39
77
  * @type {string}
40
78
  */
41
- export const CODEX_TOML = '[mcp_servers.loreli]\ncommand = "npx"\nargs = ["loreli", "mcp"]\n';
79
+ export const CODEX_TOML = [
80
+ '[mcp_servers.loreli]',
81
+ 'command = "npx"',
82
+ 'args = ["loreli", "mcp"]',
83
+ 'env_vars = ["GITHUB_TOKEN"]',
84
+ ''
85
+ ].join('\n');
42
86
 
43
87
  /**
44
88
  * Build Codex TOML config, optionally injecting agent context env vars.
@@ -50,13 +94,10 @@ export const CODEX_TOML = '[mcp_servers.loreli]\ncommand = "npx"\nargs = ["lorel
50
94
  * @returns {string} TOML string for .codex/config.toml.
51
95
  */
52
96
  export function codexToml(context) {
53
- let toml = '[mcp_servers.loreli]\ncommand = "npx"\nargs = ["loreli", "mcp"]\n';
97
+ let toml = CODEX_TOML;
54
98
  if (context?.session) {
55
99
  toml += `\n[mcp_servers.loreli.env]\nLORELI_SESSION = "${context.session}"\nLORELI_AGENT = "${context.agent}"\nLORELI_REPO = "${context.repo}"\n`;
56
100
  if (context.home) toml += `LORELI_HOME = "${context.home}"\n`;
57
- // Codex env_vars whitelists parent process env vars for forwarding
58
- // to the MCP subprocess — no literal secret in the file.
59
- toml += 'env_vars = ["GITHUB_TOKEN"]\n';
60
101
  }
61
102
  return toml;
62
103
  }
@@ -314,12 +355,19 @@ export async function mirror(url, opts = {}) {
314
355
  authUrl = parsed.toString();
315
356
  }
316
357
 
358
+ const gitEnv = { env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, timeout: GIT_TIMEOUT };
359
+
360
+ let exists = false;
317
361
  try {
318
362
  await access(dir);
319
- await run('git', ['-C', dir, 'fetch', '--prune'], { env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } });
320
- } catch {
363
+ exists = true;
364
+ } catch { /* directory doesn't exist */ }
365
+
366
+ if (exists) {
367
+ await run('git', ['-C', dir, 'fetch', '--prune'], gitEnv);
368
+ } else {
321
369
  await mkdir(reposDir, { recursive: true });
322
- await run('git', ['clone', '--bare', authUrl, dir], { env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } });
370
+ await run('git', ['clone', '--bare', authUrl, dir], gitEnv);
323
371
  }
324
372
 
325
373
  return dir;
@@ -337,6 +385,7 @@ export async function mirror(url, opts = {}) {
337
385
  * @returns {string} Absolute path to the agent's workspace.
338
386
  */
339
387
  export function pathFor(name, root = DEFAULT_ROOT) {
388
+ assertSafe(name, 'agent name');
340
389
  return join(root, `loreli-${name}`);
341
390
  }
342
391
 
@@ -359,10 +408,10 @@ export function pathFor(name, root = DEFAULT_ROOT) {
359
408
  * - Cursor IDE: `${env:GITHUB_TOKEN}`
360
409
  * The file only contains a reference — never the literal secret.
361
410
  * @param {string} [opts.envFile] - Path to a `.env` file that the
362
- * MCP host loads at spawn time. Used for cursor-agent which does
363
- * not support `${env:NAME}` interpolation. The secret lives in
364
- * the referenced file (inside `.git/`, unstageable), not in the
365
- * JSON config itself.
411
+ * MCP host loads at spawn time. Used as a fallback for Cursor
412
+ * startup paths where interpolation may not be available. The
413
+ * secret lives in the referenced file (inside `.git/`,
414
+ * unstageable), not in the JSON config itself.
366
415
  * @returns {string} JSON string for .mcp.json files.
367
416
  */
368
417
  export function mcpJson(context, { tokenRef, envFile } = {}) {
@@ -432,7 +481,9 @@ function legacyDescriptors(context) {
432
481
  {
433
482
  configs: [{
434
483
  path: '.cursor/mcp.json',
435
- content: mcpJson(context, context ? { envFile: '.git/loreli.env' } : {}),
484
+ content: mcpJson(context, context ? {
485
+ envFile: '.git/loreli.env'
486
+ } : {}),
436
487
  marker: 'loreli',
437
488
  format: 'json'
438
489
  }],
@@ -669,7 +720,7 @@ export async function create(repo, branch, name, root = DEFAULT_ROOT, context, d
669
720
  const cloneArgs = ['clone', '--single-branch'];
670
721
  if (branch && branch !== 'HEAD') cloneArgs.push('--branch', branch);
671
722
  cloneArgs.push(repo, cwd);
672
- await run('git', cloneArgs);
723
+ await run('git', cloneArgs, { timeout: GIT_TIMEOUT });
673
724
 
674
725
  // Create a named branch for the agent. A named branch lets agents
675
726
  // commit and push without extra setup — no "HEAD (no branch)" state.
@@ -871,7 +922,7 @@ export async function reset(cwd, name, issue, base = 'main', opts = {}) {
871
922
  // tracked branch. When merge.base differs (e.g. 'loreli'), the ref
872
923
  // won't exist locally without an explicit refspec.
873
924
  await run('git', ['-C', cwd, 'fetch', 'origin',
874
- `+refs/heads/${base}:refs/remotes/origin/${base}`]);
925
+ `+refs/heads/${base}:refs/remotes/origin/${base}`], { timeout: GIT_TIMEOUT });
875
926
 
876
927
  // Discard any uncommitted changes from the previous run
877
928
  await run('git', ['-C', cwd, 'checkout', '--', '.']);
@@ -915,7 +966,7 @@ export async function reset(cwd, name, issue, base = 'main', opts = {}) {
915
966
  export async function checkout(cwd, branch, base = 'main', opts = {}) {
916
967
  await run('git', ['-C', cwd, 'fetch', 'origin',
917
968
  `+refs/heads/${branch}:refs/remotes/origin/${branch}`,
918
- `+refs/heads/${base}:refs/remotes/origin/${base}`]);
969
+ `+refs/heads/${base}:refs/remotes/origin/${base}`], { timeout: GIT_TIMEOUT });
919
970
  await run('git', ['-C', cwd, 'checkout', '--', '.']);
920
971
  try { await run('git', ['-C', cwd, 'clean', '-fd']); } catch { /* ok */ }
921
972
  await cleanScaffolding(cwd);
@@ -1018,7 +1069,7 @@ export async function commitAndPush(cwd, message) {
1018
1069
  // The caller decides whether push failure is fatal.
1019
1070
  let pushed = false;
1020
1071
  try {
1021
- await run('git', ['-C', cwd, 'push', '-u', 'origin', 'HEAD']);
1072
+ await run('git', ['-C', cwd, 'push', '-u', 'origin', 'HEAD'], { timeout: GIT_TIMEOUT });
1022
1073
  pushed = true;
1023
1074
  } catch { /* push failure is non-fatal — caller may retry */ }
1024
1075