@ryan_nookpi/pi-skill-tmux-terminal 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -12,14 +12,26 @@ pi install npm:@ryan_nookpi/pi-skill-tmux-terminal
12
12
 
13
13
  ## 따로 설치해야 하는 것
14
14
 
15
- `tmux`
15
+ - macOS 또는 Linux (Windows는 WSL 안에서 실행)
16
+ - `tmux` (`brew install tmux`, `apt install tmux`)
16
17
 
17
- 처음 한 번 설치하는 방법은 [skills/tmux-terminal/references/setup.md](skills/tmux-terminal/references/setup.md)에 있습니다. 스킬도 전제가 빠졌을 때 이 문서를 보고 안내합니다.
18
+ Node는 Pi가 쓰는 런타임을 그대로 사용하므로 따로 설치하지 않습니다. 설치와 동작 확인 순서는 [skills/tmux-terminal/references/setup.md](skills/tmux-terminal/references/setup.md)에 있습니다. tmux가 없는 상태에서 스킬을 부르면 에이전트가 설치 명령만 짧게 알려 줍니다. macOS의 tmux 3.6a에서 검증했습니다.
18
19
 
19
20
  ## 사용 예
20
21
 
21
22
  ```text
22
- /skill:tmux-terminal python REPL 띄워서 이 식 계산해줘
23
+ python REPL 띄워서 이 식 계산해줘
24
+ psql 붙어서 이 테이블 스키마 확인해줘
25
+ npm create vite 실행하고 React + TypeScript 골라줘
26
+ ssh로 들어가서 비밀번호 입력하고 로그 꼬리 좀 봐줘
27
+ top 화면 캡처해서 CPU 많이 쓰는 프로세스 알려줘
23
28
  ```
24
29
 
25
- 요청 내용이 스킬 설명과 맞으면 에이전트가 알아서 불러오므로, 명시적으로 `/skill:tmux-terminal`을 쓰지 않아도 됩니다.
30
+ 요청 내용이 스킬 설명과 맞으면 에이전트가 알아서 불러옵니다. 직접 부르려면 `/skill:tmux-terminal <요청>`을 쓰세요.
31
+
32
+ ## 하지 않는 것
33
+
34
+ - 사용자의 기본 tmux 서버와 `~/.tmux.conf`는 읽지도 않고 건드리지도 않습니다. 전용 소켓만 씁니다.
35
+ - 다른 Pi 세션이 만든 세션은 들여다보거나 종료하지 않습니다.
36
+ - 개발 서버, 빌드, 테스트처럼 화면 입력이 필요 없는 명령에는 쓰지 않습니다. 그쪽은 `bash_async`가 맞습니다.
37
+ - 작업이 끝나면 세션을 남기지 않습니다. 큐, 완료 알림, 영속 로그 같은 기능은 없습니다.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryan_nookpi/pi-skill-tmux-terminal",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Pi skill for driving TUI, REPL, and stdin programs through an isolated tmux PTY helper.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -22,7 +22,8 @@
22
22
  ],
23
23
  "files": [
24
24
  "skills/tmux-terminal/SKILL.md",
25
- "skills/tmux-terminal/scripts/tmux-terminal.mjs",
25
+ "skills/tmux-terminal/scripts/",
26
+ "!skills/tmux-terminal/scripts/*.test.mjs",
26
27
  "skills/tmux-terminal/references/",
27
28
  "README.md"
28
29
  ],
@@ -1,6 +1,8 @@
1
1
  ---
2
2
  name: tmux-terminal
3
- description: "TUI, REPL, stdin, selection menu처럼 실제 PTY 화면 캡처와 키 입력이 필요한 터미널을 전용 tmux helper로 안전하게 제어할 때 사용한다. 일반 빌드, 테스트, 서버, 유한 비대화형 명령에는 사용하지 않는다."
3
+ description: "TUI·REPL처럼 화면 확인과 키 입력이 필요한 대화형 터미널을 제어할 때 사용한다."
4
+ license: MIT
5
+ compatibility: macOS와 Linux에서만 동작한다(Windows는 WSL 안에서 실행). `tmux` 실행 파일이 필요하고 tmux 3.6a에서 검증했다. Node는 Pi 런타임을 그대로 쓴다. `--command`는 기본적으로 `/bin/bash`에서 실행된다.
4
6
  ---
5
7
 
6
8
  # tmux-terminal
@@ -11,7 +13,7 @@ description: "TUI, REPL, stdin, selection menu처럼 실제 PTY 화면 캡처와
11
13
 
12
14
  ## 시작 전 확인
13
15
 
14
- Pi가 로드한 이 `SKILL.md`의 절대 경로에서 skill 디렉터리를 구한다. 현재 프로젝트 cwd에 `skills/tmux-terminal`이 있다고 가정하지 않는다.
16
+ 스크립트는 Pi가 로드한 이 `SKILL.md`의 절대 경로를 기준으로 실행한다. 현재 프로젝트 cwd에 `skills/tmux-terminal`이 있다고 가정하지 않는다.
15
17
 
