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,146 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { describe, expect, it } from "vitest";
6
+ import { codexForkHomeCleanupCommand, codexForkHomePrepareCommand } from "../../src/cli/commands/codex/forkHome";
7
+
8
+ function codexAvailable(): boolean {
9
+ const result = spawnSync("codex", ["--version"], { encoding: "utf8" });
10
+ return result.status === 0;
11
+ }
12
+
13
+ const describeIf = process.env.AGENT_TMUX_E2E_CODEX === "1" && codexAvailable() ? describe : describe.skip;
14
+
15
+ function resolveDefaultCodexHome(previousCodexHome: string | undefined): string {
16
+ if (previousCodexHome && previousCodexHome.trim()) {
17
+ return path.resolve(previousCodexHome.trim());
18
+ }
19
+ return path.resolve(path.join(process.env.HOME || "", ".codex"));
20
+ }
21
+
22
+ async function copyIfExists(src: string, dst: string): Promise<void> {
23
+ try {
24
+ await fs.stat(src);
25
+ } catch {
26
+ return;
27
+ }
28
+ await fs.copyFile(src, dst);
29
+ }
30
+
31
+ async function seedCodexHome(dstHome: string, srcHome: string): Promise<void> {
32
+ await fs.mkdir(dstHome, { recursive: true });
33
+ await copyIfExists(path.join(srcHome, "config.toml"), path.join(dstHome, "config.toml"));
34
+ await copyIfExists(path.join(srcHome, "config.json"), path.join(dstHome, "config.json"));
35
+ await copyIfExists(path.join(srcHome, "auth.json"), path.join(dstHome, "auth.json"));
36
+ await copyIfExists(path.join(srcHome, ".credentials.json"), path.join(dstHome, ".credentials.json"));
37
+ }
38
+
39
+ async function findSingleRollout(codexHome: string): Promise<string> {
40
+ const sessionsDir = path.join(codexHome, "sessions");
41
+ const stack: string[] = [sessionsDir];
42
+ const rollouts: { path: string; mtimeMs: number }[] = [];
43
+
44
+ while (stack.length) {
45
+ const current = stack.pop();
46
+ if (!current) {
47
+ continue;
48
+ }
49
+ let entries: fs.Dirent[];
50
+ try {
51
+ entries = await fs.readdir(current, { withFileTypes: true });
52
+ } catch {
53
+ continue;
54
+ }
55
+
56
+ for (const entry of entries) {
57
+ const full = path.join(current, entry.name);
58
+ if (entry.isDirectory()) {
59
+ stack.push(full);
60
+ continue;
61
+ }
62
+ if (!entry.isFile()) {
63
+ continue;
64
+ }
65
+ if (!entry.name.startsWith("rollout-") || !entry.name.endsWith(".jsonl")) {
66
+ continue;
67
+ }
68
+ const stat = await fs.stat(full);
69
+ rollouts.push({ path: full, mtimeMs: stat.mtimeMs });
70
+ }
71
+ }
72
+
73
+ if (rollouts.length === 0) {
74
+ throw new Error("no rollout file created");
75
+ }
76
+ rollouts.sort((a, b) => b.mtimeMs - a.mtimeMs);
77
+ return rollouts[0].path;
78
+ }
79
+
80
+ describeIf("codex fork-home e2e", () => {
81
+ it(
82
+ "prepare/cleanup works with guard rails (requires local codex + credentials)",
83
+ async () => {
84
+ const parentHome = await fs.mkdtemp(path.join(os.tmpdir(), "agent-tmux-codex-parent-"));
85
+ const allowedRoot = await fs.mkdtemp(path.join(os.tmpdir(), "agent-tmux-codex-allowed-"));
86
+ const forkHome = path.join(allowedRoot, "fork");
87
+
88
+ const previousHome = process.env.CODEX_HOME;
89
+ process.env.CODEX_HOME = parentHome;
90
+
91
+ try {
92
+ await seedCodexHome(parentHome, resolveDefaultCodexHome(previousHome));
93
+
94
+ const result = spawnSync(
95
+ "codex",
96
+ ["exec", "hello", "-m", "gpt-5.1-codex-mini", "-c", "model_reasoning_effort=low"],
97
+ { encoding: "utf8", env: { ...process.env, CODEX_HOME: parentHome } }
98
+ );
99
+ let parentRollout: string;
100
+ try {
101
+ parentRollout = await findSingleRollout(parentHome);
102
+ } catch {
103
+ const status = result.status ?? -1;
104
+ const stderr = (result.stderr || "").toString().slice(-2000);
105
+ const stdout = (result.stdout || "").toString().slice(-2000);
106
+ throw new Error(
107
+ `codex exec did not create rollout (status=${status}).\nstdout:\n${stdout}\nstderr:\n${stderr}`
108
+ );
109
+ }
110
+
111
+ const preparedJson = await codexForkHomePrepareCommand({
112
+ json: true,
113
+ parentRollout,
114
+ forkHome
115
+ });
116
+ const prepared = JSON.parse(preparedJson);
117
+ expect(prepared.fork_home).toBe(path.resolve(forkHome));
118
+ await fs.stat(prepared.fork_rollout_path);
119
+
120
+ await expect(
121
+ codexForkHomeCleanupCommand({
122
+ path: forkHome,
123
+ allowedRoot: path.join(os.tmpdir(), "not-allowed")
124
+ })
125
+ ).rejects.toBeTruthy();
126
+
127
+ const cleanupJson = await codexForkHomeCleanupCommand({
128
+ json: true,
129
+ path: forkHome,
130
+ allowedRoot
131
+ });
132
+ const cleaned = JSON.parse(cleanupJson);
133
+ expect(cleaned.removed).toBe(true);
134
+ } finally {
135
+ await fs.rm(parentHome, { recursive: true, force: true });
136
+ await fs.rm(allowedRoot, { recursive: true, force: true });
137
+ if (previousHome === undefined) {
138
+ delete process.env.CODEX_HOME;
139
+ } else {
140
+ process.env.CODEX_HOME = previousHome;
141
+ }
142
+ }
143
+ },
144
+ 300_000
145
+ );
146
+ });
@@ -0,0 +1,112 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { describe, expect, it } from "vitest";
6
+ import { codexSendCommand } from "../../src/cli/commands/codex/send";
7
+ import { codexSessionInfoCommand } from "../../src/cli/commands/codex/sessionInfo";
8
+ import { createTmuxHarness, tmuxAvailable } from "../integration/tmuxHarness";
9
+
10
+ function codexAvailable(): boolean {
11
+ const result = spawnSync("codex", ["--version"], { encoding: "utf8" });
12
+ return result.status === 0;
13
+ }
14
+
15
+ const describeIf =
16
+ tmuxAvailable() && process.env.AGENT_TMUX_E2E_CODEX === "1" && codexAvailable()
17
+ ? describe
18
+ : describe.skip;
19
+
20
+ function resolveDefaultCodexHome(previousCodexHome: string | undefined): string {
21
+ if (previousCodexHome && previousCodexHome.trim()) {
22
+ return path.resolve(previousCodexHome.trim());
23
+ }
24
+ return path.resolve(path.join(process.env.HOME || "", ".codex"));
25
+ }
26
+
27
+ async function copyIfExists(src: string, dst: string): Promise<void> {
28
+ try {
29
+ await fs.stat(src);
30
+ } catch {
31
+ return;
32
+ }
33
+ await fs.copyFile(src, dst);
34
+ }
35
+
36
+ async function seedCodexHome(dstHome: string, srcHome: string): Promise<void> {
37
+ await fs.mkdir(dstHome, { recursive: true });
38
+ await copyIfExists(path.join(srcHome, "config.toml"), path.join(dstHome, "config.toml"));
39
+ await copyIfExists(path.join(srcHome, "config.json"), path.join(dstHome, "config.json"));
40
+ await copyIfExists(path.join(srcHome, "auth.json"), path.join(dstHome, "auth.json"));
41
+ await copyIfExists(path.join(srcHome, ".credentials.json"), path.join(dstHome, ".credentials.json"));
42
+ }
43
+
44
+ describeIf("codex session-info e2e", () => {
45
+ it(
46
+ "returns session_id and rollout_path for a codex TUI pane",
47
+ async () => {
48
+ const harness = createTmuxHarness();
49
+ const previousServer = process.env.AGENT_TMUX_SERVER;
50
+ const previousHome = process.env.CODEX_HOME;
51
+ process.env.AGENT_TMUX_SERVER = harness.server;
52
+
53
+ const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "agent-tmux-codex-home-"));
54
+ process.env.CODEX_HOME = codexHome;
55
+
56
+ try {
57
+ await seedCodexHome(codexHome, resolveDefaultCodexHome(previousHome));
58
+
59
+ const originPaneId = harness
60
+ .run(["list-panes", "-t", `${harness.session}:0`, "-F", "#{pane_id}"])
61
+ .trim()
62
+ .split(/\r?\n/)[0]
63
+ .trim();
64
+
65
+ const paneId = harness
66
+ .run([
67
+ "split-window",
68
+ "-d",
69
+ "-P",
70
+ "-F",
71
+ "#{pane_id}",
72
+ "-t",
73
+ originPaneId,
74
+ "-e",
75
+ `CODEX_HOME=${codexHome}`,
76
+ "bash",
77
+ "-lc",
78
+ "codex -m gpt-5.1-codex-mini -c model_reasoning_effort=low"
79
+ ])
80
+ .trim();
81
+
82
+ await new Promise((resolve) => setTimeout(resolve, 2000));
83
+
84
+ await codexSendCommand(paneId, "hello", { session: harness.session, json: true });
85
+
86
+ await new Promise((resolve) => setTimeout(resolve, 2000));
87
+
88
+ const json = await codexSessionInfoCommand(paneId, { session: harness.session, json: true });
89
+ const parsed = JSON.parse(json);
90
+ expect(parsed.session_id).toMatch(
91
+ /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/
92
+ );
93
+ expect(parsed.rollout_path).toContain(path.join(codexHome, "sessions"));
94
+ await fs.stat(parsed.rollout_path);
95
+ } finally {
96
+ harness.cleanup();
97
+ await fs.rm(codexHome, { recursive: true, force: true });
98
+ if (previousServer === undefined) {
99
+ delete process.env.AGENT_TMUX_SERVER;
100
+ } else {
101
+ process.env.AGENT_TMUX_SERVER = previousServer;
102
+ }
103
+ if (previousHome === undefined) {
104
+ delete process.env.CODEX_HOME;
105
+ } else {
106
+ process.env.CODEX_HOME = previousHome;
107
+ }
108
+ }
109
+ },
110
+ 300_000
111
+ );
112
+ });
@@ -0,0 +1,68 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { describe, expect, it } from "vitest";
3
+ import { codexSendCommand } from "../../src/cli/commands/codex/send";
4
+ import { createTmuxHarness, tmuxAvailable } from "../integration/tmuxHarness";
5
+
6
+ function codexAvailable(): boolean {
7
+ const result = spawnSync("codex", ["--version"], { encoding: "utf8" });
8
+ return result.status === 0;
9
+ }
10
+
11
+ const describeIf =
12
+ tmuxAvailable() && process.env.AGENT_TMUX_E2E_CODEX === "1" && codexAvailable()
13
+ ? describe
14
+ : describe.skip;
15
+
16
+ describeIf("codex tui send e2e", () => {
17
+ it(
18
+ "can inject text and submit (requires local codex + credentials)",
19
+ async () => {
20
+ const harness = createTmuxHarness();
21
+ const previousServer = process.env.AGENT_TMUX_SERVER;
22
+ process.env.AGENT_TMUX_SERVER = harness.server;
23
+
24
+ try {
25
+ const originPaneId = harness
26
+ .run(["list-panes", "-t", `${harness.session}:0`, "-F", "#{pane_id}"])
27
+ .trim()
28
+ .split(/\r?\n/)[0]
29
+ .trim();
30
+
31
+ const paneId = harness
32
+ .run([
33
+ "split-window",
34
+ "-d",
35
+ "-P",
36
+ "-F",
37
+ "#{pane_id}",
38
+ "-t",
39
+ originPaneId,
40
+ "bash",
41
+ "-lc",
42
+ "codex -m gpt-5.1-codex-mini -c model_reasoning_effort=low"
43
+ ])
44
+ .trim();
45
+
46
+ await new Promise((resolve) => setTimeout(resolve, 1500));
47
+
48
+ const json = await codexSendCommand(paneId, "继续", {
49
+ session: harness.session,
50
+ json: true,
51
+ captureTail: 20
52
+ });
53
+ const parsed = JSON.parse(json);
54
+ expect(parsed.resolved.id).toBe(paneId);
55
+ expect(parsed.sent.submit_delay_ms).toBe(250);
56
+ expect(typeof parsed.capture_tail).toBe("string");
57
+ } finally {
58
+ harness.cleanup();
59
+ if (previousServer === undefined) {
60
+ delete process.env.AGENT_TMUX_SERVER;
61
+ } else {
62
+ process.env.AGENT_TMUX_SERVER = previousServer;
63
+ }
64
+ }
65
+ },
66
+ 300_000
67
+ );
68
+ });
@@ -0,0 +1,113 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { codexSpawnCommand } from "../../src/cli/commands/codex/spawn";
3
+ import { createTmuxHarness, tmuxAvailable } from "./tmuxHarness";
4
+
5
+ const describeIf = tmuxAvailable() ? describe : describe.skip;
6
+
7
+ function listPanes(harness: ReturnType<typeof createTmuxHarness>): Array<{ id: string; active: boolean }> {
8
+ const out = harness.run([
9
+ "list-panes",
10
+ "-t",
11
+ `${harness.session}:0`,
12
+ "-F",
13
+ "#{pane_id}\t#{pane_active}"
14
+ ]);
15
+ return out
16
+ .trim()
17
+ .split(/\r?\n/)
18
+ .map((line) => line.trim())
19
+ .filter(Boolean)
20
+ .map((line) => {
21
+ const [id, activeRaw] = line.split("\t");
22
+ return { id, active: activeRaw === "1" };
23
+ });
24
+ }
25
+
26
+ describeIf("codex spawn integration", () => {
27
+ it("creates a pane without stealing focus", async () => {
28
+ const harness = createTmuxHarness();
29
+ const previousServer = process.env.AGENT_TMUX_SERVER;
30
+ process.env.AGENT_TMUX_SERVER = harness.server;
31
+
32
+ try {
33
+ const before = listPanes(harness);
34
+ const origin = before.find((pane) => pane.active)?.id;
35
+ expect(origin).toBeTruthy();
36
+
37
+ const json = await codexSpawnCommand({ json: true, origin: String(origin) });
38
+ const parsed = JSON.parse(json);
39
+ expect(parsed.created.id).toMatch(/^%/);
40
+
41
+ const after = listPanes(harness);
42
+ const originAfter = after.find((pane) => pane.id === origin);
43
+ expect(originAfter?.active).toBe(true);
44
+ expect(after.some((pane) => pane.id === parsed.created.id)).toBe(true);
45
+ } finally {
46
+ harness.cleanup();
47
+ if (previousServer === undefined) {
48
+ delete process.env.AGENT_TMUX_SERVER;
49
+ } else {
50
+ process.env.AGENT_TMUX_SERVER = previousServer;
51
+ }
52
+ }
53
+ });
54
+
55
+ it("refuses when window layout is unsafe", async () => {
56
+ const harness = createTmuxHarness();
57
+ const previousServer = process.env.AGENT_TMUX_SERVER;
58
+ process.env.AGENT_TMUX_SERVER = harness.server;
59
+
60
+ try {
61
+ const before = listPanes(harness);
62
+ const origin = before.find((pane) => pane.active)?.id;
63
+ expect(origin).toBeTruthy();
64
+
65
+ harness.run(["split-window", "-v", "-d", "-t", String(origin), "sleep", "300"]);
66
+
67
+ await expect(codexSpawnCommand({ origin: String(origin) })).rejects.toThrow(
68
+ "leftmost column"
69
+ );
70
+
71
+ const after = listPanes(harness);
72
+ expect(after.length).toBe(before.length + 1);
73
+ } finally {
74
+ harness.cleanup();
75
+ if (previousServer === undefined) {
76
+ delete process.env.AGENT_TMUX_SERVER;
77
+ } else {
78
+ process.env.AGENT_TMUX_SERVER = previousServer;
79
+ }
80
+ }
81
+ });
82
+
83
+ it("allows explicit force-simple-split fallback", async () => {
84
+ const harness = createTmuxHarness();
85
+ const previousServer = process.env.AGENT_TMUX_SERVER;
86
+ process.env.AGENT_TMUX_SERVER = harness.server;
87
+
88
+ try {
89
+ const before = listPanes(harness);
90
+ const origin = before.find((pane) => pane.active)?.id;
91
+ expect(origin).toBeTruthy();
92
+
93
+ harness.run(["split-window", "-v", "-d", "-t", String(origin), "sleep", "300"]);
94
+
95
+ const json = await codexSpawnCommand({
96
+ json: true,
97
+ origin: String(origin),
98
+ forceSimpleSplit: true
99
+ });
100
+ const parsed = JSON.parse(json);
101
+ expect(parsed.created.id).toMatch(/^%/);
102
+ expect(parsed.meta.action).toBe("simple_split");
103
+ expect(parsed.meta.source).toBe("force-simple-split");
104
+ } finally {
105
+ harness.cleanup();
106
+ if (previousServer === undefined) {
107
+ delete process.env.AGENT_TMUX_SERVER;
108
+ } else {
109
+ process.env.AGENT_TMUX_SERVER = previousServer;
110
+ }
111
+ }
112
+ });
113
+ });
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { paneSpawnCommand } from "../../src/cli/commands/paneSpawn";
3
+ import { paneTitleCommand } from "../../src/cli/commands/paneTitle";
4
+ import { paneKillCommand } from "../../src/cli/commands/paneKill";
5
+ import { createTmuxHarness, tmuxAvailable } from "./tmuxHarness";
6
+ import { readStdin } from "../../src/lib/io/readStdin";
7
+ import { tmuxExec } from "../../src/lib/tmux/exec";
8
+
9
+ vi.mock("../../src/lib/io/readStdin", () => ({
10
+ readStdin: vi.fn()
11
+ }));
12
+
13
+ const describeIf = tmuxAvailable() ? describe : describe.skip;
14
+
15
+ describeIf("pane ops integration", () => {
16
+ it("spawns, titles, and kills panes", async () => {
17
+ const harness = createTmuxHarness();
18
+ const previousServer = process.env.AGENT_TMUX_SERVER;
19
+ process.env.AGENT_TMUX_SERVER = harness.server;
20
+
21
+ try {
22
+ vi.mocked(readStdin).mockResolvedValue("sleep 300");
23
+ const sessionList = await tmuxExec([
24
+ "list-sessions",
25
+ "-F",
26
+ "#{session_name}"
27
+ ]);
28
+ expect(sessionList.stdout).toContain(harness.session);
29
+
30
+ const spawnJson = await paneSpawnCommand("-", {
31
+ session: harness.session,
32
+ json: true,
33
+ title: "logs"
34
+ });
35
+ const created = JSON.parse(spawnJson).created;
36
+ expect(created.id).toMatch(/^%/);
37
+
38
+ const titleJson = await paneTitleCommand(created.id, "updated", {
39
+ session: harness.session,
40
+ json: true
41
+ });
42
+ const updated = JSON.parse(titleJson);
43
+ expect(updated.updated.title).toBe("updated");
44
+
45
+ const killJson = await paneKillCommand(created.id, {
46
+ session: harness.session,
47
+ json: true
48
+ });
49
+ const killed = JSON.parse(killJson);
50
+ expect(killed.killed).toBe(true);
51
+ } finally {
52
+ harness.cleanup();
53
+ if (previousServer === undefined) {
54
+ delete process.env.AGENT_TMUX_SERVER;
55
+ } else {
56
+ process.env.AGENT_TMUX_SERVER = previousServer;
57
+ }
58
+ }
59
+ });
60
+ });
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { send } from "../../src/cli/commands/send";
3
+ import { read } from "../../src/cli/commands/read";
4
+ import { createTmuxHarness, tmuxAvailable } from "./tmuxHarness";
5
+ import { readStdin } from "../../src/lib/io/readStdin";
6
+ import { tmuxExec } from "../../src/lib/tmux/exec";
7
+
8
+ vi.mock("../../src/lib/io/readStdin", () => ({
9
+ readStdin: vi.fn()
10
+ }));
11
+
12
+ const describeIf = tmuxAvailable() ? describe : describe.skip;
13
+
14
+ function wait(ms: number): Promise<void> {
15
+ return new Promise((resolve) => setTimeout(resolve, ms));
16
+ }
17
+
18
+ describeIf("send/read integration", () => {
19
+ it("sends text from stdin and reads output", async () => {
20
+ const harness = createTmuxHarness();
21
+ const previousServer = process.env.AGENT_TMUX_SERVER;
22
+ process.env.AGENT_TMUX_SERVER = harness.server;
23
+
24
+ try {
25
+ vi.mocked(readStdin).mockResolvedValue("echo stdin-test");
26
+ const sessionList = await tmuxExec([
27
+ "list-sessions",
28
+ "-F",
29
+ "#{session_name}"
30
+ ]);
31
+ expect(sessionList.stdout).toContain(harness.session);
32
+
33
+ const sendJson = await send("0", "-", { session: harness.session, json: true });
34
+ const sendData = JSON.parse(sendJson);
35
+ expect(sendData.resolved.id).toMatch(/^%/);
36
+
37
+ await send("0", "echo delay-test", { session: harness.session, enterDelayMs: 10 });
38
+
39
+ await wait(100);
40
+ const output = await read("0", { session: harness.session, lines: 20 });
41
+ expect(output).toContain("stdin-test");
42
+ expect(output).toContain("delay-test");
43
+ } finally {
44
+ harness.cleanup();
45
+ if (previousServer === undefined) {
46
+ delete process.env.AGENT_TMUX_SERVER;
47
+ } else {
48
+ process.env.AGENT_TMUX_SERVER = previousServer;
49
+ }
50
+ }
51
+ });
52
+ });
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { windowLs } from "../../src/cli/commands/windowLs";
3
+ import { snapshot } from "../../src/cli/commands/snapshot";
4
+ import { createTmuxHarness, tmuxAvailable } from "./tmuxHarness";
5
+
6
+ const describeIf = tmuxAvailable() ? describe : describe.skip;
7
+
8
+ describeIf("snapshot integration", () => {
9
+ it("lists windows and snapshots panes", async () => {
10
+ const harness = createTmuxHarness();
11
+ const previousServer = process.env.AGENT_TMUX_SERVER;
12
+ process.env.AGENT_TMUX_SERVER = harness.server;
13
+
14
+ try {
15
+ harness.run(["split-window", "-t", `${harness.session}:0`]);
16
+ harness.run(["new-window", "-t", harness.session, "-n", "logs"]);
17
+ harness.run(["split-window", "-t", `${harness.session}:1`]);
18
+
19
+ const windowJson = await windowLs({ session: harness.session, json: true });
20
+ const windowData = JSON.parse(windowJson);
21
+ expect(windowData.windows.length).toBe(2);
22
+
23
+ const snapshotJson = await snapshot({
24
+ session: harness.session,
25
+ window: "0",
26
+ json: true
27
+ });
28
+ const snapshotData = JSON.parse(snapshotJson);
29
+ expect(snapshotData.panes.length).toBe(2);
30
+ } finally {
31
+ harness.cleanup();
32
+ if (previousServer === undefined) {
33
+ delete process.env.AGENT_TMUX_SERVER;
34
+ } else {
35
+ process.env.AGENT_TMUX_SERVER = previousServer;
36
+ }
37
+ }
38
+ });
39
+ });
@@ -0,0 +1,39 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { spawnSync } from "node:child_process";
3
+
4
+ export type TmuxHarness = {
5
+ server: string;
6
+ session: string;
7
+ run: (args: string[], input?: string) => string;
8
+ cleanup: () => void;
9
+ };
10
+
11
+ export function tmuxAvailable(): boolean {
12
+ const result = spawnSync("tmux", ["-V"], { encoding: "utf8" });
13
+ return result.status === 0;
14
+ }
15
+
16
+ export function createTmuxHarness(): TmuxHarness {
17
+ const serverSuffix = randomUUID().replace(/-/g, "").slice(0, 12);
18
+ const server = `agent-tmux-test-${process.pid}-${serverSuffix}`;
19
+ const session = `session-${randomUUID()}`;
20
+
21
+ const run = (args: string[], input?: string): string => {
22
+ const result = spawnSync("tmux", ["-L", server, "-f", "/dev/null", ...args], {
23
+ encoding: "utf8",
24
+ input
25
+ });
26
+ if (result.status !== 0) {
27
+ throw new Error(result.stderr || result.stdout || "tmux command failed");
28
+ }
29
+ return result.stdout;
30
+ };
31
+
32
+ run(["new-session", "-d", "-s", session, "-n", "main", "sleep", "300"]);
33
+
34
+ const cleanup = (): void => {
35
+ spawnSync("tmux", ["-L", server, "-f", "/dev/null", "kill-server"], { encoding: "utf8" });
36
+ };
37
+
38
+ return { server, session, run, cleanup };
39
+ }
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { windowNewCommand } from "../../src/cli/commands/windowNew";
3
+ import { windowRenameCommand } from "../../src/cli/commands/windowRename";
4
+ import { windowKillCommand } from "../../src/cli/commands/windowKill";
5
+ import { createTmuxHarness, tmuxAvailable } from "./tmuxHarness";
6
+ import { readStdin } from "../../src/lib/io/readStdin";
7
+ import { tmuxExec } from "../../src/lib/tmux/exec";
8
+
9
+ vi.mock("../../src/lib/io/readStdin", () => ({
10
+ readStdin: vi.fn()
11
+ }));
12
+
13
+ const describeIf = tmuxAvailable() ? describe : describe.skip;
14
+
15
+ describeIf("window ops integration", () => {
16
+ it("creates, renames, and kills windows", async () => {
17
+ const harness = createTmuxHarness();
18
+ const previousServer = process.env.AGENT_TMUX_SERVER;
19
+ process.env.AGENT_TMUX_SERVER = harness.server;
20
+
21
+ try {
22
+ vi.mocked(readStdin).mockResolvedValue("sleep 300");
23
+ const sessionList = await tmuxExec([
24
+ "list-sessions",
25
+ "-F",
26
+ "#{session_name}"
27
+ ]);
28
+ expect(sessionList.stdout).toContain(harness.session);
29
+
30
+ const newJson = await windowNewCommand("build", {
31
+ session: harness.session,
32
+ json: true,
33
+ command: "-"
34
+ });
35
+ const created = JSON.parse(newJson).created;
36
+ expect(created.wid).toMatch(/^@/);
37
+
38
+ const renameJson = await windowRenameCommand(String(created.widx), "renamed", {
39
+ session: harness.session,
40
+ json: true
41
+ });
42
+ const renamed = JSON.parse(renameJson);
43
+ expect(renamed.renamed.name).toBe("renamed");
44
+
45
+ const killJson = await windowKillCommand(String(created.widx), {
46
+ session: harness.session,
47
+ json: true
48
+ });
49
+ const killed = JSON.parse(killJson);
50
+ expect(killed.killed).toBe(true);
51
+ } finally {
52
+ harness.cleanup();
53
+ if (previousServer === undefined) {
54
+ delete process.env.AGENT_TMUX_SERVER;
55
+ } else {
56
+ process.env.AGENT_TMUX_SERVER = previousServer;
57
+ }
58
+ }
59
+ });
60
+ });