tmux-agent 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.
Files changed (161) hide show
  1. package/.codex/skills/speckit/SKILL.md +173 -0
  2. package/.codex/skills/speckit/assets/templates/checklist-template.md +49 -0
  3. package/.codex/skills/speckit/assets/templates/notes-entrypoints-template.md +11 -0
  4. package/.codex/skills/speckit/assets/templates/notes-questions-template.md +7 -0
  5. package/.codex/skills/speckit/assets/templates/notes-readme-template.md +36 -0
  6. package/.codex/skills/speckit/assets/templates/notes-session-template.md +21 -0
  7. package/.codex/skills/speckit/assets/templates/plan-template.md +126 -0
  8. package/.codex/skills/speckit/assets/templates/spec-template.md +135 -0
  9. package/.codex/skills/speckit/assets/templates/tasks-template.md +269 -0
  10. package/.codex/skills/speckit/references/acceptance.md +183 -0
  11. package/.codex/skills/speckit/references/analyze.md +186 -0
  12. package/.codex/skills/speckit/references/checklist.md +302 -0
  13. package/.codex/skills/speckit/references/clarify-auto.md +69 -0
  14. package/.codex/skills/speckit/references/clarify-detailed.md +78 -0
  15. package/.codex/skills/speckit/references/clarify.md +189 -0
  16. package/.codex/skills/speckit/references/constitution.md +90 -0
  17. package/.codex/skills/speckit/references/group.md +89 -0
  18. package/.codex/skills/speckit/references/implement-task.md +115 -0
  19. package/.codex/skills/speckit/references/implement.md +129 -0
  20. package/.codex/skills/speckit/references/notes.md +82 -0
  21. package/.codex/skills/speckit/references/plan-deep.md +87 -0
  22. package/.codex/skills/speckit/references/plan-from-questions.md +115 -0
  23. package/.codex/skills/speckit/references/plan-from-review.md +89 -0
  24. package/.codex/skills/speckit/references/plan.md +97 -0
  25. package/.codex/skills/speckit/references/review-plan.md +156 -0
  26. package/.codex/skills/speckit/references/specify.md +246 -0
  27. package/.codex/skills/speckit/references/tasks.md +155 -0
  28. package/.codex/skills/speckit/references/taskstoissues.md +33 -0
  29. package/.codex/skills/speckit/scripts/bash/check-prerequisites.sh +206 -0
  30. package/.codex/skills/speckit/scripts/bash/common.sh +191 -0
  31. package/.codex/skills/speckit/scripts/bash/create-new-feature.sh +259 -0
  32. package/.codex/skills/speckit/scripts/bash/extract-coded-points.sh +322 -0
  33. package/.codex/skills/speckit/scripts/bash/extract-spec-ids.sh +238 -0
  34. package/.codex/skills/speckit/scripts/bash/extract-tasks.sh +295 -0
  35. package/.codex/skills/speckit/scripts/bash/extract-user-stories.sh +312 -0
  36. package/.codex/skills/speckit/scripts/bash/setup-notes.sh +182 -0
  37. package/.codex/skills/speckit/scripts/bash/setup-plan.sh +110 -0
  38. package/.codex/skills/speckit/scripts/bash/show-todo-tasks.sh +257 -0
  39. package/.codex/skills/speckit/scripts/bash/spec-group-checklist.sh +402 -0
  40. package/.codex/skills/speckit/scripts/bash/spec-group-members.sh +215 -0
  41. package/.codex/skills/speckit/scripts/bash/spec-registry-graph.sh +399 -0
  42. package/.specify/memory/constitution.md +67 -0
  43. package/.specify/templates/agent-file-template.md +28 -0
  44. package/.specify/templates/checklist-template.md +49 -0
  45. package/.specify/templates/plan-template.md +126 -0
  46. package/.specify/templates/spec-template.md +135 -0
  47. package/.specify/templates/tasks-template.md +269 -0
  48. package/README.md +128 -0
  49. package/README.zh-CN.md +127 -0
  50. package/bun.lock +269 -0
  51. package/dist/cli/commands/codex/forkHome.js +88 -0
  52. package/dist/cli/commands/codex/send.js +55 -0
  53. package/dist/cli/commands/codex/sessionInfo.js +42 -0
  54. package/dist/cli/commands/codex/spawn.js +68 -0
  55. package/dist/cli/commands/find.js +26 -0
  56. package/dist/cli/commands/paneKill.js +33 -0
  57. package/dist/cli/commands/paneSpawn.js +40 -0
  58. package/dist/cli/commands/paneTitle.js +33 -0
  59. package/dist/cli/commands/read.js +34 -0
  60. package/dist/cli/commands/send.js +51 -0
  61. package/dist/cli/commands/snapshot.js +19 -0
  62. package/dist/cli/commands/ui/select.js +41 -0
  63. package/dist/cli/commands/windowKill.js +25 -0
  64. package/dist/cli/commands/windowLs.js +15 -0
  65. package/dist/cli/commands/windowNew.js +28 -0
  66. package/dist/cli/commands/windowRename.js +25 -0
  67. package/dist/cli/index.js +365 -0
  68. package/dist/cli/parse.js +39 -0
  69. package/dist/lib/codex/forkHome.js +101 -0
  70. package/dist/lib/codex/isCodexPane.js +55 -0
  71. package/dist/lib/codex/send.js +58 -0
  72. package/dist/lib/codex/sessionInfo.js +449 -0
  73. package/dist/lib/codex/spawn.js +246 -0
  74. package/dist/lib/contracts/types.js +2 -0
  75. package/dist/lib/fs/safeRm.js +32 -0
  76. package/dist/lib/io/readStdin.js +14 -0
  77. package/dist/lib/os/process.js +55 -0
  78. package/dist/lib/output/format.js +95 -0
  79. package/dist/lib/proc/lsof.js +42 -0
  80. package/dist/lib/proc/ps.js +60 -0
  81. package/dist/lib/targeting/errors.js +13 -0
  82. package/dist/lib/targeting/resolvePaneTarget.js +91 -0
  83. package/dist/lib/targeting/resolveWindowTarget.js +40 -0
  84. package/dist/lib/targeting/scope.js +58 -0
  85. package/dist/lib/tmux/capturePane.js +20 -0
  86. package/dist/lib/tmux/exec.js +66 -0
  87. package/dist/lib/tmux/paneOps.js +29 -0
  88. package/dist/lib/tmux/paste.js +23 -0
  89. package/dist/lib/tmux/sendKeys.js +47 -0
  90. package/dist/lib/tmux/session.js +29 -0
  91. package/dist/lib/tmux/snapshotPanes.js +46 -0
  92. package/dist/lib/tmux/snapshotWindows.js +24 -0
  93. package/dist/lib/tmux/windowOps.js +32 -0
  94. package/dist/lib/ui/popupSelect.js +432 -0
  95. package/dist/lib/ui/popupSupport.js +76 -0
  96. package/package.json +23 -0
  97. package/src/cli/commands/codex/forkHome.ts +141 -0
  98. package/src/cli/commands/codex/send.ts +83 -0
  99. package/src/cli/commands/codex/sessionInfo.ts +59 -0
  100. package/src/cli/commands/codex/spawn.ts +90 -0
  101. package/src/cli/commands/find.ts +40 -0
  102. package/src/cli/commands/paneKill.ts +49 -0
  103. package/src/cli/commands/paneSpawn.ts +53 -0
  104. package/src/cli/commands/paneTitle.ts +50 -0
  105. package/src/cli/commands/read.ts +48 -0
  106. package/src/cli/commands/send.ts +71 -0
  107. package/src/cli/commands/snapshot.ts +28 -0
  108. package/src/cli/commands/ui/select.ts +49 -0
  109. package/src/cli/commands/windowKill.ts +35 -0
  110. package/src/cli/commands/windowLs.ts +20 -0
  111. package/src/cli/commands/windowNew.ts +40 -0
  112. package/src/cli/commands/windowRename.ts +36 -0
  113. package/src/cli/index.ts +430 -0
  114. package/src/lib/codex/forkHome.ts +148 -0
  115. package/src/lib/codex/isCodexPane.ts +56 -0
  116. package/src/lib/codex/send.ts +84 -0
  117. package/src/lib/codex/sessionInfo.ts +521 -0
  118. package/src/lib/codex/spawn.ts +305 -0
  119. package/src/lib/contracts/types.ts +30 -0
  120. package/src/lib/fs/safeRm.ts +32 -0
  121. package/src/lib/io/readStdin.ts +11 -0
  122. package/src/lib/output/format.ts +105 -0
  123. package/src/lib/proc/lsof.ts +44 -0
  124. package/src/lib/proc/ps.ts +70 -0
  125. package/src/lib/targeting/errors.ts +25 -0
  126. package/src/lib/targeting/resolvePaneTarget.ts +106 -0
  127. package/src/lib/targeting/resolveWindowTarget.ts +45 -0
  128. package/src/lib/targeting/scope.ts +76 -0
  129. package/src/lib/tmux/capturePane.ts +21 -0
  130. package/src/lib/tmux/exec.ts +90 -0
  131. package/src/lib/tmux/paneOps.ts +35 -0
  132. package/src/lib/tmux/paste.ts +20 -0
  133. package/src/lib/tmux/sendKeys.ts +72 -0
  134. package/src/lib/tmux/session.ts +27 -0
  135. package/src/lib/tmux/snapshotPanes.ts +52 -0
  136. package/src/lib/tmux/snapshotWindows.ts +23 -0
  137. package/src/lib/tmux/windowOps.ts +43 -0
  138. package/src/lib/ui/popupSelect.ts +561 -0
  139. package/src/lib/ui/popupSupport.ts +84 -0
  140. package/tests/e2e/codexForkHome.test.ts +146 -0
  141. package/tests/e2e/codexSessionInfo.test.ts +112 -0
  142. package/tests/e2e/codexTuiSend.test.ts +68 -0
  143. package/tests/integration/codexSpawn.test.ts +113 -0
  144. package/tests/integration/paneOps.test.ts +60 -0
  145. package/tests/integration/sendRead.test.ts +52 -0
  146. package/tests/integration/snapshot.test.ts +39 -0
  147. package/tests/integration/tmuxHarness.ts +39 -0
  148. package/tests/integration/windowOps.test.ts +60 -0
  149. package/tests/unit/codexSend.test.ts +105 -0
  150. package/tests/unit/codexSessionInfo.test.ts +88 -0
  151. package/tests/unit/codexSpawn.test.ts +34 -0
  152. package/tests/unit/keys.test.ts +30 -0
  153. package/tests/unit/outputFormat.test.ts +52 -0
  154. package/tests/unit/popupSelect.test.ts +77 -0
  155. package/tests/unit/popupSupport.test.ts +109 -0
  156. package/tests/unit/resolvePaneTarget.test.ts +43 -0
  157. package/tests/unit/resolveWindowTarget.test.ts +36 -0
  158. package/tests/unit/safeRm.test.ts +41 -0
  159. package/tests/unit/scope.test.ts +57 -0
  160. package/tsconfig.json +14 -0
  161. package/vitest.config.ts +16 -0