16
18
  ```bash
17
19
  # <loaded-skill-dir>는 이 SKILL.md가 들어 있는 절대 디렉터리다.
@@ -21,10 +23,22 @@ test -f "$HELPER"
21
23
  node "$HELPER" doctor --owner "$PI_SESSION_ID"
22
24
  ```
23
25
 
24
- `doctor`가 실패하면 이 환경은 지원되지 않는다. 일반 `bash`를 PTY인 것처럼 사용하지 말고, 사용자에게 tmux가 없다고 알린 뒤 [references/setup.md](references/setup.md)의 최초 설치 방법을 안내한다.
26
+ 서브커맨드와 옵션 전체는 `node "$HELPER" help`로 볼 수 있다.
25
27
 
26
28
  모든 세션은 `PI_SESSION_ID` 또는 명시적 `--owner`가 필요하다. 호출마다 같은 owner를 전달한다. 전용 tmux 서버와 owner 메타데이터가 다른 Pi 세션의 화면과 입력을 격리한다.
27
29
 
30
+ ## 실패했을 때 사용자에게 할 말
31
+
32
+ 아래에 해당하면 재시도하지 말고 짧게 안내한 뒤 기다린다. 일반 `bash`를 PTY인 것처럼 대신 쓰지 않는다. 설치 절차는 [references/setup.md](references/setup.md)에 있다.
33
+
34
+ | 증상 | 사용자에게 할 말 |
35
+ | --- | --- |
36
+ | `doctor`가 `"supported":false` | "`brew install tmux`(Linux는 `apt install tmux`)로 tmux를 설치해 주세요." |
37
+ | Windows 네이티브 환경 | "이 스킬은 macOS·Linux에서만 동작합니다. Windows라면 WSL 안에서 Pi를 실행해 주세요." |
38
+ | `session_not_found` | 세션이 이미 끝났다. `list`로 남은 세션을 확인하고, 필요하면 `start`로 다시 띄운다. |
39
+ | `ownership` | 다른 Pi 세션의 세션이다. 건드리지 않고 자기 owner로 새로 `start`한다. |
40
+ | `invalid_key` | 리터럴 문자열을 `send-keys`로 보냈다. `paste`로 바꿔 보낸다. |
41
+
28
42
  ## Workflow
29
43
 
30
44
  ### 1. 인터랙티브 프로그램 시작
@@ -48,7 +62,14 @@ node "$HELPER" capture --owner "$PI_SESSION_ID" --session "$SESSION" --lines 80
48
62
  node "$HELPER" send-keys --owner "$PI_SESSION_ID" --session "$SESSION" --keys Down,Enter
49
63
  ```
50
64
 
51
- `send-keys`는 `Enter`, `Escape`, 화살표, `C-c`, `C-d`, function key 같은 이름 있는 키만 허용한다. 일반 문자열이나 비밀번호, 여러 줄 입력은 반드시 `paste`로 전달한다.
65
+ `--lines N`은 화면 **아래쪽부터** N줄을 돌려준다. 화면 밑에 남은 빈 줄은 먼저 버리므로 작은 값을 줘도 최신 출력이 빠지지 않는다. 1~200이며 기본값은 200이다. 전체 화면을 봐야 하는 TUI는 기본값을 쓰고, 마지막 프롬프트 한두 줄만 필요하면 `--lines 5`처럼 줄여 컨텍스트를 아낀다.
66
+
67
+ `send-keys`는 `Enter`, `Escape`, 화살표, `C-c`, `C-d`, function key 같은 이름 있는 키만 허용한다. 키는 두 가지로 지정한다.
68
+
69
+ - `--key Enter`: 키 하나. 여러 번 반복해 순서대로 보낼 수 있다.
70
+ - `--keys Down,Enter`: 콤마로 구분한 여러 키를 한 번에 보낸다.
71
+
72
+ 일반 문자열이나 비밀번호, 여러 줄 입력은 반드시 `paste`로 전달한다.
52
73
 
53
74
  ```bash
54
75
  node "$HELPER" paste --owner "$PI_SESSION_ID" --session "$SESSION" --text 'select now();'
@@ -67,17 +88,21 @@ printf 'first line\n둘째 줄\n' | node "$HELPER" paste --owner "$PI_SESSION_ID
67
88
 
68
89
  ```bash
69
90
  node "$HELPER" status --owner "$PI_SESSION_ID" --session "$SESSION"
91
+ # 세션 이름을 잃어버렸을 때
92
+ node "$HELPER" list --owner "$PI_SESSION_ID"
70
93
  node "$HELPER" kill --owner "$PI_SESSION_ID" --session "$SESSION"
71
94
  # 이 작업에서 만든 owner 세션이 남지 않도록 마지막에 실행
72
95
  node "$HELPER" cleanup --owner "$PI_SESSION_ID"
73
96
  ```
74
97
 
