ennodia 0.1.0-rc.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/CONTRIBUTING.md +21 -0
- package/LICENSE +21 -0
- package/README.md +99 -0
- package/bench/README.md +32 -0
- package/bench/bug-recall/run.ts +331 -0
- package/bench/bug-recall/scorer.ts +424 -0
- package/bench/fixtures/bug-recall/001-missing-await/input.ts +33 -0
- package/bench/fixtures/bug-recall/001-missing-await/oracle.json +28 -0
- package/bench/fixtures/bug-recall/001-missing-await/responses/claude-code-solo.md +1 -0
- package/bench/fixtures/bug-recall/001-missing-await/responses/codex-solo.md +1 -0
- package/bench/fixtures/bug-recall/001-missing-await/responses/ennodia-parallel-compare.md +1 -0
- package/bench/fixtures/bug-recall/002-timeout-drain/input.ts +40 -0
- package/bench/fixtures/bug-recall/002-timeout-drain/oracle.json +28 -0
- package/bench/fixtures/bug-recall/002-timeout-drain/responses/claude-code-solo.md +1 -0
- package/bench/fixtures/bug-recall/002-timeout-drain/responses/codex-solo.md +1 -0
- package/bench/fixtures/bug-recall/002-timeout-drain/responses/ennodia-parallel-compare.md +1 -0
- package/bench/fixtures/bug-recall/003-schema-contract/input.ts +22 -0
- package/bench/fixtures/bug-recall/003-schema-contract/oracle.json +28 -0
- package/bench/fixtures/bug-recall/003-schema-contract/responses/claude-code-solo.md +1 -0
- package/bench/fixtures/bug-recall/003-schema-contract/responses/codex-solo.md +1 -0
- package/bench/fixtures/bug-recall/003-schema-contract/responses/ennodia-parallel-compare.md +1 -0
- package/bench/fixtures/bug-recall/004-cancel-propagation/input.ts +28 -0
- package/bench/fixtures/bug-recall/004-cancel-propagation/oracle.json +28 -0
- package/bench/fixtures/bug-recall/004-cancel-propagation/responses/claude-code-solo.md +1 -0
- package/bench/fixtures/bug-recall/004-cancel-propagation/responses/codex-solo.md +1 -0
- package/bench/fixtures/bug-recall/004-cancel-propagation/responses/ennodia-parallel-compare.md +1 -0
- package/bench/results/.gitkeep +1 -0
- package/docs/assets/agents/antigravity-color.svg +1 -0
- package/docs/assets/agents/claudecode-color.svg +1 -0
- package/docs/assets/agents/codex-color.svg +1 -0
- package/docs/assets/agents/opencode-dark.svg +1 -0
- package/docs/assets/agents/opencode-light.svg +1 -0
- package/docs/assets/icons/arrow.svg +1 -0
- package/docs/assets/icons/github.svg +1 -0
- package/docs/assets/icons/link.svg +1 -0
- package/docs/assets/logo-dark.svg +1 -0
- package/docs/assets/logo.svg +1 -0
- package/docs/assets/orchestration-pipeline.svg +74 -0
- package/docs/getting-started.md +107 -0
- package/docs/in-depth/architecture.md +53 -0
- package/docs/in-depth/releasing.md +120 -0
- package/docs/index.md +16 -0
- package/docs/reference/mcp-tools.md +104 -0
- package/package.json +87 -0
- package/src/compare.ts +777 -0
- package/src/diagnosis.ts +161 -0
- package/src/harnesses.ts +302 -0
- package/src/index.ts +52 -0
- package/src/planner.ts +106 -0
- package/src/runs.ts +777 -0
- package/src/server.ts +747 -0
- package/src/tasks.ts +602 -0
- package/src/version.ts +1 -0
package/src/diagnosis.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { TaskView } from "./tasks";
|
|
2
|
+
|
|
3
|
+
export type TaskOutputPreview = {
|
|
4
|
+
harnessId: string;
|
|
5
|
+
chars: number;
|
|
6
|
+
preview: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type TaskDiagnosis = {
|
|
10
|
+
summary: string;
|
|
11
|
+
likelyCause: string;
|
|
12
|
+
suggestions: string[];
|
|
13
|
+
partialOutputPreviews?: TaskOutputPreview[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const PREVIEW_CHARS = 240;
|
|
17
|
+
|
|
18
|
+
export function diagnoseTasks(tasks: TaskView[]): TaskDiagnosis | undefined {
|
|
19
|
+
const failedTasks = tasks.filter((task) => task.status !== "succeeded");
|
|
20
|
+
if (failedTasks.length === 0) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (failedTasks.every((task) => task.status === "cancelled")) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const summary = failedTasks.map(summaryLine).join(" ");
|
|
29
|
+
const partialOutputPreviews = failedTasks
|
|
30
|
+
.map(outputPreview)
|
|
31
|
+
.filter(isTaskOutputPreview);
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
summary,
|
|
35
|
+
likelyCause: likelyCause(failedTasks),
|
|
36
|
+
suggestions: suggestions(failedTasks, partialOutputPreviews.length > 0),
|
|
37
|
+
partialOutputPreviews: partialOutputPreviews.length > 0
|
|
38
|
+
? partialOutputPreviews
|
|
39
|
+
: undefined,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function likelyCause(tasks: TaskView[]): string {
|
|
44
|
+
if (tasks.every((task) => task.timedOut)) {
|
|
45
|
+
if (tasks.some(outputChars) && tasks.some(hasRecentOutput)) {
|
|
46
|
+
return "Task needed more time; at least one provider was still producing output before the deadline.";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (tasks.some(outputChars)) {
|
|
50
|
+
return "Provider timeout after partial progress.";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return "Provider timeout before useful output; this may be slow startup, a model hang, or provider configuration.";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (tasks.some((task) => task.drainTimedOut)) {
|
|
57
|
+
return "Output drain timed out; captured output may be incomplete.";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (tasks.some((task) => task.exitCode !== undefined && task.exitCode !== 0)) {
|
|
61
|
+
return "Provider command error or bad configuration.";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (tasks.some((task) => task.status === "cancelled")) {
|
|
65
|
+
return "Some providers were cancelled before returning an answer.";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return "Provider failure; inspect task events and captured output for details.";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function suggestions(
|
|
72
|
+
tasks: TaskView[],
|
|
73
|
+
hasPartialOutput: boolean,
|
|
74
|
+
): string[] {
|
|
75
|
+
const result = new Set<string>();
|
|
76
|
+
|
|
77
|
+
if (tasks.some((task) => task.timedOut)) {
|
|
78
|
+
result.add("Retry the timed-out provider with a longer timeoutMs.");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (hasPartialOutput) {
|
|
82
|
+
result.add("Inspect partial output and events with ennodia_get_task.");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (tasks.some((task) => task.exitCode !== undefined && task.exitCode !== 0)) {
|
|
86
|
+
result.add("Inspect stderr and task events with ennodia_get_task.");
|
|
87
|
+
result.add("Retry with a different harness if the provider command looks broken.");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (tasks.some((task) => task.drainTimedOut)) {
|
|
91
|
+
result.add("Inspect captured output; the process exited but output capture did not fully drain.");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (result.size === 0) {
|
|
95
|
+
result.add("Inspect task events with ennodia_get_task.");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return [...result];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function summaryLine(task: TaskView): string {
|
|
102
|
+
const status = task.timedOut ? "timed out" : task.status;
|
|
103
|
+
const facts = [
|
|
104
|
+
`${task.harnessName} ${status} after ${formatDuration(task.elapsedMs)}.`,
|
|
105
|
+
`${task.stdoutChars + task.stderrChars} chars captured.`,
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
if (task.exitCode !== undefined && task.exitCode !== null) {
|
|
109
|
+
facts.push(`Exit code ${task.exitCode}.`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return facts.join(" ");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function outputPreview(task: TaskView): TaskOutputPreview | undefined {
|
|
116
|
+
const output = `${task.stdout.trim()}\n${task.stderr.trim()}`.trim();
|
|
117
|
+
if (!output) {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
harnessId: task.harnessId,
|
|
123
|
+
chars: task.stdoutChars + task.stderrChars,
|
|
124
|
+
preview: truncate(output.replace(/\s+/g, " "), PREVIEW_CHARS),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function outputChars(task: TaskView): number {
|
|
129
|
+
return task.stdoutChars + task.stderrChars;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function hasRecentOutput(task: TaskView): boolean {
|
|
133
|
+
if (!task.lastOutputAt || !task.endedAt) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return new Date(task.endedAt).getTime() - new Date(task.lastOutputAt).getTime() <
|
|
138
|
+
10_000;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function formatDuration(ms: number): string {
|
|
142
|
+
if (ms < 1_000) {
|
|
143
|
+
return `${ms}ms`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return `${Math.round(ms / 1_000)}s`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function truncate(text: string, maxChars: number): string {
|
|
150
|
+
if (text.length <= maxChars) {
|
|
151
|
+
return text;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return `${text.slice(0, maxChars - 3)}...`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isTaskOutputPreview(
|
|
158
|
+
preview: TaskOutputPreview | undefined,
|
|
159
|
+
): preview is TaskOutputPreview {
|
|
160
|
+
return Boolean(preview);
|
|
161
|
+
}
|
package/src/harnesses.ts
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
export type HarnessKind = "cli" | "app";
|
|
4
|
+
|
|
5
|
+
export type HarnessRunInput = {
|
|
6
|
+
prompt: string;
|
|
7
|
+
cwd?: string;
|
|
8
|
+
model?: string;
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type CommandSpec = {
|
|
13
|
+
command: string;
|
|
14
|
+
args: string[];
|
|
15
|
+
cwd?: string;
|
|
16
|
+
env?: Record<string, string>;
|
|
17
|
+
stdin?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type HarnessAdapter = {
|
|
21
|
+
id: string;
|
|
22
|
+
name: string;
|
|
23
|
+
kind: HarnessKind;
|
|
24
|
+
commandCandidates: string[];
|
|
25
|
+
appPaths?: string[];
|
|
26
|
+
versionArgs?: string[];
|
|
27
|
+
capabilities: string[];
|
|
28
|
+
notes?: string[];
|
|
29
|
+
buildCommand?: (commandPath: string, input: HarnessRunInput) => CommandSpec;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type HarnessDiscovery = {
|
|
33
|
+
id: string;
|
|
34
|
+
name: string;
|
|
35
|
+
kind: HarnessKind;
|
|
36
|
+
available: boolean;
|
|
37
|
+
runnable: boolean;
|
|
38
|
+
commandPath?: string;
|
|
39
|
+
appPath?: string;
|
|
40
|
+
version?: string;
|
|
41
|
+
capabilities: string[];
|
|
42
|
+
notes: string[];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type DiscoverHarnessesOptions = {
|
|
46
|
+
refresh?: boolean;
|
|
47
|
+
maxAgeMs?: number;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type CaptureResult = {
|
|
51
|
+
exitCode: number | null;
|
|
52
|
+
stdout: string;
|
|
53
|
+
stderr: string;
|
|
54
|
+
timedOut: boolean;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const DEFAULT_VERSION_TIMEOUT_MS = 2_500;
|
|
58
|
+
const DEFAULT_DISCOVERY_CACHE_MS = 30_000;
|
|
59
|
+
|
|
60
|
+
let cachedDiscovery:
|
|
61
|
+
| { createdAtMs: number; harnesses: HarnessDiscovery[] }
|
|
62
|
+
| undefined;
|
|
63
|
+
let inFlightDiscovery: Promise<HarnessDiscovery[]> | undefined;
|
|
64
|
+
|
|
65
|
+
export const harnessAdapters: HarnessAdapter[] = [
|
|
66
|
+
{
|
|
67
|
+
id: "claude-code",
|
|
68
|
+
name: "Claude Code",
|
|
69
|
+
kind: "cli",
|
|
70
|
+
commandCandidates: ["claude"],
|
|
71
|
+
versionArgs: ["--version"],
|
|
72
|
+
capabilities: ["reasoning", "code", "agents", "mcp", "non-interactive-cli"],
|
|
73
|
+
notes: ["Runs through the supported Claude Code CLI surface."],
|
|
74
|
+
buildCommand: (commandPath, input) => {
|
|
75
|
+
const args = [
|
|
76
|
+
"-p",
|
|
77
|
+
"--output-format",
|
|
78
|
+
"text",
|
|
79
|
+
"--no-session-persistence",
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
if (input.model) {
|
|
83
|
+
args.push("--model", input.model);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
args.push(input.prompt);
|
|
87
|
+
return { command: commandPath, args, cwd: input.cwd };
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
id: "codex",
|
|
92
|
+
name: "Codex CLI",
|
|
93
|
+
kind: "cli",
|
|
94
|
+
commandCandidates: ["codex"],
|
|
95
|
+
versionArgs: ["--version"],
|
|
96
|
+
capabilities: ["reasoning", "code", "agents", "mcp", "non-interactive-cli"],
|
|
97
|
+
notes: ["Defaults to read-only sandboxing for Ennodia-launched tasks."],
|
|
98
|
+
buildCommand: (commandPath, input) => {
|
|
99
|
+
const args = [
|
|
100
|
+
"exec",
|
|
101
|
+
"--color",
|
|
102
|
+
"never",
|
|
103
|
+
"--sandbox",
|
|
104
|
+
"read-only",
|
|
105
|
+
"--skip-git-repo-check",
|
|
106
|
+
"--ephemeral",
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
if (input.cwd) {
|
|
110
|
+
args.push("-C", input.cwd);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (input.model) {
|
|
114
|
+
args.push("--model", input.model);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
args.push(input.prompt);
|
|
118
|
+
return { command: commandPath, args, cwd: input.cwd };
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
id: "opencode",
|
|
123
|
+
name: "OpenCode",
|
|
124
|
+
kind: "cli",
|
|
125
|
+
commandCandidates: ["opencode"],
|
|
126
|
+
versionArgs: ["--version"],
|
|
127
|
+
capabilities: ["reasoning", "code", "agents", "non-interactive-cli"],
|
|
128
|
+
notes: ["Runs through `opencode run` without permission-bypass flags."],
|
|
129
|
+
buildCommand: (commandPath, input) => {
|
|
130
|
+
const args = ["run"];
|
|
131
|
+
|
|
132
|
+
if (input.cwd) {
|
|
133
|
+
args.push("--dir", input.cwd);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (input.model) {
|
|
137
|
+
args.push("--model", input.model);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
args.push(input.prompt);
|
|
141
|
+
return { command: commandPath, args, cwd: input.cwd };
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: "antigravity",
|
|
146
|
+
name: "Antigravity",
|
|
147
|
+
kind: "cli",
|
|
148
|
+
commandCandidates: ["agy"],
|
|
149
|
+
versionArgs: ["--version"],
|
|
150
|
+
appPaths: [
|
|
151
|
+
"/Applications/Antigravity.app",
|
|
152
|
+
"/Applications/Antigravity IDE.app",
|
|
153
|
+
],
|
|
154
|
+
capabilities: ["ide", "browser-automation", "agents", "non-interactive-cli"],
|
|
155
|
+
notes: [
|
|
156
|
+
"Runs through the supported `agy` CLI surface.",
|
|
157
|
+
"Defaults to Antigravity sandbox mode for Ennodia-launched tasks.",
|
|
158
|
+
"Sends prompts through stdin because `agy --print` does not consume positional prompts.",
|
|
159
|
+
],
|
|
160
|
+
buildCommand: (commandPath, input) => {
|
|
161
|
+
const args = [
|
|
162
|
+
"--sandbox",
|
|
163
|
+
"--print",
|
|
164
|
+
"--print-timeout",
|
|
165
|
+
toGoDuration(input.timeoutMs),
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
if (input.cwd) {
|
|
169
|
+
args.push("--add-dir", input.cwd);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (input.model) {
|
|
173
|
+
args.push("--model", input.model);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return { command: commandPath, args, cwd: input.cwd, stdin: input.prompt };
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
export async function discoverHarnesses(
|
|
182
|
+
options: DiscoverHarnessesOptions = {},
|
|
183
|
+
): Promise<HarnessDiscovery[]> {
|
|
184
|
+
const now = Date.now();
|
|
185
|
+
const maxAgeMs = options.maxAgeMs ?? DEFAULT_DISCOVERY_CACHE_MS;
|
|
186
|
+
|
|
187
|
+
if (
|
|
188
|
+
!options.refresh &&
|
|
189
|
+
cachedDiscovery &&
|
|
190
|
+
now - cachedDiscovery.createdAtMs <= maxAgeMs
|
|
191
|
+
) {
|
|
192
|
+
return cachedDiscovery.harnesses;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!options.refresh && inFlightDiscovery) {
|
|
196
|
+
return inFlightDiscovery;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
inFlightDiscovery = Promise.all(harnessAdapters.map(discoverHarness)).then(
|
|
200
|
+
(harnesses) => {
|
|
201
|
+
cachedDiscovery = { createdAtMs: Date.now(), harnesses };
|
|
202
|
+
return harnesses;
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
return await inFlightDiscovery;
|
|
208
|
+
} finally {
|
|
209
|
+
inFlightDiscovery = undefined;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function findHarnessAdapter(id: string): HarnessAdapter | undefined {
|
|
214
|
+
return harnessAdapters.find((adapter) => adapter.id === id);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function discoverHarness(
|
|
218
|
+
adapter: HarnessAdapter,
|
|
219
|
+
): Promise<HarnessDiscovery> {
|
|
220
|
+
const commandPath = adapter.commandCandidates
|
|
221
|
+
.map((candidate) => Bun.which(candidate))
|
|
222
|
+
.find((match): match is string => Boolean(match));
|
|
223
|
+
|
|
224
|
+
const appPath = adapter.appPaths?.find((path) => existsSync(path));
|
|
225
|
+
const version = commandPath ? await readVersion(commandPath, adapter) : undefined;
|
|
226
|
+
const available = Boolean(commandPath || appPath);
|
|
227
|
+
const runnable = Boolean(commandPath && adapter.buildCommand);
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
id: adapter.id,
|
|
231
|
+
name: adapter.name,
|
|
232
|
+
kind: adapter.kind,
|
|
233
|
+
available,
|
|
234
|
+
runnable,
|
|
235
|
+
commandPath,
|
|
236
|
+
appPath,
|
|
237
|
+
version,
|
|
238
|
+
capabilities: adapter.capabilities,
|
|
239
|
+
notes: adapter.notes ?? [],
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function readVersion(
|
|
244
|
+
commandPath: string,
|
|
245
|
+
adapter: HarnessAdapter,
|
|
246
|
+
): Promise<string | undefined> {
|
|
247
|
+
if (!adapter.versionArgs) {
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const result = await capture(commandPath, adapter.versionArgs, {
|
|
252
|
+
timeoutMs: DEFAULT_VERSION_TIMEOUT_MS,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const text = `${result.stdout}\n${result.stderr}`.trim();
|
|
256
|
+
return text || undefined;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function capture(
|
|
260
|
+
command: string,
|
|
261
|
+
args: string[],
|
|
262
|
+
options: { timeoutMs: number },
|
|
263
|
+
): Promise<CaptureResult> {
|
|
264
|
+
const process = Bun.spawn({
|
|
265
|
+
cmd: [command, ...args],
|
|
266
|
+
stdout: "pipe",
|
|
267
|
+
stderr: "pipe",
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
let timedOut = false;
|
|
271
|
+
const timeout = setTimeout(() => {
|
|
272
|
+
timedOut = true;
|
|
273
|
+
process.kill();
|
|
274
|
+
}, options.timeoutMs);
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
278
|
+
streamToText(process.stdout),
|
|
279
|
+
streamToText(process.stderr),
|
|
280
|
+
process.exited,
|
|
281
|
+
]);
|
|
282
|
+
|
|
283
|
+
return { stdout, stderr, exitCode, timedOut };
|
|
284
|
+
} finally {
|
|
285
|
+
clearTimeout(timeout);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function streamToText(
|
|
290
|
+
stream: ReadableStream<Uint8Array> | null,
|
|
291
|
+
): Promise<string> {
|
|
292
|
+
if (!stream) {
|
|
293
|
+
return "";
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return await new Response(stream).text();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function toGoDuration(timeoutMs = 5 * 60 * 1000): string {
|
|
300
|
+
const seconds = Math.max(1, Math.ceil(timeoutMs / 1000));
|
|
301
|
+
return `${seconds}s`;
|
|
302
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { createEnnodiaServer, shutdownEnnodia } from "./server";
|
|
4
|
+
|
|
5
|
+
const server = createEnnodiaServer();
|
|
6
|
+
const transport = new StdioServerTransport();
|
|
7
|
+
const SHUTDOWN_DEADLINE_MS = 5_000;
|
|
8
|
+
let shutdownPromise: Promise<void> | undefined;
|
|
9
|
+
|
|
10
|
+
transport.onclose = () => {
|
|
11
|
+
void shutdown("transport closed");
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
process.once("SIGINT", () => {
|
|
15
|
+
void shutdown("SIGINT", 130);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
process.once("SIGTERM", () => {
|
|
19
|
+
void shutdown("SIGTERM", 143);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
await server.connect(transport);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
console.error(error instanceof Error ? error.stack ?? error.message : error);
|
|
26
|
+
await shutdownEnnodia({ deadlineMs: SHUTDOWN_DEADLINE_MS });
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function shutdown(reason: string, exitCode?: number): Promise<void> {
|
|
31
|
+
shutdownPromise ??= Promise.resolve().then(() => closeAndShutdown(reason));
|
|
32
|
+
|
|
33
|
+
await shutdownPromise;
|
|
34
|
+
|
|
35
|
+
if (exitCode !== undefined) {
|
|
36
|
+
process.exit(exitCode);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function closeAndShutdown(reason: string): Promise<void> {
|
|
41
|
+
try {
|
|
42
|
+
await server.close();
|
|
43
|
+
} catch (error) {
|
|
44
|
+
console.error(
|
|
45
|
+
`Failed to close MCP server after ${reason}: ${
|
|
46
|
+
error instanceof Error ? error.message : String(error)
|
|
47
|
+
}`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
await shutdownEnnodia({ deadlineMs: SHUTDOWN_DEADLINE_MS });
|
|
52
|
+
}
|
package/src/planner.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { HarnessDiscovery } from "./harnesses";
|
|
2
|
+
|
|
3
|
+
export type RoutePlan = {
|
|
4
|
+
category: "code" | "research" | "browser" | "image" | "general";
|
|
5
|
+
reasons: string[];
|
|
6
|
+
candidates: string[];
|
|
7
|
+
selected?: string;
|
|
8
|
+
parallelSuggested: boolean;
|
|
9
|
+
compareSuggested: boolean;
|
|
10
|
+
mermaid: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function planRoute(
|
|
14
|
+
prompt: string,
|
|
15
|
+
harnesses: HarnessDiscovery[],
|
|
16
|
+
): RoutePlan {
|
|
17
|
+
const lower = prompt.toLowerCase();
|
|
18
|
+
const runnable = harnesses.filter((harness) => harness.runnable);
|
|
19
|
+
const runnableIds = new Set(runnable.map((harness) => harness.id));
|
|
20
|
+
const reasons: string[] = [];
|
|
21
|
+
|
|
22
|
+
let category: RoutePlan["category"] = "general";
|
|
23
|
+
|
|
24
|
+
if (/\b(code|bug|diff|repo|typescript|javascript|python|test|review)\b/.test(lower)) {
|
|
25
|
+
category = "code";
|
|
26
|
+
reasons.push("The prompt looks code-oriented.");
|
|
27
|
+
} else if (/\b(browser|website|click|screenshot|ui|page|automation)\b/.test(lower)) {
|
|
28
|
+
category = "browser";
|
|
29
|
+
reasons.push("The prompt mentions browser or UI automation.");
|
|
30
|
+
} else if (/\b(image|visual|design|screenshot|mockup)\b/.test(lower)) {
|
|
31
|
+
category = "image";
|
|
32
|
+
reasons.push("The prompt mentions visual or image work.");
|
|
33
|
+
} else if (/\b(research|compare|latest|source|web|investigate)\b/.test(lower)) {
|
|
34
|
+
category = "research";
|
|
35
|
+
reasons.push("The prompt looks research-oriented.");
|
|
36
|
+
} else {
|
|
37
|
+
reasons.push("No specialist category was obvious, so Ennodia chose a general route.");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const preferredByCategory: Record<RoutePlan["category"], string[]> = {
|
|
41
|
+
code: ["codex", "claude-code", "opencode", "antigravity"],
|
|
42
|
+
research: ["claude-code", "codex", "opencode"],
|
|
43
|
+
browser: ["antigravity", "opencode", "claude-code", "codex"],
|
|
44
|
+
image: ["antigravity", "claude-code", "opencode", "codex"],
|
|
45
|
+
general: ["claude-code", "codex", "opencode", "antigravity"],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const candidates = [
|
|
49
|
+
...preferredByCategory[category].filter((id) => runnableIds.has(id)),
|
|
50
|
+
...runnable.map((harness) => harness.id).filter(
|
|
51
|
+
(id) => !preferredByCategory[category].includes(id),
|
|
52
|
+
),
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const complexSignals = /\b(compare|several|multiple|parallel|architecture|review|tradeoff|best|judge)\b/.test(
|
|
56
|
+
lower,
|
|
57
|
+
);
|
|
58
|
+
const parallelSuggested = candidates.length > 1 && complexSignals;
|
|
59
|
+
const compareSuggested = parallelSuggested;
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
category,
|
|
63
|
+
reasons,
|
|
64
|
+
candidates,
|
|
65
|
+
selected: candidates[0],
|
|
66
|
+
parallelSuggested,
|
|
67
|
+
compareSuggested,
|
|
68
|
+
mermaid: renderPlanMermaid(candidates, parallelSuggested, compareSuggested),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function renderPlanMermaid(
|
|
73
|
+
candidates: string[],
|
|
74
|
+
parallelSuggested: boolean,
|
|
75
|
+
compareSuggested: boolean,
|
|
76
|
+
): string {
|
|
77
|
+
const lines = [
|
|
78
|
+
"flowchart TD",
|
|
79
|
+
' request["Request"]',
|
|
80
|
+
' classify["Classify"]',
|
|
81
|
+
' plan["Plan route"]',
|
|
82
|
+
" request --> classify",
|
|
83
|
+
" classify --> plan",
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
const selected = parallelSuggested ? candidates : candidates.slice(0, 1);
|
|
87
|
+
|
|
88
|
+
for (const id of selected) {
|
|
89
|
+
const nodeId = id.replace(/[^a-zA-Z0-9]/g, "_");
|
|
90
|
+
lines.push(` ${nodeId}["${id}"]`);
|
|
91
|
+
lines.push(` plan --> ${nodeId}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (compareSuggested && selected.length > 1) {
|
|
95
|
+
lines.push(' compare["Compare outputs"]');
|
|
96
|
+
for (const id of selected) {
|
|
97
|
+
const nodeId = id.replace(/[^a-zA-Z0-9]/g, "_");
|
|
98
|
+
lines.push(` ${nodeId} --> compare`);
|
|
99
|
+
}
|
|
100
|
+
lines.push(' compare --> result["Final result"]');
|
|
101
|
+
} else {
|
|
102
|
+
lines.push(' plan --> result["Final result"]');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return lines.join("\n");
|
|
106
|
+
}
|