loreli 0.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.
- package/LICENSE +1 -1
- package/README.md +710 -97
- package/bin/loreli.js +89 -0
- package/package.json +77 -14
- package/packages/README.md +101 -0
- package/packages/action/README.md +98 -0
- package/packages/action/prompts/action.md +172 -0
- package/packages/action/src/index.js +684 -0
- package/packages/agent/README.md +606 -0
- package/packages/agent/src/backends/claude.js +387 -0
- package/packages/agent/src/backends/codex.js +351 -0
- package/packages/agent/src/backends/cursor.js +371 -0
- package/packages/agent/src/backends/index.js +486 -0
- package/packages/agent/src/base.js +138 -0
- package/packages/agent/src/cli.js +275 -0
- package/packages/agent/src/discover.js +396 -0
- package/packages/agent/src/factory.js +124 -0
- package/packages/agent/src/index.js +12 -0
- package/packages/agent/src/models.js +159 -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/classify/README.md +136 -0
- package/packages/classify/prompts/blocker.md +12 -0
- package/packages/classify/prompts/feedback.md +14 -0
- package/packages/classify/prompts/pane-state.md +20 -0
- package/packages/classify/src/index.js +81 -0
- package/packages/config/README.md +898 -0
- package/packages/config/src/defaults.js +145 -0
- package/packages/config/src/index.js +223 -0
- package/packages/config/src/schema.js +291 -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 +1597 -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 +217 -0
- package/packages/knowledge/src/index.js +243 -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 +323 -0
- package/packages/mcp/instructions.md +126 -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 +491 -0
- package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +4 -0
- package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +14 -0
- package/packages/mcp/scaffolding/mcp-configs/.mcp.json +14 -0
- package/packages/mcp/scaffolding/pull-request.md +23 -0
- package/packages/mcp/src/index.js +600 -0
- package/packages/mcp/src/tools/agent-context.js +44 -0
- package/packages/mcp/src/tools/agents.js +450 -0
- package/packages/mcp/src/tools/context.js +200 -0
- package/packages/mcp/src/tools/github.js +1163 -0
- package/packages/mcp/src/tools/hitl.js +162 -0
- package/packages/mcp/src/tools/index.js +18 -0
- package/packages/mcp/src/tools/refactor.js +227 -0
- package/packages/mcp/src/tools/repo.js +44 -0
- package/packages/mcp/src/tools/start.js +904 -0
- package/packages/mcp/src/tools/status.js +149 -0
- package/packages/mcp/src/tools/work.js +134 -0
- package/packages/orchestrator/README.md +192 -0
- package/packages/orchestrator/src/index.js +1492 -0
- package/packages/planner/README.md +251 -0
- package/packages/planner/prompts/plan-reviewer.md +109 -0
- package/packages/planner/prompts/planner.md +191 -0
- package/packages/planner/prompts/tiebreaker-reviewer.md +71 -0
- package/packages/planner/src/index.js +1381 -0
- package/packages/review/README.md +129 -0
- package/packages/review/prompts/reviewer.md +158 -0
- package/packages/review/src/index.js +1403 -0
- package/packages/risk/README.md +178 -0
- package/packages/risk/prompts/risk.md +272 -0
- package/packages/risk/src/index.js +439 -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 +501 -0
- package/packages/workflow/README.md +317 -0
- package/packages/workflow/prompts/preamble.md +14 -0
- package/packages/workflow/src/index.js +660 -0
- package/packages/workflow/src/proof-of-life.js +74 -0
- package/packages/workspace/README.md +143 -0
- package/packages/workspace/src/index.js +1127 -0
- package/index.js +0 -8
|
@@ -0,0 +1,501 @@
|
|
|
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';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
|
|
8
|
+
const execFile = promisify(execFileCb);
|
|
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
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Clean, modern, ESM Node.js wrapper for tmux with pane-level control.
|
|
19
|
+
*
|
|
20
|
+
* Every method maps ~1:1 to a tmux CLI command using `node:child_process`.
|
|
21
|
+
* Zero npm dependencies.
|
|
22
|
+
*
|
|
23
|
+
* Sessions created via {@link Tmux#create} are tracked in an in-memory
|
|
24
|
+
* set so they can be batch-killed via {@link Tmux#cleanup}. For crash
|
|
25
|
+
* recovery where in-memory state is lost, {@link Tmux#prune} queries
|
|
26
|
+
* tmux directly by session-name prefix.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```js
|
|
30
|
+
* import { Tmux } from 'loreli/tmux';
|
|
31
|
+
*
|
|
32
|
+
* const tmux = new Tmux();
|
|
33
|
+
*
|
|
34
|
+
* // First agent IS the session's initial window — no garbage default window.
|
|
35
|
+
* const paneId = await tmux.create('my-session', { cwd: '/tmp', command: './run.sh' });
|
|
36
|
+
* await tmux.send(paneId, 'echo hello');
|
|
37
|
+
* const output = await tmux.capture(paneId);
|
|
38
|
+
*
|
|
39
|
+
* // Subsequent agents join as additional windows.
|
|
40
|
+
* const pane2 = await tmux.window('my-session', { cwd: '/tmp', command: './run2.sh' });
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export class Tmux {
|
|
44
|
+
/** @type {Set<string>} Sessions created by this instance. */
|
|
45
|
+
#sessions = new Set();
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Check whether tmux is available on PATH without throwing.
|
|
49
|
+
*
|
|
50
|
+
* @returns {boolean} True if the tmux binary is reachable.
|
|
51
|
+
*/
|
|
52
|
+
static available() {
|
|
53
|
+
try {
|
|
54
|
+
execFileSync('which', ['tmux'], { stdio: 'ignore' });
|
|
55
|
+
return true;
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Execute a tmux command and return trimmed stdout.
|
|
63
|
+
*
|
|
64
|
+
* @param {string[]} args - Arguments to pass to the tmux binary.
|
|
65
|
+
* @returns {Promise<string>} Trimmed stdout from the command.
|
|
66
|
+
* @throws {Error} When the tmux command exits with non-zero status.
|
|
67
|
+
*/
|
|
68
|
+
async #exec(args) {
|
|
69
|
+
try {
|
|
70
|
+
const { stdout } = await execFile('tmux', args);
|
|
71
|
+
return stdout.trimEnd();
|
|
72
|
+
} catch (err) {
|
|
73
|
+
throw new Error(err.stderr?.trim() || err.message);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Session Management ──────────────────────────────────
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Create a new detached tmux session.
|
|
81
|
+
*
|
|
82
|
+
* When `opts.command` is provided, it becomes the initial window's
|
|
83
|
+
* process — no empty default window is created. This lets tmux
|
|
84
|
+
* auto-destroy the session when all windows die, preventing orphans.
|
|
85
|
+
*
|
|
86
|
+
* When called without a command, a bare session with an empty default
|
|
87
|
+
* window is created (useful for test sessions with specific dimensions).
|
|
88
|
+
*
|
|
89
|
+
* @param {string} name - The session name.
|
|
90
|
+
* @param {object} [opts] - Session options.
|
|
91
|
+
* @param {number} [opts.width] - Initial window width (tmux -x flag).
|
|
92
|
+
* @param {number} [opts.height] - Initial window height (tmux -y flag).
|
|
93
|
+
* @param {string} [opts.cwd] - Working directory for the initial window.
|
|
94
|
+
* @param {string} [opts.command] - Command for the initial window.
|
|
95
|
+
* @returns {Promise<string|void>} Pane ID when command is provided, void otherwise.
|
|
96
|
+
* @throws {Error} When a session with the same name already exists.
|
|
97
|
+
*/
|
|
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
|
+
|
|
102
|
+
if (await this.has(name)) {
|
|
103
|
+
throw new Error(`Session "${name}" already exists`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const args = ['new-session', '-d', '-s', name];
|
|
107
|
+
|
|
108
|
+
if (opts.width) args.push('-x', String(opts.width));
|
|
109
|
+
if (opts.height) args.push('-y', String(opts.height));
|
|
110
|
+
|
|
111
|
+
if (opts.command) {
|
|
112
|
+
args.push('-P', '-F', '#{pane_id}');
|
|
113
|
+
if (opts.cwd) args.push('-c', opts.cwd);
|
|
114
|
+
args.push(opts.command);
|
|
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
|
+
}
|
|
122
|
+
this.#sessions.add(name);
|
|
123
|
+
return paneId;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (opts.cwd) args.push('-c', opts.cwd);
|
|
127
|
+
await this.#exec(args);
|
|
128
|
+
this.#sessions.add(name);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Kill (destroy) a tmux session.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} name - The session name to kill.
|
|
135
|
+
* @returns {Promise<void>}
|
|
136
|
+
* @throws {Error} When the session does not exist.
|
|
137
|
+
*/
|
|
138
|
+
async kill(name) {
|
|
139
|
+
await this.#exec(['kill-session', '-t', name]);
|
|
140
|
+
this.#sessions.delete(name);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* List all active tmux session names.
|
|
145
|
+
*
|
|
146
|
+
* @returns {Promise<string[]>} Array of session name strings.
|
|
147
|
+
*/
|
|
148
|
+
async list() {
|
|
149
|
+
try {
|
|
150
|
+
const output = await this.#exec(['list-sessions', '-F', '#{session_name}']);
|
|
151
|
+
if (!output) return [];
|
|
152
|
+
return output.split('\n').filter(Boolean);
|
|
153
|
+
} catch {
|
|
154
|
+
// tmux returns error when no server is running
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Check whether a session with the given name exists.
|
|
161
|
+
*
|
|
162
|
+
* @param {string} name - The session name to check.
|
|
163
|
+
* @returns {Promise<boolean>} True if the session exists.
|
|
164
|
+
*/
|
|
165
|
+
async has(name) {
|
|
166
|
+
try {
|
|
167
|
+
await this.#exec(['has-session', '-t', name]);
|
|
168
|
+
return true;
|
|
169
|
+
} catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Return session names created by this Tmux instance.
|
|
176
|
+
*
|
|
177
|
+
* @returns {string[]} Array of tracked session names.
|
|
178
|
+
*/
|
|
179
|
+
owned() {
|
|
180
|
+
return [...this.#sessions];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Kill all sessions created by this instance. Best-effort — errors
|
|
185
|
+
* on individual sessions are swallowed so one stuck session does
|
|
186
|
+
* not prevent others from being cleaned. Clears the tracking set.
|
|
187
|
+
*
|
|
188
|
+
* @returns {Promise<void>}
|
|
189
|
+
*/
|
|
190
|
+
async cleanup() {
|
|
191
|
+
for (const name of this.#sessions) {
|
|
192
|
+
try { await this.kill(name); } catch { /* session may already be gone */ }
|
|
193
|
+
}
|
|
194
|
+
this.#sessions.clear();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Kill all tmux sessions whose name starts with the given prefix.
|
|
199
|
+
* Queries tmux directly — does not rely on in-memory tracking.
|
|
200
|
+
*
|
|
201
|
+
* This is the crash-recovery / manual-reset path. It is destructive:
|
|
202
|
+
* any matching session is killed regardless of whether its agents are
|
|
203
|
+
* still doing useful work. For graceful lifecycle cleanup, prefer
|
|
204
|
+
* {@link Tmux#cleanup} which targets only sessions this instance created.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} [prefix='loreli'] - Session name prefix to match.
|
|
207
|
+
* @returns {Promise<string[]>} Names of sessions that were killed.
|
|
208
|
+
*/
|
|
209
|
+
async prune(prefix = 'loreli') {
|
|
210
|
+
const all = await this.list();
|
|
211
|
+
const targets = all.filter(function matches(n) { return n.startsWith(prefix); });
|
|
212
|
+
const killed = [];
|
|
213
|
+
|
|
214
|
+
for (const name of targets) {
|
|
215
|
+
try {
|
|
216
|
+
await this.kill(name);
|
|
217
|
+
killed.push(name);
|
|
218
|
+
} catch { /* session may have died between list and kill */ }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return killed;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── Stale Session Detection ─────────────────────────────
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Check whether a session exists but was not created by this instance.
|
|
228
|
+
*
|
|
229
|
+
* A session is stale when it survives from a previous MCP server
|
|
230
|
+
* process. The current Tmux instance has no record of it because
|
|
231
|
+
* in-memory tracking is per-process. Stale sessions contain agent
|
|
232
|
+
* panes running with outdated environment variables (API keys,
|
|
233
|
+
* budgets, tokens) — spawning new agents into them inherits the
|
|
234
|
+
* old environment from the tmux server.
|
|
235
|
+
*
|
|
236
|
+
* @param {string} name - Session name to check.
|
|
237
|
+
* @returns {Promise<boolean>} True when the session exists and is unowned.
|
|
238
|
+
*/
|
|
239
|
+
async stale(name) {
|
|
240
|
+
if (this.#sessions.has(name)) return false;
|
|
241
|
+
return this.has(name);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Destroy a stale session and report what was killed.
|
|
246
|
+
*
|
|
247
|
+
* Only acts on sessions not owned by this instance. Owned sessions
|
|
248
|
+
* are left untouched — this prevents accidental self-destruction
|
|
249
|
+
* during a start re-run within the same process.
|
|
250
|
+
*
|
|
251
|
+
* @param {string} name - Session name to reap.
|
|
252
|
+
* @returns {Promise<{killed: boolean, panes: number}>} Whether the
|
|
253
|
+
* session was destroyed and how many panes it contained.
|
|
254
|
+
*/
|
|
255
|
+
async reap(name) {
|
|
256
|
+
if (this.#sessions.has(name)) return { killed: false, panes: 0 };
|
|
257
|
+
if (!await this.has(name)) return { killed: false, panes: 0 };
|
|
258
|
+
|
|
259
|
+
let paneCount = 0;
|
|
260
|
+
try {
|
|
261
|
+
// -s lists panes across ALL windows in the session, not just the active one
|
|
262
|
+
const fmt = '#{pane_id}';
|
|
263
|
+
const output = await this.#exec(['list-panes', '-s', '-t', name, '-F', fmt]);
|
|
264
|
+
paneCount = output.split('\n').filter(Boolean).length;
|
|
265
|
+
} catch { /* session may be in a bad state — kill it anyway */ }
|
|
266
|
+
|
|
267
|
+
await this.kill(name);
|
|
268
|
+
return { killed: true, panes: paneCount };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── Pane Management ─────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Create a new window in the session and return its pane ID.
|
|
275
|
+
*
|
|
276
|
+
* Each agent gets its own full-size window instead of splitting an
|
|
277
|
+
* existing one. This avoids the `no space for new pane` error that
|
|
278
|
+
* occurs when too many agents share a single window via split-window.
|
|
279
|
+
*
|
|
280
|
+
* When `opts.command` is provided, it becomes the window's shell
|
|
281
|
+
* command — the process runs directly without an intermediate shell.
|
|
282
|
+
* This avoids issues with `.zshrc` prompts or other shell
|
|
283
|
+
* initialization that can intercept the first send-keys input.
|
|
284
|
+
*
|
|
285
|
+
* @param {string} session - The target session name.
|
|
286
|
+
* @param {object} [opts] - Window options.
|
|
287
|
+
* @param {string} [opts.cwd] - Working directory for the new window.
|
|
288
|
+
* @param {string} [opts.command] - Shell command to run directly.
|
|
289
|
+
* @returns {Promise<string>} The new pane ID (e.g. "%3").
|
|
290
|
+
*/
|
|
291
|
+
async window(session, opts = {}) {
|
|
292
|
+
const args = ['new-window', '-d', '-P', '-F', '#{pane_id}', '-t', session];
|
|
293
|
+
|
|
294
|
+
if (opts.cwd) args.push('-c', opts.cwd);
|
|
295
|
+
if (opts.command) args.push(opts.command);
|
|
296
|
+
|
|
297
|
+
return this.#exec(args);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Split the current window into a new pane.
|
|
302
|
+
*
|
|
303
|
+
* @param {string} session - The target session name.
|
|
304
|
+
* @param {object} [opts] - Split options.
|
|
305
|
+
* @param {string} [opts.cwd] - Working directory for the new pane.
|
|
306
|
+
* @param {boolean} [opts.vertical] - Split vertically instead of horizontally.
|
|
307
|
+
* @returns {Promise<string>} The new pane ID (e.g. "%3").
|
|
308
|
+
*/
|
|
309
|
+
async split(session, opts = {}) {
|
|
310
|
+
const args = ['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', session];
|
|
311
|
+
|
|
312
|
+
if (opts.vertical) args.push('-h');
|
|
313
|
+
if (opts.cwd) args.push('-c', opts.cwd);
|
|
314
|
+
|
|
315
|
+
return this.#exec(args);
|
|
316
|
+
}
|
|
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
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Send text to a pane followed by Enter.
|
|
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
|
+
*
|
|
334
|
+
* Text and Enter are sent as separate operations with a brief delay.
|
|
335
|
+
* TUI applications process keystrokes asynchronously — splitting
|
|
336
|
+
* guarantees Enter arrives in a separate stdin data event.
|
|
337
|
+
*
|
|
338
|
+
* @param {string} target - Pane ID or session:window.pane target.
|
|
339
|
+
* @param {string} text - The text to send.
|
|
340
|
+
* @returns {Promise<void>}
|
|
341
|
+
*/
|
|
342
|
+
async send(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
|
+
|
|
361
|
+
await new Promise(function settle(r) { setTimeout(r, 200); });
|
|
362
|
+
await this.#exec(['send-keys', '-t', target, 'Enter']);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Send raw key sequences to a pane without appending Enter.
|
|
367
|
+
*
|
|
368
|
+
* Useful for TUI navigation (arrow keys, Escape, Tab) where an
|
|
369
|
+
* automatic Enter would confirm the wrong selection. Each argument
|
|
370
|
+
* is a tmux key name: `Down`, `Up`, `Enter`, `Escape`, etc.
|
|
371
|
+
*
|
|
372
|
+
* @param {string} target - Pane ID or session:window.pane target.
|
|
373
|
+
* @param {...string} keys - One or more tmux key names to send.
|
|
374
|
+
* @returns {Promise<void>}
|
|
375
|
+
*/
|
|
376
|
+
async keys(target, ...keys) {
|
|
377
|
+
await this.#exec(['send-keys', '-t', target, ...keys]);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Capture the visible content of a pane.
|
|
382
|
+
*
|
|
383
|
+
* Uses -J to join wrapped lines for clean output.
|
|
384
|
+
*
|
|
385
|
+
* @param {string} target - Pane ID or session:window.pane target.
|
|
386
|
+
* @param {number} [lines=500] - Number of history lines to capture.
|
|
387
|
+
* @returns {Promise<string>} The captured pane content.
|
|
388
|
+
*/
|
|
389
|
+
async capture(target, lines = 500) {
|
|
390
|
+
try {
|
|
391
|
+
return await this.#exec([
|
|
392
|
+
'capture-pane', '-p', '-J', '-t', target, '-S', `-${lines}`
|
|
393
|
+
]);
|
|
394
|
+
} catch {
|
|
395
|
+
return '';
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* List panes in a session with metadata.
|
|
401
|
+
*
|
|
402
|
+
* @param {string} session - The session name.
|
|
403
|
+
* @returns {Promise<Array<{id: string, pid: string, active: boolean, title: string}>>}
|
|
404
|
+
*/
|
|
405
|
+
async panes(session) {
|
|
406
|
+
const fmt = '#{pane_id}:#{pane_pid}:#{pane_active}:#{pane_title}';
|
|
407
|
+
const output = await this.#exec(['list-panes', '-t', session, '-F', fmt]);
|
|
408
|
+
|
|
409
|
+
return output.split('\n').filter(Boolean).map(function parsePane(line) {
|
|
410
|
+
const [id, pid, active, title] = line.split(':');
|
|
411
|
+
return { id, pid, active: active === '1', title: title ?? '' };
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Check whether a pane's process is still running.
|
|
417
|
+
*
|
|
418
|
+
* Uses list-panes with a filter to reliably detect if the specific
|
|
419
|
+
* pane exists and is not dead. display-message can silently fall back
|
|
420
|
+
* to the active pane when the target no longer exists.
|
|
421
|
+
*
|
|
422
|
+
* @param {string} target - Pane ID.
|
|
423
|
+
* @returns {Promise<boolean>} True if the pane is alive.
|
|
424
|
+
*/
|
|
425
|
+
async alive(target) {
|
|
426
|
+
try {
|
|
427
|
+
const output = await this.#exec([
|
|
428
|
+
'list-panes', '-a', '-F', '#{pane_id}:#{pane_dead}',
|
|
429
|
+
'-f', `#{==:#{pane_id},${target}}`
|
|
430
|
+
]);
|
|
431
|
+
|
|
432
|
+
if (!output) return false;
|
|
433
|
+
const [, dead] = output.split(':');
|
|
434
|
+
return dead !== '1';
|
|
435
|
+
} catch {
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Query the current foreground command running in a pane.
|
|
442
|
+
*
|
|
443
|
+
* Uses `pane_current_command` which reports the process name
|
|
444
|
+
* occupying the pane (e.g. 'zsh', 'claude', 'codex').
|
|
445
|
+
*
|
|
446
|
+
* @param {string} target - Pane ID.
|
|
447
|
+
* @returns {Promise<string>} Process name, or empty string if unknown.
|
|
448
|
+
*/
|
|
449
|
+
async command(target) {
|
|
450
|
+
try {
|
|
451
|
+
return await this.#exec([
|
|
452
|
+
'display-message', '-t', target, '-p', '#{pane_current_command}'
|
|
453
|
+
]);
|
|
454
|
+
} catch {
|
|
455
|
+
return '';
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* List all panes across all windows in a session.
|
|
461
|
+
*
|
|
462
|
+
* Unlike {@link Tmux#panes} which targets the current window,
|
|
463
|
+
* this uses `-s` to enumerate every pane in every window of the
|
|
464
|
+
* session. Essential for garbage collection and session-wide
|
|
465
|
+
* orphan detection.
|
|
466
|
+
*
|
|
467
|
+
* @param {string} session - The session name.
|
|
468
|
+
* @returns {Promise<Array<{id: string, pid: string, dead: boolean, title: string}>>}
|
|
469
|
+
*/
|
|
470
|
+
async allPanes(session) {
|
|
471
|
+
const fmt = '#{pane_id}:#{pane_pid}:#{pane_dead}:#{pane_title}';
|
|
472
|
+
const output = await this.#exec(['list-panes', '-s', '-t', session, '-F', fmt]);
|
|
473
|
+
|
|
474
|
+
return output.split('\n').filter(Boolean).map(function parsePane(line) {
|
|
475
|
+
const [id, pid, dead, title] = line.split(':');
|
|
476
|
+
return { id, pid, dead: dead === '1', title: title ?? '' };
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Kill a specific pane.
|
|
482
|
+
*
|
|
483
|
+
* @param {string} target - Pane ID to kill.
|
|
484
|
+
* @returns {Promise<void>}
|
|
485
|
+
*/
|
|
486
|
+
async killPane(target) {
|
|
487
|
+
await this.#exec(['kill-pane', '-t', target]);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Set a pane option.
|
|
492
|
+
*
|
|
493
|
+
* @param {string} target - Pane ID.
|
|
494
|
+
* @param {string} option - The tmux option name (e.g. 'remain-on-exit').
|
|
495
|
+
* @param {string} value - The option value (e.g. 'on').
|
|
496
|
+
* @returns {Promise<void>}
|
|
497
|
+
*/
|
|
498
|
+
async set(target, option, value) {
|
|
499
|
+
await this.#exec(['set-option', '-p', '-t', target, option, value]);
|
|
500
|
+
}
|
|
501
|
+
}
|