75
- 작업에서 만든 세션은 항상 `kill` 또는 `cleanup`한다. `list`와 `cleanup`은 현재 owner의 세션만 대상으로 하며, 다른 owner의 세션을 전역 정리하지 않는다.
98
+ 작업에서 만든 세션은 항상 `kill` 또는 `cleanup`한다. `list`와 `cleanup`은 현재 owner의 세션만 대상으로 하며, 다른 owner의 세션을 전역 정리하지 않는다. 이미 사라진 세션을 가리키면 `session_not_found`가 돌아온다.
76
99
 
77
100
  ## Boundaries
78
101
 
79
102
  - dedicated tmux server만 사용한다. 사용자의 기본 tmux server를 읽거나 죽이지 않는다.
80
103
  - `capture`, `status`, 입력, `kill`은 정확한 owner 메타데이터를 다시 확인한다.
81
104
  - owner 검사는 Pi 세션 간 실수 방지 경계다. 같은 OS 사용자가 다른 `--owner`를 사칭하거나 전용 socket에 직접 접근하는 것을 막는 보안 인증 경계는 아니다.
105
+ - `--command`는 `/bin/bash`에서 실행된다. 사용자의 zsh 함수나 alias는 적용되지 않으므로 실행 파일 경로나 완전한 명령을 넘긴다. 다른 인터프리터가 필요하면 `--shell /bin/zsh`처럼 절대 경로로 지정한다.
106
+ - macOS와 Linux에서만 동작한다. Windows 네이티브에는 tmux가 없으므로 WSL 안에서 실행해야 한다.
82
107
  - 이 helper는 큐, 자동 완료 알림, 영속 로그, 서버 관리 기능이 없다.
83
108
  - `TMUX_BIN=/path/to/tmux`로 테스트용 binary를 지정할 수 있다.
@@ -2,20 +2,44 @@
2
2
 
3
3
  helper는 전용 tmux 서버를 띄워 PTY 화면을 캡처하고 키를 보낸다. `tmux` 실행 파일이 필요하다. Node는 Pi가 이미 쓰고 있으므로 따로 설치하지 않는다.
4
4
 
5
- ## 확인
5
+ macOS와 Linux에서만 동작한다. Windows 네이티브에는 tmux가 없으므로 WSL 안에서 Pi를 실행한 뒤 아래 Linux 절차를 따른다.
6
+
7
+ ## 1. 확인
8
+
9
+ 스크립트는 스킬 디렉터리 기준 절대 경로로 실행한다.
6
10
 
7
11
  ```bash
8
12
  node "<이 스킬 디렉터리>/scripts/tmux-terminal.mjs" doctor --owner "$PI_SESSION_ID"
9
13
  ```
10
14
 
11
- `"ok":true`가 나오면 설정할 것이 없다.
15
+ `"ok":true`가 나오면 설정할 것이 없다. 3번으로 넘어간다.
16
+
17
+ ## 2. 설치 (1회)
18
+
19
+ ```bash
20
+ brew install tmux # macOS
21
+ sudo apt install tmux # Debian, Ubuntu, WSL
22
+ sudo dnf install tmux # Fedora, RHEL
23
+ ```
24
+
25
+ Homebrew가 없으면 https://brew.sh 의 설치 명령을 먼저 실행한다. 설치 뒤 1번 `doctor`를 다시 실행한다.
26
+
27
+ ## 3. 동작 확인
12
28
 
13
- ## 설치 (1회)
29
+ 실제로 세션을 띄워 화면이 읽히는지 한 번 확인한다. `HELPER`와 `OWNER`를 각자 값으로 바꾼다.
14
30
 
15
31
  ```bash
16
- brew install tmux
32
+ HELPER="<이 스킬 디렉터리>/scripts/tmux-terminal.mjs"
33
+ OWNER="${PI_SESSION_ID:-setup-check}"
34
+
35
+ node "$HELPER" start --owner "$OWNER" --title setup-check --command 'python3 -q'
36
+ # 위 출력의 result.session 값을 SESSION에 넣는다
37
+ node "$HELPER" paste --owner "$OWNER" --session "$SESSION" --text '1+1'
38
+ node "$HELPER" send-keys --owner "$OWNER" --session "$SESSION" --key Enter
39
+ node "$HELPER" capture --owner "$OWNER" --session "$SESSION" --lines 5
40
+ node "$HELPER" cleanup --owner "$OWNER"
17
41
  ```
18
42
 
19
- Homebrew가 없으면 https://brew.sh 의 설치 명령을 먼저 실행한다. Linux는 배포판 패키지(`apt install tmux`, `dnf install tmux`)를 쓴다.
43
+ `capture`의 `text`에 `>>> 1+1`과 `2`가 보이면 정상이다. 마지막 `cleanup`까지 실행해 세션을 남기지 않는다.
20
44
 
