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,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
|
+
}
|