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,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.
|