multiarena 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/config/loader.d.ts +6 -0
  2. package/dist/config/loader.js +69 -0
  3. package/dist/config/types.d.ts +15 -0
  4. package/dist/config/types.js +6 -0
  5. package/dist/core/session.d.ts +40 -0
  6. package/dist/core/session.js +155 -0
  7. package/dist/core/turn.d.ts +31 -0
  8. package/dist/core/turn.js +112 -0
  9. package/dist/core/types.d.ts +25 -0
  10. package/dist/core/types.js +1 -0
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.js +76 -0
  13. package/dist/isolation/worktree.d.ts +11 -0
  14. package/dist/isolation/worktree.js +117 -0
  15. package/dist/persistence/session.d.ts +17 -0
  16. package/dist/persistence/session.js +27 -0
  17. package/dist/provider/adapters/anthropic.d.ts +11 -0
  18. package/dist/provider/adapters/anthropic.js +146 -0
  19. package/dist/provider/adapters/google.d.ts +11 -0
  20. package/dist/provider/adapters/google.js +177 -0
  21. package/dist/provider/adapters/ollama.d.ts +11 -0
  22. package/dist/provider/adapters/ollama.js +147 -0
  23. package/dist/provider/adapters/openai.d.ts +11 -0
  24. package/dist/provider/adapters/openai.js +167 -0
  25. package/dist/provider/provider.d.ts +7 -0
  26. package/dist/provider/provider.js +21 -0
  27. package/dist/provider/types.d.ts +41 -0
  28. package/dist/provider/types.js +1 -0
  29. package/dist/tools/builtin/bash.d.ts +2 -0
  30. package/dist/tools/builtin/bash.js +34 -0
  31. package/dist/tools/builtin/editFile.d.ts +2 -0
  32. package/dist/tools/builtin/editFile.js +40 -0
  33. package/dist/tools/builtin/glob.d.ts +2 -0
  34. package/dist/tools/builtin/glob.js +77 -0
  35. package/dist/tools/builtin/grep.d.ts +2 -0
  36. package/dist/tools/builtin/grep.js +120 -0
  37. package/dist/tools/builtin/readFile.d.ts +2 -0
  38. package/dist/tools/builtin/readFile.js +27 -0
  39. package/dist/tools/builtin/writeFile.d.ts +2 -0
  40. package/dist/tools/builtin/writeFile.js +29 -0
  41. package/dist/tools/permission.d.ts +7 -0
  42. package/dist/tools/permission.js +31 -0
  43. package/dist/tools/registry.d.ts +9 -0
  44. package/dist/tools/registry.js +37 -0
  45. package/dist/tools/types.d.ts +11 -0
  46. package/dist/tools/types.js +1 -0
  47. package/dist/ui/app.d.ts +4 -0
  48. package/dist/ui/app.js +343 -0
  49. package/dist/ui/components/BroadcastSummary.d.ts +7 -0
  50. package/dist/ui/components/BroadcastSummary.js +18 -0
  51. package/dist/ui/components/InputBar.d.ts +9 -0
  52. package/dist/ui/components/InputBar.js +11 -0
  53. package/dist/ui/components/ModelDetail.d.ts +8 -0
  54. package/dist/ui/components/ModelDetail.js +13 -0
  55. package/dist/ui/components/OutputArea.d.ts +15 -0
  56. package/dist/ui/components/OutputArea.js +29 -0
  57. package/dist/ui/components/StatusBar.d.ts +9 -0
  58. package/dist/ui/components/StatusBar.js +51 -0
  59. package/package.json +60 -0
