@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,57 @@
1
+ import { execSync } from "node:child_process";
2
+ import type { MultiplexerType } from "./types.js";
3
+ import { TmuxMultiplexer } from "./multiplexers/tmux.js";
4
+ import { CmuxMultiplexer } from "./multiplexers/cmux.js";
5
+
6
+ export interface Multiplexer {
7
+ readonly name: string;
8
+
9
+ // Session lifecycle
10
+ createSession(name: string, cwd: string): void;
11
+ killSession(name: string): void;
12
+ attach(name: string): void;
13
+
14
+ // Pane operations
15
+ getActivePaneId(session: string): string;
16
+ listPaneIds(session: string): string[];
17
+ splitPane(
18
+ target: string,
19
+ direction: "horizontal" | "vertical",
20
+ sizePercent: number,
21
+ cwd: string
22
+ ): void;
23
+ focusPane(paneId: string): void;
24
+ renamePaneTitle(paneId: string, name: string): void;
25
+ sendCommand(target: string, command: string): void;
26
+
27
+ // Detection
28
+ isInsideSession(): boolean;
29
+ detectFirstPane(): string | undefined;
30
+ }
31
+
32
+ function isAvailable(bin: string): boolean {
33
+ try {
34
+ execSync(`which ${bin}`, { stdio: "ignore" });
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ export function getMultiplexer(preference: MultiplexerType = "auto"): Multiplexer {
42
+ if (preference === "tmux") {
43
+ if (!isAvailable("tmux")) throw new Error("tmux not found — brew install tmux");
44
+ return new TmuxMultiplexer();
45
+ }
46
+
47
+ if (preference === "cmux") {
48
+ if (!isAvailable("cmux")) throw new Error("cmux not found — https://cmux.com");
49
+ return new CmuxMultiplexer();
50
+ }
51
+
52
+ // auto: prefer cmux if available, fall back to tmux
53
+ if (isAvailable("cmux")) return new CmuxMultiplexer();
54
+ if (isAvailable("tmux")) return new TmuxMultiplexer();
55
+
56
+ throw new Error("no multiplexer found — install tmux (brew install tmux) or cmux (https://cmux.com)");
57
+ }
@@ -0,0 +1,143 @@
1
+ import { execSync } from "node:child_process";
2
+ import type { Multiplexer } from "../multiplexer.js";
3
+
4
+ const DEBUG = !!process.env.ORCHE_DEBUG;
5
+
6
+ function log(...args: unknown[]): void {
7
+ if (DEBUG) console.error("[cmux]", ...args);
8
+ }
9
+
10
+ function run(cmd: string): string {
11
+ log("$", cmd);
12
+ const out = execSync(cmd).toString().trim();
13
+ log("→", out.length > 200 ? out.slice(0, 200) + "…" : out);
14
+ return out;
15
+ }
16
+
17
+ export class CmuxMultiplexer implements Multiplexer {
18
+ readonly name = "cmux";
19
+
20
+ // Track surfaces we created so layout can target them
21
+ private surfaces: string[] = [];
22
+
23
+ createSession(_name: string, cwd: string): void {
24
+ const initial = process.env.CMUX_SURFACE_ID;
25
+ log("createSession initial surface:", initial);
26
+ if (initial) {
27
+ this.surfaces = [initial];
28
+ this.sendCommand(initial, `cd "${cwd}"`);
29
+ } else {
30
+ log("warning: CMUX_SURFACE_ID not set — are you running inside cmux?");
31
+ }
32
+ }
33
+
34
+ killSession(_name: string): void {
35
+ // No-op — we didn't create a workspace
36
+ }
37
+
38
+ attach(_name: string): void {
39
+ // cmux is a native macOS app — already visible
40
+ }
41
+
42
+ getActivePaneId(_session: string): string {
43
+ const id = this.surfaces[this.surfaces.length - 1] ?? "";
44
+ log("getActivePaneId →", id);
45
+ return id;
46
+ }
47
+
48
+ listPaneIds(_session: string): string[] {
49
+ log("listPaneIds →", this.surfaces);
50
+ return [...this.surfaces];
51
+ }
52
+
53
+ splitPane(
54
+ _target: string,
55
+ direction: "horizontal" | "vertical",
56
+ _sizePercent: number,
57
+ cwd: string
58
+ ): void {
59
+ const cmuxDir = direction === "horizontal" ? "right" : "down";
60
+ // new-split returns "OK surface:<ref> workspace:<ref>"
61
+ const output = run(`cmux new-split ${cmuxDir}`);
62
+
63
+ const match = output.match(/surface:(\S+)/);
64
+ if (match) {
65
+ const newRef = `surface:${match[1]}`;
66
+ log("splitPane new surface:", newRef);
67
+ this.surfaces.push(newRef);
68
+ // cd into worktree on the new surface
69
+ this.sendCommand(newRef, `cd "${cwd}"`);
70
+ } else {
71
+ log("warning: could not parse surface from new-split output:", output);
72
+ }
73
+ log("splitPane surfaces now:", this.surfaces);
74
+ }
75
+
76
+ focusPane(_paneId: string): void {
77
+ // no-op — cmux auto-focuses after split
78
+ }
79
+
80
+ renamePaneTitle(paneId: string, name: string): void {
81
+ try {
82
+ execSync(`cmux rename-tab --surface ${paneId} "${name}"`, { stdio: "ignore" });
83
+ } catch {
84
+ // ignore
85
+ }
86
+ }
87
+
88
+ sendCommand(target: string, command: string): void {
89
+ if (!command || !target) {
90
+ log("sendCommand skipped — target:", target, "command:", command);
91
+ return;
92
+ }
93
+ log("sendCommand →", target, command);
94
+ execSync(
95
+ `cmux send --surface ${target} "${command.replace(/"/g, '\\"')}"`,
96
+ { stdio: "ignore" }
97
+ );
98
+ execSync(`cmux send-key --surface ${target} enter`, { stdio: "ignore" });
99
+ }
100
+
101
+ isInsideSession(): boolean {
102
+ return !!process.env.CMUX_WORKSPACE_ID;
103
+ }
104
+
105
+ detectFirstPane(): string | undefined {
106
+ // Return the first surface in the workspace (where the agent typically runs)
107
+ // NOT the current surface (which is where `orche review` was called)
108
+ try {
109
+ const output = run(`cmux tree --json`);
110
+ const tree = JSON.parse(output);
111
+ // Walk the tree to find the first terminal surface
112
+ const first = this.findFirstSurface(tree);
113
+ if (first) return first;
114
+ } catch {
115
+ // fallback
116
+ }
117
+ return process.env.CMUX_SURFACE_ID ?? undefined;
118
+ }
119
+
120
+ private findFirstSurface(node: unknown): string | null {
121
+ if (!node || typeof node !== 'object') return null;
122
+ const obj = node as Record<string, unknown>;
123
+ if (obj.surface_ref && typeof obj.surface_ref === 'string') {
124
+ // Skip the current surface
125
+ if (obj.surface_ref !== `surface:${process.env.CMUX_SURFACE_ID}`) {
126
+ return obj.surface_ref as string;
127
+ }
128
+ }
129
+ // Recurse into arrays and objects
130
+ for (const val of Object.values(obj)) {
131
+ if (Array.isArray(val)) {
132
+ for (const item of val) {
133
+ const found = this.findFirstSurface(item);
134
+ if (found) return found;
135
+ }
136
+ } else if (val && typeof val === 'object') {
137
+ const found = this.findFirstSurface(val);
138
+ if (found) return found;
139
+ }
140
+ }
141
+ return null;
142
+ }
143
+ }
@@ -0,0 +1,96 @@
1
+ import { execSync } from "node:child_process";
2
+ import type { Multiplexer } from "../multiplexer.js";
3
+
4
+ function esc(str: string): string {
5
+ return `"${str.replace(/"/g, '\\"')}"`;
6
+ }
7
+
8
+ export class TmuxMultiplexer implements Multiplexer {
9
+ readonly name = "tmux";
10
+
11
+ createSession(name: string, cwd: string): void {
12
+ // Kill existing session with same name
13
+ try {
14
+ execSync(`tmux kill-session -t "${name}"`, { stdio: "ignore" });
15
+ } catch {
16
+ // no existing session
17
+ }
18
+ execSync(`tmux new-session -d -s "${name}" -c "${cwd}"`, { stdio: "ignore" });
19
+ execSync(`tmux set-option -t "${name}" mouse on`, { stdio: "ignore" });
20
+ }
21
+
22
+ killSession(name: string): void {
23
+ try {
24
+ execSync(`tmux kill-session -t "${name}"`, { stdio: "ignore" });
25
+ } catch {
26
+ // ignore
27
+ }
28
+ }
29
+
30
+ attach(name: string): void {
31
+ if (process.env.TMUX) {
32
+ execSync(`tmux switch-client -t "${name}"`, { stdio: "inherit" });
33
+ } else {
34
+ execSync(`tmux attach-session -t "${name}"`, { stdio: "inherit" });
35
+ }
36
+ }
37
+
38
+ getActivePaneId(session: string): string {
39
+ return execSync(`tmux display-message -t ${session} -p "#{pane_id}"`)
40
+ .toString()
41
+ .trim();
42
+ }
43
+
44
+ listPaneIds(session: string): string[] {
45
+ return execSync(`tmux list-panes -t ${session} -F "#{pane_id}"`)
46
+ .toString()
47
+ .trim()
48
+ .split("\n");
49
+ }
50
+
51
+ splitPane(
52
+ target: string,
53
+ direction: "horizontal" | "vertical",
54
+ sizePercent: number,
55
+ cwd: string
56
+ ): void {
57
+ const flag = direction === "horizontal" ? "-h" : "-v";
58
+ execSync(
59
+ `tmux split-window ${flag} -t ${target} -p ${sizePercent} -c "${cwd}"`,
60
+ { stdio: "ignore" }
61
+ );
62
+ }
63
+
64
+ focusPane(paneId: string): void {
65
+ execSync(`tmux select-pane -t ${paneId}`, { stdio: "ignore" });
66
+ }
67
+
68
+ renamePaneTitle(paneId: string, name: string): void {
69
+ execSync(`tmux select-pane -t ${paneId} -T ${esc(name)}`, { stdio: "ignore" });
70
+ }
71
+
72
+ sendCommand(target: string, command: string): void {
73
+ execSync(`tmux send-keys -t ${target} ${esc(command)} Enter`);
74
+ }
75
+
76
+ isInsideSession(): boolean {
77
+ return !!process.env.TMUX;
78
+ }
79
+
80
+ detectFirstPane(): string | undefined {
81
+ if (!process.env.TMUX) return undefined;
82
+ try {
83
+ const sessionName = execSync("tmux display-message -p '#S'")
84
+ .toString()
85
+ .trim();
86
+ const paneId = execSync(
87
+ `tmux list-panes -t "${sessionName}" -F "#{pane_id}" | head -1`
88
+ )
89
+ .toString()
90
+ .trim();
91
+ return paneId || undefined;
92
+ } catch {
93
+ return undefined;
94
+ }
95
+ }
96
+ }
@@ -0,0 +1,283 @@
1
+ import { execSync } from "node:child_process";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ writeFileSync,
7
+ unlinkSync,
8
+ createWriteStream,
9
+ } from "node:fs";
10
+ import path from "node:path";
11
+ import https from "node:https";
12
+
13
+ const REPO = "taranek/orche";
14
+ const TAG_PREFIX = "review-v";
15
+
16
+ // Pin review binary version to the CLI version
17
+ const CLI_VERSION = (() => {
18
+ try {
19
+ const pkgPath = new URL("../package.json", import.meta.url).pathname;
20
+ return JSON.parse(readFileSync(pkgPath, "utf-8")).version as string;
21
+ } catch {
22
+ return null;
23
+ }
24
+ })();
25
+ const ORCHE_DIR = path.join(
26
+ process.env.HOME || process.env.USERPROFILE || "~",
27
+ ".orche",
28
+ "review"
29
+ );
30
+ const BIN_DIR = path.join(ORCHE_DIR, "bin");
31
+ const VERSION_FILE = path.join(ORCHE_DIR, "version.json");
32
+ const LOCK_FILE = path.join(ORCHE_DIR, ".downloading");
33
+
34
+ interface VersionInfo {
35
+ version: string;
36
+ downloadedAt: string;
37
+ lastCheckedAt: string;
38
+ }
39
+
40
+ interface ReleaseAsset {
41
+ name: string;
42
+ browser_download_url: string;
43
+ }
44
+
45
+ interface GitHubRelease {
46
+ tag_name: string;
47
+ assets: ReleaseAsset[];
48
+ }
49
+
50
+ function readVersionInfo(): VersionInfo | null {
51
+ if (!existsSync(VERSION_FILE)) return null;
52
+ try {
53
+ return JSON.parse(readFileSync(VERSION_FILE, "utf-8")) as VersionInfo;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ function writeVersionInfo(info: VersionInfo): void {
60
+ mkdirSync(ORCHE_DIR, { recursive: true });
61
+ writeFileSync(VERSION_FILE, JSON.stringify(info, null, 2));
62
+ }
63
+
64
+ function getPlatformKey(): { platform: string; arch: string; ext: string } {
65
+ const arch = process.arch === "arm64" ? "arm64" : "x64";
66
+ if (process.platform === "darwin") {
67
+ return { platform: "mac", arch, ext: "zip" };
68
+ }
69
+ if (process.platform === "linux") {
70
+ return { platform: "linux", arch, ext: "tar.gz" };
71
+ }
72
+ throw new Error(`unsupported platform: ${process.platform}`);
73
+ }
74
+
75
+ function getExecutablePath(): string {
76
+ if (process.platform === "darwin") {
77
+ return path.join(BIN_DIR, "orche-review.app", "Contents", "MacOS", "orche-review");
78
+ }
79
+ return path.join(BIN_DIR, "orche-review", "orche-review");
80
+ }
81
+
82
+ function fetchJson<T>(url: string): Promise<T> {
83
+ return new Promise((resolve, reject) => {
84
+ const get = (requestUrl: string) => {
85
+ https.get(
86
+ requestUrl,
87
+ { headers: { "User-Agent": "orche-cli", Accept: "application/vnd.github+json" } },
88
+ (res) => {
89
+ if (res.statusCode === 301 || res.statusCode === 302) {
90
+ get(res.headers.location!);
91
+ return;
92
+ }
93
+ if (res.statusCode !== 200) {
94
+ reject(new Error(`HTTP ${res.statusCode} from ${requestUrl}`));
95
+ return;
96
+ }
97
+ let data = "";
98
+ res.on("data", (chunk) => (data += chunk));
99
+ res.on("end", () => resolve(JSON.parse(data) as T));
100
+ res.on("error", reject);
101
+ }
102
+ ).on("error", reject);
103
+ };
104
+ get(url);
105
+ });
106
+ }
107
+
108
+ function download(url: string, dest: string): Promise<void> {
109
+ return new Promise((resolve, reject) => {
110
+ mkdirSync(path.dirname(dest), { recursive: true });
111
+ const file = createWriteStream(dest);
112
+
113
+ const get = (requestUrl: string) => {
114
+ https.get(requestUrl, { headers: { "User-Agent": "orche-cli" } }, (res) => {
115
+ if (res.statusCode === 301 || res.statusCode === 302) {
116
+ get(res.headers.location!);
117
+ return;
118
+ }
119
+ if (res.statusCode !== 200) {
120
+ file.close();
121
+ reject(new Error(`HTTP ${res.statusCode} downloading ${requestUrl}`));
122
+ return;
123
+ }
124
+
125
+ const totalBytes = parseInt(res.headers["content-length"] || "0", 10);
126
+ let downloadedBytes = 0;
127
+
128
+ res.on("data", (chunk: Buffer) => {
129
+ downloadedBytes += chunk.length;
130
+ if (totalBytes > 0) {
131
+ const pct = Math.round((downloadedBytes / totalBytes) * 100);
132
+ const mb = (downloadedBytes / 1024 / 1024).toFixed(1);
133
+ const totalMb = (totalBytes / 1024 / 1024).toFixed(1);
134
+ process.stderr.write(`\r downloading: ${mb}/${totalMb} MB (${pct}%)`);
135
+ }
136
+ });
137
+
138
+ res.pipe(file);
139
+ file.on("finish", () => {
140
+ if (totalBytes > 0) process.stderr.write("\n");
141
+ file.close();
142
+ resolve();
143
+ });
144
+ file.on("error", reject);
145
+ }).on("error", reject);
146
+ };
147
+ get(url);
148
+ });
149
+ }
150
+
151
+ async function findRelease(): Promise<{
152
+ version: string;
153
+ downloadUrl: string;
154
+ } | null> {
155
+ const releases = await fetchJson<GitHubRelease[]>(
156
+ `https://api.github.com/repos/${REPO}/releases`
157
+ );
158
+
159
+ // Find release matching CLI version, or fall back to latest review release
160
+ const targetTag = CLI_VERSION ? `${TAG_PREFIX}${CLI_VERSION}` : null;
161
+ const reviewRelease = targetTag
162
+ ? releases.find((r) => r.tag_name === targetTag)
163
+ : releases.find((r) => r.tag_name.startsWith(TAG_PREFIX));
164
+
165
+ if (!reviewRelease) return null;
166
+
167
+ const version = reviewRelease.tag_name.slice(TAG_PREFIX.length);
168
+ const { platform, arch, ext } = getPlatformKey();
169
+
170
+ // Match asset by platform and arch in filename
171
+ const asset = reviewRelease.assets.find((a) => {
172
+ const name = a.name.toLowerCase();
173
+ if (!name.includes(platform) || !name.endsWith(`.${ext}`) || name.includes("blockmap")) return false;
174
+ if (arch === "arm64") return name.includes("arm64");
175
+ return !name.includes("arm64");
176
+ });
177
+
178
+ if (!asset) return null;
179
+ return { version, downloadUrl: asset.browser_download_url };
180
+ }
181
+
182
+ async function downloadAndExtract(
183
+ url: string,
184
+ version: string
185
+ ): Promise<void> {
186
+ const tmpDir = path.join(ORCHE_DIR, ".tmp");
187
+ mkdirSync(tmpDir, { recursive: true });
188
+
189
+ const { ext } = getPlatformKey();
190
+ const archivePath = path.join(tmpDir, `orche-review.${ext}`);
191
+
192
+ await download(url, archivePath);
193
+
194
+ // Clean existing bin directory
195
+ execSync(`rm -rf "${BIN_DIR}"`);
196
+ mkdirSync(BIN_DIR, { recursive: true });
197
+
198
+ // Extract
199
+ if (ext === "zip") {
200
+ execSync(`unzip -q -o "${archivePath}" -d "${BIN_DIR}"`);
201
+ // Clear macOS quarantine
202
+ try {
203
+ execSync(`xattr -cr "${BIN_DIR}"`, { stdio: "ignore" });
204
+ } catch {
205
+ // xattr may not exist on all systems
206
+ }
207
+ } else {
208
+ execSync(`tar xzf "${archivePath}" -C "${BIN_DIR}"`);
209
+ }
210
+
211
+ // Ensure executable permissions on Linux
212
+ if (process.platform === "linux") {
213
+ const executablePath = getExecutablePath();
214
+ if (existsSync(executablePath)) {
215
+ execSync(`chmod +x "${executablePath}"`);
216
+ }
217
+ }
218
+
219
+ // Write version info
220
+ writeVersionInfo({
221
+ version,
222
+ downloadedAt: new Date().toISOString(),
223
+ lastCheckedAt: new Date().toISOString(),
224
+ });
225
+
226
+ // Cleanup
227
+ execSync(`rm -rf "${tmpDir}"`);
228
+ }
229
+
230
+ function acquireLock(): boolean {
231
+ if (existsSync(LOCK_FILE)) {
232
+ try {
233
+ const lockTime = parseInt(readFileSync(LOCK_FILE, "utf-8"), 10);
234
+ // Stale if older than 10 minutes
235
+ if (Date.now() - lockTime < 10 * 60 * 1000) return false;
236
+ } catch {
237
+ // corrupt lock, proceed
238
+ }
239
+ }
240
+ mkdirSync(ORCHE_DIR, { recursive: true });
241
+ writeFileSync(LOCK_FILE, String(Date.now()));
242
+ return true;
243
+ }
244
+
245
+ function releaseLock(): void {
246
+ try {
247
+ unlinkSync(LOCK_FILE);
248
+ } catch {
249
+ // ignore
250
+ }
251
+ }
252
+
253
+ export async function getReviewBinaryPath(): Promise<string> {
254
+ const executablePath = getExecutablePath();
255
+ const info = readVersionInfo();
256
+ const needsDownload = !info || !existsSync(executablePath)
257
+ || (CLI_VERSION && info.version !== CLI_VERSION);
258
+
259
+ if (!needsDownload) return executablePath;
260
+
261
+ if (info && CLI_VERSION && info.version !== CLI_VERSION) {
262
+ console.log(`review app version mismatch (${info.version} → ${CLI_VERSION}), updating...`);
263
+ } else {
264
+ console.log("review app not found, downloading...");
265
+ }
266
+
267
+ if (!acquireLock()) {
268
+ throw new Error("another download is in progress — try again in a moment");
269
+ }
270
+ try {
271
+ const release = await findRelease();
272
+ if (!release) {
273
+ throw new Error(
274
+ "no review app release found — check https://github.com/taranek/orche/releases"
275
+ );
276
+ }
277
+ await downloadAndExtract(release.downloadUrl, release.version);
278
+ } finally {
279
+ releaseLock();
280
+ }
281
+
282
+ return executablePath;
283
+ }
package/src/types.ts ADDED
@@ -0,0 +1,24 @@
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
+
8
+ export interface SplitConfig {
9
+ direction: "horizontal" | "vertical";
10
+ panes: (PaneConfig | SplitConfig)[];
11
+ /** Percentage size of this split (optional, defaults to equal) */
12
+ size?: number;
13
+ }
14
+
15
+ export type MultiplexerType = "tmux" | "cmux" | "auto";
16
+
17
+ export interface AgentsConfig {
18
+ multiplexer?: MultiplexerType;
19
+ layout: PaneConfig | SplitConfig;
20
+ }
21
+
22
+ export function isSplit(node: PaneConfig | SplitConfig): node is SplitConfig {
23
+ return "direction" in node && "panes" in node;
24
+ }