@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
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, createWriteStream, } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import https from "node:https";
|
|
5
|
+
const REPO = "taranek/orche";
|
|
6
|
+
const TAG_PREFIX = "review-v";
|
|
7
|
+
// Pin review binary version to the CLI version
|
|
8
|
+
const CLI_VERSION = (() => {
|
|
9
|
+
try {
|
|
10
|
+
const pkgPath = new URL("../package.json", import.meta.url).pathname;
|
|
11
|
+
return JSON.parse(readFileSync(pkgPath, "utf-8")).version;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
})();
|
|
17
|
+
const ORCHE_DIR = path.join(process.env.HOME || process.env.USERPROFILE || "~", ".orche", "review");
|
|
18
|
+
const BIN_DIR = path.join(ORCHE_DIR, "bin");
|
|
19
|
+
const VERSION_FILE = path.join(ORCHE_DIR, "version.json");
|
|
20
|
+
const LOCK_FILE = path.join(ORCHE_DIR, ".downloading");
|
|
21
|
+
function readVersionInfo() {
|
|
22
|
+
if (!existsSync(VERSION_FILE))
|
|
23
|
+
return null;
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(readFileSync(VERSION_FILE, "utf-8"));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function writeVersionInfo(info) {
|
|
32
|
+
mkdirSync(ORCHE_DIR, { recursive: true });
|
|
33
|
+
writeFileSync(VERSION_FILE, JSON.stringify(info, null, 2));
|
|
34
|
+
}
|
|
35
|
+
function getPlatformKey() {
|
|
36
|
+
const arch = process.arch === "arm64" ? "arm64" : "x64";
|
|
37
|
+
if (process.platform === "darwin") {
|
|
38
|
+
return { platform: "mac", arch, ext: "zip" };
|
|
39
|
+
}
|
|
40
|
+
if (process.platform === "linux") {
|
|
41
|
+
return { platform: "linux", arch, ext: "tar.gz" };
|
|
42
|
+
}
|
|
43
|
+
throw new Error(`unsupported platform: ${process.platform}`);
|
|
44
|
+
}
|
|
45
|
+
function getExecutablePath() {
|
|
46
|
+
if (process.platform === "darwin") {
|
|
47
|
+
return path.join(BIN_DIR, "orche-review.app", "Contents", "MacOS", "orche-review");
|
|
48
|
+
}
|
|
49
|
+
return path.join(BIN_DIR, "orche-review", "orche-review");
|
|
50
|
+
}
|
|
51
|
+
function fetchJson(url) {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
const get = (requestUrl) => {
|
|
54
|
+
https.get(requestUrl, { headers: { "User-Agent": "orche-cli", Accept: "application/vnd.github+json" } }, (res) => {
|
|
55
|
+
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
56
|
+
get(res.headers.location);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (res.statusCode !== 200) {
|
|
60
|
+
reject(new Error(`HTTP ${res.statusCode} from ${requestUrl}`));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
let data = "";
|
|
64
|
+
res.on("data", (chunk) => (data += chunk));
|
|
65
|
+
res.on("end", () => resolve(JSON.parse(data)));
|
|
66
|
+
res.on("error", reject);
|
|
67
|
+
}).on("error", reject);
|
|
68
|
+
};
|
|
69
|
+
get(url);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
function download(url, dest) {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
75
|
+
const file = createWriteStream(dest);
|
|
76
|
+
const get = (requestUrl) => {
|
|
77
|
+
https.get(requestUrl, { headers: { "User-Agent": "orche-cli" } }, (res) => {
|
|
78
|
+
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
79
|
+
get(res.headers.location);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (res.statusCode !== 200) {
|
|
83
|
+
file.close();
|
|
84
|
+
reject(new Error(`HTTP ${res.statusCode} downloading ${requestUrl}`));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const totalBytes = parseInt(res.headers["content-length"] || "0", 10);
|
|
88
|
+
let downloadedBytes = 0;
|
|
89
|
+
res.on("data", (chunk) => {
|
|
90
|
+
downloadedBytes += chunk.length;
|
|
91
|
+
if (totalBytes > 0) {
|
|
92
|
+
const pct = Math.round((downloadedBytes / totalBytes) * 100);
|
|
93
|
+
const mb = (downloadedBytes / 1024 / 1024).toFixed(1);
|
|
94
|
+
const totalMb = (totalBytes / 1024 / 1024).toFixed(1);
|
|
95
|
+
process.stderr.write(`\r downloading: ${mb}/${totalMb} MB (${pct}%)`);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
res.pipe(file);
|
|
99
|
+
file.on("finish", () => {
|
|
100
|
+
if (totalBytes > 0)
|
|
101
|
+
process.stderr.write("\n");
|
|
102
|
+
file.close();
|
|
103
|
+
resolve();
|
|
104
|
+
});
|
|
105
|
+
file.on("error", reject);
|
|
106
|
+
}).on("error", reject);
|
|
107
|
+
};
|
|
108
|
+
get(url);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
async function findRelease() {
|
|
112
|
+
const releases = await fetchJson(`https://api.github.com/repos/${REPO}/releases`);
|
|
113
|
+
// Find release matching CLI version, or fall back to latest review release
|
|
114
|
+
const targetTag = CLI_VERSION ? `${TAG_PREFIX}${CLI_VERSION}` : null;
|
|
115
|
+
const reviewRelease = targetTag
|
|
116
|
+
? releases.find((r) => r.tag_name === targetTag)
|
|
117
|
+
: releases.find((r) => r.tag_name.startsWith(TAG_PREFIX));
|
|
118
|
+
if (!reviewRelease)
|
|
119
|
+
return null;
|
|
120
|
+
const version = reviewRelease.tag_name.slice(TAG_PREFIX.length);
|
|
121
|
+
const { platform, arch, ext } = getPlatformKey();
|
|
122
|
+
// Match asset by platform and arch in filename
|
|
123
|
+
const asset = reviewRelease.assets.find((a) => {
|
|
124
|
+
const name = a.name.toLowerCase();
|
|
125
|
+
if (!name.includes(platform) || !name.endsWith(`.${ext}`) || name.includes("blockmap"))
|
|
126
|
+
return false;
|
|
127
|
+
if (arch === "arm64")
|
|
128
|
+
return name.includes("arm64");
|
|
129
|
+
return !name.includes("arm64");
|
|
130
|
+
});
|
|
131
|
+
if (!asset)
|
|
132
|
+
return null;
|
|
133
|
+
return { version, downloadUrl: asset.browser_download_url };
|
|
134
|
+
}
|
|
135
|
+
async function downloadAndExtract(url, version) {
|
|
136
|
+
const tmpDir = path.join(ORCHE_DIR, ".tmp");
|
|
137
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
138
|
+
const { ext } = getPlatformKey();
|
|
139
|
+
const archivePath = path.join(tmpDir, `orche-review.${ext}`);
|
|
140
|
+
await download(url, archivePath);
|
|
141
|
+
// Clean existing bin directory
|
|
142
|
+
execSync(`rm -rf "${BIN_DIR}"`);
|
|
143
|
+
mkdirSync(BIN_DIR, { recursive: true });
|
|
144
|
+
// Extract
|
|
145
|
+
if (ext === "zip") {
|
|
146
|
+
execSync(`unzip -q -o "${archivePath}" -d "${BIN_DIR}"`);
|
|
147
|
+
// Clear macOS quarantine
|
|
148
|
+
try {
|
|
149
|
+
execSync(`xattr -cr "${BIN_DIR}"`, { stdio: "ignore" });
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// xattr may not exist on all systems
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
execSync(`tar xzf "${archivePath}" -C "${BIN_DIR}"`);
|
|
157
|
+
}
|
|
158
|
+
// Ensure executable permissions on Linux
|
|
159
|
+
if (process.platform === "linux") {
|
|
160
|
+
const executablePath = getExecutablePath();
|
|
161
|
+
if (existsSync(executablePath)) {
|
|
162
|
+
execSync(`chmod +x "${executablePath}"`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// Write version info
|
|
166
|
+
writeVersionInfo({
|
|
167
|
+
version,
|
|
168
|
+
downloadedAt: new Date().toISOString(),
|
|
169
|
+
lastCheckedAt: new Date().toISOString(),
|
|
170
|
+
});
|
|
171
|
+
// Cleanup
|
|
172
|
+
execSync(`rm -rf "${tmpDir}"`);
|
|
173
|
+
}
|
|
174
|
+
function acquireLock() {
|
|
175
|
+
if (existsSync(LOCK_FILE)) {
|
|
176
|
+
try {
|
|
177
|
+
const lockTime = parseInt(readFileSync(LOCK_FILE, "utf-8"), 10);
|
|
178
|
+
// Stale if older than 10 minutes
|
|
179
|
+
if (Date.now() - lockTime < 10 * 60 * 1000)
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// corrupt lock, proceed
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
mkdirSync(ORCHE_DIR, { recursive: true });
|
|
187
|
+
writeFileSync(LOCK_FILE, String(Date.now()));
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
function releaseLock() {
|
|
191
|
+
try {
|
|
192
|
+
unlinkSync(LOCK_FILE);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
// ignore
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
export async function getReviewBinaryPath() {
|
|
199
|
+
const executablePath = getExecutablePath();
|
|
200
|
+
const info = readVersionInfo();
|
|
201
|
+
const needsDownload = !info || !existsSync(executablePath)
|
|
202
|
+
|| (CLI_VERSION && info.version !== CLI_VERSION);
|
|
203
|
+
if (!needsDownload)
|
|
204
|
+
return executablePath;
|
|
205
|
+
if (info && CLI_VERSION && info.version !== CLI_VERSION) {
|
|
206
|
+
console.log(`review app version mismatch (${info.version} → ${CLI_VERSION}), updating...`);
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
console.log("review app not found, downloading...");
|
|
210
|
+
}
|
|
211
|
+
if (!acquireLock()) {
|
|
212
|
+
throw new Error("another download is in progress — try again in a moment");
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
const release = await findRelease();
|
|
216
|
+
if (!release) {
|
|
217
|
+
throw new Error("no review app release found — check https://github.com/taranek/orche/releases");
|
|
218
|
+
}
|
|
219
|
+
await downloadAndExtract(release.downloadUrl, release.version);
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
releaseLock();
|
|
223
|
+
}
|
|
224
|
+
return executablePath;
|
|
225
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface PaneConfig {
|
|
2
|
+
name: string;
|
|
3
|
+
command: string;
|
|
4
|
+
/** Percentage size of this pane (optional, defaults to equal) */
|
|
5
|
+
size?: number;
|
|
6
|
+
}
|
|
7
|
+
export interface SplitConfig {
|
|
8
|
+
direction: "horizontal" | "vertical";
|
|
9
|
+
panes: (PaneConfig | SplitConfig)[];
|
|
10
|
+
/** Percentage size of this split (optional, defaults to equal) */
|
|
11
|
+
size?: number;
|
|
12
|
+
}
|
|
13
|
+
export type MultiplexerType = "tmux" | "cmux" | "auto";
|
|
14
|
+
export interface AgentsConfig {
|
|
15
|
+
multiplexer?: MultiplexerType;
|
|
16
|
+
layout: PaneConfig | SplitConfig;
|
|
17
|
+
}
|
|
18
|
+
export declare function isSplit(node: PaneConfig | SplitConfig): node is SplitConfig;
|
package/dist/types.js
ADDED
package/dist/worktree.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, appendFileSync, mkdirSync, symlinkSync, readdirSync, lstatSync, } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
function slugify(text) {
|
|
5
|
+
return text
|
|
6
|
+
.toLowerCase()
|
|
7
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
8
|
+
.replace(/^-|-$/g, "")
|
|
9
|
+
.slice(0, 50);
|
|
10
|
+
}
|
|
11
|
+
function ensureGitignore(repoPath, pattern) {
|
|
12
|
+
const gitignorePath = path.join(repoPath, ".gitignore");
|
|
13
|
+
let content = "";
|
|
14
|
+
if (existsSync(gitignorePath)) {
|
|
15
|
+
content = readFileSync(gitignorePath, "utf-8");
|
|
16
|
+
}
|
|
17
|
+
if (!content.includes(pattern)) {
|
|
18
|
+
appendFileSync(gitignorePath, `\n# Agent worktrees\n${pattern}\n`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function ensureGitRepo(repoPath) {
|
|
22
|
+
try {
|
|
23
|
+
execSync("git rev-parse --git-dir", { cwd: repoPath, stdio: "ignore" });
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
execSync('git init && git add -A && git commit -m "Initial commit" --allow-empty', { cwd: repoPath, stdio: "ignore" });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function createWorktree(repoPath, name) {
|
|
30
|
+
ensureGitRepo(repoPath);
|
|
31
|
+
const slug = slugify(name);
|
|
32
|
+
const timestamp = Date.now();
|
|
33
|
+
const branchName = `agent/${slug}-${timestamp}`;
|
|
34
|
+
const worktreesDir = path.join(repoPath, ".orche", "worktrees");
|
|
35
|
+
const worktreePath = path.join(worktreesDir, `${slug}-${timestamp}`);
|
|
36
|
+
mkdirSync(worktreesDir, { recursive: true });
|
|
37
|
+
ensureGitignore(repoPath, ".orche/");
|
|
38
|
+
execSync(`git worktree add -b "${branchName}" "${worktreePath}"`, {
|
|
39
|
+
cwd: repoPath,
|
|
40
|
+
stdio: "ignore",
|
|
41
|
+
});
|
|
42
|
+
symlinkNodeModules(repoPath, worktreePath);
|
|
43
|
+
console.log(` worktree: ${worktreePath} (${branchName})`);
|
|
44
|
+
return { worktreePath, branchName };
|
|
45
|
+
}
|
|
46
|
+
function symlinkNodeModules(repoPath, worktreePath) {
|
|
47
|
+
// Symlink root node_modules
|
|
48
|
+
const rootNm = path.join(repoPath, "node_modules");
|
|
49
|
+
const targetNm = path.join(worktreePath, "node_modules");
|
|
50
|
+
if (existsSync(rootNm) && !existsSync(targetNm)) {
|
|
51
|
+
symlinkSync(rootNm, targetNm);
|
|
52
|
+
}
|
|
53
|
+
// Symlink node_modules in subdirectories (monorepo packages)
|
|
54
|
+
symlinkNestedNodeModules(repoPath, worktreePath, repoPath);
|
|
55
|
+
}
|
|
56
|
+
function symlinkNestedNodeModules(dir, worktreePath, repoPath) {
|
|
57
|
+
let entries;
|
|
58
|
+
try {
|
|
59
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
if (!entry.isDirectory())
|
|
66
|
+
continue;
|
|
67
|
+
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === ".orche")
|
|
68
|
+
continue;
|
|
69
|
+
const fullPath = path.join(dir, entry.name);
|
|
70
|
+
// Skip symlinks
|
|
71
|
+
try {
|
|
72
|
+
if (lstatSync(fullPath).isSymbolicLink())
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const nm = path.join(fullPath, "node_modules");
|
|
79
|
+
if (existsSync(nm)) {
|
|
80
|
+
const rel = path.relative(repoPath, fullPath);
|
|
81
|
+
const targetDir = path.join(worktreePath, rel);
|
|
82
|
+
const targetNm = path.join(targetDir, "node_modules");
|
|
83
|
+
if (!existsSync(targetNm) && existsSync(targetDir)) {
|
|
84
|
+
symlinkSync(nm, targetNm);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
symlinkNestedNodeModules(fullPath, worktreePath, repoPath);
|
|
88
|
+
}
|
|
89
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@taranek/orche",
|
|
3
|
+
"version": "0.0.8",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"orche": "./dist/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/taranek/orche.git",
|
|
11
|
+
"directory": "packages/cli"
|
|
12
|
+
},
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"registry": "https://registry.npmjs.org",
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc",
|
|
19
|
+
"dev": "tsc --watch"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"typescript": "^5.2.2",
|
|
23
|
+
"@types/node": "^25.2.0"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFileSync, existsSync, watch, unlinkSync, writeFileSync, mkdirSync } from "node:fs";
|
|
4
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { createWorktree } from "./worktree.js";
|
|
7
|
+
import { getMultiplexer } from "./multiplexer.js";
|
|
8
|
+
import { buildLayout } from "./layout.js";
|
|
9
|
+
import { getReviewBinaryPath } from "./review-manager.js";
|
|
10
|
+
import type { AgentsConfig, MultiplexerType } from "./types.js";
|
|
11
|
+
|
|
12
|
+
const CONFIG_NAME = ".orche.json";
|
|
13
|
+
const CONFIG_LOCAL_NAME = ".orche.local.json";
|
|
14
|
+
|
|
15
|
+
function die(msg: string): never {
|
|
16
|
+
console.error(`error: ${msg}`);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
function loadConfig(cwd: string): AgentsConfig {
|
|
22
|
+
const localPath = path.join(cwd, CONFIG_LOCAL_NAME);
|
|
23
|
+
const configPath = path.join(cwd, CONFIG_NAME);
|
|
24
|
+
|
|
25
|
+
if (existsSync(localPath)) {
|
|
26
|
+
const raw = readFileSync(localPath, "utf-8");
|
|
27
|
+
return JSON.parse(raw) as AgentsConfig;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!existsSync(configPath)) {
|
|
31
|
+
die(`no ${CONFIG_NAME} found in ${cwd}`);
|
|
32
|
+
}
|
|
33
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
34
|
+
return JSON.parse(raw) as AgentsConfig;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function deliverReview(pendingPath: string): void {
|
|
38
|
+
try {
|
|
39
|
+
const pending = JSON.parse(readFileSync(pendingPath, "utf-8"));
|
|
40
|
+
const { reviewPath, multiplexer, paneId, workspaceId } = pending;
|
|
41
|
+
const markdown = readFileSync(reviewPath, "utf-8");
|
|
42
|
+
|
|
43
|
+
if (multiplexer === "tmux" && paneId) {
|
|
44
|
+
// Use load-buffer/paste-buffer instead of send-keys to safely handle
|
|
45
|
+
// multi-line markdown with special characters
|
|
46
|
+
const tmpFile = pendingPath + ".tmp";
|
|
47
|
+
writeFileSync(tmpFile, markdown);
|
|
48
|
+
execFileSync("tmux", ["load-buffer", tmpFile]);
|
|
49
|
+
execFileSync("tmux", ["paste-buffer", "-t", paneId]);
|
|
50
|
+
execFileSync("tmux", ["send-keys", "-t", paneId, "Enter"]);
|
|
51
|
+
try { unlinkSync(tmpFile); } catch {}
|
|
52
|
+
} else if (multiplexer === "cmux" && paneId) {
|
|
53
|
+
const escaped = markdown.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
|
|
54
|
+
execFileSync("cmux", ["send", "--surface", paneId, ...(workspaceId ? ["--workspace", workspaceId] : []), "--", escaped]);
|
|
55
|
+
execFileSync("cmux", ["send-key", "--surface", paneId, ...(workspaceId ? ["--workspace", workspaceId] : []), "enter"]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try { unlinkSync(pendingPath); } catch {}
|
|
59
|
+
console.log("[review] delivered review to agent");
|
|
60
|
+
} catch (err: any) {
|
|
61
|
+
console.error("[review] delivery failed:", err?.message ?? err);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function runReview(worktreePath: string): Promise<void> {
|
|
66
|
+
const resolvedPath = path.resolve(worktreePath);
|
|
67
|
+
const args = ["--worktree=" + resolvedPath];
|
|
68
|
+
|
|
69
|
+
console.log(`opening review for ${worktreePath}...`);
|
|
70
|
+
|
|
71
|
+
// Watch for .pending files from the Electron app and deliver them
|
|
72
|
+
// (Electron can't access the cmux socket — only CLI processes can)
|
|
73
|
+
const repoRoot = getRepoRoot(resolvedPath);
|
|
74
|
+
const worktreeName = path.basename(resolvedPath);
|
|
75
|
+
const reviewsDir = path.join(repoRoot, ".orche", "reviews", worktreeName);
|
|
76
|
+
mkdirSync(reviewsDir, { recursive: true });
|
|
77
|
+
|
|
78
|
+
// CLI stays alive to watch for .pending files and deliver them via cmux/tmux
|
|
79
|
+
// (Electron can't access the cmux socket — only processes started inside cmux can)
|
|
80
|
+
const watcher = watch(reviewsDir, (event, filename) => {
|
|
81
|
+
if (event === "rename" && filename?.endsWith(".pending")) {
|
|
82
|
+
const pendingPath = path.join(reviewsDir, filename);
|
|
83
|
+
deliverReview(pendingPath);
|
|
84
|
+
watcher.close();
|
|
85
|
+
process.exit(0);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Dev mode: launch electron from local monorepo
|
|
90
|
+
const cliDir = path.dirname(new URL(import.meta.url).pathname);
|
|
91
|
+
const localReviewDir = path.resolve(cliDir, "../../review");
|
|
92
|
+
const devReviewPath = process.env.ORCHE_REVIEW_DEV ||
|
|
93
|
+
(existsSync(path.join(localReviewDir, "package.json")) ? localReviewDir : undefined);
|
|
94
|
+
const debug = !!process.env.ORCHE_DEBUG;
|
|
95
|
+
if (devReviewPath) {
|
|
96
|
+
const electronBin = path.join(devReviewPath, "node_modules/.bin/electron");
|
|
97
|
+
if (debug) console.error(`[review] launching: ${electronBin} ${[devReviewPath, ...args].join(" ")}`);
|
|
98
|
+
spawn(electronBin, [devReviewPath, ...args], {
|
|
99
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
100
|
+
env: { ...process.env, VITE_DEV_SERVER_URL: undefined },
|
|
101
|
+
});
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const binaryPath = await getReviewBinaryPath();
|
|
106
|
+
if (debug) console.error(`[review] launching: ${binaryPath} ${args.join(" ")}`);
|
|
107
|
+
spawn(binaryPath, args, {
|
|
108
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function getRepoRoot(cwd: string): string {
|
|
113
|
+
// If we're inside an orche worktree, walk up to the real repo
|
|
114
|
+
const orcheIdx = cwd.indexOf("/.orche/worktrees/");
|
|
115
|
+
if (orcheIdx !== -1) {
|
|
116
|
+
return cwd.slice(0, orcheIdx);
|
|
117
|
+
}
|
|
118
|
+
return cwd;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function startSession(): void {
|
|
122
|
+
const cwd = getRepoRoot(process.cwd());
|
|
123
|
+
const repoName = path.basename(cwd);
|
|
124
|
+
const taskName = process.argv.slice(3).find((a) => !a.startsWith("--")) || "session";
|
|
125
|
+
|
|
126
|
+
const config = loadConfig(cwd);
|
|
127
|
+
const muxFlag: MultiplexerType | undefined =
|
|
128
|
+
process.argv.includes("--tmux") ? "tmux" :
|
|
129
|
+
process.argv.includes("--cmux") ? "cmux" :
|
|
130
|
+
undefined;
|
|
131
|
+
const mux = getMultiplexer(muxFlag ?? config.multiplexer);
|
|
132
|
+
|
|
133
|
+
// 1. Create worktree
|
|
134
|
+
console.log(`creating worktree for "${taskName}"...`);
|
|
135
|
+
const { worktreePath } = createWorktree(cwd, taskName);
|
|
136
|
+
|
|
137
|
+
// 2. Create session
|
|
138
|
+
const sessionName = `${repoName}-${taskName}`.replace(
|
|
139
|
+
/[^a-zA-Z0-9_-]/g,
|
|
140
|
+
"-"
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
mux.createSession(sessionName, worktreePath);
|
|
144
|
+
console.log(`${mux.name} session: ${sessionName}`);
|
|
145
|
+
|
|
146
|
+
// 3. Build pane layout
|
|
147
|
+
buildLayout(mux, sessionName, config.layout, worktreePath);
|
|
148
|
+
|
|
149
|
+
// 4. Attach
|
|
150
|
+
mux.attach(sessionName);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function printUsage(): void {
|
|
154
|
+
console.log(`
|
|
155
|
+
orche — orchestrate agents across git worktrees
|
|
156
|
+
|
|
157
|
+
Usage:
|
|
158
|
+
orche start <task> Start a new session for <task>
|
|
159
|
+
orche review [path] Open the review UI for a worktree
|
|
160
|
+
|
|
161
|
+
Examples:
|
|
162
|
+
orche start fix-auth Create worktree + session for "fix-auth"
|
|
163
|
+
orche review Review changes in current directory
|
|
164
|
+
orche review ./worktree Review changes in a specific worktree
|
|
165
|
+
|
|
166
|
+
Requires a ${CONFIG_NAME} file in the current directory.
|
|
167
|
+
Use ${CONFIG_LOCAL_NAME} for local overrides (not committed).
|
|
168
|
+
See .orche.example.json for the config format.
|
|
169
|
+
`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function main(): Promise<void> {
|
|
173
|
+
const subcommand = process.argv[2];
|
|
174
|
+
|
|
175
|
+
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
|
|
176
|
+
printUsage();
|
|
177
|
+
process.exit(0);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (subcommand === "start") {
|
|
181
|
+
startSession();
|
|
182
|
+
} else if (subcommand === "review") {
|
|
183
|
+
const explicitPath = process.argv[3] && !process.argv[3].startsWith("--")
|
|
184
|
+
? process.argv[3]
|
|
185
|
+
: undefined;
|
|
186
|
+
const worktreePath = explicitPath || process.cwd();
|
|
187
|
+
await runReview(worktreePath);
|
|
188
|
+
} else {
|
|
189
|
+
console.error(`Unknown command: ${subcommand}\nRun "orche --help" for usage.`);
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
main().catch((err) => {
|
|
195
|
+
console.error(`error: ${err.message}`);
|
|
196
|
+
process.exit(1);
|
|
197
|
+
});
|
package/src/layout.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { writeFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isSplit, type PaneConfig, type SplitConfig } from "./types.js";
|
|
4
|
+
import type { Multiplexer } from "./multiplexer.js";
|
|
5
|
+
|
|
6
|
+
export interface SessionInfo {
|
|
7
|
+
multiplexer: string;
|
|
8
|
+
panes: Record<string, string>; // name → pane/surface ID
|
|
9
|
+
workspaceId?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Recursively build a pane layout using the given multiplexer.
|
|
14
|
+
*/
|
|
15
|
+
export function buildLayout(
|
|
16
|
+
mux: Multiplexer,
|
|
17
|
+
session: string,
|
|
18
|
+
node: PaneConfig | SplitConfig,
|
|
19
|
+
worktreePath: string
|
|
20
|
+
): void {
|
|
21
|
+
const paneMap: Record<string, string> = {};
|
|
22
|
+
|
|
23
|
+
if (!isSplit(node)) {
|
|
24
|
+
const paneId = mux.getActivePaneId(session);
|
|
25
|
+
paneMap[node.name] = paneId;
|
|
26
|
+
mux.renamePaneTitle(paneId, node.name);
|
|
27
|
+
if (node.command) mux.sendCommand(session, node.command);
|
|
28
|
+
saveSessionInfo(mux.name, paneMap, worktreePath);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const paneIds = [mux.getActivePaneId(session)];
|
|
33
|
+
|
|
34
|
+
for (let i = 1; i < node.panes.length; i++) {
|
|
35
|
+
const child = node.panes[i];
|
|
36
|
+
const sizePercent = child.size ?? Math.floor(100 / (node.panes.length - i + 1));
|
|
37
|
+
mux.splitPane(paneIds[0], node.direction, sizePercent, worktreePath);
|
|
38
|
+
paneIds.push(mux.getActivePaneId(session));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const allPaneIds = mux.listPaneIds(session);
|
|
42
|
+
|
|
43
|
+
for (let i = 0; i < node.panes.length; i++) {
|
|
44
|
+
const child = node.panes[i];
|
|
45
|
+
const paneId = allPaneIds[i];
|
|
46
|
+
|
|
47
|
+
if (isSplit(child)) {
|
|
48
|
+
mux.focusPane(paneId);
|
|
49
|
+
collectPaneMap(mux, session, child, paneId, worktreePath, paneMap);
|
|
50
|
+
} else {
|
|
51
|
+
paneMap[child.name] = paneId;
|
|
52
|
+
mux.renamePaneTitle(paneId, child.name);
|
|
53
|
+
if (child.command) mux.sendCommand(paneId, child.command);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
saveSessionInfo(mux.name, paneMap, worktreePath);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function collectPaneMap(
|
|
61
|
+
mux: Multiplexer,
|
|
62
|
+
session: string,
|
|
63
|
+
node: SplitConfig,
|
|
64
|
+
parentPaneId: string,
|
|
65
|
+
worktreePath: string,
|
|
66
|
+
paneMap: Record<string, string>
|
|
67
|
+
): void {
|
|
68
|
+
const paneIds = [parentPaneId];
|
|
69
|
+
|
|
70
|
+
for (let i = 1; i < node.panes.length; i++) {
|
|
71
|
+
const child = node.panes[i];
|
|
72
|
+
const sizePercent = child.size ?? Math.floor(100 / (node.panes.length - i + 1));
|
|
73
|
+
mux.splitPane(paneIds[i - 1], node.direction, sizePercent, worktreePath);
|
|
74
|
+
paneIds.push(mux.getActivePaneId(session));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (let i = 0; i < node.panes.length; i++) {
|
|
78
|
+
const child = node.panes[i];
|
|
79
|
+
if (isSplit(child)) {
|
|
80
|
+
collectPaneMap(mux, session, child, paneIds[i], worktreePath, paneMap);
|
|
81
|
+
} else {
|
|
82
|
+
paneMap[child.name] = paneIds[i];
|
|
83
|
+
mux.renamePaneTitle(paneIds[i], child.name);
|
|
84
|
+
if (child.command) mux.sendCommand(paneIds[i], child.command);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function saveSessionInfo(
|
|
90
|
+
multiplexer: string,
|
|
91
|
+
panes: Record<string, string>,
|
|
92
|
+
worktreePath: string
|
|
93
|
+
): void {
|
|
94
|
+
const orcheDir = path.join(worktreePath, ".orche");
|
|
95
|
+
mkdirSync(orcheDir, { recursive: true });
|
|
96
|
+
const workspaceId = process.env.CMUX_WORKSPACE_ID || undefined;
|
|
97
|
+
const info: SessionInfo = { multiplexer, panes, workspaceId };
|
|
98
|
+
writeFileSync(path.join(orcheDir, "session.json"), JSON.stringify(info, null, 2));
|
|
99
|
+
}
|