@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.
@@ -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
+ }
@@ -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
@@ -0,0 +1,3 @@
1
+ export function isSplit(node) {
2
+ return "direction" in node && "panes" in node;
3
+ }
@@ -0,0 +1,5 @@
1
+ export interface WorktreeInfo {
2
+ worktreePath: string;
3
+ branchName: string;
4
+ }
5
+ export declare function createWorktree(repoPath: string, name: string): WorktreeInfo;
@@ -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
+ }