21
- 설치 뒤 위 `doctor`를 다시 실행한다. 이 helper는 사용자의 기존 tmux 세션이나 `~/.tmux.conf`와 섞이지 않도록 별도 소켓을 쓴다.
45
+ 이 helper는 사용자의 기존 tmux 세션이나 `~/.tmux.conf`와 섞이지 않도록 별도 소켓을 쓴다. 서브커맨드와 옵션 전체는 `node "$HELPER" help`로 볼 수 있다.
@@ -92,9 +92,14 @@ export function newPasteBuffer(owner) {
92
92
  }
93
93
 
94
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)));
95
+ const limit = Math.max(1, Math.min(MAX_CAPTURE_LINES, maxLines));
96
+ // tmux always returns the full pane height and `-S -N` prepends scrollback on top
97
+ // of it, so the tail of the capture is the pane's unused bottom rows. Drop those
98
+ // before slicing, otherwise a small maxLines yields a screen of blank lines.
99
+ const all = String(value).split("\n");
100
+ let end = all.length;
101
+ while (end > 0 && all[end - 1].trim() === "") end -= 1;
102
+ const lines = all.slice(Math.max(0, end - limit), end);
98
103
  const text = lines.join("\n");
99
104
  if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
100
105
 
@@ -232,16 +237,26 @@ export async function doctor(options = {}) {
232
237
 
233
238
  async function statusUnchecked(session, options = {}) {
234
239
  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
- );
240
+ let result;
241
+ try {
242
+ result = await runTmux(
243
+ [
244
+ "display-message",
245
+ "-p",
246
+ "-t",
247
+ target,
248
+ "#{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}",
249
+ ],
250
+ options,
251
+ );
252
+ } catch (error) {
253
+ // The dedicated server exits once its last session is killed.
254
+ if (error instanceof TerminalError && /no server running|no sessions|failed to connect/i.test(error.message))
255
+ throw new TerminalError("session_not_found", `no session named ${session}`);
256
+ throw error;
257
+ }
258
+ // tmux exits 0 with empty stdout when the target session no longer exists.
259
+ if (!result.stdout.trim()) throw new TerminalError("session_not_found", `no session named ${session}`);
245
260
  return parseTmuxStatus(result.stdout);
246
261
  }
247
262
 
@@ -421,7 +436,33 @@ export async function cleanupOwner({ owner: inputOwner, tmuxBin } = {}) {
421
436
  }
422
437
 
423
438
  function usage() {
424
- return "usage: tmux-terminal.mjs <doctor|start|capture|status|send-keys|paste|list|kill|cleanup> [--owner OWNER]";
439
+ return [
440
+ "usage: tmux-terminal.mjs <action> [options]",
441
+ "",
442
+ "actions:",
443
+ " doctor report tmux availability for this environment",
444
+ " start launch --command in a new owned session",
445
+ " capture read the current screen text",
446
+ " status report pane liveness and exit status",
447
+ " send-keys send named keys (Enter, Down, C-c, ...)",
448
+ " paste send literal text from --text, --file, or stdin",
449
+ " list list sessions owned by this owner",
450
+ " kill kill one owned session",
451
+ " cleanup kill every session owned by this owner",
452
+ " help print this message",
453
+ "",
454
+ "options:",
455
+ " --owner OWNER required, defaults to $PI_SESSION_ID",
456
+ " --session NAME session returned by start",
457
+ " --command CMD start: the command line to run, passed through verbatim",
458
+ " --title TEXT start: human-readable label",
459
+ " --shell PATH start: interpreter for --command (default /bin/bash)",
460
+ ` --lines N capture: screen lines to return, 1-${MAX_CAPTURE_LINES} (default ${MAX_CAPTURE_LINES})`,
461
+ " --key NAME send-keys: one named key, repeatable",
462
+ " --keys A,B send-keys: comma-separated named keys",
463
+ " --text TEXT paste: literal text",
464
+ " --file PATH paste: literal text read from a file",
465
+ ].join("\n");
425
466
  }
426
467
 
