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,354 @@
1
+ /**
2
+ * Consolidated test infrastructure for loreli integration tests.
3
+ *
4
+ * Combines helpers from hub/test/helpers.js and mcp/test/helpers.js
5
+ * into a single canonical location. Uses real backends, real GitHub,
6
+ * and real agents — no stubs, no mocks.
7
+ */
8
+ import { mkdtemp } from 'node:fs/promises';
9
+ import { join } from 'node:path';
10
+ import { tmpdir } from 'node:os';
11
+ import { GitHubHub, definitions } from 'loreli/hub';
12
+ import { Config } from 'loreli/config';
13
+ import { Identity, Registry } from 'loreli/identity';
14
+ import { BackendRegistry } from 'loreli/agent';
15
+ import { Storage } from 'loreli/session';
16
+ import { Tmux } from 'loreli/tmux';
17
+
18
+ /**
19
+ * Test repository — set via LORELI_TEST_REPO env var.
20
+ * No default is provided to avoid leaking org-specific names.
21
+ *
22
+ * @type {string|undefined}
23
+ */
24
+ export const TEST_REPO = process.env.LORELI_TEST_REPO;
25
+
26
+ /**
27
+ * Tmux session name used by all test agents. Isolated from the
28
+ * production `loreli` session to prevent cross-contamination.
29
+ *
30
+ * Appends process.pid so parallel test processes get their own
31
+ * tmux sessions — prevents "can't find pane" and "duplicate session"
32
+ * errors when multiple test files share the same name.
33
+ *
34
+ * @type {string}
35
+ */
36
+ export const TEST_SESSION = `loreli-test-${process.pid}`;
37
+
38
+ // ── Identity & Labels ───────────────────────────────────
39
+
40
+ /**
41
+ * Create a stable test identity for integration tests.
42
+ * All automated test interactions use this identity so every
43
+ * issue, PR, comment, and review carries a signature and labels.
44
+ *
45
+ * @returns {Identity} A test-runner identity.
46
+ */
47
+ export function testIdentity() {
48
+ return new Identity({
49
+ name: 'test-runner', instance: 0, faction: 'autobots',
50
+ provider: 'loreli', model: 'integration-test', theme: 'transformers'
51
+ });
52
+ }
53
+
54
+ /**
55
+ * Ensure the test identity's labels exist in the test repository.
56
+ * Must be called once before any scoped hub operations in tests.
57
+ *
58
+ * @param {GitHubHub} hub - Real hub instance.
59
+ * @param {string} repo - "owner/name" repository string.
60
+ * @returns {Promise<void>}
61
+ */
62
+ export async function ensureTestLabels(hub, repo) {
63
+ const id = testIdentity();
64
+ await hub.ensure(repo, definitions(id.labels('action')));
65
+ }
66
+
67
+ // ── Skip Guards ─────────────────────────────────────────
68
+
69
+ /**
70
+ * Resolve the current test mode from the environment.
71
+ *
72
+ * @returns {string} Normalized test mode string.
73
+ */
74
+ function testMode() {
75
+ const raw = process.env.LORELI_TEST_MODE ?? 'full';
76
+ const normalized = raw.trim().toLowerCase();
77
+ return normalized.length ? normalized : 'full';
78
+ }
79
+
80
+ /**
81
+ * Return a skip reason when agentic tests are disabled.
82
+ *
83
+ * @returns {string|false} Skip reason string or false when enabled.
84
+ */
85
+ export function agenticSkipReason() {
86
+ if (testMode() !== 'unit') return false;
87
+ return 'Agentic tests disabled (LORELI_TEST_MODE=unit)';
88
+ }
89
+
90
+ /**
91
+ * Skip guard — call at the start of tests that need GitHub.
92
+ *
93
+ * @param {object} t - Test context with skip().
94
+ * @returns {boolean} True if the test should be skipped.
95
+ */
96
+ export function skipWithoutToken(t) {
97
+ if (!process.env.GITHUB_TOKEN) {
98
+ t.skip('GITHUB_TOKEN not set');
99
+ return true;
100
+ }
101
+ return false;
102
+ }
103
+
104
+ /**
105
+ * Skip guard — call at the start of tests that need a test repo.
106
+ *
107
+ * @param {object} t - Test context with skip().
108
+ * @returns {boolean} True if the test should be skipped.
109
+ */
110
+ export function skipWithoutTestRepo(t) {
111
+ if (!TEST_REPO) {
112
+ t.skip('LORELI_TEST_REPO not set');
113
+ return true;
114
+ }
115
+ return false;
116
+ }
117
+
118
+ /**
119
+ * Skip guard for tests that need real backends (claude, codex, etc.)
120
+ * on PATH and a working tmux install. Discovers backends and returns
121
+ * true if none are found or tmux is missing.
122
+ *
123
+ * @param {object} t - Test context with skip().
124
+ * @param {BackendRegistry} backends - Backend registry instance.
125
+ * @returns {Promise<boolean>} True if the test should be skipped.
126
+ */
127
+ export async function skipWithoutBackends(t, backends) {
128
+ const reason = agenticSkipReason();
129
+ if (reason) {
130
+ t.skip(reason);
131
+ return true;
132
+ }
133
+ if (!Tmux.available()) {
134
+ t.skip('tmux not available');
135
+ return true;
136
+ }
137
+
138
+ await backends.discover();
139
+ if (!backends.available().length) {
140
+ t.skip('No CLI backends available (install claude or codex)');
141
+ return true;
142
+ }
143
+ return false;
144
+ }
145
+
146
+ // ── Factory Helpers ─────────────────────────────────────
147
+
148
+ /**
149
+ * Create a real GitHubHub instance from the environment token.
150
+ *
151
+ * @returns {GitHubHub}
152
+ */
153
+ export function createHub() {
154
+ return new GitHubHub({ token: process.env.GITHUB_TOKEN });
155
+ }
156
+
157
+ /**
158
+ * Create a real Config instance, optionally loading from the test repo.
159
+ *
160
+ * @param {GitHubHub} [hub] - Hub to load loreli.yml from.
161
+ * @param {object} [overrides] - Overrides to merge.
162
+ * @returns {Promise<Config>}
163
+ */
164
+ export async function createConfig(hub, overrides) {
165
+ const config = new Config();
166
+ if (hub) await config.load(hub, TEST_REPO);
167
+ if (overrides) config.merge(overrides);
168
+ return config;
169
+ }
170
+
171
+ /**
172
+ * Discover real backends and create a real agent for the given provider
173
+ * and role. Uses BackendRegistry.discover() to find what's on PATH,
174
+ * then creates a real agent via backendRegistry.create().
175
+ *
176
+ * Agents are assigned to the per-PID `loreli-test-<pid>` tmux
177
+ * session to keep test panes isolated from both production
178
+ * sessions and other parallel test processes.
179
+ *
180
+ * By default, `readyTimeout` is set to 0, which skips the CLI
181
+ * readiness poll (60s for Claude/Cursor). State-management tests
182
+ * don't need the CLI to initialize — they only need entries in the
183
+ * orchestrator's agent map. Tests that exercise real prompt delivery
184
+ * should pass `readyTimeout: 60000` (or omit 0) to restore the wait.
185
+ *
186
+ * @param {BackendRegistry} backends - Backend registry instance.
187
+ * @param {Registry} registry - Identity registry instance.
188
+ * @param {string} role - Agent role ('action', 'reviewer', 'planner').
189
+ * @param {string} [provider='openai'] - Provider for identity.
190
+ * @param {object} [opts] - Additional options.
191
+ * @param {string} [opts.backend] - Force a specific backend name.
192
+ * @param {string} [opts.prompt] - Override prompt text.
193
+ * @param {number} [opts.readyTimeout=0] - Max ms to wait for CLI readiness.
194
+ * @returns {Promise<object>} Real agent instance.
195
+ */
196
+ export async function createAgent(backends, registry, role, provider = 'openai', opts = {}) {
197
+ await backends.discover();
198
+
199
+ const identity = registry.acquire('transformers', provider);
200
+ const cwd = await mkdtemp(join(tmpdir(), `loreli-test-${identity.name}-`));
201
+ const name = opts.backend ?? backends.defaultBackend();
202
+ const config = new Config();
203
+
204
+ return backends.create(name, {
205
+ identity,
206
+ role,
207
+ cwd,
208
+ model: 'balanced',
209
+ session: TEST_SESSION,
210
+ prompt: opts.prompt,
211
+ readyTimeout: opts.readyTimeout ?? 0,
212
+ config
213
+ });
214
+ }
215
+
216
+ /**
217
+ * Create a temporary directory for test artifacts.
218
+ *
219
+ * @param {string} [prefix='loreli-test-'] - Directory name prefix.
220
+ * @returns {Promise<string>} Path to the temporary directory.
221
+ */
222
+ export async function createTmpDir(prefix = 'loreli-test-') {
223
+ return mkdtemp(join(tmpdir(), prefix));
224
+ }
225
+
226
+ // ── Tmux Session Management ─────────────────────────────
227
+
228
+ /**
229
+ * Reset the test tmux session. Kills an existing session and creates
230
+ * a fresh one with generous dimensions so split-window has enough
231
+ * space for multiple agent panes.
232
+ *
233
+ * Test sessions are created without a command (bare session) since
234
+ * tests explicitly call killTestSession() in teardown — no need for
235
+ * tmux auto-destruction here.
236
+ *
237
+ * Uses retry logic because tmux's kill-session is asynchronous —
238
+ * the session name may not be fully released by the time new-session
239
+ * runs, causing a "duplicate session" error.
240
+ *
241
+ * @returns {Promise<void>}
242
+ */
243
+ export async function resetTestSession() {
244
+ const { Tmux } = await import('loreli/tmux');
245
+ const tmux = new Tmux();
246
+
247
+ try { await tmux.kill(TEST_SESSION); } catch { /* may not exist */ }
248
+
249
+ for (let attempt = 0; attempt < 5; attempt++) {
250
+ try {
251
+ await tmux.create(TEST_SESSION, { width: 200, height: 50 });
252
+ return;
253
+ } catch (err) {
254
+ // tmux reports both "duplicate session" and "already exists" depending
255
+ // on version — match either. Without this, fast tests outrun tmux's
256
+ // async session teardown and the retry logic never fires.
257
+ if (!err.message?.includes('duplicate session') && !err.message?.includes('already exists')) throw err;
258
+ try { await tmux.kill(TEST_SESSION); } catch { /* ignore */ }
259
+ await new Promise(function wait(r) { setTimeout(r, 100 * (attempt + 1)); });
260
+ }
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Kill the test tmux session entirely. Call in suite-level after() hooks.
266
+ *
267
+ * @returns {Promise<void>}
268
+ */
269
+ export async function killTestSession() {
270
+ const { Tmux } = await import('loreli/tmux');
271
+ const tmux = new Tmux();
272
+ try {
273
+ if (await tmux.has(TEST_SESSION)) {
274
+ await tmux.kill(TEST_SESSION);
275
+ }
276
+ } catch { /* session may not exist */ }
277
+ }
278
+
279
+ // ── Cleanup ─────────────────────────────────────────────
280
+
281
+ /**
282
+ * Tracks GitHub resources created during integration tests and
283
+ * provides a flush() method to clean them all up.
284
+ */
285
+ export class Cleanup {
286
+ /**
287
+ * @param {GitHubHub} hub - Real hub instance.
288
+ * @param {string} repo - "owner/name" repository string.
289
+ */
290
+ constructor(hub, repo) {
291
+ this.hub = hub;
292
+ this.repo = repo;
293
+
294
+ /** @type {number[]} Issue numbers to close. */
295
+ this.issues = [];
296
+
297
+ /** @type {number[]} PR numbers to close. */
298
+ this.prs = [];
299
+
300
+ /** @type {string[]} Branch names to delete. */
301
+ this.branches = [];
302
+
303
+ /** @type {string[]} Discussion node IDs to delete. */
304
+ this.discussions = [];
305
+
306
+ /** @type {string[]} Label names to delete. */
307
+ this.labels = [];
308
+ }
309
+
310
+ /**
311
+ * Clean up all tracked resources. Errors are swallowed — best-effort
312
+ * cleanup should not cause test failures.
313
+ */
314
+ async flush() {
315
+ const [owner, name] = this.repo.split('/');
316
+
317
+ for (const num of this.prs) {
318
+ try {
319
+ await this.hub.client.pulls.update({ owner, repo: name, pull_number: num, state: 'closed' });
320
+ } catch { /* best-effort */ }
321
+ }
322
+
323
+ for (const num of this.issues) {
324
+ try {
325
+ await this.hub.client.issues.update({ owner, repo: name, issue_number: num, state: 'closed' });
326
+ } catch { /* best-effort */ }
327
+ }
328
+
329
+ for (const ref of this.branches) {
330
+ try {
331
+ await this.hub.client.git.deleteRef({ owner, repo: name, ref: `heads/${ref}` });
332
+ } catch { /* best-effort */ }
333
+ }
334
+
335
+ for (const discId of this.discussions) {
336
+ try { await this.hub.deleteDiscussion(discId); } catch { /* best-effort */ }
337
+ }
338
+
339
+ for (const label of this.labels) {
340
+ try {
341
+ await this.hub.client.issues.deleteLabel({ owner, repo: name, name: label });
342
+ } catch { /* best-effort */ }
343
+ }
344
+
345
+ this.issues.length = 0;
346
+ this.prs.length = 0;
347
+ this.branches.length = 0;
348
+ this.discussions.length = 0;
349
+ this.labels.length = 0;
350
+ }
351
+ }
352
+
353
+ // Re-export dependencies for convenience
354
+ export { GitHubHub, Config, Identity, Registry, BackendRegistry, Storage };
@@ -0,0 +1,261 @@
1
+ # loreli/tmux
2
+
3
+ Zero-dependency Node.js wrapper for tmux with pane-level control and automatic session lifecycle management.
4
+
5
+ This package exists because no adequate Node.js tmux library provides pane-level management. It wraps the `tmux` CLI binary with a clean async API, mapping each method ~1:1 to a tmux command.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pnpm add loreli
11
+ ```
12
+
13
+ Requires `tmux` to be installed on the system (`brew install tmux` on macOS, `apt install tmux` on Linux).
14
+
15
+ ## Session Lifecycle
16
+
17
+ Sessions created via `create()` are tracked in an in-memory set. This enables batch cleanup via `cleanup()` when the orchestrator shuts down gracefully. For crash recovery where in-memory state is lost, `prune()` queries tmux directly by session-name prefix.
18
+
19
+ The key design insight is **no garbage default windows**. When `create()` is called with a `command` option, that command becomes the initial window's process. tmux auto-destroys a session when its last window dies, so when all agents exit, the session disappears naturally — no orphaned sessions.
20
+
21
+ The following example shows the recommended spawn pattern used by Loreli's backends. The first agent creates the session, and subsequent agents join as additional windows.
22
+
23
+ ```js
24
+ import { Tmux } from 'loreli/tmux';
25
+
26
+ const tmux = new Tmux();
27
+
28
+ // First agent IS the session's initial window — no garbage default window
29
+ const pane1 = await tmux.create('loreli', { cwd: '/tmp', command: './agent1.sh' });
30
+
31
+ // Second agent joins as an additional window
32
+ const pane2 = await tmux.window('loreli', { cwd: '/tmp', command: './agent2.sh' });
33
+
34
+ // Both agents die → tmux auto-destroys the session. No orphans.
35
+ ```
36
+
37
+ ## API Reference
38
+
39
+ ### `Tmux.available()`
40
+
41
+ Static method. Returns `true` if tmux is on PATH, `false` otherwise. Does not throw.
42
+
43
+ ```js
44
+ import { Tmux } from 'loreli/tmux';
45
+
46
+ if (Tmux.available()) {
47
+ const tmux = new Tmux();
48
+ }
49
+ ```
50
+
51
+ ### `new Tmux()`
52
+
53
+ Creates a new Tmux instance. All subsequent methods are called on this instance. Each instance maintains its own set of tracked sessions.
54
+
55
+ ### Session Management
56
+
57
+ #### `tmux.create(name, opts?)` → `Promise<string|void>`
58
+
59
+ Create a new detached tmux session. Throws if a session with the same name already exists.
60
+
61
+ When `opts.command` is provided, it becomes the initial window's process and the pane ID is returned. When no command is provided, a bare session with an empty default window is created (useful for test sessions with specific dimensions).
62
+
63
+ - `name` `{string}` — The session name.
64
+ - `opts.width` `{number}` — Initial window width (tmux `-x` flag).
65
+ - `opts.height` `{number}` — Initial window height (tmux `-y` flag).
66
+ - `opts.cwd` `{string}` — Working directory for the initial window.
67
+ - `opts.command` `{string}` — Command for the initial window.
68
+
69
+ The following example shows creating a session with an agent command as its initial window. The pane ID is returned so you can capture output or set options on the pane.
70
+
71
+ ```js
72
+ // Agent command IS the initial window — no garbage default window
73
+ const paneId = await tmux.create('loreli', {
74
+ cwd: '/tmp/workspace',
75
+ command: '/path/to/launch.sh'
76
+ });
77
+
78
+ // Test session with specific dimensions (no command — bare session)
79
+ await tmux.create('test-session', { width: 200, height: 50 });
80
+ ```
81
+
82
+ #### `tmux.kill(name)` → `Promise<void>`
83
+
84
+ Kill (destroy) a tmux session. Throws if the session does not exist. Removes the session from the tracked set.
85
+
86
+ #### `tmux.list()` → `Promise<string[]>`
87
+
88
+ List all active tmux session names. Returns an empty array when no server is running.
89
+
90
+ #### `tmux.has(name)` → `Promise<boolean>`
91
+
92
+ Check whether a session with the given name exists.
93
+
94
+ #### `tmux.owned()` → `string[]`
95
+
96
+ Return session names created by this Tmux instance. Synchronous — reads from the in-memory tracking set.
97
+
98
+ ```js
99
+ const tmux = new Tmux();
100
+ await tmux.create('session-a');
101
+ await tmux.create('session-b');
102
+ tmux.owned(); // ['session-a', 'session-b']
103
+ ```
104
+
105
+ #### `tmux.cleanup()` → `Promise<void>`
106
+
107
+ Kill all sessions created by this instance. Best-effort — errors on individual sessions are swallowed so one stuck session does not prevent others from being cleaned. Clears the tracking set.
108
+
109
+ This is the recommended path for graceful orchestrator shutdown.
110
+
111
+ ```js
112
+ // In graceful shutdown handler
113
+ await tmux.cleanup();
114
+ ```
115
+
116
+ #### `tmux.prune(prefix?)` → `Promise<string[]>`
117
+
118
+ Kill all tmux sessions whose name starts with the given prefix (default: `'loreli'`). Queries tmux directly — does not rely on in-memory tracking. Returns the names of sessions that were killed.
119
+
120
+ This is the crash-recovery / manual-reset path. It is destructive: any matching session is killed regardless of whether its agents are still doing useful work.
121
+
122
+ ```js
123
+ // After a crash, clean up all loreli-prefixed sessions
124
+ const killed = await tmux.prune('loreli');
125
+ // ['loreli', 'loreli-debug', 'loreli-agent-test-12345']
126
+ ```
127
+
128
+ ### Stale Session Detection
129
+
130
+ #### `tmux.stale(name)` → `Promise<boolean>`
131
+
132
+ Check whether a session exists but was not created by this instance. A session is stale when it survives from a previous MCP server process — the current Tmux instance has no record of it because in-memory tracking is per-process. Stale sessions contain agent panes running with outdated environment variables (API keys, budgets, tokens).
133
+
134
+ The following example shows the pattern start uses to detect leftover sessions before spawning new agents.
135
+
136
+ ```js
137
+ const tmux = new Tmux();
138
+ if (await tmux.stale('loreli')) {
139
+ // Session exists from a dead MCP server — agents inside have stale env
140
+ }
141
+ ```
142
+
143
+ #### `tmux.reap(name)` → `Promise<{killed: boolean, panes: number}>`
144
+
145
+ Destroy a stale session and report what was killed. Only acts on sessions not owned by this instance — owned sessions are left untouched, preventing accidental self-destruction during a start re-run within the same process.
146
+
147
+ Returns `{killed: true, panes: N}` when a stale session was destroyed, or `{killed: false, panes: 0}` when the session doesn't exist or belongs to this instance.
148
+
149
+ The following example shows how start reaps stale tmux sessions during its cleanup phase. The pane count is logged so operators know how many ghost agents were terminated.
150
+
151
+ ```js
152
+ const tmux = new Tmux();
153
+ const result = await tmux.reap('loreli');
154
+ if (result.killed) {
155
+ console.log(`reaped stale session (${result.panes} panes)`);
156
+ }
157
+ ```
158
+
159
+ ### Window Management
160
+
161
+ #### `tmux.window(session, opts?)` → `Promise<string>`
162
+
163
+ Create a new window in an existing session and return its pane ID (e.g. `%3`). Each agent gets its own full-size window instead of splitting an existing one, avoiding the `no space for new pane` error.
164
+
165
+ When `opts.command` is provided, the process runs directly without an intermediate shell, avoiding `.zshrc` initialization prompts.
166
+
167
+ - `session` `{string}` — The target session name.
168
+ - `opts.cwd` `{string}` — Working directory for the new window.
169
+ - `opts.command` `{string}` — Shell command to run directly.
170
+
171
+ ```js
172
+ const paneId = await tmux.window('loreli', {
173
+ cwd: '/tmp/workspace',
174
+ command: '/path/to/launch.sh'
175
+ });
176
+ ```
177
+
178
+ ### Pane Management
179
+
180
+ #### `tmux.split(session, opts?)` → `Promise<string>`
181
+
182
+ Split the current window into a new pane. Returns the new pane ID (e.g. `%3`).
183
+
184
+ - `opts.cwd` `{string}` — Working directory for the new pane.
185
+ - `opts.vertical` `{boolean}` — Split vertically instead of horizontally.
186
+
187
+ #### `tmux.send(target, text)` → `Promise<void>`
188
+
189
+ Send keystrokes followed by Enter to a pane. The `target` is a pane ID (e.g. `%3`).
190
+
191
+ #### `tmux.keys(target, ...keys)` → `Promise<void>`
192
+
193
+ Send raw key sequences to a pane without appending Enter. Useful for TUI navigation (arrow keys, Escape, Tab). Each argument is a tmux key name: `Down`, `Up`, `Enter`, `Escape`, etc.
194
+
195
+ The following example shows navigating a TUI dialog, which is how the Claude backend accepts its bypass-permissions confirmation.
196
+
197
+ ```js
198
+ await tmux.keys(paneId, 'Down'); // Move to "Yes, I accept"
199
+ await tmux.keys(paneId, 'Enter'); // Confirm selection
200
+ ```
201
+
202
+ #### `tmux.capture(target, lines?)` → `Promise<string>`
203
+
204
+ Capture the visible content of a pane. Uses `-J` to join wrapped lines. Default capture depth is 500 lines.
205
+
206
+ #### `tmux.command(target)` → `Promise<string>`
207
+
208
+ Query the current foreground command running in a pane. Returns the process name (e.g. `'zsh'`, `'claude'`, `'codex'`), or empty string if the pane is dead.
209
+
210
+ ```js
211
+ const cmd = await tmux.command(paneId);
212
+ if (cmd === 'claude') { /* agent is still running */ }
213
+ ```
214
+
215
+ #### `tmux.panes(session)` → `Promise<Array<{id, pid, active, title}>>`
216
+
217
+ List panes in a session with metadata.
218
+
219
+ #### `tmux.alive(target)` → `Promise<boolean>`
220
+
221
+ Check whether a specific pane still exists and its process is running. Uses `list-panes` with filtering for reliable detection.
222
+
223
+ #### `tmux.killPane(target)` → `Promise<void>`
224
+
225
+ Kill a specific pane by ID.
226
+
227
+ #### `tmux.set(target, option, value)` → `Promise<void>`
228
+
229
+ Set a pane-level option. Commonly used with `remain-on-exit` for agent backends:
230
+
231
+ ```js
232
+ await tmux.set(paneId, 'remain-on-exit', 'on');
233
+ ```
234
+
235
+ ## Error Handling
236
+
237
+ | Scenario | Error |
238
+ |----------|-------|
239
+ | tmux not installed | `Tmux.available()` returns `false`; instance methods throw with the underlying stderr message |
240
+ | Session already exists | `create()` throws `Session "<name>" already exists` |
241
+ | Session not found | `kill()` throws with tmux's error message |
242
+ | Pane not found | `killPane()`, `send()`, `capture()` throw with tmux's error message |
243
+
244
+ ## Configuration via loreli/config
245
+
246
+ When used through Loreli's orchestration layer, the following tmux settings are configurable via `loreli.yml`:
247
+
248
+ | Config Key | Default | Description |
249
+ |------------|---------|-------------|
250
+ | `tmux.session` | `loreli` | Tmux session name used by `CliAgent` |
251
+ | `tmux.capture` | `500` | Number of history lines captured by `capture()` |
252
+
253
+ These values are resolved through `loreli/config`'s four-layer resolution (start params > `loreli.yml` > env vars > defaults). When using `loreli/tmux` directly without the orchestration layer, the defaults above apply.
254
+
255
+ ## Testing
256
+
257
+ ```bash
258
+ node --test packages/tmux/test/index.test.js
259
+ ```
260
+
261
+ Tests require tmux to be installed on the system.