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.
- package/.codex/skills/speckit/SKILL.md +173 -0
- package/.codex/skills/speckit/assets/templates/checklist-template.md +49 -0
- package/.codex/skills/speckit/assets/templates/notes-entrypoints-template.md +11 -0
- package/.codex/skills/speckit/assets/templates/notes-questions-template.md +7 -0
- package/.codex/skills/speckit/assets/templates/notes-readme-template.md +36 -0
- package/.codex/skills/speckit/assets/templates/notes-session-template.md +21 -0
- package/.codex/skills/speckit/assets/templates/plan-template.md +126 -0
- package/.codex/skills/speckit/assets/templates/spec-template.md +135 -0
- package/.codex/skills/speckit/assets/templates/tasks-template.md +269 -0
- package/.codex/skills/speckit/references/acceptance.md +183 -0
- package/.codex/skills/speckit/references/analyze.md +186 -0
- package/.codex/skills/speckit/references/checklist.md +302 -0
- package/.codex/skills/speckit/references/clarify-auto.md +69 -0
- package/.codex/skills/speckit/references/clarify-detailed.md +78 -0
- package/.codex/skills/speckit/references/clarify.md +189 -0
- package/.codex/skills/speckit/references/constitution.md +90 -0
- package/.codex/skills/speckit/references/group.md +89 -0
- package/.codex/skills/speckit/references/implement-task.md +115 -0
- package/.codex/skills/speckit/references/implement.md +129 -0
- package/.codex/skills/speckit/references/notes.md +82 -0
- package/.codex/skills/speckit/references/plan-deep.md +87 -0
- package/.codex/skills/speckit/references/plan-from-questions.md +115 -0
- package/.codex/skills/speckit/references/plan-from-review.md +89 -0
- package/.codex/skills/speckit/references/plan.md +97 -0
- package/.codex/skills/speckit/references/review-plan.md +156 -0
- package/.codex/skills/speckit/references/specify.md +246 -0
- package/.codex/skills/speckit/references/tasks.md +155 -0
- package/.codex/skills/speckit/references/taskstoissues.md +33 -0
- package/.codex/skills/speckit/scripts/bash/check-prerequisites.sh +206 -0
- package/.codex/skills/speckit/scripts/bash/common.sh +191 -0
- package/.codex/skills/speckit/scripts/bash/create-new-feature.sh +259 -0
- package/.codex/skills/speckit/scripts/bash/extract-coded-points.sh +322 -0
- package/.codex/skills/speckit/scripts/bash/extract-spec-ids.sh +238 -0
- package/.codex/skills/speckit/scripts/bash/extract-tasks.sh +295 -0
- package/.codex/skills/speckit/scripts/bash/extract-user-stories.sh +312 -0
- package/.codex/skills/speckit/scripts/bash/setup-notes.sh +182 -0
- package/.codex/skills/speckit/scripts/bash/setup-plan.sh +110 -0
- package/.codex/skills/speckit/scripts/bash/show-todo-tasks.sh +257 -0
- package/.codex/skills/speckit/scripts/bash/spec-group-checklist.sh +402 -0
- package/.codex/skills/speckit/scripts/bash/spec-group-members.sh +215 -0
- package/.codex/skills/speckit/scripts/bash/spec-registry-graph.sh +399 -0
- package/.specify/memory/constitution.md +67 -0
- package/.specify/templates/agent-file-template.md +28 -0
- package/.specify/templates/checklist-template.md +49 -0
- package/.specify/templates/plan-template.md +126 -0
- package/.specify/templates/spec-template.md +135 -0
- package/.specify/templates/tasks-template.md +269 -0
- package/README.md +128 -0
- package/README.zh-CN.md +127 -0
- package/bun.lock +269 -0
- package/dist/cli/commands/codex/forkHome.js +88 -0
- package/dist/cli/commands/codex/send.js +55 -0
- package/dist/cli/commands/codex/sessionInfo.js +42 -0
- package/dist/cli/commands/codex/spawn.js +68 -0
- package/dist/cli/commands/find.js +26 -0
- package/dist/cli/commands/paneKill.js +33 -0
- package/dist/cli/commands/paneSpawn.js +40 -0
- package/dist/cli/commands/paneTitle.js +33 -0
- package/dist/cli/commands/read.js +34 -0
- package/dist/cli/commands/send.js +51 -0
- package/dist/cli/commands/snapshot.js +19 -0
- package/dist/cli/commands/ui/select.js +41 -0
- package/dist/cli/commands/windowKill.js +25 -0
- package/dist/cli/commands/windowLs.js +15 -0
- package/dist/cli/commands/windowNew.js +28 -0
- package/dist/cli/commands/windowRename.js +25 -0
- package/dist/cli/index.js +365 -0
- package/dist/cli/parse.js +39 -0
- package/dist/lib/codex/forkHome.js +101 -0
- package/dist/lib/codex/isCodexPane.js +55 -0
- package/dist/lib/codex/send.js +58 -0
- package/dist/lib/codex/sessionInfo.js +449 -0
- package/dist/lib/codex/spawn.js +246 -0
- package/dist/lib/contracts/types.js +2 -0
- package/dist/lib/fs/safeRm.js +32 -0
- package/dist/lib/io/readStdin.js +14 -0
- package/dist/lib/os/process.js +55 -0
- package/dist/lib/output/format.js +95 -0
- package/dist/lib/proc/lsof.js +42 -0
- package/dist/lib/proc/ps.js +60 -0
- package/dist/lib/targeting/errors.js +13 -0
- package/dist/lib/targeting/resolvePaneTarget.js +91 -0
- package/dist/lib/targeting/resolveWindowTarget.js +40 -0
- package/dist/lib/targeting/scope.js +58 -0
- package/dist/lib/tmux/capturePane.js +20 -0
- package/dist/lib/tmux/exec.js +66 -0
- package/dist/lib/tmux/paneOps.js +29 -0
- package/dist/lib/tmux/paste.js +23 -0
- package/dist/lib/tmux/sendKeys.js +47 -0
- package/dist/lib/tmux/session.js +29 -0
- package/dist/lib/tmux/snapshotPanes.js +46 -0
- package/dist/lib/tmux/snapshotWindows.js +24 -0
- package/dist/lib/tmux/windowOps.js +32 -0
- package/dist/lib/ui/popupSelect.js +432 -0
- package/dist/lib/ui/popupSupport.js +76 -0
- package/package.json +23 -0
- package/src/cli/commands/codex/forkHome.ts +141 -0
- package/src/cli/commands/codex/send.ts +83 -0
- package/src/cli/commands/codex/sessionInfo.ts +59 -0
- package/src/cli/commands/codex/spawn.ts +90 -0
- package/src/cli/commands/find.ts +40 -0
- package/src/cli/commands/paneKill.ts +49 -0
- package/src/cli/commands/paneSpawn.ts +53 -0
- package/src/cli/commands/paneTitle.ts +50 -0
- package/src/cli/commands/read.ts +48 -0
- package/src/cli/commands/send.ts +71 -0
- package/src/cli/commands/snapshot.ts +28 -0
- package/src/cli/commands/ui/select.ts +49 -0
- package/src/cli/commands/windowKill.ts +35 -0
- package/src/cli/commands/windowLs.ts +20 -0
- package/src/cli/commands/windowNew.ts +40 -0
- package/src/cli/commands/windowRename.ts +36 -0
- package/src/cli/index.ts +430 -0
- package/src/lib/codex/forkHome.ts +148 -0
- package/src/lib/codex/isCodexPane.ts +56 -0
- package/src/lib/codex/send.ts +84 -0
- package/src/lib/codex/sessionInfo.ts +521 -0
- package/src/lib/codex/spawn.ts +305 -0
- package/src/lib/contracts/types.ts +30 -0
- package/src/lib/fs/safeRm.ts +32 -0
- package/src/lib/io/readStdin.ts +11 -0
- package/src/lib/output/format.ts +105 -0
- package/src/lib/proc/lsof.ts +44 -0
- package/src/lib/proc/ps.ts +70 -0
- package/src/lib/targeting/errors.ts +25 -0
- package/src/lib/targeting/resolvePaneTarget.ts +106 -0
- package/src/lib/targeting/resolveWindowTarget.ts +45 -0
- package/src/lib/targeting/scope.ts +76 -0
- package/src/lib/tmux/capturePane.ts +21 -0
- package/src/lib/tmux/exec.ts +90 -0
- package/src/lib/tmux/paneOps.ts +35 -0
- package/src/lib/tmux/paste.ts +20 -0
- package/src/lib/tmux/sendKeys.ts +72 -0
- package/src/lib/tmux/session.ts +27 -0
- package/src/lib/tmux/snapshotPanes.ts +52 -0
- package/src/lib/tmux/snapshotWindows.ts +23 -0
- package/src/lib/tmux/windowOps.ts +43 -0
- package/src/lib/ui/popupSelect.ts +561 -0
- package/src/lib/ui/popupSupport.ts +84 -0
- package/tests/e2e/codexForkHome.test.ts +146 -0
- package/tests/e2e/codexSessionInfo.test.ts +112 -0
- package/tests/e2e/codexTuiSend.test.ts +68 -0
- package/tests/integration/codexSpawn.test.ts +113 -0
- package/tests/integration/paneOps.test.ts +60 -0
- package/tests/integration/sendRead.test.ts +52 -0
- package/tests/integration/snapshot.test.ts +39 -0
- package/tests/integration/tmuxHarness.ts +39 -0
- package/tests/integration/windowOps.test.ts +60 -0
- package/tests/unit/codexSend.test.ts +105 -0
- package/tests/unit/codexSessionInfo.test.ts +88 -0
- package/tests/unit/codexSpawn.test.ts +34 -0
- package/tests/unit/keys.test.ts +30 -0
- package/tests/unit/outputFormat.test.ts +52 -0
- package/tests/unit/popupSelect.test.ts +77 -0
- package/tests/unit/popupSupport.test.ts +109 -0
- package/tests/unit/resolvePaneTarget.test.ts +43 -0
- package/tests/unit/resolveWindowTarget.test.ts +36 -0
- package/tests/unit/safeRm.test.ts +41 -0
- package/tests/unit/scope.test.ts +57 -0
- package/tsconfig.json +14 -0
- 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
|
+
});
|