@@ -0,0 +1,106 @@
1
+ import type { PaneInfo, ResolvedPane } from "../contracts/types";
2
+ import { TargetResolutionError } from "./errors";
3
+
4
+ function isNumeric(value: string): boolean {
5
+ return /^\d+$/.test(value);
6
+ }
7
+
8
+ const COMPOSITE_REGEX = /^(@[^%]+)(%[^%]+)$/;
9
+
10
+ export function resolvePaneTarget(
11
+ panes: PaneInfo[],
12
+ target: string,
13
+ options: { windowId?: string } = {}
14
+ ): ResolvedPane {
15
+ const compositeMatch = target.match(COMPOSITE_REGEX);
16
+ if (compositeMatch) {
17
+ const [, windowId, paneId] = compositeMatch;
18
+ const matches = panes.filter((pane) => pane.id === paneId);
19
+ const inWindow = matches.find((pane) => pane.windowId === windowId);
20
+ if (!inWindow) {
21
+ throw new TargetResolutionError({
22
+ targetKind: "pane",
23
+ kind: "invalid",
24
+ target,
25
+ message: `pane ${paneId} is not in window ${windowId}`
26
+ });
27
+ }
28
+ return { id: inWindow.id, idx: inWindow.idx };
29
+ }
30
+
31
+ if (target.startsWith("%")) {
32
+ const matches = panes.filter((pane) => pane.id === target);
33
+ if (matches.length === 0) {
34
+ throw new TargetResolutionError({
35
+ targetKind: "pane",
36
+ kind: "not_found",
37
+ target,
38
+ message: `pane target "${target}" not found`
39
+ });
40
+ }
41
+ return { id: matches[0].id, idx: matches[0].idx };
42
+ }
43
+
44
+ if (!options.windowId) {
45
+ throw new TargetResolutionError({
46
+ targetKind: "pane",
47
+ kind: "invalid",
48
+ target,
49
+ message: "window scope is required for IDX/keyword targets"
50
+ });
51
+ }
52
+
53
+ const scoped = panes.filter((pane) => pane.windowId === options.windowId);
54
+
55
+ if (isNumeric(target)) {
56
+ const idx = Number(target);
57
+ const matches = scoped.filter((pane) => pane.idx === idx);
58
+ if (matches.length === 0) {
59
+ throw new TargetResolutionError({
60
+ targetKind: "pane",
61
+ kind: "not_found",
62
+ target,
63
+ message: `pane target "${target}" not found`
64
+ });
65
+ }
66
+ if (matches.length > 1) {
67
+ throw new TargetResolutionError({
68
+ targetKind: "pane",
69
+ kind: "ambiguous",
70
+ target,
71
+ message: `target "${target}" matched ${matches.length} panes; use IDX or %pane_id`,
72
+ candidates: matches
73
+ });
74
+ }
75
+ return { id: matches[0].id, idx: matches[0].idx };
76
+ }
77
+
78
+ const query = target.toLowerCase();
79
+ const matches = scoped.filter((pane) => {
80
+ return (
81
+ pane.command.toLowerCase().includes(query) ||
82
+ pane.title.toLowerCase().includes(query)
83
+ );
84
+ });
85
+
86
+ if (matches.length === 0) {
87
+ throw new TargetResolutionError({
88
+ targetKind: "pane",
89
+ kind: "not_found",
90
+ target,
91
+ message: `pane target "${target}" not found`
92
+ });
93
+ }
94
+
95
+ if (matches.length > 1) {
96
+ throw new TargetResolutionError({
97
+ targetKind: "pane",
98
+ kind: "ambiguous",
99
+ target,
100
+ message: `target "${target}" matched ${matches.length} panes; use IDX or %pane_id`,
101
+ candidates: matches
102
+ });
103
+ }
104
+
105
+ return { id: matches[0].id, idx: matches[0].idx };
106
+ }
@@ -0,0 +1,45 @@
1
+ import type { ResolvedWindow, WindowInfo } from "../contracts/types";
2
+ import { TargetResolutionError } from "./errors";
3
+
4
+ function isNumeric(value: string): boolean {
5
+ return /^\d+$/.test(value);
6
+ }
7
+
8
+ export function resolveWindowTarget(
9
+ windows: WindowInfo[],
10
+ target: string
11
+ ): ResolvedWindow {
12
+ let matches: WindowInfo[] = [];
13
+
14
+ if (target.startsWith("@")) {
15
+ matches = windows.filter((window) => window.wid === target);
16
+ } else if (isNumeric(target)) {
17
+ const index = Number(target);
18
+ matches = windows.filter((window) => window.widx === index);
19
+ } else {
20
+ const query = target.toLowerCase();
21
+ matches = windows.filter((window) => window.name.toLowerCase().includes(query));
22
+ }
23
+
24
+ if (matches.length === 0) {
25
+ throw new TargetResolutionError({
26
+ targetKind: "window",
27
+ kind: "not_found",
28
+ target,
29
+ message: `window target "${target}" not found`
30
+ });
31
+ }
32
+
33
+ if (matches.length > 1) {
34
+ throw new TargetResolutionError({
35
+ targetKind: "window",
36
+ kind: "ambiguous",
37
+ target,
38
+ message: `target "${target}" matched ${matches.length} windows; use WIDX or @window_id`,
39
+ candidates: matches
40
+ });
41
+ }
42
+
43
+ const match = matches[0];
44
+ return { widx: match.widx, wid: match.wid, name: match.name };
45
+ }
@@ -0,0 +1,76 @@
1
+ import type { ResolvedWindow } from "../contracts/types";
2
+ import { snapshotWindows } from "../tmux/snapshotWindows";
3
+ import { tmuxExec } from "../tmux/exec";
4
+ import { TargetResolutionError } from "./errors";
5
+ import { resolveWindowTarget } from "./resolveWindowTarget";
6
+
7
+ export type SessionScope = {
8
+ session: string;
9
+ source: "explicit" | "tmux" | "unique";
10
+ };
11
+
12
+ export async function resolveSessionScope(
13
+ sessionArg?: string,
14
+ env: NodeJS.ProcessEnv = process.env
15
+ ): Promise<SessionScope> {
16
+ if (sessionArg) {
17
+ return { session: sessionArg, source: "explicit" };
18
+ }
19
+
20
+ if (env.TMUX) {
21
+ const result = await tmuxExec([
22
+ "display-message",
23
+ "-p",
24
+ "#{session_name}"
25
+ ]);
26
+ const session = result.stdout.trim();
27
+ if (!session) {
28
+ throw new Error("unable to resolve current session");
29
+ }
30
+ return { session, source: "tmux" };
31
+ }
32
+
33
+ const result = await tmuxExec(["list-sessions", "-F", "#{session_name}"]);
34
+ const sessions = result.stdout
35
+ .trim()
36
+ .split(/\r?\n/)
37
+ .map((line) => line.trim())
38
+ .filter(Boolean);
39
+
40
+ if (sessions.length === 0) {
41
+ throw new Error("no tmux sessions found");
42
+ }
43
+
44
+ if (sessions.length > 1) {
45
+ throw new Error("default session is ambiguous; use --session <name>");
46
+ }
47
+
48
+ return { session: sessions[0], source: "unique" };
49
+ }
50
+
51
+ export async function resolveWindowScope(
52
+ session: string,
53
+ windowTarget?: string
54
+ ): Promise<ResolvedWindow> {
55
+ const windows = await snapshotWindows(session);
56
+ if (windows.length === 0) {
57
+ throw new Error(`session ${session} has no windows`);
58
+ }
59
+
60
+ if (windowTarget) {
61
+ return resolveWindowTarget(windows, windowTarget);
62
+ }
63
+
64
+ const active = windows.find((window) => window.status === "active");
65
+ if (!active) {
66
+ throw new TargetResolutionError({
67
+ targetKind: "window",
68
+ kind: "not_found",
69
+ target: "active",
70
+ message: "no active window found",
71
+ candidates: windows
72
+ });
73
+ }
74
+
75
+ return { widx: active.widx, wid: active.wid, name: active.name };
76
+ }
@@ -0,0 +1,21 @@
1
+ import { tmuxExec } from "./exec";
2
+
3
+ export async function capturePane(
4
+ paneId: string,
5
+ lines: number
6
+ ): Promise<string> {
7
+ const args = ["capture-pane", "-p", "-t", paneId];
8
+ if (Number.isFinite(lines) && lines > 0) {
9
+ args.push("-S", `-${lines}`);
10
+ }
11
+ const result = await tmuxExec(args);
12
+ return result.stdout.replace(/\r?\n$/, "");
13
+ }
14
+
15
+ export function splitLines(text: string): string[] {
16
+ const lines = text.split(/\r?\n/);
17
+ if (lines.length && lines[lines.length - 1] === "") {
18
+ lines.pop();
19
+ }
20
+ return lines;
21
+ }
@@ -0,0 +1,90 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ export type TmuxExecOptions = {
4
+ input?: string;
5
+ env?: NodeJS.ProcessEnv;
6
+ };
7
+
8
+ export type TmuxExecResult = {
9
+ stdout: string;
10
+ stderr: string;
11
+ exitCode: number;
12
+ };
13
+
14
+ export class TmuxExecError extends Error {
15
+ readonly stdout: string;
16
+ readonly stderr: string;
17
+ readonly exitCode: number;
18
+
19
+ constructor(message: string, result: TmuxExecResult) {
20
+ super(message);
21
+ this.stdout = result.stdout;
22
+ this.stderr = result.stderr;
23
+ this.exitCode = result.exitCode;
24
+ }
25
+ }
26
+
27
+ function getServerArgs(env: NodeJS.ProcessEnv): string[] {
28
+ const server = env.AGENT_TMUX_SERVER || env.AGENT_TMUX_SOCKET;
29
+ if (!server) {
30
+ return [];
31
+ }
32
+ return ["-L", server];
33
+ }
34
+
35
+ export async function tmuxExec(
36
+ args: string[],
37
+ options: TmuxExecOptions = {}
38
+ ): Promise<TmuxExecResult> {
39
+ const baseEnv = options.env ?? process.env;
40
+ const serverArgs = getServerArgs(baseEnv);
41
+ const env = serverArgs.length > 0 ? { ...baseEnv } : baseEnv;
42
+ if (serverArgs.length > 0 && "TMUX" in env) {
43
+ delete env.TMUX;
44
+ }
45
+ const finalArgs = [...serverArgs, ...args];
46
+
47
+ return await new Promise<TmuxExecResult>((resolve, reject) => {
48
+ const child = spawn("tmux", finalArgs, { env });
49
+ let stdout = "";
50
+ let stderr = "";
51
+
52
+ if (child.stdout) {
53
+ child.stdout.on("data", (data) => {
54
+ stdout += data.toString();
55
+ });
56
+ }
57
+
58
+ if (child.stderr) {
59
+ child.stderr.on("data", (data) => {
60
+ stderr += data.toString();
61
+ });
62
+ }
63
+
64
+ child.on("error", (error) => {
65
+ reject(
66
+ new TmuxExecError(error.message, {
67
+ stdout,
68
+ stderr,
69
+ exitCode: 1
70
+ })
71
+ );
72
+ });
73
+
74
+ child.on("close", (code) => {
75
+ const exitCode = code ?? 0;
76
+ const result = { stdout, stderr, exitCode };
77
+ if (exitCode === 0) {
78
+ resolve(result);
79
+ return;
80
+ }
81
+ const message = stderr.trim() || stdout.trim() || `tmux exited with ${exitCode}`;
82
+ reject(new TmuxExecError(message, result));
83
+ });
84
+
85
+ if (options.input !== undefined) {
86
+ child.stdin?.write(options.input);
87
+ child.stdin?.end();
88
+ }
89
+ });
90
+ }
@@ -0,0 +1,35 @@
1
+ import { tmuxExec } from "./exec";
2
+
3
+ export type PaneCreateResult = {
4
+ id: string;
5
+ idx: number;
6
+ };
7
+
8
+ export async function paneSpawn(
9
+ windowId: string
10
+ ): Promise<PaneCreateResult> {
11
+ const args = [
12
+ "split-window",
13
+ "-P",
14
+ "-F",
15
+ "#{pane_id}\t#{pane_index}",
16
+ "-t",
17
+ windowId,
18
+ "-d"
19
+ ];
20
+ const result = await tmuxExec(args);
21
+ const [id, idxRaw] = result.stdout.trim().split("\t");
22
+
23
+ return {
24
+ id,
25
+ idx: Number(idxRaw)
26
+ };
27
+ }
28
+
29
+ export async function paneTitle(paneId: string, title: string): Promise<void> {
30
+ await tmuxExec(["select-pane", "-t", paneId, "-T", title]);
31
+ }
32
+
33
+ export async function paneKill(paneId: string): Promise<void> {
34
+ await tmuxExec(["kill-pane", "-t", paneId]);
35
+ }
@@ -0,0 +1,20 @@
1
+ import { tmuxExec } from "./exec";
2
+
3
+ function createBufferName(): string {
4
+ const rand = Math.random().toString(36).slice(2, 10);
5
+ return `__agent_tmux_${process.pid}_${Date.now()}_${rand}`;
6
+ }
7
+
8
+ export async function pasteText(paneId: string, text: string): Promise<void> {
9
+ const bufferName = createBufferName();
10
+ await tmuxExec(["load-buffer", "-b", bufferName, "-"], { input: text });
11
+ try {
12
+ await tmuxExec(["paste-buffer", "-p", "-d", "-b", bufferName, "-t", paneId]);
13
+ } finally {
14
+ try {
15
+ await tmuxExec(["delete-buffer", "-b", bufferName]);
16
+ } catch {
17
+ // ignore best-effort cleanup failures
18
+ }
19
+ }
20
+ }
@@ -0,0 +1,72 @@
1
+ import { tmuxExec } from "./exec";
2
+ import { pasteText } from "./paste";
3
+
4
+ export type SendMode = "text" | "keys";
5
+
6
+ export type SendKeysResult = {
7
+ mode: SendMode;
8
+ text: string;
9
+ enter: boolean;
10
+ };
11
+
12
+ export type SendKeysOptions = {
13
+ noEnter?: boolean;
14
+ enterDelayMs?: number;
15
+ };
16
+
17
+ function sleep(ms: number): Promise<void> {
18
+ return new Promise((resolve) => setTimeout(resolve, ms));
19
+ }
20
+
21
+ export function parseKeyExpression(input: string): string | null {
22
+ const trimmed = input.trim();
23
+ if (!trimmed) {
24
+ return null;
25
+ }
26
+ if (trimmed.toLowerCase() === "enter") {
27
+ return "Enter";
28
+ }
29
+ const ctrlMatch = trimmed.match(/^Ctrl\+([A-Za-z])$/);
30
+ if (ctrlMatch) {
31
+ return `C-${ctrlMatch[1].toLowerCase()}`;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ export function classifySendInput(
37
+ input: string,
38
+ noEnter: boolean
39
+ ): { mode: SendMode; tmuxKey?: string; enter: boolean } {
40
+ const tmuxKey = parseKeyExpression(input);
41
+ if (tmuxKey) {
42
+ return { mode: "keys", tmuxKey, enter: false };
43
+ }
44
+ return { mode: "text", enter: !noEnter };
45
+ }
46
+
47
+ export async function sendKeys(
48
+ paneId: string,
49
+ input: string,
50
+ options: SendKeysOptions = {}
51
+ ): Promise<SendKeysResult> {
52
+ const { mode, tmuxKey, enter } = classifySendInput(
53
+ input,
54
+ options.noEnter ?? false
55
+ );
56
+
57
+ if (mode === "keys" && tmuxKey) {
58
+ await tmuxExec(["send-keys", "-t", paneId, tmuxKey]);
59
+ return { mode, text: input, enter };
60
+ }
61
+
62
+ await pasteText(paneId, input);
63
+ if (enter) {
64
+ const enterDelayMs = options.enterDelayMs ?? 0;
65
+ if (enterDelayMs > 0) {
66
+ await sleep(enterDelayMs);
67
+ }
68
+ await tmuxExec(["send-keys", "-t", paneId, "Enter"]);
69
+ }
70
+
71
+ return { mode: "text", text: input, enter };
72
+ }
@@ -0,0 +1,27 @@
1
+ import { tmuxExec } from "./exec";
2
+
3
+ export async function resolveSessionId(sessionName: string): Promise<string> {
4
+ const result = await tmuxExec([
5
+ "list-sessions",
6
+ "-F",
7
+ "#{session_name}\t#{session_id}"
8
+ ]);
9
+ const lines = result.stdout.trim().split(/\r?\n/).filter(Boolean);
10
+ let fallbackId: string | undefined;
11
+ for (const line of lines) {
12
+ const [name, id] = line.split("\t");
13
+ if (name === sessionName && id) {
14
+ return id;
15
+ }
16
+ if (!fallbackId && id && name.endsWith(sessionName)) {
17
+ const prefix = name.slice(0, name.length - sessionName.length);
18
+ if (/^\d+-$/.test(prefix)) {
19
+ fallbackId = id;
20
+ }
21
+ }
22
+ }
23
+ if (fallbackId) {
24
+ return fallbackId;
25
+ }
26
+ throw new Error(`can't find session: ${sessionName}`);
27
+ }
@@ -0,0 +1,52 @@
1
+ import type { PaneInfo } from "../contracts/types";
2
+ import { tmuxExec } from "./exec";
3
+ import { resolveSessionId } from "./session";
4
+
5
+ export type SnapshotPanesOptions = {
6
+ session?: string;
7
+ windowId?: string;
8
+ };
9
+
10
+ export async function snapshotPanes(options: SnapshotPanesOptions): Promise<PaneInfo[]> {
11
+ const sessionId = options.session
12
+ ? await resolveSessionId(options.session)
13
+ : undefined;
14
+ const args: string[] = ["list-panes"];
15
+ if (options.windowId) {
16
+ args.push("-t", options.windowId);
17
+ } else {
18
+ args.push("-a");
19
+ }
20
+ args.push(
21
+ "-F",
22
+ "#{session_id}\t#{pane_index}\t#{pane_id}\t#{pane_pid}\t#{pane_current_command}\t#{pane_title}\t#{pane_active}\t#{pane_dead}\t#{window_id}"
23
+ );
24
+ const result = await tmuxExec(args);
25
+ const lines = result.stdout.trim().split(/\r?\n/).filter(Boolean);
26
+ return lines
27
+ .map((line) => {
28
+ const [lineSessionId, idxRaw, id, pidRaw, command, title, activeRaw, deadRaw, windowId] =
29
+ line.split("\t");
30
+ const idx = Number(idxRaw);
31
+ const pidValue = Number(pidRaw);
32
+ const pid = Number.isFinite(pidValue) ? pidValue : undefined;
33
+ let status: PaneInfo["status"] = "idle";
34
+ if (deadRaw === "1") {
35
+ status = "dead";
36
+ } else if (activeRaw === "1") {
37
+ status = "active";
38
+ }
39
+ return {
40
+ sessionId: lineSessionId,
41
+ idx,
42
+ id,
43
+ pid,
44
+ command,
45
+ title,
46
+ status,
47
+ windowId
48
+ };
49
+ })
50
+ .filter((pane) => (sessionId ? pane.sessionId === sessionId : true))
51
+ .map(({ sessionId: _sessionId, ...pane }) => pane);
52
+ }
@@ -0,0 +1,23 @@
1
+ import type { WindowInfo } from "../contracts/types";
2
+ import { tmuxExec } from "./exec";
3
+ import { resolveSessionId } from "./session";
4
+
5
+ export async function snapshotWindows(session: string): Promise<WindowInfo[]> {
6
+ const sessionId = await resolveSessionId(session);
7
+ const result = await tmuxExec([
8
+ "list-windows",
9
+ "-a",
10
+ "-F",
11
+ "#{session_id}\t#{window_index}\t#{window_id}\t#{window_name}\t#{window_active}"
12
+ ]);
13
+ const lines = result.stdout.trim().split(/\r?\n/).filter(Boolean);
14
+ return lines
15
+ .map((line) => {
16
+ const [lineSessionId, widxRaw, wid, name, activeRaw] = line.split("\t");
17
+ const widx = Number(widxRaw);
18
+ const status: WindowInfo["status"] = activeRaw === "1" ? "active" : "inactive";
19
+ return { sessionId: lineSessionId, widx, wid, name, status };
20
+ })
21
+ .filter((window) => window.sessionId === sessionId)
22
+ .map(({ sessionId: _sessionId, ...window }) => window);
23
+ }
@@ -0,0 +1,43 @@
1
+ import { tmuxExec } from "./exec";
2
+
3
+ export type WindowCreateResult = {
4
+ wid: string;
5
+ widx: number;
6
+ name: string;
7
+ paneId: string;
8
+ };
9
+
10
+ export async function windowNew(
11
+ session: string,
12
+ name: string
13
+ ): Promise<WindowCreateResult> {
14
+ const args = [
15
+ "new-window",
16
+ "-P",
17
+ "-F",
18
+ "#{window_id}\t#{window_index}\t#{window_name}\t#{pane_id}",
19
+ "-t",
20
+ session,
21
+ "-n",
22
+ name
23
+ ];
24
+ const result = await tmuxExec(args);
25
+ const [wid, widxRaw, windowName, paneId] = result.stdout.trim().split("\t");
26
+ return {
27
+ wid,
28
+ widx: Number(widxRaw),
29
+ name: windowName || name,
30
+ paneId
31
+ };
32
+ }
33
+
34
+ export async function windowRename(
35
+ windowId: string,
36
+ name: string
37
+ ): Promise<void> {
38
+ await tmuxExec(["rename-window", "-t", windowId, name]);
39
+ }
40
+
41
+ export async function windowKill(windowId: string): Promise<void> {
42
+ await tmuxExec(["kill-window", "-t", windowId]);
43
+ }