427
468
  function parseCli(argv) {
@@ -478,6 +519,9 @@ export async function main(argv = process.argv.slice(2)) {
478
519
  const options = parseCli(argv);
479
520
  let result;
480
521
  switch (options.action) {
522
+ case "help":
523
+ process.stdout.write(`${usage()}\n`);
524
+ return { usage: usage() };
481
525
  case "doctor":
482
526
  result = await doctor(options);
483
527
  process.stdout.write(`${JSON.stringify({ ok: result.supported, ...result })}\n`);
@@ -0,0 +1,362 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFileSync, spawnSync } from "node:child_process";
3
+ import { access, mkdir, mkdtemp, readdir, rm, symlink, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import test from "node:test";
7
+ import { fileURLToPath } from "node:url";
8
+ import {
9
+ assertPasteTextSize,
10
+ captureSession,
11
+ cleanupOwner,
12
+ doctor,
13
+ isNamedKey,
14
+ killSession,
15
+ listOwned,
16
+ MAX_PASTE_BYTES,
17
+ newPasteBuffer,
18
+ ownerHash,
19
+ parseTmuxList,
20
+ parseTmuxStatus,
21
+ pasteSession,
22
+ readPasteText,
23
+ sanitizeSegment,
24
+ sendKeys,
25
+ sessionName,
26
+ shellQuote,
27
+ startSession,
28
+ statusSession,
29
+ TerminalError,
30
+ truncateCapture,
31
+ } from "./tmux-terminal.mjs";
32
+
33
+ const owner = (suffix) => `tmux-terminal-test-${suffix}-${process.pid}`;
34
+ const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
35
+
36
+ async function waitForDead(input) {
37
+ for (let attempt = 0; attempt < 100; attempt += 1) {
38
+ const status = await statusSession(input);
39
+ if (status.paneDead) return status;
40
+ await delay(10); // Poll process scheduling only, never an assumed command duration.
41
+ }
42
+ throw new Error(`pane did not exit: ${input.session}`);
43
+ }
44
+
45
+ function defaultServerSnapshot() {
46
+ try {
47
+ return execFileSync("tmux", ["list-sessions", "-F", "#{session_name}"], {
48
+ encoding: "utf8",
49
+ stdio: ["ignore", "pipe", "ignore"],
50
+ });
51
+ } catch {
52
+ return "";
53
+ }
54
+ }
55
+
56
+ async function start(ownerName, command) {
57
+ const started = await startSession({ owner: ownerName, command });
58
+ return started;
59
+ }
60
+
61
+ test("pure helpers keep owner and generated-shell boundaries deterministic", () => {
62
+ assert.equal(sanitizeSegment("PI Session / 01"), "pi-session-01");
63
+ assert.equal(sanitizeSegment("---"), "owner");
64
+ assert.equal(ownerHash("a"), ownerHash("a"));
65
+ assert.notEqual(ownerHash("a"), ownerHash("b"));
66
+ assert.match(sessionName("PI Session", "abc123"), /^pi-pi-session-abc123$/);
67
+ assert.equal(shellQuote("/tmp/a b/'c'"), "'/tmp/a b/'\"'\"'c'\"'\"''");
68
+ assert.equal(isNamedKey("Enter"), true);
69
+ assert.equal(isNamedKey("C-c"), true);
70
+ assert.equal(isNamedKey("F12"), true);
71
+ assert.equal(isNamedKey("literal text"), false);
72
+ assert.notEqual(newPasteBuffer("same"), newPasteBuffer("same"));
73
+ });
74
+
75
+ test("paste input accepts exactly 5 MiB and rejects larger text, files, and stdin", async (t) => {
76
+ const exact = "x".repeat(MAX_PASTE_BYTES);
77
+ assert.equal(assertPasteTextSize(exact), MAX_PASTE_BYTES);
78
+ assert.throws(
79
+ () => assertPasteTextSize(`${exact}x`),
80
+ (error) => error instanceof TerminalError && error.code === "paste_too_large",
81
+ );
82
+
83
+ const directory = await mkdir(join(tmpdir(), `tmux-terminal-paste-limit-${process.pid}`), { recursive: true }).then(
84
+ () => join(tmpdir(), `tmux-terminal-paste-limit-${process.pid}`),
85
+ );
86
+ const exactFile = join(directory, "exact.txt");
87
+ const oversizedFile = join(directory, "oversized.txt");
88
+ await writeFile(exactFile, exact);
89
+ await writeFile(oversizedFile, `${exact}x`);
90
+ t.after(() => rm(directory, { recursive: true, force: true }));
91
+ assert.equal((await readPasteText({ file: exactFile })).length, MAX_PASTE_BYTES);
92
+ await assert.rejects(
93
+ readPasteText({ file: oversizedFile }),
94
+ (error) => error instanceof TerminalError && error.code === "paste_too_large",
95
+ );
96
+
97
+ const stdin = {
98
+ isTTY: false,
99
+ async *[Symbol.asyncIterator]() {
100
+ yield Buffer.alloc(MAX_PASTE_BYTES);
101
+ yield Buffer.from("x");
102
+ },
103
+ };
104
+ await assert.rejects(
105
+ readPasteText({}, stdin),
106
+ (error) => error instanceof TerminalError && error.code === "paste_too_large",
107
+ );
108
+ });
109
+
110
+ test("capture truncation is line-bounded, byte-bounded, and UTF-8 safe", () => {
111
+ assert.equal(truncateCapture("one\ntwo\nthree", 2, 1024), "two\nthree");
112
+ const output = truncateCapture("x한글🙂", 200, 7);
113
+ assert.ok(Buffer.byteLength(output, "utf8") <= 7);
114
+ assert.equal(output.includes("�"), false);
115
+ });
116
+
117
+ test("capture truncation keeps content when the pane's unused bottom rows are blank", () => {
118
+ // tmux pads every capture to the full pane height, so slicing before dropping the
119
+ // blank tail used to return an empty screen for any small line budget.
120
+ const padded = `one\ntwo\nthree${"\n ".repeat(21)}`;
121
+ assert.equal(truncateCapture(padded, 2, 1024), "two\nthree");
122
+ assert.equal(truncateCapture(padded, 1, 1024), "three");
123
+ assert.equal(truncateCapture("\n\n\n", 5, 1024), "");
124
+ });
125
+
126
+ test("tmux format parsers retain metadata and dead status", () => {
127
+ assert.deepEqual(parseTmuxList("s\to\th\t/tmp/x\tt\t2026\n"), [
128
+ { session: "s", owner: "o", helperId: "h", tempPath: "/tmp/x", title: "t", createdAt: "2026" },
129
+ ]);
130
+ const status = parseTmuxStatus("s\to\th\t/tmp/x\tt\t2026\t1\t7\t123\tbash\n");
131
+ assert.equal(status.paneDead, true);
132
+ assert.equal(status.paneDeadStatus, 7);
133
+ assert.equal(status.panePid, 123);
134
+ });
135
+
136
+ test("doctor reports a usable tmux and a clear unavailable capability", async () => {
137
+ const available = await doctor();
138
+ assert.equal(available.supported, true);
139
+ assert.match(available.version, /^tmux /);
140
+ const unavailable = await doctor({ tmuxBin: "/definitely/missing/tmux" });
141
+ assert.equal(unavailable.supported, false);
142
+ assert.match(unavailable.reason, /unavailable/);
143
+ });
144
+
145
+ test("CLI entrypoint works through a symlink and preserves doctor exit codes", async (t) => {
146
+ const directory = await mkdtemp(join(tmpdir(), "tmux-terminal-cli-symlink-"));
147
+ const script = fileURLToPath(new URL("./tmux-terminal.mjs", import.meta.url));
148
+ const linkedScript = join(directory, "tmux-terminal.mjs");
149
+ await symlink(script, linkedScript);
150
+ t.after(() => rm(directory, { recursive: true, force: true }));
151
+
152
+ const available = spawnSync(process.execPath, [linkedScript, "doctor", "--owner", owner("cli")], {
153
+ encoding: "utf8",
154
+ });
155
+ assert.equal(available.status, 0, available.stderr);
156
+ assert.equal(JSON.parse(available.stdout).supported, true);
157
+
158
+ const unavailable = spawnSync(process.execPath, [linkedScript, "doctor", "--owner", owner("cli")], {
159
+ encoding: "utf8",
160
+ env: { ...process.env, TMUX_BIN: "/definitely/missing/tmux" },
161
+ });
162
+ assert.equal(unavailable.status, 1, unavailable.stderr);
163
+ const unavailableResult = JSON.parse(unavailable.stdout);
164
+ assert.equal(unavailableResult.ok, false);
165
+ assert.equal(unavailableResult.supported, false);
166
+
167
+ const help = spawnSync(process.execPath, [linkedScript, "help"], { encoding: "utf8" });
168
+ assert.equal(help.status, 0, help.stderr);
169
+ assert.match(help.stdout, /^usage: tmux-terminal\.mjs/);
170
+ assert.match(help.stdout, /--lines N/);
171
+ });
172
+
173
+ test("capture returns the newest screen lines even when --lines is below the pane height", async (t) => {
174
+ const currentOwner = owner("capture-lines");
175
+ t.after(() => cleanupOwner({ owner: currentOwner }));
176
+ const started = await start(currentOwner, "printf 'FIRST\\nSECOND\\nTHIRD\\n'; while :; do sleep 1; done");
177
+ for (let attempt = 0; attempt < 100; attempt += 1) {
178
+ if ((await captureSession({ owner: currentOwner, session: started.session })).text.includes("THIRD")) break;
179
+ await delay(10);
180
+ }
181
+ // A default pane is 24 rows tall; line budgets below that used to return only blank rows.
182
+ const narrow = await captureSession({ owner: currentOwner, session: started.session, lines: 3 });
183
+ assert.deepEqual(narrow.text.split("\n"), ["FIRST", "SECOND", "THIRD"]);
184
+ assert.equal((await captureSession({ owner: currentOwner, session: started.session, lines: 1 })).text, "THIRD");
185
+ });
186
+
187
+ test("a missing session reports session_not_found rather than a format failure", async (t) => {
188
+ const currentOwner = owner("missing-session");
189
+ t.after(() => cleanupOwner({ owner: currentOwner }));
190
+ // Keep the dedicated server alive: tmux then answers with empty stdout and exit 0.
191
+ await start(currentOwner, "sleep 30");
192
+ const missing = `pi-missing-${process.pid}`;
193
+ for (const operation of [
194
+ () => statusSession({ owner: currentOwner, session: missing }),
195
+ () => captureSession({ owner: currentOwner, session: missing }),
196
+ () => sendKeys({ owner: currentOwner, session: missing, keys: ["Enter"] }),
197
+ () => killSession({ owner: currentOwner, session: missing }),
198
+ ]) {
199
+ await assert.rejects(
200
+ operation,
201
+ (error) =>
202
+ error instanceof TerminalError && error.code === "session_not_found" && error.message.includes(missing),
203
+ );
204
+ }
205
+ });
206
+
207
+ test("gated start preserves compound shell command semantics and immediate exit statuses", async (t) => {
208
+ const currentOwner = owner("compound");
209
+ t.after(() => cleanupOwner({ owner: currentOwner }));
210
+ const zero = await start(currentOwner, "printf one; printf two");
211
+ const zeroStatus = await waitForDead({ owner: currentOwner, session: zero.session });
212
+ assert.equal(zeroStatus.paneDeadStatus, 0);
213
+ assert.match((await captureSession({ owner: currentOwner, session: zero.session })).text, /onetwo/);
214
+
215
+ const semantics = await start(currentOwner, "printf 'a b' && printf ' $HOME' | tr a-z A-Z\nprintf '\\n한글'");
216
+ const semanticStatus = await waitForDead({ owner: currentOwner, session: semantics.session });
217
+ assert.equal(semanticStatus.paneDeadStatus, 0);
218
+ const captured = (await captureSession({ owner: currentOwner, session: semantics.session })).text;
219
+ assert.match(captured, /a b \$HOME/);
220
+ assert.match(captured, /한글/);
221
+
222
+ const seven = await start(currentOwner, "exit 7");
223
+ assert.equal((await waitForDead({ owner: currentOwner, session: seven.session })).paneDeadStatus, 7);
224
+ });
225
+
226
+ test("raw selector receives Down then Enter", async (t) => {
227
+ const currentOwner = owner("selector");
228
+ t.after(() => cleanupOwner({ owner: currentOwner }));
229
+ const command =
230
+ "python3 -u -c \"import sys,tty; tty.setraw(sys.stdin.fileno()); print('READY', flush=True); value=sys.stdin.buffer.read(4); print('SELECTED' if value == b'\\x1b[B\\r' else repr(value), flush=True)\"";
231
+ const started = await start(currentOwner, command);
232
+ for (let attempt = 0; attempt < 50; attempt += 1) {
233
+ if ((await captureSession({ owner: currentOwner, session: started.session })).text.includes("READY")) break;
234
+ await delay(10);
235
+ }
236
+ await sendKeys({ owner: currentOwner, session: started.session, keys: ["Down", "Enter"] });
237
+ await waitForDead({ owner: currentOwner, session: started.session });
238
+ assert.match((await captureSession({ owner: currentOwner, session: started.session })).text, /SELECTED/);
239
+ });
240
+
241
+ test("REPL-style input accepts literal paste, Enter, and Ctrl-C", async (t) => {
242
+ const currentOwner = owner("repl");
243
+ t.after(() => cleanupOwner({ owner: currentOwner }));
244
+ const started = await start(
245
+ currentOwner,
246
+ `trap 'printf INT; exit 0' INT; printf READY; IFS= read -r value; printf "GOT:%s" "$value"; while :; do sleep 1; done`,
247
+ );
248
+ await pasteSession({ owner: currentOwner, session: started.session, text: "literal $ text" });
249
+ await sendKeys({ owner: currentOwner, session: started.session, keys: ["Enter"] });
250
+ for (let attempt = 0; attempt < 50; attempt += 1) {
251
+ if ((await captureSession({ owner: currentOwner, session: started.session })).text.includes("GOT:literal $ text"))
252
+ break;
253
+ await delay(10);
254
+ }
255
+ await sendKeys({ owner: currentOwner, session: started.session, keys: ["C-c"] });
256
+ await waitForDead({ owner: currentOwner, session: started.session });
257
+ const text = (await captureSession({ owner: currentOwner, session: started.session })).text;
258
+ assert.match(text, /GOT:literal \$ text/);
259
+ assert.match(text, /INT/);
260
+ });
261
+
262
+ test("multiline Unicode paste arrives exactly once and concurrent buffers do not cross", async (t) => {
263
+ const currentOwner = owner("paste");
264
+ t.after(() => cleanupOwner({ owner: currentOwner }));
265
+ const multi = await start(currentOwner, 'IFS= read -r a; IFS= read -r b; printf \'<%s>|<%s>\' "$a" "$b"');
266
+ await pasteSession({ owner: currentOwner, session: multi.session, text: "하나\n둘\n" });
267
+ await waitForDead({ owner: currentOwner, session: multi.session });
268
+ const multiText = (await captureSession({ owner: currentOwner, session: multi.session })).text;
269
+ assert.equal((multiText.match(/<하나>\|<둘>/g) || []).length, 1);
270
+
271
+ const first = await start(currentOwner, "IFS= read -r v; printf 'VALUE:%s' \"$v\"");
272
+ const second = await start(currentOwner, "IFS= read -r v; printf 'VALUE:%s' \"$v\"");
273
+ await Promise.all([
274
+ pasteSession({ owner: currentOwner, session: first.session, text: "alpha\n" }),
275
+ pasteSession({ owner: currentOwner, session: second.session, text: "bravo\n" }),
276
+ ]);
277
+ await Promise.all([
278
+ waitForDead({ owner: currentOwner, session: first.session }),
279
+ waitForDead({ owner: currentOwner, session: second.session }),
280
+ ]);
281
+ assert.match((await captureSession({ owner: currentOwner, session: first.session })).text, /VALUE:alpha/);
282
+ assert.match((await captureSession({ owner: currentOwner, session: second.session })).text, /VALUE:bravo/);
283
+ });
284
+
285
+ test("owner isolation blocks inspect, input, paste, kill, and cleanup of another owner", async (t) => {
286
+ const ownerA = owner("owner-a");
287
+ const ownerB = owner("owner-b");
288
+ t.after(async () => {
289
+ await cleanupOwner({ owner: ownerA });
290
+ await cleanupOwner({ owner: ownerB });
291
+ });
292
+ const b = await start(ownerB, "sleep 30");
293
+ for (const operation of [
294
+ () => statusSession({ owner: ownerA, session: b.session }),
295
+ () => captureSession({ owner: ownerA, session: b.session }),
296
+ () => sendKeys({ owner: ownerA, session: b.session, keys: ["Enter"] }),
297
+ () => pasteSession({ owner: ownerA, session: b.session, text: "nope" }),
298
+ () => killSession({ owner: ownerA, session: b.session }),
299
+ ]) {
300
+ await assert.rejects(operation, (error) => error instanceof TerminalError && error.code === "ownership");
301
+ }
302
+ await cleanupOwner({ owner: ownerA });
303
+ assert.equal((await statusSession({ owner: ownerB, session: b.session })).owner, ownerB);
304
+ });
305
+
306
+ test("failed start after session creation removes its session and temporary directory", async (t) => {
307
+ const failedOwner = owner("failed-after-create");
308
+ const wrapper = join(tmpdir(), `tmux-terminal-fail-wrapper-${process.pid}.sh`);
309
+ const available = await doctor();
310
+ await writeFile(
311
+ wrapper,
312
+ `#!/bin/sh\nif [ "$3" = set-window-option ]; then echo forced failure >&2; exit 23; fi\nexec ${shellQuote(available.executable)} "$@"\n`,
313
+ { mode: 0o700 },
314
+ );
315
+ t.after(async () => {
316
+ await cleanupOwner({ owner: failedOwner });
317
+ await rm(wrapper, { force: true });
318
+ });
319
+
320
+ await assert.rejects(
321
+ startSession({ owner: failedOwner, command: "sleep 1", tmuxBin: wrapper }),
322
+ (error) => error instanceof TerminalError && /forced failure/.test(error.message),
323
+ );
324
+ assert.deepEqual(await listOwned({ owner: failedOwner }), []);
325
+ const failedRoot = join(tmpdir(), "pi-tmux-terminal", ownerHash(failedOwner));
326
+ const remaining = await readdir(failedRoot).catch((error) => (error.code === "ENOENT" ? [] : Promise.reject(error)));
327
+ assert.deepEqual(remaining, []);
328
+ });
329
+
330
+ test("cleanup preserves same-owner temp paths created outside its session snapshot", async (t) => {
331
+ const currentOwner = owner("cleanup-race");
332
+ const root = join(tmpdir(), "pi-tmux-terminal", ownerHash(currentOwner));
333
+ const concurrentPath = join(root, "concurrent-start");
334
+ await mkdir(concurrentPath, { recursive: true, mode: 0o700 });
335
+ t.after(() => rm(root, { recursive: true, force: true }));
336
+
337
+ await cleanupOwner({ owner: currentOwner });
338
+ await access(concurrentPath);
339
+ });
340
+
341
+ test("cleanup is owner-scoped and removes owned temporary files", async (t) => {
342
+ const failedOwner = owner("cleanup-empty");
343
+ const goodOwner = owner("good");
344
+ t.after(async () => {
345
+ await cleanupOwner({ owner: failedOwner });
346
+ await cleanupOwner({ owner: goodOwner });
347
+ });
348
+ const good = await start(goodOwner, "sleep 30");
349
+ await cleanupOwner({ owner: failedOwner });
350
+ assert.equal((await statusSession({ owner: goodOwner, session: good.session })).owner, goodOwner);
351
+ await cleanupOwner({ owner: goodOwner });
352
+ await assert.rejects(access(good.tempPath), /ENOENT/);
353
+ });
354
+
355
+ test("dedicated socket leaves default tmux sessions untouched", async (t) => {
356
+ const before = defaultServerSnapshot();
357
+ const currentOwner = owner("default-server");
358
+ t.after(() => cleanupOwner({ owner: currentOwner }));
359
+ const started = await start(currentOwner, "printf done");
360
+ await waitForDead({ owner: currentOwner, session: started.session });
361
+ assert.equal(defaultServerSnapshot(), before);
362
+ });