@ryan_nookpi/pi-skill-tmux-terminal 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jonghak Seo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,25 @@
1
+ # @ryan_nookpi/pi-skill-tmux-terminal
2
+
3
+ TUI, REPL, stdin 입력, 선택 메뉴처럼 실제 터미널 화면이 필요한 프로그램을 전용 tmux 서버로 실행하고 화면 캡처와 키 입력으로 조작하는 스킬입니다. 세션은 Pi 세션 ID로 소유권을 나눠 다른 세션의 화면과 섞이지 않습니다.
4
+
5
+ ## 설치
6
+
7
+ ```bash
8
+ pi install npm:@ryan_nookpi/pi-skill-tmux-terminal
9
+ ```
10
+
11
+ 같은 이름의 스킬이 `~/.pi/agent/skills/tmux-terminal` 등 다른 위치에 있으면 Pi는 먼저 찾은 쪽 하나만 씁니다. 기존 로컬 복사본은 지우고 설치하세요.
12
+
13
+ ## 따로 설치해야 하는 것
14
+
15
+ `tmux`
16
+
17
+ 처음 한 번 설치하는 방법은 [skills/tmux-terminal/references/setup.md](skills/tmux-terminal/references/setup.md)에 있습니다. 스킬도 전제가 빠졌을 때 이 문서를 보고 안내합니다.
18
+
19
+ ## 사용 예
20
+
21
+ ```text
22
+ /skill:tmux-terminal python REPL 띄워서 이 식 계산해줘
23
+ ```
24
+
25
+ 요청 내용이 스킬 설명과 맞으면 에이전트가 알아서 불러오므로, 명시적으로 `/skill:tmux-terminal`을 쓰지 않아도 됩니다.
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@ryan_nookpi/pi-skill-tmux-terminal",
3
+ "version": "0.1.0",
4
+ "description": "Pi skill for driving TUI, REPL, and stdin programs through an isolated tmux PTY helper.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/Jonghakseo/pi-extension.git",
9
+ "directory": "packages/skill-tmux-terminal"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/Jonghakseo/pi-extension/issues"
13
+ },
14
+ "homepage": "https://github.com/Jonghakseo/pi-extension/tree/main/packages/skill-tmux-terminal#readme",
15
+ "type": "module",
16
+ "keywords": [
17
+ "pi-package",
18
+ "pi-skill",
19
+ "tmux",
20
+ "terminal",
21
+ "pty"
22
+ ],
23
+ "files": [
24
+ "skills/tmux-terminal/SKILL.md",
25
+ "skills/tmux-terminal/scripts/tmux-terminal.mjs",
26
+ "skills/tmux-terminal/references/",
27
+ "README.md"
28
+ ],
29
+ "pi": {
30
+ "skills": [
31
+ "./skills/tmux-terminal"
32
+ ]
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "scripts": {
38
+ "test": "node --test skills/tmux-terminal/scripts/tmux-terminal.test.mjs"
39
+ }
40
+ }
@@ -0,0 +1,83 @@
1
+ ---
2
+ name: tmux-terminal
3
+ description: "TUI, REPL, stdin, selection menu처럼 실제 PTY 화면 캡처와 키 입력이 필요한 터미널을 전용 tmux helper로 안전하게 제어할 때 사용한다. 일반 빌드, 테스트, 서버, 유한 비대화형 명령에는 사용하지 않는다."
4
+ ---
5
+
6
+ # tmux-terminal
7
+
8
+ `bash_async`는 유한한 비대화형 작업용이다. 이 helper는 TUI, REPL, stdin, 선택 메뉴처럼 PTY 입력과 화면 캡처가 필요한 경우에만 쓴다. 짧은 동기 `bash` 호출로 이 스크립트를 실행한다.
9
+
10
+ 이 도구는 native overlay, attach view, `/attach`, `/dismiss`, 위젯, reattach UI를 제공하지 않는다.
11
+
12
+ ## 시작 전 확인
13
+
14
+ Pi가 로드한 이 `SKILL.md`의 절대 경로에서 skill 디렉터리를 구한다. 현재 프로젝트 cwd에 `skills/tmux-terminal`이 있다고 가정하지 않는다.
15
+
16
+ ```bash
17
+ # <loaded-skill-dir>는 이 SKILL.md가 들어 있는 절대 디렉터리다.
18
+ SKILL_DIR="<loaded-skill-dir>"
19
+ HELPER="$SKILL_DIR/scripts/tmux-terminal.mjs"
20
+ test -f "$HELPER"
21
+ node "$HELPER" doctor --owner "$PI_SESSION_ID"
22
+ ```
23
+
24
+ `doctor`가 실패하면 이 환경은 지원되지 않는다. 일반 `bash`를 PTY인 것처럼 사용하지 말고, 사용자에게 tmux가 없다고 알린 뒤 [references/setup.md](references/setup.md)의 최초 설치 방법을 안내한다.
25
+
26
+ 모든 세션은 `PI_SESSION_ID` 또는 명시적 `--owner`가 필요하다. 호출마다 같은 owner를 전달한다. 전용 tmux 서버와 owner 메타데이터가 다른 Pi 세션의 화면과 입력을 격리한다.
27
+
28
+ ## Workflow
29
+
30
+ ### 1. 인터랙티브 프로그램 시작
31
+
32
+ 명령은 `--command` 하나에 원문 그대로 전달한다. helper는 mode `0700` 스크립트에 저장한 뒤 gate를 열어 실행하므로 `;`, `&&`, 파이프, 인용, 줄바꿈이 보존된다.
33
+
34
+ ```bash
35
+ node "$HELPER" start --owner "$PI_SESSION_ID" \
36
+ --title "database prompt" \
37
+ --command 'psql -d app'
38
+ ```
39
+
40
+ 결과 JSON의 `result.session`을 이후 호출에 사용한다. 개발 서버, 빌드, 테스트, headless 유한 명령에는 이 helper를 쓰지 말고 `bash_async`를 사용한다.
41
+
42
+ ### 2. 화면을 확인하고 입력
43
+
44
+ 키를 보내기 전에 항상 capture한다. 커서 위치를 가정하지 말고, `READY`, 메뉴 제목, `>` 같은 안정적인 prompt 텍스트를 찾는다.
45
+
46
+ ```bash
47
+ node "$HELPER" capture --owner "$PI_SESSION_ID" --session "$SESSION" --lines 80
48
+ node "$HELPER" send-keys --owner "$PI_SESSION_ID" --session "$SESSION" --keys Down,Enter
49
+ ```
50
+
51
+ `send-keys`는 `Enter`, `Escape`, 화살표, `C-c`, `C-d`, function key 같은 이름 있는 키만 허용한다. 일반 문자열이나 비밀번호, 여러 줄 입력은 반드시 `paste`로 전달한다.
52
+
53
+ ```bash
54
+ node "$HELPER" paste --owner "$PI_SESSION_ID" --session "$SESSION" --text 'select now();'
55
+ node "$HELPER" send-keys --owner "$PI_SESSION_ID" --session "$SESSION" --key Enter
56
+ ```
57
+
58
+ 긴 텍스트는 stdin 또는 파일로 넘길 수 있다. `paste` 입력은 UTF-8 기준 최대 5 MiB이며, 초과한 `--text`, 파일, stdin은 tmux buffer나 임시 파일을 만들기 전에 거절된다.
59
+
60
+ ```bash
61
+ printf 'first line\n둘째 줄\n' | node "$HELPER" paste --owner "$PI_SESSION_ID" --session "$SESSION"
62
+ ```
63
+
64
+ ### 3. 상태 확인과 정리
65
+
66
+ `status`는 pane 종료 여부와 종료 상태를, `capture`는 최대 200줄과 5KB의 화면 텍스트를 JSON `text` 필드로 돌려준다.
67
+
68
+ ```bash
69
+ node "$HELPER" status --owner "$PI_SESSION_ID" --session "$SESSION"
70
+ node "$HELPER" kill --owner "$PI_SESSION_ID" --session "$SESSION"
71
+ # 이 작업에서 만든 owner 세션이 남지 않도록 마지막에 실행
72
+ node "$HELPER" cleanup --owner "$PI_SESSION_ID"
73
+ ```
74
+
75
+ 작업에서 만든 세션은 항상 `kill` 또는 `cleanup`한다. `list`와 `cleanup`은 현재 owner의 세션만 대상으로 하며, 다른 owner의 세션을 전역 정리하지 않는다.
76
+
77
+ ## Boundaries
78
+
79
+ - dedicated tmux server만 사용한다. 사용자의 기본 tmux server를 읽거나 죽이지 않는다.
80
+ - `capture`, `status`, 입력, `kill`은 정확한 owner 메타데이터를 다시 확인한다.
81
+ - owner 검사는 Pi 세션 간 실수 방지 경계다. 같은 OS 사용자가 다른 `--owner`를 사칭하거나 전용 socket에 직접 접근하는 것을 막는 보안 인증 경계는 아니다.
82
+ - 이 helper는 큐, 자동 완료 알림, 영속 로그, 서버 관리 기능이 없다.
83
+ - `TMUX_BIN=/path/to/tmux`로 테스트용 binary를 지정할 수 있다.
@@ -0,0 +1,21 @@
1
+ # tmux-terminal 최초 설정
2
+
3
+ helper는 전용 tmux 서버를 띄워 PTY 화면을 캡처하고 키를 보낸다. `tmux` 실행 파일이 필요하다. Node는 Pi가 이미 쓰고 있으므로 따로 설치하지 않는다.
4
+
5
+ ## 확인
6
+
7
+ ```bash
8
+ node "<이 스킬 디렉터리>/scripts/tmux-terminal.mjs" doctor --owner "$PI_SESSION_ID"
9
+ ```
10
+
11
+ `"ok":true`가 나오면 설정할 것이 없다.
12
+
13
+ ## 설치 (1회)
14
+
15
+ ```bash
16
+ brew install tmux
17
+ ```
18
+
19
+ Homebrew가 없으면 https://brew.sh 의 설치 명령을 먼저 실행한다. Linux는 배포판 패키지(`apt install tmux`, `dnf install tmux`)를 쓴다.
20
+
21
+ 설치 뒤 위 `doctor`를 다시 실행한다. 이 helper는 사용자의 기존 tmux 세션이나 `~/.tmux.conf`와 섞이지 않도록 별도 소켓을 쓴다.
@@ -0,0 +1,533 @@
1
+ #!/usr/bin/env node
2
+ import { execFile as execFileCallback, execFileSync } from "node:child_process";
3
+ import { createHash, randomBytes } from "node:crypto";
4
+ import { constants as fsConstants, realpathSync } from "node:fs";
5
+ import { access, chmod, mkdir, readFile, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
6
+ import { tmpdir } from "node:os";
7
+ import { join, relative, resolve } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { promisify } from "node:util";
10
+
11
+ const execFile = promisify(execFileCallback);
12
+ export const SOCKET_NAME = "pi-tmux-terminal";
13
+ export const MAX_CAPTURE_LINES = 200;
14
+ export const MAX_CAPTURE_BYTES = 5 * 1024;
15
+ export const MAX_PASTE_BYTES = 5 * 1024 * 1024;
16
+ const KEY_NAMES = new Set([
17
+ "Enter",
18
+ "Escape",
19
+ "Tab",
20
+ "BTab",
21
+ "Space",
22
+ "BSpace",
23
+ "DC",
24
+ "IC",
25
+ "Up",
26
+ "Down",
27
+ "Left",
28
+ "Right",
29
+ "Home",
30
+ "End",
31
+ "PPage",
32
+ "NPage",
33
+ "C-c",
34
+ "C-d",
35
+ "C-z",
36
+ "C-l",
37
+ "C-r",
38
+ "C-u",
39
+ "C-w",
40
+ "F1",
41
+ "F2",
42
+ "F3",
43
+ "F4",
44
+ "F5",
45
+ "F6",
46
+ "F7",
47
+ "F8",
48
+ "F9",
49
+ "F10",
50
+ "F11",
51
+ "F12",
52
+ ]);
53
+
54
+ export class TerminalError extends Error {
55
+ constructor(code, message) {
56
+ super(message);
57
+ this.code = code;
58
+ }
59
+ }
60
+
61
+ export function ownerHash(owner) {
62
+ return createHash("sha256").update(owner).digest("hex").slice(0, 16);
63
+ }
64
+
65
+ export function sanitizeSegment(value) {
66
+ const cleaned = String(value)
67
+ .toLowerCase()
68
+ .replace(/[^a-z0-9]+/g, "-")
69
+ .replace(/^-+|-+$/g, "");
70
+ return (cleaned || "owner").slice(0, 32);
71
+ }
72
+
73
+ export function sessionName(owner, helperId = randomId()) {
74
+ return `pi-${sanitizeSegment(owner)}-${helperId}`;
75
+ }
76
+
77
+ export function randomId() {
78
+ return randomBytes(12).toString("hex");
79
+ }
80
+
81
+ /** Quote a helper-generated path for the shell tmux uses to launch a pane. */
82
+ export function shellQuote(value) {
83
+ return `'${String(value).replace(/'/g, `'"'"'`)}'`;
84
+ }
85
+
86
+ export function isNamedKey(key) {
87
+ return KEY_NAMES.has(key);
88
+ }
89
+
90
+ export function newPasteBuffer(owner) {
91
+ return `pi-paste-${ownerHash(owner)}-${randomId()}`;
92
+ }
93
+
94
+ export function truncateCapture(value, maxLines = MAX_CAPTURE_LINES, maxBytes = MAX_CAPTURE_BYTES) {
95
+ const lines = String(value)
96
+ .split("\n")
97
+ .slice(-Math.max(1, Math.min(MAX_CAPTURE_LINES, maxLines)));
98
+ const text = lines.join("\n");
99
+ if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
100
+
101
+ const codePoints = Array.from(text);
102
+ const suffix = [];
103
+ let bytes = 0;
104
+ for (let index = codePoints.length - 1; index >= 0; index -= 1) {
105
+ const width = Buffer.byteLength(codePoints[index], "utf8");
106
+ if (bytes + width > maxBytes) break;
107
+ suffix.push(codePoints[index]);
108
+ bytes += width;
109
+ }
110
+ return suffix.reverse().join("");
111
+ }
112
+
113
+ export function parseTmuxStatus(line) {
114
+ const [session, owner, helperId, tempPath, title, createdAt, paneDead, paneDeadStatus, panePid, command] = String(
115
+ line,
116
+ )
117
+ .trimEnd()
118
+ .split("\t");
119
+ if (!session || owner === undefined)
120
+ throw new TerminalError("tmux_format", "tmux returned an incomplete status record");
121
+ return {
122
+ session,
123
+ owner,
124
+ helperId,
125
+ tempPath,
126
+ title,
127
+ createdAt,
128
+ paneDead: paneDead === "1",
129
+ paneDeadStatus: paneDeadStatus === "" ? null : Number(paneDeadStatus),
130
+ panePid: panePid === "" ? null : Number(panePid),
131
+ command,
132
+ };
133
+ }
134
+
135
+ export function parseTmuxList(output) {
136
+ if (!output.trim()) return [];
137
+ return output
138
+ .trimEnd()
139
+ .split("\n")
140
+ .map((line) => {
141
+ const [session, owner, helperId, tempPath, title, createdAt] = line.split("\t");
142
+ return { session, owner, helperId, tempPath, title, createdAt };
143
+ });
144
+ }
145
+
146
+ function tmuxBinary(value = process.env.TMUX_BIN || "tmux") {
147
+ return value;
148
+ }
149
+
150
+ async function runTmux(args, options = {}) {
151
+ const binary = tmuxBinary(options.tmuxBin);
152
+ try {
153
+ return await execFile(binary, ["-L", SOCKET_NAME, ...args], { encoding: "utf8", maxBuffer: 1024 * 1024 });
154
+ } catch (error) {
155
+ const detail = error.stderr?.trim() || error.message;
156
+ throw new TerminalError("tmux", detail);
157
+ }
158
+ }
159
+
160
+ function resolveOwner(owner) {
161
+ const value = owner || process.env.PI_SESSION_ID;
162
+ if (!value) throw new TerminalError("owner_required", "PI_SESSION_ID or --owner is required");
163
+ if (/[\0\n\t]/.test(value)) throw new TerminalError("invalid_owner", "owner contains an unsupported character");
164
+ return value;
165
+ }
166
+
167
+ function ownerRoot(owner) {
168
+ return join(tmpdir(), "pi-tmux-terminal", ownerHash(owner));
169
+ }
170
+
171
+ function assertManagedTempPath(owner, tempPath) {
172
+ const root = resolve(ownerRoot(owner));
173
+ const candidate = resolve(tempPath);
174
+ const remaining = relative(root, candidate);
175
+ if (!remaining || remaining.startsWith("..") || remaining.includes("..")) {
176
+ throw new TerminalError("temp_path", "session metadata has an unsafe temporary path");
177
+ }
178
+ return candidate;
179
+ }
180
+
181
+ async function removeTempPath(owner, tempPath) {
182
+ if (!tempPath) return;
183
+ await rm(assertManagedTempPath(owner, tempPath), { recursive: true, force: true });
184
+ }
185
+
186
+ async function removeOwnerRootIfEmpty(owner) {
187
+ try {
188
+ await rmdir(ownerRoot(owner));
189
+ } catch (error) {
190
+ if (error.code !== "ENOENT" && error.code !== "ENOTEMPTY" && error.code !== "EEXIST") throw error;
191
+ }
192
+ }
193
+
194
+ async function existingSessions(options = {}) {
195
+ try {
196
+ const result = await runTmux(
197
+ [
198
+ "list-sessions",
199
+ "-F",
200
+ "#{session_name}\t#{@pi_owner}\t#{@pi_helper_id}\t#{@pi_temp_path}\t#{@pi_original_title}\t#{@pi_created_at}",
201
+ ],
202
+ options,
203
+ );
204
+ return parseTmuxList(result.stdout);
205
+ } catch (error) {
206
+ if (error instanceof TerminalError && /no server running|no sessions|failed to connect/i.test(error.message))
207
+ return [];
208
+ throw error;
209
+ }
210
+ }
211
+
212
+ export async function doctor(options = {}) {
213
+ const binary = tmuxBinary(options.tmuxBin);
214
+ try {
215
+ const version = execFileSync(binary, ["-V"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
216
+ let executable = binary;
217
+ if (!binary.includes("/")) {
218
+ executable =
219
+ execFileSync("/usr/bin/which", [binary], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim() ||
220
+ binary;
221
+ }
222
+ return { supported: true, executable, version, socket: SOCKET_NAME };
223
+ } catch (error) {
224
+ return {
225
+ supported: false,
226
+ executable: binary,
227
+ reason: `tmux is unavailable: ${error.message.split("\n")[0]}`,
228
+ socket: SOCKET_NAME,
229
+ };
230
+ }
231
+ }
232
+
233
+ async function statusUnchecked(session, options = {}) {
234
+ const target = `${session}:0.0`;
235
+ const result = await runTmux(
236
+ [
237
+ "display-message",
238
+ "-p",
239
+ "-t",
240
+ target,
241
+ "#{session_name}\t#{@pi_owner}\t#{@pi_helper_id}\t#{@pi_temp_path}\t#{@pi_original_title}\t#{@pi_created_at}\t#{pane_dead}\t#{pane_dead_status}\t#{pane_pid}\t#{pane_current_command}",
242
+ ],
243
+ options,
244
+ );
245
+ return parseTmuxStatus(result.stdout);
246
+ }
247
+
248
+ async function ownedStatus(owner, session, options = {}) {
249
+ if (!session) throw new TerminalError("session_required", "--session is required");
250
+ const status = await statusUnchecked(session, options);
251
+ if (status.owner !== owner) throw new TerminalError("ownership", "session is not owned by this PI session");
252
+ return status;
253
+ }
254
+
255
+ function shellPath(shell) {
256
+ const chosen = shell || "/bin/bash";
257
+ if (!chosen.startsWith("/") || chosen.includes("\n") || chosen.includes("\0")) {
258
+ throw new TerminalError("invalid_shell", "--shell must be an absolute executable path");
259
+ }
260
+ return chosen;
261
+ }
262
+
263
+ async function writeExecutable(file, contents) {
264
+ await writeFile(file, contents, { mode: 0o700, flag: "wx" });
265
+ await chmod(file, 0o700);
266
+ }
267
+
268
+ export async function startSession({ owner: inputOwner, command, title = "", shell, tmuxBin } = {}) {
269
+ const owner = resolveOwner(inputOwner);
270
+ if (typeof command !== "string" || command.length === 0)
271
+ throw new TerminalError("command_required", "--command is required");
272
+ if (typeof title !== "string" || /[\u0000\n\t]/.test(title))
273
+ throw new TerminalError("invalid_title", "--title cannot contain a tab or newline");
274
+ const interpreter = shellPath(shell);
275
+ try {
276
+ await access(interpreter, fsConstants.X_OK);
277
+ } catch {
278
+ throw new TerminalError("invalid_shell", `configured shell is not executable: ${interpreter}`);
279
+ }
280
+
281
+ const helperId = randomId();
282
+ const session = sessionName(owner, helperId);
283
+ const root = ownerRoot(owner);
284
+ const tempPath = join(root, helperId);
285
+ const commandPath = join(tempPath, "command.sh");
286
+ const gatePath = join(tempPath, "gate.sh");
287
+ const markerPath = join(tempPath, "release");
288
+ let sessionCreated = false;
289
+ let released = false;
290
+
291
+ try {
292
+ await mkdir(tempPath, { recursive: true, mode: 0o700 });
293
+ await chmod(tempPath, 0o700);
294
+ await writeExecutable(commandPath, `#!${interpreter}\n${command}\n`);
295
+ await writeExecutable(
296
+ gatePath,
297
+ `#!/bin/sh\nwhile [ ! -e ${shellQuote(markerPath)} ]; do sleep 0.01; done\nexec ${shellQuote(commandPath)}\n`,
298
+ );
299
+
300
+ // The only shell-parsed pane command is a quote-tested, helper-generated path.
301
+ await runTmux(["new-session", "-d", "-s", session, shellQuote(gatePath)], { tmuxBin });
302
+ sessionCreated = true;
303
+ await runTmux(["set-window-option", "-t", `${session}:0`, "remain-on-exit", "on"], { tmuxBin });
304
+ const metadata = [
305
+ ["@pi_owner", owner],
306
+ ["@pi_helper_id", helperId],
307
+ ["@pi_temp_path", tempPath],
308
+ ["@pi_original_title", title],
309
+ ["@pi_created_at", new Date().toISOString()],
310
+ ];
311
+ for (const [key, value] of metadata) {
312
+ await runTmux(["set-option", "-t", session, key, value], { tmuxBin });
313
+ }
314
+ await writeFile(markerPath, "", { mode: 0o600, flag: "wx" });
315
+ released = true;
316
+ return { session, owner, helperId, tempPath, title };
317
+ } catch (error) {
318
+ if (!released) {
319
+ if (sessionCreated) {
320
+ try {
321
+ await runTmux(["kill-session", "-t", session], { tmuxBin });
322
+ } catch {
323
+ /* best effort for only this new session */
324
+ }
325
+ }
326
+ await rm(tempPath, { recursive: true, force: true });
327
+ // A concurrent same-owner start may have populated the root after our snapshot.
328
+ await removeOwnerRootIfEmpty(owner);
329
+ }
330
+ throw error;
331
+ }
332
+ }
333
+
334
+ export async function statusSession({ owner: inputOwner, session, tmuxBin } = {}) {
335
+ const owner = resolveOwner(inputOwner);
336
+ return ownedStatus(owner, session, { tmuxBin });
337
+ }
338
+
339
+ export async function captureSession({ owner: inputOwner, session, lines = MAX_CAPTURE_LINES, tmuxBin } = {}) {
340
+ const owner = resolveOwner(inputOwner);
341
+ const status = await ownedStatus(owner, session, { tmuxBin });
342
+ const requestedLines = Math.max(1, Math.min(MAX_CAPTURE_LINES, Number(lines) || MAX_CAPTURE_LINES));
343
+ const result = await runTmux(["capture-pane", "-p", "-t", `${status.session}:0.0`, "-S", `-${requestedLines}`], {
344
+ tmuxBin,
345
+ });
346
+ return { ...status, text: truncateCapture(result.stdout, requestedLines, MAX_CAPTURE_BYTES) };
347
+ }
348
+
349
+ export async function sendKeys({ owner: inputOwner, session, keys, tmuxBin } = {}) {
350
+ const owner = resolveOwner(inputOwner);
351
+ const status = await ownedStatus(owner, session, { tmuxBin });
352
+ if (!Array.isArray(keys) || keys.length === 0) throw new TerminalError("keys_required", "--keys is required");
353
+ for (const key of keys) {
354
+ if (!isNamedKey(key))
355
+ throw new TerminalError(
356
+ "invalid_key",
357
+ `literal or unsupported key ${JSON.stringify(key)} rejected, use paste for text`,
358
+ );
359
+ }
360
+ await runTmux(["send-keys", "-t", `${status.session}:0.0`, ...keys], { tmuxBin });
361
+ return { session: status.session, keys };
362
+ }
363
+
364
+ export function assertPasteTextSize(text) {
365
+ if (typeof text !== "string") throw new TerminalError("text_required", "paste text is required");
366
+ const bytes = Buffer.byteLength(text, "utf8");
367
+ if (bytes > MAX_PASTE_BYTES) {
368
+ throw new TerminalError("paste_too_large", `paste input exceeds the ${MAX_PASTE_BYTES}-byte limit`);
369
+ }
370
+ return bytes;
371
+ }
372
+
373
+ export async function pasteSession({ owner: inputOwner, session, text, tmuxBin } = {}) {
374
+ const bytes = assertPasteTextSize(text);
375
+ const owner = resolveOwner(inputOwner);
376
+ const status = await ownedStatus(owner, session, { tmuxBin });
377
+ const pasteFile = join(assertManagedTempPath(owner, status.tempPath), `.paste-${randomId()}`);
378
+ const buffer = newPasteBuffer(owner);
379
+ let loaded = false;
380
+ try {
381
+ await writeFile(pasteFile, text, { mode: 0o600, flag: "wx" });
382
+ await chmod(pasteFile, 0o600);
383
+ await runTmux(["load-buffer", "-b", buffer, pasteFile], { tmuxBin });
384
+ loaded = true;
385
+ await runTmux(["paste-buffer", "-d", "-b", buffer, "-t", `${status.session}:0.0`], { tmuxBin });
386
+ loaded = false; // -d deletes it after a successful paste
387
+ return { session: status.session, bytes };
388
+ } finally {
389
+ await unlink(pasteFile).catch(() => {});
390
+ if (loaded) await runTmux(["delete-buffer", "-b", buffer], { tmuxBin }).catch(() => {});
391
+ }
392
+ }
393
+
394
+ export async function listOwned({ owner: inputOwner, tmuxBin } = {}) {
395
+ const owner = resolveOwner(inputOwner);
396
+ const sessions = await existingSessions({ tmuxBin });
397
+ return sessions.filter((session) => session.owner === owner);
398
+ }
399
+
400
+ export async function killSession({ owner: inputOwner, session, tmuxBin } = {}) {
401
+ const owner = resolveOwner(inputOwner);
402
+ const status = await ownedStatus(owner, session, { tmuxBin });
403
+ await runTmux(["kill-session", "-t", status.session], { tmuxBin });
404
+ await removeTempPath(owner, status.tempPath);
405
+ return { session: status.session, killed: true };
406
+ }
407
+
408
+ export async function cleanupOwner({ owner: inputOwner, tmuxBin } = {}) {
409
+ const owner = resolveOwner(inputOwner);
410
+ const sessions = await listOwned({ owner, tmuxBin });
411
+ const removed = [];
412
+ for (const session of sessions) {
413
+ // Re-check ownership immediately before destructive work.
414
+ await killSession({ owner, session: session.session, tmuxBin });
415
+ removed.push(session.session);
416
+ }
417
+ // Never recursively remove the owner root. A same-owner start may have created
418
+ // a new session directory after listOwned() took its snapshot.
419
+ await removeOwnerRootIfEmpty(owner);
420
+ return { owner, removed };
421
+ }
422
+
423
+ function usage() {
424
+ return "usage: tmux-terminal.mjs <doctor|start|capture|status|send-keys|paste|list|kill|cleanup> [--owner OWNER]";
425
+ }
426
+
427
+ function parseCli(argv) {
428
+ const [action, ...rest] = argv;
429
+ if (!action) throw new TerminalError("action_required", usage());
430
+ const options = { action, keys: [] };
431
+ for (let index = 0; index < rest.length; index += 1) {
432
+ const token = rest[index];
433
+ if (!token.startsWith("--")) throw new TerminalError("invalid_argument", `unexpected argument: ${token}`);
434
+ const key = token.slice(2);
435
+ const value = rest[index + 1];
436
+ if (["owner", "session", "command", "title", "shell", "lines", "text", "file", "keys", "key"].includes(key)) {
437
+ if (value === undefined) throw new TerminalError("invalid_argument", `${token} requires a value`);
438
+ index += 1;
439
+ if (key === "key") options.keys.push(value);
440
+ else if (key === "keys") options.keys.push(...value.split(",").filter(Boolean));
441
+ else options[key] = value;
442
+ } else throw new TerminalError("invalid_argument", `unknown option: ${token}`);
443
+ }
444
+ return options;
445
+ }
446
+
447
+ export async function readPasteText(options, stdin = process.stdin) {
448
+ if (options.text !== undefined) {
449
+ assertPasteTextSize(options.text);
450
+ return options.text;
451
+ }
452
+ if (options.file) {
453
+ const file = await stat(options.file);
454
+ if (file.size > MAX_PASTE_BYTES) {
455
+ throw new TerminalError("paste_too_large", `paste input exceeds the ${MAX_PASTE_BYTES}-byte limit`);
456
+ }
457
+ const text = await readFile(options.file, "utf8");
458
+ assertPasteTextSize(text);
459
+ return text;
460
+ }
461
+ if (!stdin.isTTY) {
462
+ const chunks = [];
463
+ let bytes = 0;
464
+ for await (const chunk of stdin) {
465
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
466
+ bytes += buffer.length;
467
+ if (bytes > MAX_PASTE_BYTES) {
468
+ throw new TerminalError("paste_too_large", `paste input exceeds the ${MAX_PASTE_BYTES}-byte limit`);
469
+ }
470
+ chunks.push(buffer);
471
+ }
472
+ return Buffer.concat(chunks, bytes).toString("utf8");
473
+ }
474
+ throw new TerminalError("text_required", "provide --text, --file, or stdin for paste");
475
+ }
476
+
477
+ export async function main(argv = process.argv.slice(2)) {
478
+ const options = parseCli(argv);
479
+ let result;
480
+ switch (options.action) {
481
+ case "doctor":
482
+ result = await doctor(options);
483
+ process.stdout.write(`${JSON.stringify({ ok: result.supported, ...result })}\n`);
484
+ if (!result.supported) process.exitCode = 1;
485
+ return result;
486
+ case "start":
487
+ result = await startSession(options);
488
+ break;
489
+ case "capture":
490
+ result = await captureSession(options);
491
+ break;
492
+ case "status":
493
+ result = await statusSession(options);
494
+ break;
495
+ case "send-keys":
496
+ result = await sendKeys(options);
497
+ break;
498
+ case "paste":
499
+ result = await pasteSession({ ...options, text: await readPasteText(options) });
500
+ break;
501
+ case "list":
502
+ result = await listOwned(options);
503
+ break;
504
+ case "kill":
505
+ result = await killSession(options);
506
+ break;
507
+ case "cleanup":
508
+ result = await cleanupOwner(options);
509
+ break;
510
+ default:
511
+ throw new TerminalError("invalid_action", `unsupported action: ${options.action}`);
512
+ }
513
+ process.stdout.write(`${JSON.stringify({ ok: true, result })}\n`);
514
+ return result;
515
+ }
516
+
517
+ function isDirectExecution(moduleUrl, argvPath) {
518
+ if (!argvPath) return false;
519
+ try {
520
+ return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(argvPath);
521
+ } catch {
522
+ return false;
523
+ }
524
+ }
525
+
526
+ if (isDirectExecution(import.meta.url, process.argv[1])) {
527
+ main().catch((error) => {
528
+ process.stdout.write(
529
+ `${JSON.stringify({ ok: false, error: { code: error.code || "error", message: error.message } })}\n`,
530
+ );
531
+ process.exitCode = 1;
532
+ });
533
+ }