@taranek/orche 0.0.8
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/.orche.example.json +10 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +175 -0
- package/dist/layout.d.ts +11 -0
- package/dist/layout.js +69 -0
- package/dist/multiplexer.d.ts +16 -0
- package/dist/multiplexer.js +30 -0
- package/dist/multiplexers/cmux.d.ts +17 -0
- package/dist/multiplexers/cmux.js +126 -0
- package/dist/multiplexers/tmux.d.ts +15 -0
- package/dist/multiplexers/tmux.js +77 -0
- package/dist/review-manager.d.ts +1 -0
- package/dist/review-manager.js +225 -0
- package/dist/types.d.ts +18 -0
- package/dist/types.js +3 -0
- package/dist/worktree.d.ts +5 -0
- package/dist/worktree.js +89 -0
- package/package.json +25 -0
- package/src/cli.ts +197 -0
- package/src/layout.ts +99 -0
- package/src/multiplexer.ts +57 -0
- package/src/multiplexers/cmux.ts +143 -0
- package/src/multiplexers/tmux.ts +96 -0
- package/src/review-manager.ts +283 -0
- package/src/types.ts +24 -0
- package/src/worktree.ts +121 -0
- package/tsconfig.json +14 -0
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, existsSync, watch, unlinkSync, writeFileSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { createWorktree } from "./worktree.js";
|
|
6
|
+
import { getMultiplexer } from "./multiplexer.js";
|
|
7
|
+
import { buildLayout } from "./layout.js";
|
|
8
|
+
import { getReviewBinaryPath } from "./review-manager.js";
|
|
9
|
+
const CONFIG_NAME = ".orche.json";
|
|
10
|
+
const CONFIG_LOCAL_NAME = ".orche.local.json";
|
|
11
|
+
function die(msg) {
|
|
12
|
+
console.error(`error: ${msg}`);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
function loadConfig(cwd) {
|
|
16
|
+
const localPath = path.join(cwd, CONFIG_LOCAL_NAME);
|
|
17
|
+
const configPath = path.join(cwd, CONFIG_NAME);
|
|
18
|
+
if (existsSync(localPath)) {
|
|
19
|
+
const raw = readFileSync(localPath, "utf-8");
|
|
20
|
+
return JSON.parse(raw);
|
|
21
|
+
}
|
|
22
|
+
if (!existsSync(configPath)) {
|
|
23
|
+
die(`no ${CONFIG_NAME} found in ${cwd}`);
|
|
24
|
+
}
|
|
25
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
26
|
+
return JSON.parse(raw);
|
|
27
|
+
}
|
|
28
|
+
function deliverReview(pendingPath) {
|
|
29
|
+
try {
|
|
30
|
+
const pending = JSON.parse(readFileSync(pendingPath, "utf-8"));
|
|
31
|
+
const { reviewPath, multiplexer, paneId, workspaceId } = pending;
|
|
32
|
+
const markdown = readFileSync(reviewPath, "utf-8");
|
|
33
|
+
if (multiplexer === "tmux" && paneId) {
|
|
34
|
+
// Use load-buffer/paste-buffer instead of send-keys to safely handle
|
|
35
|
+
// multi-line markdown with special characters
|
|
36
|
+
const tmpFile = pendingPath + ".tmp";
|
|
37
|
+
writeFileSync(tmpFile, markdown);
|
|
38
|
+
execFileSync("tmux", ["load-buffer", tmpFile]);
|
|
39
|
+
execFileSync("tmux", ["paste-buffer", "-t", paneId]);
|
|
40
|
+
execFileSync("tmux", ["send-keys", "-t", paneId, "Enter"]);
|
|
41
|
+
try {
|
|
42
|
+
unlinkSync(tmpFile);
|
|
43
|
+
}
|
|
44
|
+
catch { }
|
|
45
|
+
}
|
|
46
|
+
else if (multiplexer === "cmux" && paneId) {
|
|
47
|
+
const escaped = markdown.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
|
|
48
|
+
execFileSync("cmux", ["send", "--surface", paneId, ...(workspaceId ? ["--workspace", workspaceId] : []), "--", escaped]);
|
|
49
|
+
execFileSync("cmux", ["send-key", "--surface", paneId, ...(workspaceId ? ["--workspace", workspaceId] : []), "enter"]);
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
unlinkSync(pendingPath);
|
|
53
|
+
}
|
|
54
|
+
catch { }
|
|
55
|
+
console.log("[review] delivered review to agent");
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
console.error("[review] delivery failed:", err?.message ?? err);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function runReview(worktreePath) {
|
|
62
|
+
const resolvedPath = path.resolve(worktreePath);
|
|
63
|
+
const args = ["--worktree=" + resolvedPath];
|
|
64
|
+
console.log(`opening review for ${worktreePath}...`);
|
|
65
|
+
// Watch for .pending files from the Electron app and deliver them
|
|
66
|
+
// (Electron can't access the cmux socket — only CLI processes can)
|
|
67
|
+
const repoRoot = getRepoRoot(resolvedPath);
|
|
68
|
+
const worktreeName = path.basename(resolvedPath);
|
|
69
|
+
const reviewsDir = path.join(repoRoot, ".orche", "reviews", worktreeName);
|
|
70
|
+
mkdirSync(reviewsDir, { recursive: true });
|
|
71
|
+
// CLI stays alive to watch for .pending files and deliver them via cmux/tmux
|
|
72
|
+
// (Electron can't access the cmux socket — only processes started inside cmux can)
|
|
73
|
+
const watcher = watch(reviewsDir, (event, filename) => {
|
|
74
|
+
if (event === "rename" && filename?.endsWith(".pending")) {
|
|
75
|
+
const pendingPath = path.join(reviewsDir, filename);
|
|
76
|
+
deliverReview(pendingPath);
|
|
77
|
+
watcher.close();
|
|
78
|
+
process.exit(0);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
// Dev mode: launch electron from local monorepo
|
|
82
|
+
const cliDir = path.dirname(new URL(import.meta.url).pathname);
|
|
83
|
+
const localReviewDir = path.resolve(cliDir, "../../review");
|
|
84
|
+
const devReviewPath = process.env.ORCHE_REVIEW_DEV ||
|
|
85
|
+
(existsSync(path.join(localReviewDir, "package.json")) ? localReviewDir : undefined);
|
|
86
|
+
const debug = !!process.env.ORCHE_DEBUG;
|
|
87
|
+
if (devReviewPath) {
|
|
88
|
+
const electronBin = path.join(devReviewPath, "node_modules/.bin/electron");
|
|
89
|
+
if (debug)
|
|
90
|
+
console.error(`[review] launching: ${electronBin} ${[devReviewPath, ...args].join(" ")}`);
|
|
91
|
+
spawn(electronBin, [devReviewPath, ...args], {
|
|
92
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
93
|
+
env: { ...process.env, VITE_DEV_SERVER_URL: undefined },
|
|
94
|
+
});
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const binaryPath = await getReviewBinaryPath();
|
|
98
|
+
if (debug)
|
|
99
|
+
console.error(`[review] launching: ${binaryPath} ${args.join(" ")}`);
|
|
100
|
+
spawn(binaryPath, args, {
|
|
101
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
function getRepoRoot(cwd) {
|
|
105
|
+
// If we're inside an orche worktree, walk up to the real repo
|
|
106
|
+
const orcheIdx = cwd.indexOf("/.orche/worktrees/");
|
|
107
|
+
if (orcheIdx !== -1) {
|
|
108
|
+
return cwd.slice(0, orcheIdx);
|
|
109
|
+
}
|
|
110
|
+
return cwd;
|
|
111
|
+
}
|
|
112
|
+
function startSession() {
|
|
113
|
+
const cwd = getRepoRoot(process.cwd());
|
|
114
|
+
const repoName = path.basename(cwd);
|
|
115
|
+
const taskName = process.argv.slice(3).find((a) => !a.startsWith("--")) || "session";
|
|
116
|
+
const config = loadConfig(cwd);
|
|
117
|
+
const muxFlag = process.argv.includes("--tmux") ? "tmux" :
|
|
118
|
+
process.argv.includes("--cmux") ? "cmux" :
|
|
119
|
+
undefined;
|
|
120
|
+
const mux = getMultiplexer(muxFlag ?? config.multiplexer);
|
|
121
|
+
// 1. Create worktree
|
|
122
|
+
console.log(`creating worktree for "${taskName}"...`);
|
|
123
|
+
const { worktreePath } = createWorktree(cwd, taskName);
|
|
124
|
+
// 2. Create session
|
|
125
|
+
const sessionName = `${repoName}-${taskName}`.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
126
|
+
mux.createSession(sessionName, worktreePath);
|
|
127
|
+
console.log(`${mux.name} session: ${sessionName}`);
|
|
128
|
+
// 3. Build pane layout
|
|
129
|
+
buildLayout(mux, sessionName, config.layout, worktreePath);
|
|
130
|
+
// 4. Attach
|
|
131
|
+
mux.attach(sessionName);
|
|
132
|
+
}
|
|
133
|
+
function printUsage() {
|
|
134
|
+
console.log(`
|
|
135
|
+
orche — orchestrate agents across git worktrees
|
|
136
|
+
|
|
137
|
+
Usage:
|
|
138
|
+
orche start <task> Start a new session for <task>
|
|
139
|
+
orche review [path] Open the review UI for a worktree
|
|
140
|
+
|
|
141
|
+
Examples:
|
|
142
|
+
orche start fix-auth Create worktree + session for "fix-auth"
|
|
143
|
+
orche review Review changes in current directory
|
|
144
|
+
orche review ./worktree Review changes in a specific worktree
|
|
145
|
+
|
|
146
|
+
Requires a ${CONFIG_NAME} file in the current directory.
|
|
147
|
+
Use ${CONFIG_LOCAL_NAME} for local overrides (not committed).
|
|
148
|
+
See .orche.example.json for the config format.
|
|
149
|
+
`);
|
|
150
|
+
}
|
|
151
|
+
async function main() {
|
|
152
|
+
const subcommand = process.argv[2];
|
|
153
|
+
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
|
|
154
|
+
printUsage();
|
|
155
|
+
process.exit(0);
|
|
156
|
+
}
|
|
157
|
+
if (subcommand === "start") {
|
|
158
|
+
startSession();
|
|
159
|
+
}
|
|
160
|
+
else if (subcommand === "review") {
|
|
161
|
+
const explicitPath = process.argv[3] && !process.argv[3].startsWith("--")
|
|
162
|
+
? process.argv[3]
|
|
163
|
+
: undefined;
|
|
164
|
+
const worktreePath = explicitPath || process.cwd();
|
|
165
|
+
await runReview(worktreePath);
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
console.error(`Unknown command: ${subcommand}\nRun "orche --help" for usage.`);
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
main().catch((err) => {
|
|
173
|
+
console.error(`error: ${err.message}`);
|
|
174
|
+
process.exit(1);
|
|
175
|
+
});
|
package/dist/layout.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type PaneConfig, type SplitConfig } from "./types.js";
|
|
2
|
+
import type { Multiplexer } from "./multiplexer.js";
|
|
3
|
+
export interface SessionInfo {
|
|
4
|
+
multiplexer: string;
|
|
5
|
+
panes: Record<string, string>;
|
|
6
|
+
workspaceId?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Recursively build a pane layout using the given multiplexer.
|
|
10
|
+
*/
|
|
11
|
+
export declare function buildLayout(mux: Multiplexer, session: string, node: PaneConfig | SplitConfig, worktreePath: string): void;
|
package/dist/layout.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { writeFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isSplit } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Recursively build a pane layout using the given multiplexer.
|
|
6
|
+
*/
|
|
7
|
+
export function buildLayout(mux, session, node, worktreePath) {
|
|
8
|
+
const paneMap = {};
|
|
9
|
+
if (!isSplit(node)) {
|
|
10
|
+
const paneId = mux.getActivePaneId(session);
|
|
11
|
+
paneMap[node.name] = paneId;
|
|
12
|
+
mux.renamePaneTitle(paneId, node.name);
|
|
13
|
+
if (node.command)
|
|
14
|
+
mux.sendCommand(session, node.command);
|
|
15
|
+
saveSessionInfo(mux.name, paneMap, worktreePath);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const paneIds = [mux.getActivePaneId(session)];
|
|
19
|
+
for (let i = 1; i < node.panes.length; i++) {
|
|
20
|
+
const child = node.panes[i];
|
|
21
|
+
const sizePercent = child.size ?? Math.floor(100 / (node.panes.length - i + 1));
|
|
22
|
+
mux.splitPane(paneIds[0], node.direction, sizePercent, worktreePath);
|
|
23
|
+
paneIds.push(mux.getActivePaneId(session));
|
|
24
|
+
}
|
|
25
|
+
const allPaneIds = mux.listPaneIds(session);
|
|
26
|
+
for (let i = 0; i < node.panes.length; i++) {
|
|
27
|
+
const child = node.panes[i];
|
|
28
|
+
const paneId = allPaneIds[i];
|
|
29
|
+
if (isSplit(child)) {
|
|
30
|
+
mux.focusPane(paneId);
|
|
31
|
+
collectPaneMap(mux, session, child, paneId, worktreePath, paneMap);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
paneMap[child.name] = paneId;
|
|
35
|
+
mux.renamePaneTitle(paneId, child.name);
|
|
36
|
+
if (child.command)
|
|
37
|
+
mux.sendCommand(paneId, child.command);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
saveSessionInfo(mux.name, paneMap, worktreePath);
|
|
41
|
+
}
|
|
42
|
+
function collectPaneMap(mux, session, node, parentPaneId, worktreePath, paneMap) {
|
|
43
|
+
const paneIds = [parentPaneId];
|
|
44
|
+
for (let i = 1; i < node.panes.length; i++) {
|
|
45
|
+
const child = node.panes[i];
|
|
46
|
+
const sizePercent = child.size ?? Math.floor(100 / (node.panes.length - i + 1));
|
|
47
|
+
mux.splitPane(paneIds[i - 1], node.direction, sizePercent, worktreePath);
|
|
48
|
+
paneIds.push(mux.getActivePaneId(session));
|
|
49
|
+
}
|
|
50
|
+
for (let i = 0; i < node.panes.length; i++) {
|
|
51
|
+
const child = node.panes[i];
|
|
52
|
+
if (isSplit(child)) {
|
|
53
|
+
collectPaneMap(mux, session, child, paneIds[i], worktreePath, paneMap);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
paneMap[child.name] = paneIds[i];
|
|
57
|
+
mux.renamePaneTitle(paneIds[i], child.name);
|
|
58
|
+
if (child.command)
|
|
59
|
+
mux.sendCommand(paneIds[i], child.command);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function saveSessionInfo(multiplexer, panes, worktreePath) {
|
|
64
|
+
const orcheDir = path.join(worktreePath, ".orche");
|
|
65
|
+
mkdirSync(orcheDir, { recursive: true });
|
|
66
|
+
const workspaceId = process.env.CMUX_WORKSPACE_ID || undefined;
|
|
67
|
+
const info = { multiplexer, panes, workspaceId };
|
|
68
|
+
writeFileSync(path.join(orcheDir, "session.json"), JSON.stringify(info, null, 2));
|
|
69
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { MultiplexerType } from "./types.js";
|
|
2
|
+
export interface Multiplexer {
|
|
3
|
+
readonly name: string;
|
|
4
|
+
createSession(name: string, cwd: string): void;
|
|
5
|
+
killSession(name: string): void;
|
|
6
|
+
attach(name: string): void;
|
|
7
|
+
getActivePaneId(session: string): string;
|
|
8
|
+
listPaneIds(session: string): string[];
|
|
9
|
+
splitPane(target: string, direction: "horizontal" | "vertical", sizePercent: number, cwd: string): void;
|
|
10
|
+
focusPane(paneId: string): void;
|
|
11
|
+
renamePaneTitle(paneId: string, name: string): void;
|
|
12
|
+
sendCommand(target: string, command: string): void;
|
|
13
|
+
isInsideSession(): boolean;
|
|
14
|
+
detectFirstPane(): string | undefined;
|
|
15
|
+
}
|
|
16
|
+
export declare function getMultiplexer(preference?: MultiplexerType): Multiplexer;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { TmuxMultiplexer } from "./multiplexers/tmux.js";
|
|
3
|
+
import { CmuxMultiplexer } from "./multiplexers/cmux.js";
|
|
4
|
+
function isAvailable(bin) {
|
|
5
|
+
try {
|
|
6
|
+
execSync(`which ${bin}`, { stdio: "ignore" });
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function getMultiplexer(preference = "auto") {
|
|
14
|
+
if (preference === "tmux") {
|
|
15
|
+
if (!isAvailable("tmux"))
|
|
16
|
+
throw new Error("tmux not found — brew install tmux");
|
|
17
|
+
return new TmuxMultiplexer();
|
|
18
|
+
}
|
|
19
|
+
if (preference === "cmux") {
|
|
20
|
+
if (!isAvailable("cmux"))
|
|
21
|
+
throw new Error("cmux not found — https://cmux.com");
|
|
22
|
+
return new CmuxMultiplexer();
|
|
23
|
+
}
|
|
24
|
+
// auto: prefer cmux if available, fall back to tmux
|
|
25
|
+
if (isAvailable("cmux"))
|
|
26
|
+
return new CmuxMultiplexer();
|
|
27
|
+
if (isAvailable("tmux"))
|
|
28
|
+
return new TmuxMultiplexer();
|
|
29
|
+
throw new Error("no multiplexer found — install tmux (brew install tmux) or cmux (https://cmux.com)");
|
|
30
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Multiplexer } from "../multiplexer.js";
|
|
2
|
+
export declare class CmuxMultiplexer implements Multiplexer {
|
|
3
|
+
readonly name = "cmux";
|
|
4
|
+
private surfaces;
|
|
5
|
+
createSession(_name: string, cwd: string): void;
|
|
6
|
+
killSession(_name: string): void;
|
|
7
|
+
attach(_name: string): void;
|
|
8
|
+
getActivePaneId(_session: string): string;
|
|
9
|
+
listPaneIds(_session: string): string[];
|
|
10
|
+
splitPane(_target: string, direction: "horizontal" | "vertical", _sizePercent: number, cwd: string): void;
|
|
11
|
+
focusPane(_paneId: string): void;
|
|
12
|
+
renamePaneTitle(paneId: string, name: string): void;
|
|
13
|
+
sendCommand(target: string, command: string): void;
|
|
14
|
+
isInsideSession(): boolean;
|
|
15
|
+
detectFirstPane(): string | undefined;
|
|
16
|
+
private findFirstSurface;
|
|
17
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
const DEBUG = !!process.env.ORCHE_DEBUG;
|
|
3
|
+
function log(...args) {
|
|
4
|
+
if (DEBUG)
|
|
5
|
+
console.error("[cmux]", ...args);
|
|
6
|
+
}
|
|
7
|
+
function run(cmd) {
|
|
8
|
+
log("$", cmd);
|
|
9
|
+
const out = execSync(cmd).toString().trim();
|
|
10
|
+
log("→", out.length > 200 ? out.slice(0, 200) + "…" : out);
|
|
11
|
+
return out;
|
|
12
|
+
}
|
|
13
|
+
export class CmuxMultiplexer {
|
|
14
|
+
name = "cmux";
|
|
15
|
+
// Track surfaces we created so layout can target them
|
|
16
|
+
surfaces = [];
|
|
17
|
+
createSession(_name, cwd) {
|
|
18
|
+
const initial = process.env.CMUX_SURFACE_ID;
|
|
19
|
+
log("createSession initial surface:", initial);
|
|
20
|
+
if (initial) {
|
|
21
|
+
this.surfaces = [initial];
|
|
22
|
+
this.sendCommand(initial, `cd "${cwd}"`);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
log("warning: CMUX_SURFACE_ID not set — are you running inside cmux?");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
killSession(_name) {
|
|
29
|
+
// No-op — we didn't create a workspace
|
|
30
|
+
}
|
|
31
|
+
attach(_name) {
|
|
32
|
+
// cmux is a native macOS app — already visible
|
|
33
|
+
}
|
|
34
|
+
getActivePaneId(_session) {
|
|
35
|
+
const id = this.surfaces[this.surfaces.length - 1] ?? "";
|
|
36
|
+
log("getActivePaneId →", id);
|
|
37
|
+
return id;
|
|
38
|
+
}
|
|
39
|
+
listPaneIds(_session) {
|
|
40
|
+
log("listPaneIds →", this.surfaces);
|
|
41
|
+
return [...this.surfaces];
|
|
42
|
+
}
|
|
43
|
+
splitPane(_target, direction, _sizePercent, cwd) {
|
|
44
|
+
const cmuxDir = direction === "horizontal" ? "right" : "down";
|
|
45
|
+
// new-split returns "OK surface:<ref> workspace:<ref>"
|
|
46
|
+
const output = run(`cmux new-split ${cmuxDir}`);
|
|
47
|
+
const match = output.match(/surface:(\S+)/);
|
|
48
|
+
if (match) {
|
|
49
|
+
const newRef = `surface:${match[1]}`;
|
|
50
|
+
log("splitPane new surface:", newRef);
|
|
51
|
+
this.surfaces.push(newRef);
|
|
52
|
+
// cd into worktree on the new surface
|
|
53
|
+
this.sendCommand(newRef, `cd "${cwd}"`);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
log("warning: could not parse surface from new-split output:", output);
|
|
57
|
+
}
|
|
58
|
+
log("splitPane surfaces now:", this.surfaces);
|
|
59
|
+
}
|
|
60
|
+
focusPane(_paneId) {
|
|
61
|
+
// no-op — cmux auto-focuses after split
|
|
62
|
+
}
|
|
63
|
+
renamePaneTitle(paneId, name) {
|
|
64
|
+
try {
|
|
65
|
+
execSync(`cmux rename-tab --surface ${paneId} "${name}"`, { stdio: "ignore" });
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// ignore
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
sendCommand(target, command) {
|
|
72
|
+
if (!command || !target) {
|
|
73
|
+
log("sendCommand skipped — target:", target, "command:", command);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
log("sendCommand →", target, command);
|
|
77
|
+
execSync(`cmux send --surface ${target} "${command.replace(/"/g, '\\"')}"`, { stdio: "ignore" });
|
|
78
|
+
execSync(`cmux send-key --surface ${target} enter`, { stdio: "ignore" });
|
|
79
|
+
}
|
|
80
|
+
isInsideSession() {
|
|
81
|
+
return !!process.env.CMUX_WORKSPACE_ID;
|
|
82
|
+
}
|
|
83
|
+
detectFirstPane() {
|
|
84
|
+
// Return the first surface in the workspace (where the agent typically runs)
|
|
85
|
+
// NOT the current surface (which is where `orche review` was called)
|
|
86
|
+
try {
|
|
87
|
+
const output = run(`cmux tree --json`);
|
|
88
|
+
const tree = JSON.parse(output);
|
|
89
|
+
// Walk the tree to find the first terminal surface
|
|
90
|
+
const first = this.findFirstSurface(tree);
|
|
91
|
+
if (first)
|
|
92
|
+
return first;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// fallback
|
|
96
|
+
}
|
|
97
|
+
return process.env.CMUX_SURFACE_ID ?? undefined;
|
|
98
|
+
}
|
|
99
|
+
findFirstSurface(node) {
|
|
100
|
+
if (!node || typeof node !== 'object')
|
|
101
|
+
return null;
|
|
102
|
+
const obj = node;
|
|
103
|
+
if (obj.surface_ref && typeof obj.surface_ref === 'string') {
|
|
104
|
+
// Skip the current surface
|
|
105
|
+
if (obj.surface_ref !== `surface:${process.env.CMUX_SURFACE_ID}`) {
|
|
106
|
+
return obj.surface_ref;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Recurse into arrays and objects
|
|
110
|
+
for (const val of Object.values(obj)) {
|
|
111
|
+
if (Array.isArray(val)) {
|
|
112
|
+
for (const item of val) {
|
|
113
|
+
const found = this.findFirstSurface(item);
|
|
114
|
+
if (found)
|
|
115
|
+
return found;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else if (val && typeof val === 'object') {
|
|
119
|
+
const found = this.findFirstSurface(val);
|
|
120
|
+
if (found)
|
|
121
|
+
return found;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Multiplexer } from "../multiplexer.js";
|
|
2
|
+
export declare class TmuxMultiplexer implements Multiplexer {
|
|
3
|
+
readonly name = "tmux";
|
|
4
|
+
createSession(name: string, cwd: string): void;
|
|
5
|
+
killSession(name: string): void;
|
|
6
|
+
attach(name: string): void;
|
|
7
|
+
getActivePaneId(session: string): string;
|
|
8
|
+
listPaneIds(session: string): string[];
|
|
9
|
+
splitPane(target: string, direction: "horizontal" | "vertical", sizePercent: number, cwd: string): void;
|
|
10
|
+
focusPane(paneId: string): void;
|
|
11
|
+
renamePaneTitle(paneId: string, name: string): void;
|
|
12
|
+
sendCommand(target: string, command: string): void;
|
|
13
|
+
isInsideSession(): boolean;
|
|
14
|
+
detectFirstPane(): string | undefined;
|
|
15
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
function esc(str) {
|
|
3
|
+
return `"${str.replace(/"/g, '\\"')}"`;
|
|
4
|
+
}
|
|
5
|
+
export class TmuxMultiplexer {
|
|
6
|
+
name = "tmux";
|
|
7
|
+
createSession(name, cwd) {
|
|
8
|
+
// Kill existing session with same name
|
|
9
|
+
try {
|
|
10
|
+
execSync(`tmux kill-session -t "${name}"`, { stdio: "ignore" });
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
// no existing session
|
|
14
|
+
}
|
|
15
|
+
execSync(`tmux new-session -d -s "${name}" -c "${cwd}"`, { stdio: "ignore" });
|
|
16
|
+
execSync(`tmux set-option -t "${name}" mouse on`, { stdio: "ignore" });
|
|
17
|
+
}
|
|
18
|
+
killSession(name) {
|
|
19
|
+
try {
|
|
20
|
+
execSync(`tmux kill-session -t "${name}"`, { stdio: "ignore" });
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// ignore
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
attach(name) {
|
|
27
|
+
if (process.env.TMUX) {
|
|
28
|
+
execSync(`tmux switch-client -t "${name}"`, { stdio: "inherit" });
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
execSync(`tmux attach-session -t "${name}"`, { stdio: "inherit" });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
getActivePaneId(session) {
|
|
35
|
+
return execSync(`tmux display-message -t ${session} -p "#{pane_id}"`)
|
|
36
|
+
.toString()
|
|
37
|
+
.trim();
|
|
38
|
+
}
|
|
39
|
+
listPaneIds(session) {
|
|
40
|
+
return execSync(`tmux list-panes -t ${session} -F "#{pane_id}"`)
|
|
41
|
+
.toString()
|
|
42
|
+
.trim()
|
|
43
|
+
.split("\n");
|
|
44
|
+
}
|
|
45
|
+
splitPane(target, direction, sizePercent, cwd) {
|
|
46
|
+
const flag = direction === "horizontal" ? "-h" : "-v";
|
|
47
|
+
execSync(`tmux split-window ${flag} -t ${target} -p ${sizePercent} -c "${cwd}"`, { stdio: "ignore" });
|
|
48
|
+
}
|
|
49
|
+
focusPane(paneId) {
|
|
50
|
+
execSync(`tmux select-pane -t ${paneId}`, { stdio: "ignore" });
|
|
51
|
+
}
|
|
52
|
+
renamePaneTitle(paneId, name) {
|
|
53
|
+
execSync(`tmux select-pane -t ${paneId} -T ${esc(name)}`, { stdio: "ignore" });
|
|
54
|
+
}
|
|
55
|
+
sendCommand(target, command) {
|
|
56
|
+
execSync(`tmux send-keys -t ${target} ${esc(command)} Enter`);
|
|
57
|
+
}
|
|
58
|
+
isInsideSession() {
|
|
59
|
+
return !!process.env.TMUX;
|
|
60
|
+
}
|
|
61
|
+
detectFirstPane() {
|
|
62
|
+
if (!process.env.TMUX)
|
|
63
|
+
return undefined;
|
|
64
|
+
try {
|
|
65
|
+
const sessionName = execSync("tmux display-message -p '#S'")
|
|
66
|
+
.toString()
|
|
67
|
+
.trim();
|
|
68
|
+
const paneId = execSync(`tmux list-panes -t "${sessionName}" -F "#{pane_id}" | head -1`)
|
|
69
|
+
.toString()
|
|
70
|
+
.trim();
|
|
71
|
+
return paneId || undefined;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function getReviewBinaryPath(): Promise<string>;
|