@@ -0,0 +1,6 @@
1
+ import { ArenaConfig } from "./types.js";
2
+ export interface ConfigWarning {
3
+ message: string;
4
+ }
5
+ export declare function validateConfig(config: ArenaConfig): ConfigWarning[];
6
+ export declare function loadConfig(): ArenaConfig;
@@ -0,0 +1,69 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import * as os from "os";
4
+ import TOML from "@iarna/toml";
5
+ import { DEFAULT_CONFIG } from "./types.js";
6
+ function resolveEnvVars(value) {
7
+ return value.replace(/\$\{(\w+)\}/g, (_, name) => process.env[name] ?? "");
8
+ }
9
+ function resolveConfig(raw) {
10
+ const walk = (obj) => {
11
+ if (typeof obj === "string")
12
+ return resolveEnvVars(obj);
13
+ if (Array.isArray(obj))
14
+ return obj.map(walk);
15
+ if (obj && typeof obj === "object") {
16
+ const result = {};
17
+ for (const [k, v] of Object.entries(obj)) {
18
+ result[k] = walk(v);
19
+ }
20
+ return result;
21
+ }
22
+ return obj;
23
+ };
24
+ return walk(raw);
25
+ }
26
+ export function validateConfig(config) {
27
+ const warnings = [];
28
+ for (const name of config.defaults.active) {
29
+ const mc = config.models[name];
30
+ if (!mc) {
31
+ warnings.push({
32
+ message: `Model "${name}" is in defaults.active but has no [models.${name}] config section`,
33
+ });
34
+ continue;
35
+ }
36
+ if (!mc.provider) {
37
+ warnings.push({
38
+ message: `Model "${name}" has no provider set`,
39
+ });
40
+ }
41
+ if (!mc.api_key && mc.provider !== "ollama") {
42
+ warnings.push({
43
+ message: `Model "${name}" (${mc.provider}) has no api_key — set it or the \${ENV_VAR} may be missing`,
44
+ });
45
+ }
46
+ }
47
+ return warnings;
48
+ }
49
+ export function loadConfig() {
50
+ const candidates = [
51
+ path.join(process.cwd(), ".arenarc"),
52
+ path.join(os.homedir(), ".arenarc"),
53
+ ];
54
+ let resolved = {};
55
+ for (const p of candidates) {
56
+ if (fs.existsSync(p)) {
57
+ const raw = TOML.parse(fs.readFileSync(p, "utf-8"));
58
+ resolved = resolveConfig(raw);
59
+ break;
60
+ }
61
+ }
62
+ return {
63
+ models: resolved.models ?? {},
64
+ defaults: {
65
+ active: resolved.defaults?.active ?? DEFAULT_CONFIG.defaults?.active ?? [],
66
+ broadcast: resolved.defaults?.broadcast ?? DEFAULT_CONFIG.defaults?.broadcast ?? true,
67
+ },
68
+ };
69
+ }
@@ -0,0 +1,15 @@
1
+ export interface ModelConfig {
2
+ provider: "anthropic" | "openai" | "google" | "ollama";
3
+ model: string;
4
+ api_key?: string;
5
+ endpoint?: string;
6
+ context_limit?: number;
7
+ }
8
+ export interface ArenaConfig {
9
+ models: Record<string, ModelConfig>;
10
+ defaults: {
11
+ active: string[];
12
+ broadcast: boolean;
13
+ };
14
+ }
15
+ export declare const DEFAULT_CONFIG: Partial<ArenaConfig>;
@@ -0,0 +1,6 @@
1
+ export const DEFAULT_CONFIG = {
2
+ defaults: {
3
+ active: [],
4
+ broadcast: true,
5
+ },
6
+ };
@@ -0,0 +1,40 @@
1
+ import { Message } from "../provider/types.js";
2
+ import { ModelState, TargetMode } from "./types.js";
3
+ import { ArenaConfig } from "../config/types.js";
4
+ export interface SessionSnapshot {
5
+ models: Array<{
6
+ name: string;
7
+ provider: string;
8
+ messages: Message[];
9
+ muted: boolean;
10
+ buffer: string;
11
+ usage: {
12
+ input: number;
13
+ output: number;
14
+ };
15
+ contextLimit: number;
16
+ }>;
17
+ targetMode: TargetMode;
18
+ worktreeBase: string;
19
+ }
20
+ export declare class Session {
21
+ private state;
22
+ constructor(config: ArenaConfig, worktreeBase: string, snapshot?: SessionSnapshot);
23
+ get models(): ModelState[];
24
+ get targetMode(): TargetMode;
25
+ /** Add a user message to the target model(s). Returns affected models. */
26
+ addUserMessage(content: string): ModelState[];
27
+ /** Append assistant response to a model's history */
28
+ addAssistantMessage(modelName: string, content: string): void;
29
+ /** Append tool result to a model's history */
30
+ addToolResult(modelName: string, toolCallId: string, result: string): void;
31
+ /** Cycle Tab through targets: broadcast → model1 → model2 → ... → broadcast */
32
+ cycleTarget(): TargetMode;
33
+ jumpToModel(modelName: string): void;
34
+ jumpToBroadcast(): void;
35
+ toggleMute(modelName: string): boolean;
36
+ resetModel(modelName: string): void;
37
+ getContextUsage(modelName: string): number;
38
+ toJSON(): SessionSnapshot;
39
+ private findModel;
40
+ }
@@ -0,0 +1,155 @@
1
+ export class Session {
2
+ state;
3
+ constructor(config, worktreeBase, snapshot) {
4
+ if (snapshot) {
5
+ this.state = {
6
+ models: snapshot.models.map((m) => ({
7
+ ...m,
8
+ isStreaming: false,
9
+ buffer: "",
10
+ })),
11
+ targetMode: snapshot.targetMode,
12
+ worktreeBase: snapshot.worktreeBase,
13
+ };
14
+ }
15
+ else {
16
+ const models = (config.defaults.active ?? []).map((name) => {
17
+ const mc = config.models[name];
18
+ return {
19
+ name,
20
+ provider: mc?.provider ?? "unknown",
21
+ messages: [],
22
+ muted: false,
23
+ buffer: "",
24
+ isStreaming: false,
25
+ usage: { input: 0, output: 0 },
26
+ contextLimit: contextLimitForModel(mc?.model ?? "", mc?.context_limit),
27
+ };
28
+ });
29
+ this.state = {
30
+ models,
31
+ targetMode: { type: "broadcast" },
32
+ worktreeBase,
33
+ };
34
+ }
35
+ }
36
+ get models() {
37
+ return this.state.models;
38
+ }
39
+ get targetMode() {
40
+ return this.state.targetMode;
41
+ }
42
+ /** Add a user message to the target model(s). Returns affected models. */
43
+ addUserMessage(content) {
44
+ if (this.state.targetMode.type === "broadcast") {
45
+ for (const m of this.state.models) {
46
+ if (!m.muted) {
47
+ m.messages.push({ role: "user", content });
48
+ }
49
+ }
50
+ return this.state.models.filter((m) => !m.muted);
51
+ }
52
+ else {
53
+ const target = this.findModel(this.state.targetMode.modelName);
54
+ if (target) {
55
+ target.messages.push({ role: "user", content });
56
+ }
57
+ return target ? [target] : [];
58
+ }
59
+ }
60
+ /** Append assistant response to a model's history */
61
+ addAssistantMessage(modelName, content) {
62
+ const m = this.findModel(modelName);
63
+ if (m) {
64
+ m.messages.push({ role: "assistant", content });
65
+ }
66
+ }
67
+ /** Append tool result to a model's history */
68
+ addToolResult(modelName, toolCallId, result) {
69
+ const m = this.findModel(modelName);
70
+ if (m) {
71
+ m.messages.push({ role: "tool", content: result, tool_call_id: toolCallId });
72
+ }
73
+ }
74
+ /** Cycle Tab through targets: broadcast → model1 → model2 → ... → broadcast */
75
+ cycleTarget() {
76
+ const current = this.state.targetMode;
77
+ if (current.type === "broadcast") {
78
+ const first = this.state.models[0];
79
+ this.state.targetMode = first
80
+ ? { type: "directed", modelName: first.name }
81
+ : current;
82
+ }
83
+ else {
84
+ const idx = this.state.models.findIndex((m) => m.name === current.modelName);
85
+ const next = this.state.models[idx + 1];
86
+ this.state.targetMode = next
87
+ ? { type: "directed", modelName: next.name }
88
+ : { type: "broadcast" };
89
+ }
90
+ return this.state.targetMode;
91
+ }
92
+ jumpToModel(modelName) {
93
+ const exists = this.state.models.some((m) => m.name === modelName);
94
+ if (exists) {
95
+ this.state.targetMode = { type: "directed", modelName };
96
+ }
97
+ }
98
+ jumpToBroadcast() {
99
+ this.state.targetMode = { type: "broadcast" };
100
+ }
101
+ toggleMute(modelName) {
102
+ const m = this.findModel(modelName);
103
+ if (m)
104
+ m.muted = !m.muted;
105
+ return m?.muted ?? false;
106
+ }
107
+ resetModel(modelName) {
108
+ const m = this.findModel(modelName);
109
+ if (m) {
110
+ m.messages = [];
111
+ m.buffer = "";
112
+ m.usage = { input: 0, output: 0 };
113
+ }
114
+ }
115
+ getContextUsage(modelName) {
116
+ const m = this.findModel(modelName);
117
+ if (!m || m.contextLimit <= 0)
118
+ return 0;
119
+ const totalTokens = m.usage.input + m.usage.output;
120
+ return Math.min(1, totalTokens / m.contextLimit);
121
+ }
122
+ toJSON() {
123
+ return {
124
+ models: this.state.models.map((m) => ({
125
+ name: m.name,
126
+ provider: m.provider,
127
+ messages: [...m.messages],
128
+ muted: m.muted,
129
+ buffer: m.buffer,
130
+ usage: { ...m.usage },
131
+ contextLimit: m.contextLimit,
132
+ })),
133
+ targetMode: this.state.targetMode,
134
+ worktreeBase: this.state.worktreeBase,
135
+ };
136
+ }
137
+ findModel(name) {
138
+ return this.state.models.find((m) => m.name === name);
139
+ }
140
+ }
141
+ function contextLimitForModel(model, explicit) {
142
+ if (explicit && explicit > 0)
143
+ return explicit;
144
+ if (model.includes("claude"))
145
+ return 200000;
146
+ if (model.includes("gpt-4"))
147
+ return 128000;
148
+ if (model.includes("gpt-3.5"))
149
+ return 16384;
150
+ if (model.includes("gemini"))
151
+ return 1048576;
152
+ if (model.includes("deepseek"))
153
+ return 128000;
154
+ return 128000;
155
+ }
@@ -0,0 +1,31 @@
1
+ import type { StreamEvent, Message, ToolDef } from "../provider/types.js";
2
+ import type { ModelConfig } from "../config/types.js";
3
+ import { ToolRegistry } from "../tools/registry.js";
4
+ import { PermissionManager } from "../tools/permission.js";
5
+ export interface TurnContext {
6
+ modelName: string;
7
+ config: ModelConfig;
8
+ messages: Message[];
9
+ systemPrompt: string;
10
+ tools: ToolDef[];
11
+ registry: ToolRegistry;
12
+ permission: PermissionManager;
13
+ worktreePath: string;
14
+ }
15
+ export interface TurnResult {
16
+ /** The final assistant text (excluding tool result annotations). */
17
+ text: string;
18
+ /** Whether the turn ended with an error. */
19
+ error: boolean;
20
+ }
21
+ /**
22
+ * Run a single model turn with tool-call looping.
23
+ *
24
+ * Yields StreamEvents for real-time UI rendering. Tool calls are intercepted,
25
+ * executed, and their results are fed back to the model via ctx.messages so
26
+ * the conversation can continue.
27
+ *
28
+ * After the turn completes, all messages (assistant, tool calls, tool results)
29
+ * have been pushed into ctx.messages — the caller only needs to persist.
30
+ */
31
+ export declare function runTurn(ctx: TurnContext): AsyncGenerator<StreamEvent>;
@@ -0,0 +1,112 @@
1
+ import { createProvider } from "../provider/provider.js";
2
+ const MAX_TOOL_ROUNDS = 10;
3
+ /**
4
+ * Run a single model turn with tool-call looping.
5
+ *
6
+ * Yields StreamEvents for real-time UI rendering. Tool calls are intercepted,
7
+ * executed, and their results are fed back to the model via ctx.messages so
8
+ * the conversation can continue.
9
+ *
10
+ * After the turn completes, all messages (assistant, tool calls, tool results)
11
+ * have been pushed into ctx.messages — the caller only needs to persist.
12
+ */
13
+ export async function* runTurn(ctx) {
14
+ const allText = [];
15
+ let provider = null;
16
+ try {
17
+ for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
18
+ provider = createProvider(ctx.config);
19
+ const request = {
20
+ messages: [...ctx.messages],
21
+ tools: ctx.tools.length > 0 ? ctx.tools : undefined,
22
+ system: ctx.systemPrompt,
23
+ model: ctx.config.model,
24
+ };
25
+ const pendingToolCalls = [];
26
+ let hasToolCall = false;
27
+ let roundText = "";
28
+ let lastUsage = { input: 0, output: 0 };
29
+ for await (const event of provider.chat(request)) {
30
+ switch (event.type) {
31
+ case "text":
32
+ roundText += event.content;
33
+ allText.push(event.content);
34
+ yield event;
35
+ break;
36
+ case "tool_call":
37
+ hasToolCall = true;
38
+ pendingToolCalls.push({
39
+ id: event.id,
40
+ name: event.name,
41
+ arguments: event.args,
42
+ });
43
+ break;
44
+ case "error":
45
+ yield event;
46
+ return;
47
+ case "done":
48
+ lastUsage = event.usage;
49
+ break;
50
+ }
51
+ }
52
+ // No tool calls — this round is the final answer
53
+ if (!hasToolCall) {
54
+ if (roundText) {
55
+ ctx.messages.push({ role: "assistant", content: roundText });
56
+ }
57
+ yield { type: "done", usage: lastUsage };
58
+ return;
59
+ }
60
+ // Record assistant message with any text + tool calls
61
+ ctx.messages.push({
62
+ role: "assistant",
63
+ content: roundText,
64
+ tool_calls: pendingToolCalls,
65
+ });
66
+ // Execute tools and feed results back
67
+ for (const tc of pendingToolCalls) {
68
+ const toolLabel = `\n[Tool: ${tc.name}]\n`;
69
+ allText.push(toolLabel);
70
+ yield { type: "text", content: toolLabel };
71
+ let args;
72
+ try {
73
+ args = JSON.parse(tc.arguments);
74
+ }
75
+ catch {
76
+ const errMsg = `Failed to parse tool arguments: ${tc.arguments}`;
77
+ ctx.messages.push({ role: "tool", content: errMsg, tool_call_id: tc.id });
78
+ allText.push(errMsg + "\n");
79
+ yield { type: "text", content: errMsg + "\n" };
80
+ continue;
81
+ }
82
+ const decision = ctx.permission.check(tc.name, args);
83
+ if (decision === "deny" || decision === "deny_always") {
84
+ const errMsg = `Permission denied for tool: ${tc.name}`;
85
+ ctx.messages.push({ role: "tool", content: errMsg, tool_call_id: tc.id });
86
+ allText.push(errMsg + "\n");
87
+ yield { type: "text", content: errMsg + "\n" };
88
+ continue;
89
+ }
90
+ let result;
91
+ try {
92
+ result = await ctx.registry.execute(tc.name, args, ctx.worktreePath);
93
+ }
94
+ catch (err) {
95
+ result = `Error executing ${tc.name}: ${err.message}`;
96
+ }
97
+ ctx.messages.push({ role: "tool", content: result, tool_call_id: tc.id });
98
+ allText.push(result + "\n");
99
+ yield { type: "text", content: result + "\n" };
100
+ }
101
+ // Loop continues — model sees tool results via ctx.messages
102
+ }
103
+ // Exceeded max rounds
104
+ yield {
105
+ type: "error",
106
+ message: `Exceeded maximum tool rounds (${MAX_TOOL_ROUNDS})`,
107
+ };
108
+ }
109
+ finally {
110
+ provider?.abort();
111
+ }
112
+ }
@@ -0,0 +1,25 @@
1
+ import { Message } from "../provider/types.js";
2
+ export type TargetMode = {
3
+ type: "broadcast";
4
+ } | {
5
+ type: "directed";
6
+ modelName: string;
7
+ };
8
+ export interface ModelState {
9
+ name: string;
10
+ provider: string;
11
+ messages: Message[];
12
+ muted: boolean;
13
+ buffer: string;
14
+ isStreaming: boolean;
15
+ usage: {
16
+ input: number;
17
+ output: number;
18
+ };
19
+ contextLimit: number;
20
+ }
21
+ export interface SessionState {
22
+ models: ModelState[];
23
+ targetMode: TargetMode;
24
+ worktreeBase: string;
25
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ import React from "react";
3
+ import { render } from "ink";
4
+ import * as fs from "node:fs";
5
+ import * as path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { App } from "./ui/app.js";
8
+ import { listSessions } from "./persistence/session.js";
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
+ const PKG_VERSION = (() => {
11
+ try {
12
+ const pkgPath = path.join(__dirname, "..", "package.json");
13
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
14
+ return pkg.version ?? "0.1.0";
15
+ }
16
+ catch {
17
+ return "0.1.0";
18
+ }
19
+ })();
20
+ const HELP = `Arena — Multi-Model AI Coding Assistant
21
+
22
+ Usage:
23
+ arena [options]
24
+
25
+ Options:
26
+ --new Start a new session (default)
27
+ --resume <id> Resume a saved session
28
+ --list List saved sessions
29
+ --help Show this help
30
+ --version Show version`;
31
+ function parseArgs() {
32
+ const args = process.argv.slice(2);
33
+ if (args.includes("--help") || args.includes("-h")) {
34
+ return { listOnly: false, showHelp: true, showVersion: false };
35
+ }
36
+ if (args.includes("--version") || args.includes("-v")) {
37
+ return { listOnly: false, showHelp: false, showVersion: true };
38
+ }
39
+ const resumeIdx = args.indexOf("--resume");
40
+ if (resumeIdx >= 0 && args[resumeIdx + 1]) {
41
+ return {
42
+ sessionId: args[resumeIdx + 1],
43
+ listOnly: false,
44
+ showHelp: false,
45
+ showVersion: false,
46
+ };
47
+ }
48
+ if (args.includes("--list") || args.includes("--list-sessions")) {
49
+ return { listOnly: true, showHelp: false, showVersion: false };
50
+ }
51
+ return { listOnly: false, showHelp: false, showVersion: false };
52
+ }
53
+ const { sessionId, listOnly, showHelp, showVersion } = parseArgs();
54
+ if (showHelp) {
55
+ console.log(HELP);
56
+ process.exit(0);
57
+ }
58
+ if (showVersion) {
59
+ console.log(`arena v${PKG_VERSION}`);
60
+ process.exit(0);
61
+ }
62
+ if (listOnly) {
63
+ const sessions = listSessions();
64
+ if (sessions.length === 0) {
65
+ console.log("No saved sessions.");
66
+ }
67
+ else {
68
+ console.log("Saved sessions:");
69
+ for (const s of sessions) {
70
+ const models = s.models.map((m) => m.name).join(", ");
71
+ console.log(` ${s.id} ${s.timestamp} [${models}]`);
72
+ }
73
+ }
74
+ process.exit(0);
75
+ }
76
+ render(React.createElement(App, { sessionId }));
@@ -0,0 +1,11 @@
1
+ export declare class WorktreeManager {
2
+ private git;
3
+ private worktrees;
4
+ constructor(repoPath: string);
5
+ /** Clean up orphaned arena branches and worktree directories from prior crashes. */
6
+ sweepOrphans(): Promise<number>;
7
+ setup(taskId: string, modelNames: string[]): Promise<Map<string, string>>;
8
+ getWorktreePath(modelName: string): string | undefined;
9
+ getDiff(modelName: string): Promise<string>;
10
+ cleanup(taskId: string, keepModel?: string): Promise<void>;
11
+ }
@@ -0,0 +1,117 @@
1
+ import { simpleGit } from "simple-git";
2
+ import * as path from "path";
3
+ import * as os from "os";
4
+ import * as fs from "fs";
5
+ export class WorktreeManager {
6
+ git;
7
+ worktrees = new Map();
8
+ constructor(repoPath) {
9
+ this.git = simpleGit(repoPath);
10
+ }
11
+ /** Clean up orphaned arena branches and worktree directories from prior crashes. */
12
+ async sweepOrphans() {
13
+ let cleaned = 0;
14
+ // Parse registered worktrees from `git worktree list --porcelain`
15
+ const registeredPaths = new Set();
16
+ const registeredBranches = new Set();
17
+ try {
18
+ const raw = await this.git.raw(["worktree", "list", "--porcelain"]);
19
+ let currentPath = null;
20
+ for (const line of raw.split("\n")) {
21
+ if (line.startsWith("worktree ")) {
22
+ currentPath = line.slice("worktree ".length);
23
+ registeredPaths.add(currentPath);
24
+ }
25
+ else if (line.startsWith("branch ") && currentPath) {
26
+ // branch line looks like "branch refs/heads/arena/..."
27
+ const ref = line.slice("branch ".length);
28
+ const branchName = ref.replace("refs/heads/", "");
29
+ registeredBranches.add(branchName);
30
+ }
31
+ }
32
+ }
33
+ catch {
34
+ return cleaned;
35
+ }
36
+ // Remove orphaned arena branches (branch exists but no worktree)
37
+ const branches = await this.git.branchLocal();
38
+ for (const branch of branches.all) {
39
+ if (!branch.startsWith("arena/"))
40
+ continue;
41
+ if (!registeredBranches.has(branch)) {
42
+ await this.git.deleteLocalBranch(branch, true).catch(() => { });
43
+ cleaned++;
44
+ }
45
+ }
46
+ // Remove orphaned worktree directories (dir exists but not registered)
47
+ const arenaDir = path.join(os.tmpdir(), "arena-worktrees");
48
+ if (fs.existsSync(arenaDir)) {
49
+ let entries = [];
50
+ try {
51
+ entries = fs.readdirSync(arenaDir);
52
+ }
53
+ catch { /* ignore */ }
54
+ for (const entry of entries) {
55
+ const fullPath = path.join(arenaDir, entry);
56
+ if (!registeredPaths.has(fullPath)) {
57
+ try {
58
+ // Try git worktree remove first, then force delete
59
+ await this.git.raw(["worktree", "remove", fullPath, "--force"]).catch(() => { });
60
+ fs.rmSync(fullPath, { recursive: true, force: true });
61
+ cleaned++;
62
+ }
63
+ catch { /* best effort */ }
64
+ }
65
+ }
66
+ }
67
+ return cleaned;
68
+ }
69
+ async setup(taskId, modelNames) {
70
+ const baseName = `arena/${taskId}`;
71
+ for (const name of modelNames) {
72
+ const branchName = `${baseName}-${name}`;
73
+ const worktreePath = path.join(os.tmpdir(), "arena-worktrees", `${taskId}-${name}`);
74
+ // Ensure the parent directory exists; git worktree add creates the leaf directory
75
+ fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
76
+ // Remove leftover directory from a previous run that wasn't cleaned up
77
+ if (fs.existsSync(worktreePath)) {
78
+ await this.git.raw(["worktree", "remove", worktreePath, "--force"]).catch(() => {
79
+ fs.rmSync(worktreePath, { recursive: true, force: true });
80
+ });
81
+ }
82
+ // Clean up any leftover branch from previous runs
83
+ await this.git.deleteLocalBranch(branchName, true).catch(() => { });
84
+ // Create the branch and add the worktree
85
+ await this.git.branch([branchName]);
86
+ await this.git.raw(["worktree", "add", worktreePath, branchName]);
87
+ this.worktrees.set(name, worktreePath);
88
+ }
89
+ return this.worktrees;
90
+ }
91
+ getWorktreePath(modelName) {
92
+ return this.worktrees.get(modelName);
93
+ }
94
+ async getDiff(modelName) {
95
+ const wt = this.worktrees.get(modelName);
96
+ if (!wt)
97
+ return "";
98
+ const wtGit = simpleGit(wt);
99
+ return await wtGit.diff();
100
+ }
101
+ async cleanup(taskId, keepModel) {
102
+ for (const [modelName, wtPath] of this.worktrees) {
103
+ if (modelName === keepModel)
104
+ continue;
105
+ try {
106
+ await this.git.raw(["worktree", "remove", wtPath, "--force"]);
107
+ }
108
+ catch {
109
+ fs.rmSync(wtPath, { recursive: true, force: true });
110
+ }
111
+ await this.git
112
+ .deleteLocalBranch(`arena/${taskId}-${modelName}`, true)
113
+ .catch(() => { });
114
+ this.worktrees.delete(modelName);
115
+ }
116
+ }
117
+ }