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.
- package/dist/config/loader.d.ts +6 -0
- package/dist/config/loader.js +69 -0
- package/dist/config/types.d.ts +15 -0
- package/dist/config/types.js +6 -0
- package/dist/core/session.d.ts +40 -0
- package/dist/core/session.js +155 -0
- package/dist/core/turn.d.ts +31 -0
- package/dist/core/turn.js +112 -0
- package/dist/core/types.d.ts +25 -0
- package/dist/core/types.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +76 -0
- package/dist/isolation/worktree.d.ts +11 -0
- package/dist/isolation/worktree.js +117 -0
- package/dist/persistence/session.d.ts +17 -0
- package/dist/persistence/session.js +27 -0
- package/dist/provider/adapters/anthropic.d.ts +11 -0
- package/dist/provider/adapters/anthropic.js +146 -0
- package/dist/provider/adapters/google.d.ts +11 -0
- package/dist/provider/adapters/google.js +177 -0
- package/dist/provider/adapters/ollama.d.ts +11 -0
- package/dist/provider/adapters/ollama.js +147 -0
- package/dist/provider/adapters/openai.d.ts +11 -0
- package/dist/provider/adapters/openai.js +167 -0
- package/dist/provider/provider.d.ts +7 -0
- package/dist/provider/provider.js +21 -0
- package/dist/provider/types.d.ts +41 -0
- package/dist/provider/types.js +1 -0
- package/dist/tools/builtin/bash.d.ts +2 -0
- package/dist/tools/builtin/bash.js +34 -0
- package/dist/tools/builtin/editFile.d.ts +2 -0
- package/dist/tools/builtin/editFile.js +40 -0
- package/dist/tools/builtin/glob.d.ts +2 -0
- package/dist/tools/builtin/glob.js +77 -0
- package/dist/tools/builtin/grep.d.ts +2 -0
- package/dist/tools/builtin/grep.js +120 -0
- package/dist/tools/builtin/readFile.d.ts +2 -0
- package/dist/tools/builtin/readFile.js +27 -0
- package/dist/tools/builtin/writeFile.d.ts +2 -0
- package/dist/tools/builtin/writeFile.js +29 -0
- package/dist/tools/permission.d.ts +7 -0
- package/dist/tools/permission.js +31 -0
- package/dist/tools/registry.d.ts +9 -0
- package/dist/tools/registry.js +37 -0
- package/dist/tools/types.d.ts +11 -0
- package/dist/tools/types.js +1 -0
- package/dist/ui/app.d.ts +4 -0
- package/dist/ui/app.js +343 -0
- package/dist/ui/components/BroadcastSummary.d.ts +7 -0
- package/dist/ui/components/BroadcastSummary.js +18 -0
- package/dist/ui/components/InputBar.d.ts +9 -0
- package/dist/ui/components/InputBar.js +11 -0
- package/dist/ui/components/ModelDetail.d.ts +8 -0
- package/dist/ui/components/ModelDetail.js +13 -0
- package/dist/ui/components/OutputArea.d.ts +15 -0
- package/dist/ui/components/OutputArea.js +29 -0
- package/dist/ui/components/StatusBar.d.ts +9 -0
- package/dist/ui/components/StatusBar.js +51 -0
- package/package.json +60 -0
|
@@ -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,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 {};
|
package/dist/index.d.ts
ADDED
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
|
+
}
|