@ordewell/web 0.4.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/LICENSE +211 -0
- package/README.md +34 -0
- package/dist/server/main.js +1159 -0
- package/dist/server/main.js.map +1 -0
- package/package.json +51 -0
|
@@ -0,0 +1,1159 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
var hono = require('hono');
|
|
5
|
+
var cors = require('hono/cors');
|
|
6
|
+
var core = require('@ordewell/core');
|
|
7
|
+
var fs = require('fs');
|
|
8
|
+
var path = require('path');
|
|
9
|
+
var child_process = require('child_process');
|
|
10
|
+
var ws = require('ws');
|
|
11
|
+
var http = require('http');
|
|
12
|
+
|
|
13
|
+
function _interopNamespace(e) {
|
|
14
|
+
if (e && e.__esModule) return e;
|
|
15
|
+
var n = Object.create(null);
|
|
16
|
+
if (e) {
|
|
17
|
+
Object.keys(e).forEach(function (k) {
|
|
18
|
+
if (k !== 'default') {
|
|
19
|
+
var d = Object.getOwnPropertyDescriptor(e, k);
|
|
20
|
+
Object.defineProperty(n, k, d.get ? d : {
|
|
21
|
+
enumerable: true,
|
|
22
|
+
get: function () { return e[k]; }
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
n.default = e;
|
|
28
|
+
return Object.freeze(n);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
var fs__namespace = /*#__PURE__*/_interopNamespace(fs);
|
|
32
|
+
var path__namespace = /*#__PURE__*/_interopNamespace(path);
|
|
33
|
+
|
|
34
|
+
var WebConfig = class extends core.BaseConfig {
|
|
35
|
+
_provider = null;
|
|
36
|
+
_enabledRunnersOverride;
|
|
37
|
+
_providerModelLists;
|
|
38
|
+
_modelOverride;
|
|
39
|
+
constructor(opts) {
|
|
40
|
+
super();
|
|
41
|
+
this._enabledRunnersOverride = opts?.enabledRunners;
|
|
42
|
+
this._providerModelLists = opts?.providerModelLists;
|
|
43
|
+
this._modelOverride = opts?.modelOverride;
|
|
44
|
+
}
|
|
45
|
+
setProviderModelLists(lists) {
|
|
46
|
+
this._providerModelLists = lists;
|
|
47
|
+
this._provider = null;
|
|
48
|
+
}
|
|
49
|
+
get orchestratorModel() {
|
|
50
|
+
return this._modelOverride || process.env.ORCHESTRATOR_MODEL || "";
|
|
51
|
+
}
|
|
52
|
+
get aiProvider() {
|
|
53
|
+
if (this._provider) return this._provider;
|
|
54
|
+
const explicit = process.env.AI_PROVIDER;
|
|
55
|
+
if (explicit && core.isCliProvider(explicit)) {
|
|
56
|
+
this._provider = explicit;
|
|
57
|
+
return this._provider;
|
|
58
|
+
}
|
|
59
|
+
const model = this._modelOverride || process.env.ORCHESTRATOR_MODEL;
|
|
60
|
+
if (model && this._providerModelLists) {
|
|
61
|
+
const resolved = core.resolveProvider(model, this._providerModelLists);
|
|
62
|
+
if (resolved) {
|
|
63
|
+
this._provider = resolved;
|
|
64
|
+
return this._provider;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
this._provider = core.BaseConfig.detectProvider("openrouter");
|
|
68
|
+
return this._provider;
|
|
69
|
+
}
|
|
70
|
+
get apiKey() {
|
|
71
|
+
return this.getProviderApiKey(this.aiProvider);
|
|
72
|
+
}
|
|
73
|
+
get planningModel() {
|
|
74
|
+
if (this.aiProvider === "google")
|
|
75
|
+
return core.normalizeGeminiModel(process.env.GEMINI_MODEL || process.env.ORCHESTRATOR_MODEL || super.geminiModel);
|
|
76
|
+
return process.env.ORCHESTRATOR_MODEL || "";
|
|
77
|
+
}
|
|
78
|
+
get enabledRunners() {
|
|
79
|
+
if (this._enabledRunnersOverride) return this._enabledRunnersOverride;
|
|
80
|
+
const envRunners = process.env.ORDEWELL_ENABLED_RUNNERS;
|
|
81
|
+
if (envRunners) {
|
|
82
|
+
const parsed = envRunners.split(",").map((s) => s.trim()).filter(Boolean);
|
|
83
|
+
if (parsed.length > 0) return parsed;
|
|
84
|
+
}
|
|
85
|
+
const out = [];
|
|
86
|
+
if (process.env.ORDEWELL_CLAUDE_CODE_ENABLED !== "false" && process.env.ORDEWELL_CLAUDE_CODE_ENABLED !== "0") {
|
|
87
|
+
out.push("claude-code");
|
|
88
|
+
}
|
|
89
|
+
if (process.env.ORDEWELL_CODEX_ENABLED === "true" || process.env.ORDEWELL_CODEX_ENABLED === "1") {
|
|
90
|
+
out.push("codex");
|
|
91
|
+
}
|
|
92
|
+
if (process.env.ORDEWELL_OPENCODE_ENABLED === "true" || process.env.ORDEWELL_OPENCODE_ENABLED === "1") {
|
|
93
|
+
out.push("opencode");
|
|
94
|
+
}
|
|
95
|
+
return out.length > 0 ? out : ["claude-code"];
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
function scanWorkspacesImpl(deps) {
|
|
99
|
+
const { fsModule, pathModule, cwd, home, projectDir } = deps;
|
|
100
|
+
const paths = /* @__PURE__ */ new Set();
|
|
101
|
+
const searchRoots = [
|
|
102
|
+
cwd,
|
|
103
|
+
home,
|
|
104
|
+
pathModule.join(home, "projects"),
|
|
105
|
+
pathModule.join(home, "dev"),
|
|
106
|
+
pathModule.join(home, "code"),
|
|
107
|
+
pathModule.join(home, "src"),
|
|
108
|
+
pathModule.join(home, "Desktop"),
|
|
109
|
+
pathModule.join(home, "Documents")
|
|
110
|
+
];
|
|
111
|
+
for (const root of searchRoots) {
|
|
112
|
+
try {
|
|
113
|
+
if (fsModule.existsSync(pathModule.join(root, ".ordewell"))) paths.add(root);
|
|
114
|
+
const entries = fsModule.readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory());
|
|
115
|
+
for (const e of entries.slice(0, 50)) {
|
|
116
|
+
const sub = pathModule.join(root, e.name);
|
|
117
|
+
if (fsModule.existsSync(pathModule.join(sub, ".ordewell"))) paths.add(sub);
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return [...paths].filter((p) => p !== pathModule.resolve(projectDir)).sort();
|
|
123
|
+
}
|
|
124
|
+
function scanWorkspaces() {
|
|
125
|
+
return scanWorkspacesImpl({
|
|
126
|
+
fsModule: fs__namespace,
|
|
127
|
+
pathModule: path__namespace,
|
|
128
|
+
cwd: process.cwd(),
|
|
129
|
+
home: process.env.HOME || "/root",
|
|
130
|
+
projectDir: path__namespace.resolve(__dirname, "..", "..", "..", "..")
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
var MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
|
|
134
|
+
function run(file, args2, opts) {
|
|
135
|
+
return new Promise((resolve2) => {
|
|
136
|
+
try {
|
|
137
|
+
child_process.execFile(file, args2, {
|
|
138
|
+
cwd: opts.cwd,
|
|
139
|
+
timeout: opts.timeout,
|
|
140
|
+
maxBuffer: MAX_OUTPUT_BYTES,
|
|
141
|
+
encoding: "utf8",
|
|
142
|
+
shell: opts.shell,
|
|
143
|
+
env: opts.env
|
|
144
|
+
}, (err, stdout, stderr) => {
|
|
145
|
+
const code = err && typeof err.code === "number" ? err.code : err ? 1 : 0;
|
|
146
|
+
resolve2({ stdout: stdout ?? "", stderr: stderr ?? "", code });
|
|
147
|
+
});
|
|
148
|
+
} catch (err) {
|
|
149
|
+
resolve2({ stdout: "", stderr: err.message, code: null });
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// server/adapters/PoolFileSystem.ts
|
|
155
|
+
var SEARCH_TIMEOUT_MS = 2e4;
|
|
156
|
+
var BASH_TIMEOUT_MS = 12e4;
|
|
157
|
+
var PoolFileSystem = class extends core.BaseFileSystem {
|
|
158
|
+
constructor(workspaceRoot) {
|
|
159
|
+
super();
|
|
160
|
+
this.workspaceRoot = workspaceRoot;
|
|
161
|
+
}
|
|
162
|
+
workspaceRoot;
|
|
163
|
+
getWorkspaceRoot() {
|
|
164
|
+
return this.workspaceRoot;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* PATH for the search subprocesses. `execFile` starts them with no shell, so
|
|
168
|
+
* on a Windows box whose POSIX tools live in the Git tree (`grep.exe` beside
|
|
169
|
+
* the research shell) they are only resolvable with that directory prepended.
|
|
170
|
+
* Identity on POSIX, where `utilsDir` is null. Kept in step with
|
|
171
|
+
* VsCodeFileSystem.searchEnv.
|
|
172
|
+
*/
|
|
173
|
+
searchEnv() {
|
|
174
|
+
const resolved = core.researchToolsPath(this.researchShell, process.env.PATH);
|
|
175
|
+
return resolved === (process.env.PATH ?? "") ? void 0 : core.withPath(process.env, resolved);
|
|
176
|
+
}
|
|
177
|
+
rgAvailable = null;
|
|
178
|
+
hasRg() {
|
|
179
|
+
this.rgAvailable ??= run("rg", ["--version"], { timeout: 5e3, env: this.searchEnv() }).then((r) => r.code === 0).catch(() => false);
|
|
180
|
+
return this.rgAvailable;
|
|
181
|
+
}
|
|
182
|
+
async readFileImpl(absPath, opts) {
|
|
183
|
+
try {
|
|
184
|
+
const stat = await fs.promises.stat(absPath);
|
|
185
|
+
if (!stat.isFile()) return { success: false, output: `Not a file: ${absPath}`, truncated: false };
|
|
186
|
+
const maxBytes = opts?.maxBytes ?? 1024;
|
|
187
|
+
if (stat.size > maxBytes * 1024) {
|
|
188
|
+
const sizeMB = (stat.size / (1024 * 1024)).toFixed(1);
|
|
189
|
+
return {
|
|
190
|
+
success: true,
|
|
191
|
+
output: `File too large (${sizeMB} MB). Maximum is ${maxBytes} KB. Use grep to search for specific patterns instead.`,
|
|
192
|
+
truncated: false
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
const content = await fs.promises.readFile(absPath, "utf8");
|
|
196
|
+
const allLines = content.split("\n");
|
|
197
|
+
if (allLines.length > 0 && allLines[allLines.length - 1] === "") allLines.pop();
|
|
198
|
+
const totalLines = allLines.length;
|
|
199
|
+
const startLine = opts?.offset ?? 0;
|
|
200
|
+
const maxLines = opts?.limit ?? 2e3;
|
|
201
|
+
if (startLine >= totalLines) return { success: true, output: "", truncated: false };
|
|
202
|
+
const selectedLines = allLines.slice(startLine, startLine + maxLines);
|
|
203
|
+
const truncated = startLine + maxLines < totalLines;
|
|
204
|
+
const lineWidth = String(totalLines).length;
|
|
205
|
+
let output = selectedLines.map((line, i) => {
|
|
206
|
+
const lineNum = String(startLine + i + 1).padStart(lineWidth);
|
|
207
|
+
return `${lineNum}|${line}`;
|
|
208
|
+
}).join("\n");
|
|
209
|
+
if (truncated) {
|
|
210
|
+
const nextOffset = startLine + maxLines;
|
|
211
|
+
output += `
|
|
212
|
+
|
|
213
|
+
(File has more lines \u2014 use offset=${nextOffset} to read beyond line ${nextOffset})
|
|
214
|
+
`;
|
|
215
|
+
}
|
|
216
|
+
return { success: true, output, truncated };
|
|
217
|
+
} catch (err) {
|
|
218
|
+
return { success: false, output: `Error reading ${absPath}: ${err.message}`, truncated: false };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
async globImpl(pattern, absRoot, headLimit) {
|
|
222
|
+
if (await this.hasRg()) {
|
|
223
|
+
const anchor = await this.searchAnchor(absRoot);
|
|
224
|
+
const result2 = await run("rg", core.buildGlobArgs(pattern, absRoot), { timeout: SEARCH_TIMEOUT_MS, cwd: anchor, env: this.searchEnv() });
|
|
225
|
+
if (result2.code !== 0 && result2.code !== 1 && !result2.stdout) {
|
|
226
|
+
return { success: false, output: `Glob failed: ${result2.stderr.trim() || "ripgrep error"}`, truncated: false };
|
|
227
|
+
}
|
|
228
|
+
const capped2 = core.applyHeadLimit(result2.stdout, headLimit);
|
|
229
|
+
return {
|
|
230
|
+
success: true,
|
|
231
|
+
output: core.formatSearchOutput(capped2, anchor, { emptyMessage: "No files matched.", hint: "Narrow the pattern." }),
|
|
232
|
+
truncated: capped2.truncated
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
const basename = pattern.split("/").pop() || pattern;
|
|
236
|
+
const excludeArgs = core.SEARCH_EXCLUSIONS.flatMap((dir) => ["-not", "-path", `*/${dir}/*`]);
|
|
237
|
+
const result = await run("find", [absRoot, "-type", "f", "-name", basename, ...excludeArgs], { timeout: SEARCH_TIMEOUT_MS, env: this.searchEnv() });
|
|
238
|
+
if (result.code !== 0 && !result.stdout) {
|
|
239
|
+
return { success: false, output: `Glob failed: ${result.stderr.trim() || "find error"}`, truncated: false };
|
|
240
|
+
}
|
|
241
|
+
const capped = core.applyHeadLimit(result.stdout, headLimit);
|
|
242
|
+
return {
|
|
243
|
+
success: true,
|
|
244
|
+
output: core.formatSearchOutput(capped, absRoot, { emptyMessage: "No files matched." }),
|
|
245
|
+
truncated: capped.truncated
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* A directory to anchor a search in. `cwd` exists only so anchored
|
|
250
|
+
* `--glob`/`--include` patterns resolve against the search root (see
|
|
251
|
+
* globImpl), and spawn(2) rejects a `cwd` that is a file with ENOTDIR.
|
|
252
|
+
*
|
|
253
|
+
* `grep`'s `path` is documented as a directory, but the tool description
|
|
254
|
+
* tells the model to survey with output_mode="files" and then re-run "on a
|
|
255
|
+
* narrower path" — and what that survey hands back are files. rg and grep
|
|
256
|
+
* both search a single file happily, so anchor at the parent rather than
|
|
257
|
+
* failing a legitimately file-scoped search.
|
|
258
|
+
*/
|
|
259
|
+
async searchAnchor(absRoot) {
|
|
260
|
+
try {
|
|
261
|
+
return (await fs.promises.stat(absRoot)).isDirectory() ? absRoot : path__namespace.dirname(absRoot);
|
|
262
|
+
} catch {
|
|
263
|
+
return absRoot;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
async grepImpl(pattern, absRoot, opts) {
|
|
267
|
+
const headLimit = opts.headLimit ?? 100;
|
|
268
|
+
const useRg = await this.hasRg();
|
|
269
|
+
const anchor = await this.searchAnchor(absRoot);
|
|
270
|
+
const anchoredInclude = !useRg && opts.include?.includes("/") ? opts.include : void 0;
|
|
271
|
+
const result = useRg ? await run("rg", core.buildGrepArgs(pattern, opts, absRoot).args, { timeout: SEARCH_TIMEOUT_MS, cwd: anchor, env: this.searchEnv() }) : await run("grep", core.buildFallbackGrepArgs(pattern, anchoredInclude ? { ...opts, include: void 0 } : opts, absRoot), { timeout: SEARCH_TIMEOUT_MS, cwd: anchor, env: this.searchEnv() });
|
|
272
|
+
if (result.code !== 0 && result.code !== 1 && !result.stdout) {
|
|
273
|
+
return { success: false, output: `Search failed: ${result.stderr.trim() || "search error"}`, truncated: false };
|
|
274
|
+
}
|
|
275
|
+
const filteredStdout = anchoredInclude ? core.filterFallbackByAnchoredInclude(result.stdout, anchoredInclude, anchor, opts.outputMode) : result.stdout;
|
|
276
|
+
const capped = core.applyHeadLimit(filteredStdout, headLimit);
|
|
277
|
+
const hint = opts.outputMode === "content" ? 'Re-run with output_mode="files" to see the full breadth, or narrow with include/path.' : "Narrow with include/path.";
|
|
278
|
+
return {
|
|
279
|
+
success: true,
|
|
280
|
+
output: core.formatSearchOutput(capped, anchor, { emptyMessage: "No matches found.", hint }),
|
|
281
|
+
truncated: capped.truncated
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
async listDirImpl(absPath, depth) {
|
|
285
|
+
if (depth > 1) {
|
|
286
|
+
const result = await run("tree", ["-L", String(depth), "-I", "node_modules|.git|dist|build", absPath], { timeout: 1e4, env: this.searchEnv() });
|
|
287
|
+
if (result.code === 0 && result.stdout.trim()) {
|
|
288
|
+
const lines = result.stdout.trim().split("\n");
|
|
289
|
+
return { success: true, output: lines.slice(0, 300).join("\n"), truncated: lines.length > 300 };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
try {
|
|
293
|
+
const entries = await fs.promises.readdir(absPath, { withFileTypes: true });
|
|
294
|
+
const lines = entries.slice(0, 200).map((e) => `${e.isDirectory() ? "D" : "F"} ${e.name}`);
|
|
295
|
+
return { success: true, output: lines.join("\n") || "(empty directory)", truncated: entries.length > 200 };
|
|
296
|
+
} catch (err) {
|
|
297
|
+
return { success: false, output: `Error listing ${absPath}: ${err.message}`, truncated: false };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
async execBashImpl(command) {
|
|
301
|
+
const { file, args: args2 } = this.researchShell;
|
|
302
|
+
const result = file === null ? await run(command, [], { cwd: this.workspaceRoot, timeout: BASH_TIMEOUT_MS, shell: true }) : await run(file, [...args2, command], { cwd: this.workspaceRoot, timeout: BASH_TIMEOUT_MS });
|
|
303
|
+
const out = (result.stdout || "").trim();
|
|
304
|
+
const err = (result.stderr || "").trim();
|
|
305
|
+
if (result.code !== 0) {
|
|
306
|
+
const detail = err || out || `exited with code ${result.code}`;
|
|
307
|
+
return { success: false, output: `Command exited ${result.code}:
|
|
308
|
+
${detail.slice(0, 2e4)}`, truncated: detail.length > 2e4 };
|
|
309
|
+
}
|
|
310
|
+
const combined = err ? `${out}
|
|
311
|
+
[stderr]
|
|
312
|
+
${err}` : out;
|
|
313
|
+
return { success: true, output: combined || "(no output)", truncated: combined.length > 2e4 };
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
var PoolAwareRunner = class {
|
|
317
|
+
constructor(sessionId, broadcast, inner) {
|
|
318
|
+
this.sessionId = sessionId;
|
|
319
|
+
this.broadcast = broadcast;
|
|
320
|
+
this.inner = inner ?? new core.HeadlessRunner();
|
|
321
|
+
}
|
|
322
|
+
sessionId;
|
|
323
|
+
broadcast;
|
|
324
|
+
inner;
|
|
325
|
+
/**
|
|
326
|
+
* Sessions this plan spawned. The inner runner may be shared by every plan
|
|
327
|
+
* in the pool (one tmux session per daemon), so "stop everything" and
|
|
328
|
+
* "how many are running" must be answered from this set, never delegated —
|
|
329
|
+
* delegating would let one plan's /stop kill another plan's tasks.
|
|
330
|
+
*/
|
|
331
|
+
owned = /* @__PURE__ */ new Set();
|
|
332
|
+
get activeCount() {
|
|
333
|
+
return this.owned.size;
|
|
334
|
+
}
|
|
335
|
+
async spawn(opts) {
|
|
336
|
+
const session = await this.inner.spawn({ ...opts, planSessionId: this.sessionId });
|
|
337
|
+
this.owned.add(session.id);
|
|
338
|
+
session.onExit(() => this.owned.delete(session.id));
|
|
339
|
+
this.broadcast({ type: "task_started", taskId: opts.taskId, order: opts.order ?? 0, title: opts.title ?? "", runner: opts.runner, modelId: opts.modelId });
|
|
340
|
+
session.onOutput((text) => {
|
|
341
|
+
this.broadcast({ type: "task_output", taskId: opts.taskId, text });
|
|
342
|
+
});
|
|
343
|
+
return session;
|
|
344
|
+
}
|
|
345
|
+
stop(sessionId) {
|
|
346
|
+
this.inner.stop(sessionId);
|
|
347
|
+
this.owned.delete(sessionId);
|
|
348
|
+
}
|
|
349
|
+
stopAll() {
|
|
350
|
+
for (const id of this.owned) this.inner.stop(id);
|
|
351
|
+
this.owned.clear();
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
// server/pool/orchestratorPool.ts
|
|
356
|
+
var OrchestratorPool = class {
|
|
357
|
+
sessions = /* @__PURE__ */ new Map();
|
|
358
|
+
clients = /* @__PURE__ */ new Map();
|
|
359
|
+
registry = (() => {
|
|
360
|
+
const r = new core.RunnerRegistry();
|
|
361
|
+
r.loadUserPlugins();
|
|
362
|
+
return r;
|
|
363
|
+
})();
|
|
364
|
+
modelResolver = new core.ModelResolver(this.registry, new WebConfig());
|
|
365
|
+
runnerInstallation = new core.RunnerInstallation(this.registry);
|
|
366
|
+
enabledRunnerOverride;
|
|
367
|
+
cachedProviderLists;
|
|
368
|
+
settingsService = new core.SettingsService();
|
|
369
|
+
sharedRunner;
|
|
370
|
+
constructor(deps = {}) {
|
|
371
|
+
this.sharedRunner = deps.runner;
|
|
372
|
+
}
|
|
373
|
+
subscribe(sessionId, ws) {
|
|
374
|
+
if (!this.clients.has(sessionId)) this.clients.set(sessionId, /* @__PURE__ */ new Set());
|
|
375
|
+
this.clients.get(sessionId).add(ws);
|
|
376
|
+
const plan = this.sessions.get(sessionId)?.planState;
|
|
377
|
+
const history = plan?.conversationHistory ?? [];
|
|
378
|
+
if (history.length > 0 && ws.readyState === ws.OPEN) {
|
|
379
|
+
ws.send(JSON.stringify({ type: "chat_backlog", history }));
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
unsubscribe(sessionId, ws) {
|
|
383
|
+
this.clients.get(sessionId)?.delete(ws);
|
|
384
|
+
if (this.clients.get(sessionId)?.size === 0) this.clients.delete(sessionId);
|
|
385
|
+
}
|
|
386
|
+
broadcast(sessionId, msg) {
|
|
387
|
+
const payload = JSON.stringify(msg);
|
|
388
|
+
for (const ws of this.clients.get(sessionId) ?? []) {
|
|
389
|
+
if (ws.readyState === ws.OPEN) ws.send(payload);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
hasSession(sessionId) {
|
|
393
|
+
return this.sessions.has(sessionId);
|
|
394
|
+
}
|
|
395
|
+
/** Answer a planner approval prompt. False means the id was unknown or already settled. */
|
|
396
|
+
resolveApproval(sessionId, approvalId, granted) {
|
|
397
|
+
return this.sessions.get(sessionId)?.resolveApproval(approvalId, granted) ?? false;
|
|
398
|
+
}
|
|
399
|
+
outstandingApprovals(sessionId) {
|
|
400
|
+
return this.sessions.get(sessionId)?.outstandingApprovals() ?? [];
|
|
401
|
+
}
|
|
402
|
+
approvedScopes(sessionId) {
|
|
403
|
+
return this.sessions.get(sessionId)?.approvedScopes() ?? [];
|
|
404
|
+
}
|
|
405
|
+
isExecuting(sessionId) {
|
|
406
|
+
return this.sessions.get(sessionId)?.isExecuting ?? false;
|
|
407
|
+
}
|
|
408
|
+
getPlan(sessionId) {
|
|
409
|
+
return this.sessions.get(sessionId)?.planState ?? null;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* The plan of a session this process is holding, straight from its store —
|
|
413
|
+
* null for an id the pool never adopted. Reading it through the saved file
|
|
414
|
+
* instead answers as if nothing were running.
|
|
415
|
+
*/
|
|
416
|
+
getPlanState(sessionId) {
|
|
417
|
+
return this.sessions.get(sessionId)?.currentPlanState ?? null;
|
|
418
|
+
}
|
|
419
|
+
getGoal(sessionId) {
|
|
420
|
+
return this.sessions.get(sessionId)?.currentGoal ?? "";
|
|
421
|
+
}
|
|
422
|
+
runtimeSettings() {
|
|
423
|
+
return core.sessionRuntimeSettings(this.settingsService.getAll());
|
|
424
|
+
}
|
|
425
|
+
createSessionFor(sessionId, workspace, modelOverride) {
|
|
426
|
+
const config = new WebConfig({
|
|
427
|
+
enabledRunners: this.enabledRunnerOverride,
|
|
428
|
+
modelOverride,
|
|
429
|
+
providerModelLists: this.cachedProviderLists
|
|
430
|
+
});
|
|
431
|
+
const fsAdapter = new PoolFileSystem(workspace);
|
|
432
|
+
const broadcast = (msg) => this.broadcast(sessionId, msg);
|
|
433
|
+
const runner = new PoolAwareRunner(sessionId, broadcast, this.sharedRunner);
|
|
434
|
+
return new core.Session({
|
|
435
|
+
config,
|
|
436
|
+
notifications: { info() {
|
|
437
|
+
}, warn() {
|
|
438
|
+
}, error() {
|
|
439
|
+
}, async confirm() {
|
|
440
|
+
return void 0;
|
|
441
|
+
} },
|
|
442
|
+
runner,
|
|
443
|
+
registry: this.registry,
|
|
444
|
+
workspaceRoot: () => workspace,
|
|
445
|
+
fsAdapter,
|
|
446
|
+
broadcast,
|
|
447
|
+
modelResolver: this.modelResolver,
|
|
448
|
+
settings: () => this.runtimeSettings(),
|
|
449
|
+
sessionId
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
setRunnerEnabled(id, enabled) {
|
|
453
|
+
if (this.enabledRunnerOverride === void 0) {
|
|
454
|
+
this.enabledRunnerOverride = new WebConfig().enabledRunners;
|
|
455
|
+
}
|
|
456
|
+
if (enabled && !this.enabledRunnerOverride.includes(id)) {
|
|
457
|
+
this.enabledRunnerOverride.push(id);
|
|
458
|
+
} else if (!enabled) {
|
|
459
|
+
this.enabledRunnerOverride = this.enabledRunnerOverride.filter((r) => r !== id);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
getSettings() {
|
|
463
|
+
const config = new WebConfig({ enabledRunners: this.enabledRunnerOverride });
|
|
464
|
+
const userSettings = this.settingsService.getAll();
|
|
465
|
+
return {
|
|
466
|
+
orchestratorModel: config.orchestratorModel,
|
|
467
|
+
// Who plans (ADR-0009) — a vendor id, or one of the three harness
|
|
468
|
+
// planners. Surfaces need it to render the planner picker and to know
|
|
469
|
+
// whether the planner model comes from a runner catalog or a vendor one.
|
|
470
|
+
aiProvider: config.aiProvider,
|
|
471
|
+
// A harness planner's thinking effort (ADR-0009). Surfaces need it back
|
|
472
|
+
// to render the current level; without it the picker shows "default"
|
|
473
|
+
// for a planner that is running at "high".
|
|
474
|
+
plannerThinkingEffort: config.plannerThinkingEffort ?? "",
|
|
475
|
+
grillMe: userSettings.grillMe,
|
|
476
|
+
tdd: userSettings.tdd,
|
|
477
|
+
prd: userSettings.prd,
|
|
478
|
+
review: userSettings.review,
|
|
479
|
+
verification: userSettings.verification,
|
|
480
|
+
researchSubagents: userSettings.researchSubagents,
|
|
481
|
+
modelAllowlist: userSettings.modelAllowlist
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
updateSettings(changes) {
|
|
485
|
+
if (typeof changes.orchestratorModel === "string") {
|
|
486
|
+
process.env.ORCHESTRATOR_MODEL = changes.orchestratorModel;
|
|
487
|
+
}
|
|
488
|
+
if (typeof changes.plannerThinkingEffort === "string") {
|
|
489
|
+
process.env.ORDEWELL_PLANNER_EFFORT = changes.plannerThinkingEffort;
|
|
490
|
+
}
|
|
491
|
+
if (changes.grillMe && typeof changes.grillMe.enabled === "boolean") {
|
|
492
|
+
this.settingsService.setGrillMe(changes.grillMe.enabled);
|
|
493
|
+
}
|
|
494
|
+
if (changes.tdd && typeof changes.tdd.enabled === "boolean") {
|
|
495
|
+
this.settingsService.setTdd(changes.tdd.enabled);
|
|
496
|
+
}
|
|
497
|
+
if (changes.prd && typeof changes.prd.enabled === "boolean") {
|
|
498
|
+
this.settingsService.setPrd(changes.prd.enabled);
|
|
499
|
+
}
|
|
500
|
+
if (changes.review && typeof changes.review.enabled === "boolean") {
|
|
501
|
+
this.settingsService.setReview(changes.review.enabled);
|
|
502
|
+
}
|
|
503
|
+
if (changes.verification && typeof changes.verification.enabled === "boolean") {
|
|
504
|
+
this.settingsService.setVerification(changes.verification.enabled);
|
|
505
|
+
}
|
|
506
|
+
if (changes.researchSubagents && typeof changes.researchSubagents.enabled === "boolean") {
|
|
507
|
+
this.settingsService.setResearchSubagents(changes.researchSubagents.enabled);
|
|
508
|
+
}
|
|
509
|
+
if (changes.env && typeof changes.env === "object" && !Array.isArray(changes.env)) {
|
|
510
|
+
const env = changes.env;
|
|
511
|
+
let touched = false;
|
|
512
|
+
for (const [key, value] of Object.entries(env)) {
|
|
513
|
+
if (typeof value === "string" && /^[A-Z_]+$/.test(key)) {
|
|
514
|
+
process.env[key] = value;
|
|
515
|
+
touched = true;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
if (touched) {
|
|
519
|
+
this.modelResolver.invalidate();
|
|
520
|
+
this.modelResolver.refreshRunnerModels();
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (changes.modelAllowlist && typeof changes.modelAllowlist === "object") {
|
|
524
|
+
const allowlist = changes.modelAllowlist;
|
|
525
|
+
for (const [runner, ids] of Object.entries(allowlist)) {
|
|
526
|
+
if (Array.isArray(ids)) {
|
|
527
|
+
this.settingsService.setModelAllowlist(runner, ids.map(String));
|
|
528
|
+
} else if (ids === null) {
|
|
529
|
+
this.settingsService.setModelAllowlist(runner, void 0);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return this.getSettings();
|
|
534
|
+
}
|
|
535
|
+
setOrchestratorModel(model) {
|
|
536
|
+
process.env.ORCHESTRATOR_MODEL = model;
|
|
537
|
+
return model;
|
|
538
|
+
}
|
|
539
|
+
async getProviderModels() {
|
|
540
|
+
const config = new WebConfig();
|
|
541
|
+
const runnerIds = this.registry.list().map((p) => p.manifest.name);
|
|
542
|
+
const [runnerModels, providerLists] = await Promise.all([
|
|
543
|
+
this.modelResolver.modelsForRunners(runnerIds),
|
|
544
|
+
this.modelResolver.refresh()
|
|
545
|
+
]);
|
|
546
|
+
const orchestratorModels = await this.modelResolver.pickerOptions();
|
|
547
|
+
const providerErrors = this.modelResolver.getDiscoveryErrors();
|
|
548
|
+
this.cachedProviderLists = providerLists;
|
|
549
|
+
const allModels = [];
|
|
550
|
+
const seen = /* @__PURE__ */ new Set();
|
|
551
|
+
const modelsByRunner = {};
|
|
552
|
+
for (const id of runnerIds) {
|
|
553
|
+
modelsByRunner[id] = runnerModels[id] ?? [];
|
|
554
|
+
for (const m of modelsByRunner[id]) {
|
|
555
|
+
if (!seen.has(m.modelId)) {
|
|
556
|
+
seen.add(m.modelId);
|
|
557
|
+
allModels.push(m);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
const providers = [];
|
|
562
|
+
for (const provider of core.PROVIDER_PRIORITY) {
|
|
563
|
+
if (provider === "openai_compatible") {
|
|
564
|
+
if (config.openaiCompatibleBaseUrl) providers.push(provider);
|
|
565
|
+
} else if (provider === "openrouter") {
|
|
566
|
+
if (config.openrouterKey) providers.push(provider);
|
|
567
|
+
} else if (provider === "google") {
|
|
568
|
+
if (config.geminiKey) providers.push(provider);
|
|
569
|
+
} else if (config.getProviderApiKey(provider)) {
|
|
570
|
+
providers.push(provider);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return {
|
|
574
|
+
models: allModels,
|
|
575
|
+
modelsByRunner,
|
|
576
|
+
modesByRunner: core.runnerModesFrom(this.registry, runnerIds),
|
|
577
|
+
orchestratorModel: process.env.ORCHESTRATOR_MODEL || "",
|
|
578
|
+
providers,
|
|
579
|
+
orchestratorModels,
|
|
580
|
+
providerErrors
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
getRunnerState() {
|
|
584
|
+
const config = new WebConfig({ enabledRunners: this.enabledRunnerOverride });
|
|
585
|
+
return { enabledRunners: config.enabledRunners, orchestratorModel: config.orchestratorModel };
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Runners whose CLI is actually installed on the host, with display name and
|
|
589
|
+
* enabled state. Only these should be offered for selection — an uninstalled
|
|
590
|
+
* runner can't be spawned.
|
|
591
|
+
*/
|
|
592
|
+
async getInstalledRunners() {
|
|
593
|
+
const config = new WebConfig({ enabledRunners: this.enabledRunnerOverride });
|
|
594
|
+
const enabled = new Set(config.enabledRunners);
|
|
595
|
+
const plugins = this.registry.list();
|
|
596
|
+
const installedIds = new Set(
|
|
597
|
+
await this.runnerInstallation.filterInstalled(plugins.map((p) => p.manifest.name))
|
|
598
|
+
);
|
|
599
|
+
return plugins.filter((p) => installedIds.has(p.manifest.name)).map((p) => ({
|
|
600
|
+
id: p.manifest.name,
|
|
601
|
+
name: p.manifest.displayName,
|
|
602
|
+
enabled: enabled.has(p.manifest.name)
|
|
603
|
+
}));
|
|
604
|
+
}
|
|
605
|
+
async generatePlan(sessionId, goal, runners, workspace, modelOverride) {
|
|
606
|
+
const session = this.createSessionFor(sessionId, workspace, modelOverride);
|
|
607
|
+
this.sessions.set(sessionId, session);
|
|
608
|
+
const plan = await session.generatePlan(goal, runners);
|
|
609
|
+
return core.migratePlanState(plan);
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Conversational planning (ADR-0002): start the planner dialogue for a
|
|
613
|
+
* session. Returns the current plan state — a committed plan when the
|
|
614
|
+
* planner had enough context, otherwise the dialogue so far (last
|
|
615
|
+
* assistant message lives in conversationHistory).
|
|
616
|
+
*/
|
|
617
|
+
async startPlanning(sessionId, goal, runners, workspace, modelOverride) {
|
|
618
|
+
const session = this.createSessionFor(sessionId, workspace, modelOverride);
|
|
619
|
+
this.sessions.set(sessionId, session);
|
|
620
|
+
const plan = await session.startPlanning(goal, runners);
|
|
621
|
+
return plan;
|
|
622
|
+
}
|
|
623
|
+
/** Continue the planner dialogue with the user's reply. */
|
|
624
|
+
async continuePlanning(sessionId, message) {
|
|
625
|
+
return this.session(sessionId).continueConversation(message);
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Register a session persisted on disk so its plan becomes executable and
|
|
629
|
+
* editable. Only sessions planned in this process are otherwise addressable,
|
|
630
|
+
* which left a restored plan readable but inert — every task endpoint
|
|
631
|
+
* answered "Session not found".
|
|
632
|
+
*
|
|
633
|
+
* No LLM call happens here: the plan is adopted as saved, and the planner is
|
|
634
|
+
* only contacted when the user next sends a message.
|
|
635
|
+
*/
|
|
636
|
+
adoptSavedSession(sessionId, workspace) {
|
|
637
|
+
const live = this.sessions.get(sessionId);
|
|
638
|
+
if (live?.planState) return live.planState;
|
|
639
|
+
const saved = core.loadSession(sessionId, workspace);
|
|
640
|
+
if (!saved) throw new Error("Session not found");
|
|
641
|
+
const session = this.createSessionFor(sessionId, workspace);
|
|
642
|
+
session.loadPlan(saved.plan, saved.meta.goal, workspace, { sessionId: saved.meta.id });
|
|
643
|
+
this.sessions.set(sessionId, session);
|
|
644
|
+
return session.planState ?? saved.plan;
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Direct access to a session's interface — routes call the Session; the
|
|
648
|
+
* pool only registers sessions and fans broadcasts out to WS clients.
|
|
649
|
+
*/
|
|
650
|
+
session(sessionId) {
|
|
651
|
+
const session = this.sessions.get(sessionId);
|
|
652
|
+
if (!session) throw new Error("Session not found");
|
|
653
|
+
return session;
|
|
654
|
+
}
|
|
655
|
+
destroy(sessionId) {
|
|
656
|
+
this.sessions.get(sessionId)?.destroy();
|
|
657
|
+
this.sessions.delete(sessionId);
|
|
658
|
+
}
|
|
659
|
+
destroyAll() {
|
|
660
|
+
for (const id of this.sessions.keys()) this.destroy(id);
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
var scanWorkspaces2 = scanWorkspaces;
|
|
664
|
+
function getSessionList(workspace) {
|
|
665
|
+
return core.listSessions(workspace);
|
|
666
|
+
}
|
|
667
|
+
function removeSession(sessionId, workspace) {
|
|
668
|
+
return core.deleteSession(sessionId, workspace);
|
|
669
|
+
}
|
|
670
|
+
function sessionsRoute(pool2) {
|
|
671
|
+
const router = new hono.Hono();
|
|
672
|
+
router.get("/", (c) => {
|
|
673
|
+
const ws = c.req.query("workspace") || process.cwd();
|
|
674
|
+
const sessions = getSessionList(ws);
|
|
675
|
+
return c.json(sessions);
|
|
676
|
+
});
|
|
677
|
+
router.get("/:id", (c) => {
|
|
678
|
+
const ws = c.req.query("workspace") || process.cwd();
|
|
679
|
+
const id = c.req.param("id");
|
|
680
|
+
const saved = core.loadSessionPlanState(id, ws);
|
|
681
|
+
if (!saved) return c.json({ error: "Session not found" }, 404);
|
|
682
|
+
return c.json({ meta: saved.meta, plan: pool2.getPlanState(id) ?? saved.plan });
|
|
683
|
+
});
|
|
684
|
+
router.post("/:id/load", (c) => {
|
|
685
|
+
const ws = c.req.query("workspace") || process.cwd();
|
|
686
|
+
try {
|
|
687
|
+
const id = c.req.param("id");
|
|
688
|
+
const plan = pool2.adoptSavedSession(id, ws);
|
|
689
|
+
return c.json({ ok: true, plan, goal: pool2.getGoal(id) });
|
|
690
|
+
} catch (err) {
|
|
691
|
+
const status = err?.message === "Session not found" ? 404 : 500;
|
|
692
|
+
return c.json({ error: err?.message ?? "Failed to load session" }, status);
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
router.delete("/:id", (c) => {
|
|
696
|
+
const ws = c.req.query("workspace") || process.cwd();
|
|
697
|
+
const ok = removeSession(c.req.param("id"), ws);
|
|
698
|
+
return c.json({ ok });
|
|
699
|
+
});
|
|
700
|
+
router.post("/:id/close", (c) => {
|
|
701
|
+
pool2.destroy(c.req.param("id"));
|
|
702
|
+
return c.json({ ok: true });
|
|
703
|
+
});
|
|
704
|
+
return router;
|
|
705
|
+
}
|
|
706
|
+
function plansRoute(pool2) {
|
|
707
|
+
const router = new hono.Hono();
|
|
708
|
+
router.post("/:sessionId/generate", async (c) => {
|
|
709
|
+
try {
|
|
710
|
+
const { goal, runners, workspace, model } = await c.req.json();
|
|
711
|
+
if (!goal) return c.json({ error: "goal is required" }, 400);
|
|
712
|
+
const ws = workspace || c.req.query("workspace") || process.cwd();
|
|
713
|
+
const queryRunners = c.req.query("runners");
|
|
714
|
+
const runnerList = Array.isArray(runners) ? runners : runners ? [runners] : queryRunners ? queryRunners.split(",").map((s) => s.trim()).filter(Boolean) : pool2.getRunnerState().enabledRunners;
|
|
715
|
+
const plan = await pool2.generatePlan(c.req.param("sessionId"), goal, runnerList, ws, model);
|
|
716
|
+
const { models, modelsByRunner } = await pool2.getProviderModels();
|
|
717
|
+
return c.json({ plan, models, modelsByRunner });
|
|
718
|
+
} catch (err) {
|
|
719
|
+
console.error("[plans] generate failed:", err);
|
|
720
|
+
return c.json({ error: err instanceof Error ? err.message : "Plan generation failed" }, 500);
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
router.post("/:sessionId/execute", async (c) => {
|
|
724
|
+
try {
|
|
725
|
+
await pool2.session(c.req.param("sessionId")).executePlan();
|
|
726
|
+
return c.json({ status: "running" });
|
|
727
|
+
} catch (err) {
|
|
728
|
+
const e = err;
|
|
729
|
+
const status = e.message === "Session not found" ? 404 : e.message === "No plan to execute" ? 400 : e.message === "Session already executing" ? 409 : 500;
|
|
730
|
+
return c.json({ error: e.message }, status);
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
router.post("/:sessionId/stop", (c) => {
|
|
734
|
+
if (pool2.hasSession(c.req.param("sessionId"))) pool2.session(c.req.param("sessionId")).stopExecution();
|
|
735
|
+
return c.json({ status: "stopped" });
|
|
736
|
+
});
|
|
737
|
+
router.post("/:sessionId/tasks/:taskId/complete", async (c) => {
|
|
738
|
+
try {
|
|
739
|
+
await pool2.session(c.req.param("sessionId")).markTaskComplete(c.req.param("taskId"));
|
|
740
|
+
return c.json({ ok: true });
|
|
741
|
+
} catch (err) {
|
|
742
|
+
return c.json({ error: err instanceof Error ? err.message : "Not found" }, 404);
|
|
743
|
+
}
|
|
744
|
+
});
|
|
745
|
+
router.post("/:sessionId/tasks/:taskId/uncomplete", async (c) => {
|
|
746
|
+
try {
|
|
747
|
+
await pool2.session(c.req.param("sessionId")).markTaskIncomplete(c.req.param("taskId"));
|
|
748
|
+
return c.json({ ok: true });
|
|
749
|
+
} catch (err) {
|
|
750
|
+
return c.json({ error: err instanceof Error ? err.message : "Not found" }, 404);
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
for (const [segment, run2] of [
|
|
754
|
+
["run", (s, id) => s.runTask(id)],
|
|
755
|
+
["force-start", (s, id) => s.forceStartTask(id)],
|
|
756
|
+
["retry", (s, id) => s.retryTask(id)],
|
|
757
|
+
["cancel", (s, id) => s.cancelTask(id)]
|
|
758
|
+
]) {
|
|
759
|
+
router.post(`/:sessionId/tasks/:taskId/${segment}`, async (c) => {
|
|
760
|
+
try {
|
|
761
|
+
await run2(pool2.session(c.req.param("sessionId")), c.req.param("taskId"));
|
|
762
|
+
return c.json({ ok: true });
|
|
763
|
+
} catch (err) {
|
|
764
|
+
const status = err.message === "Session not found" ? 404 : 500;
|
|
765
|
+
return c.json({ error: err.message }, status);
|
|
766
|
+
}
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
router.put("/:sessionId/tasks/:taskId", async (c) => {
|
|
770
|
+
try {
|
|
771
|
+
const { assignedRunner, dependencies, ...rest } = await c.req.json();
|
|
772
|
+
const session = pool2.session(c.req.param("sessionId"));
|
|
773
|
+
const taskId = c.req.param("taskId");
|
|
774
|
+
const hasRunner = typeof assignedRunner === "string";
|
|
775
|
+
const hasDeps = Array.isArray(dependencies);
|
|
776
|
+
let result = null;
|
|
777
|
+
if (hasRunner) result = await session.setTaskRunner(taskId, assignedRunner);
|
|
778
|
+
if (hasDeps) {
|
|
779
|
+
try {
|
|
780
|
+
result = session.setTaskDependencies(taskId, dependencies.map(String));
|
|
781
|
+
} catch (err) {
|
|
782
|
+
return c.json({ error: err.message }, 400);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
if (Object.keys(rest).length > 0 || !hasRunner && !hasDeps) result = session.updateTask(taskId, rest);
|
|
786
|
+
if (!result) return c.json({ error: "Task not found" }, 404);
|
|
787
|
+
return c.json({ ok: true });
|
|
788
|
+
} catch (err) {
|
|
789
|
+
return c.json({ error: err instanceof Error ? err.message : "Internal error" }, 500);
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
router.delete("/:sessionId/tasks/:taskId", (c) => {
|
|
793
|
+
const result = pool2.session(c.req.param("sessionId")).removeTask(c.req.param("taskId"));
|
|
794
|
+
if (!result) return c.json({ error: "Task not found" }, 404);
|
|
795
|
+
return c.json({ ok: true });
|
|
796
|
+
});
|
|
797
|
+
router.post("/:sessionId/tasks", async (c) => {
|
|
798
|
+
try {
|
|
799
|
+
const body = await c.req.json();
|
|
800
|
+
const result = await pool2.session(c.req.param("sessionId")).addTask(body);
|
|
801
|
+
if (!result) return c.json({ error: "Session not found" }, 404);
|
|
802
|
+
return c.json({ ok: true });
|
|
803
|
+
} catch (err) {
|
|
804
|
+
return c.json({ error: err instanceof Error ? err.message : "Internal error" }, 500);
|
|
805
|
+
}
|
|
806
|
+
});
|
|
807
|
+
router.post("/:sessionId/review/approve", async (c) => {
|
|
808
|
+
try {
|
|
809
|
+
const plan = await pool2.session(c.req.param("sessionId")).approveReview();
|
|
810
|
+
return c.json({ plan });
|
|
811
|
+
} catch (err) {
|
|
812
|
+
const e = err;
|
|
813
|
+
const status = e.message === "Session not found" ? 404 : e.message === "No plan to review" ? 400 : 500;
|
|
814
|
+
return c.json({ error: e.message }, status);
|
|
815
|
+
}
|
|
816
|
+
});
|
|
817
|
+
router.post("/:sessionId/tasks/merge", async (c) => {
|
|
818
|
+
try {
|
|
819
|
+
const { taskIds } = await c.req.json();
|
|
820
|
+
if (!taskIds || !Array.isArray(taskIds) || taskIds.length < 2) {
|
|
821
|
+
return c.json({ error: "taskIds array with at least two ids is required" }, 400);
|
|
822
|
+
}
|
|
823
|
+
const plan = await pool2.session(c.req.param("sessionId")).requestMerge(taskIds);
|
|
824
|
+
return c.json({ plan });
|
|
825
|
+
} catch (err) {
|
|
826
|
+
const e = err;
|
|
827
|
+
const status = e.message === "Session not found" ? 404 : 400;
|
|
828
|
+
return c.json({ error: e.message }, status);
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
router.post("/:sessionId/tasks/:taskId/split", async (c) => {
|
|
832
|
+
try {
|
|
833
|
+
const plan = await pool2.session(c.req.param("sessionId")).requestSplit(c.req.param("taskId"));
|
|
834
|
+
return c.json({ plan });
|
|
835
|
+
} catch (err) {
|
|
836
|
+
const e = err;
|
|
837
|
+
const status = e.message === "Session not found" ? 404 : 400;
|
|
838
|
+
return c.json({ error: e.message }, status);
|
|
839
|
+
}
|
|
840
|
+
});
|
|
841
|
+
router.post("/:sessionId/converse/start", async (c) => {
|
|
842
|
+
try {
|
|
843
|
+
const { goal, runners, workspace, model } = await c.req.json();
|
|
844
|
+
if (!goal) return c.json({ error: "goal is required" }, 400);
|
|
845
|
+
const ws = workspace || c.req.query("workspace") || process.cwd();
|
|
846
|
+
const runnerList = Array.isArray(runners) ? runners : runners ? [runners] : pool2.getRunnerState().enabledRunners;
|
|
847
|
+
const plan = await pool2.startPlanning(c.req.param("sessionId"), goal, runnerList, ws, model);
|
|
848
|
+
return c.json({ plan });
|
|
849
|
+
} catch (err) {
|
|
850
|
+
return c.json({ error: err instanceof Error ? err.message : "Planning failed" }, 500);
|
|
851
|
+
}
|
|
852
|
+
});
|
|
853
|
+
router.post("/:sessionId/converse/message", async (c) => {
|
|
854
|
+
try {
|
|
855
|
+
const { message } = await c.req.json();
|
|
856
|
+
if (!message) return c.json({ error: "message is required" }, 400);
|
|
857
|
+
const plan = await pool2.continuePlanning(c.req.param("sessionId"), message);
|
|
858
|
+
return c.json({ plan });
|
|
859
|
+
} catch (err) {
|
|
860
|
+
const e = err;
|
|
861
|
+
const status = e.message === "Session not found" ? 404 : 500;
|
|
862
|
+
return c.json({ error: e.message }, status);
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
router.post("/:sessionId/process-queued", async (c) => {
|
|
866
|
+
try {
|
|
867
|
+
await pool2.session(c.req.param("sessionId")).processQueuedMessages();
|
|
868
|
+
return c.json({ ok: true });
|
|
869
|
+
} catch (err) {
|
|
870
|
+
const e = err;
|
|
871
|
+
const status = e.message === "Session not found" ? 404 : 500;
|
|
872
|
+
return c.json({ error: e.message }, status);
|
|
873
|
+
}
|
|
874
|
+
});
|
|
875
|
+
router.get("/:sessionId/prd", (c) => {
|
|
876
|
+
const plan = pool2.getPlan(c.req.param("sessionId"));
|
|
877
|
+
if (!plan?.prdMarkdown) return c.json({ error: "No PRD found" }, 404);
|
|
878
|
+
return c.json({ prdMarkdown: plan.prdMarkdown });
|
|
879
|
+
});
|
|
880
|
+
return router;
|
|
881
|
+
}
|
|
882
|
+
function runnersRoute(pool2) {
|
|
883
|
+
const router = new hono.Hono();
|
|
884
|
+
router.get("/", async (c) => {
|
|
885
|
+
const state = pool2.getRunnerState();
|
|
886
|
+
const runners = await pool2.getInstalledRunners();
|
|
887
|
+
return c.json({
|
|
888
|
+
runners,
|
|
889
|
+
orchestratorModel: state.orchestratorModel
|
|
890
|
+
});
|
|
891
|
+
});
|
|
892
|
+
router.put("/:id", async (c) => {
|
|
893
|
+
const id = c.req.param("id");
|
|
894
|
+
const body = await c.req.json();
|
|
895
|
+
pool2.setRunnerEnabled(id, body.enabled);
|
|
896
|
+
return c.json({ ok: true });
|
|
897
|
+
});
|
|
898
|
+
return router;
|
|
899
|
+
}
|
|
900
|
+
function modelsRoute(pool2) {
|
|
901
|
+
const router = new hono.Hono();
|
|
902
|
+
router.get("/", async (c) => {
|
|
903
|
+
const result = await pool2.getProviderModels();
|
|
904
|
+
return c.json(result);
|
|
905
|
+
});
|
|
906
|
+
return router;
|
|
907
|
+
}
|
|
908
|
+
function workspacesRoute() {
|
|
909
|
+
const router = new hono.Hono();
|
|
910
|
+
router.get("/", (c) => {
|
|
911
|
+
const workspaces = scanWorkspaces2();
|
|
912
|
+
return c.json({ workspaces });
|
|
913
|
+
});
|
|
914
|
+
return router;
|
|
915
|
+
}
|
|
916
|
+
function approvalsRoute(pool2) {
|
|
917
|
+
const router = new hono.Hono();
|
|
918
|
+
router.get("/:sessionId", (c) => {
|
|
919
|
+
const sessionId = c.req.param("sessionId");
|
|
920
|
+
if (!pool2.hasSession(sessionId)) return c.json({ error: `Unknown session: ${sessionId}` }, 404);
|
|
921
|
+
return c.json({
|
|
922
|
+
pending: pool2.outstandingApprovals(sessionId),
|
|
923
|
+
approvedScopes: pool2.approvedScopes(sessionId)
|
|
924
|
+
});
|
|
925
|
+
});
|
|
926
|
+
router.post("/:sessionId/:approvalId", async (c) => {
|
|
927
|
+
const sessionId = c.req.param("sessionId");
|
|
928
|
+
const approvalId = c.req.param("approvalId");
|
|
929
|
+
if (!pool2.hasSession(sessionId)) return c.json({ error: `Unknown session: ${sessionId}` }, 404);
|
|
930
|
+
const body = await c.req.json().catch(() => ({}));
|
|
931
|
+
const granted = body?.granted === true;
|
|
932
|
+
if (!pool2.resolveApproval(sessionId, approvalId, granted)) {
|
|
933
|
+
return c.json({ error: "Approval is no longer outstanding \u2014 it timed out or was already answered." }, 409);
|
|
934
|
+
}
|
|
935
|
+
return c.json({ ok: true, granted });
|
|
936
|
+
});
|
|
937
|
+
return router;
|
|
938
|
+
}
|
|
939
|
+
function settingsRoute(pool2) {
|
|
940
|
+
const router = new hono.Hono();
|
|
941
|
+
router.get("/", (c) => {
|
|
942
|
+
return c.json(pool2.getSettings());
|
|
943
|
+
});
|
|
944
|
+
router.patch("/", async (c) => {
|
|
945
|
+
const body = await c.req.json();
|
|
946
|
+
if (!body || typeof body !== "object" || Object.keys(body).length === 0) {
|
|
947
|
+
return c.json({ error: "At least one setting field is required" }, 400);
|
|
948
|
+
}
|
|
949
|
+
const result = pool2.updateSettings(body);
|
|
950
|
+
return c.json(result);
|
|
951
|
+
});
|
|
952
|
+
return router;
|
|
953
|
+
}
|
|
954
|
+
var COMMANDS = [
|
|
955
|
+
{ name: "grill-me", description: "Toggle Grill-Me challenge mode (on|off|status)" },
|
|
956
|
+
{ name: "tdd", description: "Toggle Test-Driven Development mode (on|off|status)" },
|
|
957
|
+
{ name: "prd", description: "Toggle PRD mode \u2014 planner writes a markdown PRD before the plan (on|off|status)" },
|
|
958
|
+
{ name: "review", description: "Toggle review mode \u2014 adds a final opinion-review task (human sign-off) to the plan (on|off|status)" },
|
|
959
|
+
{ name: "verify", description: "Toggle verification mode \u2014 adds a final evidence-based verification task that runs the full suite (on|off|status)" },
|
|
960
|
+
{ name: "research-subagents", description: "Toggle parallel research subagents \u2014 the planner may fan out read-only research agents during planning (on|off|status)" }
|
|
961
|
+
];
|
|
962
|
+
function commandsRoute(pool2) {
|
|
963
|
+
const router = new hono.Hono();
|
|
964
|
+
router.get("/", (c) => {
|
|
965
|
+
return c.json({ commands: COMMANDS });
|
|
966
|
+
});
|
|
967
|
+
router.post("/:name", async (c) => {
|
|
968
|
+
const name = c.req.param("name");
|
|
969
|
+
const body = await c.req.json().catch(() => ({}));
|
|
970
|
+
const args2 = body?.args || {};
|
|
971
|
+
const command = COMMANDS.find((cmd) => cmd.name === name);
|
|
972
|
+
if (!command) {
|
|
973
|
+
return c.json({ error: `Unknown command: ${name}` }, 404);
|
|
974
|
+
}
|
|
975
|
+
if (name === "grill-me") {
|
|
976
|
+
const action = args2.action || "status";
|
|
977
|
+
if (action === "on") {
|
|
978
|
+
pool2.updateSettings({ grillMe: { enabled: true } });
|
|
979
|
+
} else if (action === "off") {
|
|
980
|
+
pool2.updateSettings({ grillMe: { enabled: false } });
|
|
981
|
+
}
|
|
982
|
+
return c.json({ ok: true, settings: pool2.getSettings() });
|
|
983
|
+
}
|
|
984
|
+
if (name === "tdd") {
|
|
985
|
+
const action = args2.action || "status";
|
|
986
|
+
if (action === "on") {
|
|
987
|
+
pool2.updateSettings({ tdd: { enabled: true } });
|
|
988
|
+
} else if (action === "off") {
|
|
989
|
+
pool2.updateSettings({ tdd: { enabled: false } });
|
|
990
|
+
}
|
|
991
|
+
return c.json({ ok: true, settings: pool2.getSettings() });
|
|
992
|
+
}
|
|
993
|
+
if (name === "prd") {
|
|
994
|
+
const action = args2.action || "status";
|
|
995
|
+
if (action === "on") {
|
|
996
|
+
pool2.updateSettings({ prd: { enabled: true } });
|
|
997
|
+
} else if (action === "off") {
|
|
998
|
+
pool2.updateSettings({ prd: { enabled: false } });
|
|
999
|
+
}
|
|
1000
|
+
return c.json({ ok: true, settings: pool2.getSettings() });
|
|
1001
|
+
}
|
|
1002
|
+
if (name === "review") {
|
|
1003
|
+
const action = args2.action || "status";
|
|
1004
|
+
if (action === "on") {
|
|
1005
|
+
pool2.updateSettings({ review: { enabled: true } });
|
|
1006
|
+
} else if (action === "off") {
|
|
1007
|
+
pool2.updateSettings({ review: { enabled: false } });
|
|
1008
|
+
}
|
|
1009
|
+
return c.json({ ok: true, settings: pool2.getSettings() });
|
|
1010
|
+
}
|
|
1011
|
+
if (name === "verify") {
|
|
1012
|
+
const action = args2.action || "status";
|
|
1013
|
+
if (action === "on") {
|
|
1014
|
+
pool2.updateSettings({ verification: { enabled: true } });
|
|
1015
|
+
} else if (action === "off") {
|
|
1016
|
+
pool2.updateSettings({ verification: { enabled: false } });
|
|
1017
|
+
}
|
|
1018
|
+
return c.json({ ok: true, settings: pool2.getSettings() });
|
|
1019
|
+
}
|
|
1020
|
+
if (name === "research-subagents") {
|
|
1021
|
+
const action = args2.action || "status";
|
|
1022
|
+
if (action === "on") {
|
|
1023
|
+
pool2.updateSettings({ researchSubagents: { enabled: true } });
|
|
1024
|
+
} else if (action === "off") {
|
|
1025
|
+
pool2.updateSettings({ researchSubagents: { enabled: false } });
|
|
1026
|
+
}
|
|
1027
|
+
return c.json({ ok: true, settings: pool2.getSettings() });
|
|
1028
|
+
}
|
|
1029
|
+
return c.json({ error: `Unknown command: ${name}` }, 404);
|
|
1030
|
+
});
|
|
1031
|
+
return router;
|
|
1032
|
+
}
|
|
1033
|
+
function createWsHandler(pool2) {
|
|
1034
|
+
const wss = new ws.WebSocketServer({ noServer: true });
|
|
1035
|
+
wss.on("connection", (ws, request) => {
|
|
1036
|
+
const url = new URL(request.url ?? "", `http://${request.headers.host}`);
|
|
1037
|
+
const sessionId = url.pathname.split("/").pop() || "";
|
|
1038
|
+
ws.on("close", () => {
|
|
1039
|
+
pool2.unsubscribe(sessionId, ws);
|
|
1040
|
+
});
|
|
1041
|
+
ws.on("error", () => {
|
|
1042
|
+
pool2.unsubscribe(sessionId, ws);
|
|
1043
|
+
});
|
|
1044
|
+
ws.send(JSON.stringify({ type: "connected", sessionId }));
|
|
1045
|
+
pool2.subscribe(sessionId, ws);
|
|
1046
|
+
});
|
|
1047
|
+
return (request, socket, head) => {
|
|
1048
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
1049
|
+
wss.emit("connection", ws, request);
|
|
1050
|
+
});
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// server/app.ts
|
|
1055
|
+
function createApp(pool2) {
|
|
1056
|
+
const app2 = new hono.Hono();
|
|
1057
|
+
app2.use("*", cors.cors({
|
|
1058
|
+
origin: ["http://localhost:5173", "http://127.0.0.1:5173", "http://localhost:3742", "http://127.0.0.1:3742"],
|
|
1059
|
+
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
1060
|
+
allowHeaders: ["Content-Type"]
|
|
1061
|
+
}));
|
|
1062
|
+
app2.route("/api/sessions", sessionsRoute(pool2));
|
|
1063
|
+
app2.route("/api/plans", plansRoute(pool2));
|
|
1064
|
+
app2.route("/api/runners", runnersRoute(pool2));
|
|
1065
|
+
app2.route("/api/models", modelsRoute(pool2));
|
|
1066
|
+
app2.route("/api/settings", settingsRoute(pool2));
|
|
1067
|
+
app2.route("/api/commands", commandsRoute(pool2));
|
|
1068
|
+
app2.route("/api/workspaces", workspacesRoute());
|
|
1069
|
+
app2.route("/api/approvals", approvalsRoute(pool2));
|
|
1070
|
+
return app2;
|
|
1071
|
+
}
|
|
1072
|
+
function attachWsHandler(server2, pool2) {
|
|
1073
|
+
const handler = createWsHandler(pool2);
|
|
1074
|
+
server2.on("upgrade", (request, socket, head) => {
|
|
1075
|
+
const url = new URL(request.url ?? "", `http://${request.headers.host}`);
|
|
1076
|
+
if (url.pathname.startsWith("/ws/session/")) {
|
|
1077
|
+
handler(request, socket, head);
|
|
1078
|
+
} else {
|
|
1079
|
+
socket.destroy();
|
|
1080
|
+
}
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
var args = process.argv.slice(2);
|
|
1084
|
+
var port = 3742;
|
|
1085
|
+
var portIdx = args.indexOf("--port");
|
|
1086
|
+
if (portIdx !== -1 && args[portIdx + 1]) {
|
|
1087
|
+
port = parseInt(args[portIdx + 1], 10) || 3742;
|
|
1088
|
+
}
|
|
1089
|
+
var tmuxRunner = core.hasTmux() ? new core.TmuxRunner({ port }) : void 0;
|
|
1090
|
+
if (tmuxRunner) {
|
|
1091
|
+
tmuxRunner.ensureSession().catch((err) => {
|
|
1092
|
+
console.error(`[web] tmux session setup failed (will retry on next task spawn): ${err?.message ?? err}`);
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
var pool = new OrchestratorPool({ runner: tmuxRunner });
|
|
1096
|
+
var app = createApp(pool);
|
|
1097
|
+
pool.getProviderModels().catch((err) => {
|
|
1098
|
+
console.error(`[web] model discovery warmup failed: ${err?.message ?? err}`);
|
|
1099
|
+
});
|
|
1100
|
+
app.get("/", (c) => c.json({ message: "Ordewell API", docs: "/api/workspaces" }));
|
|
1101
|
+
var server = http.createServer(async (req, res) => {
|
|
1102
|
+
const bufs = [];
|
|
1103
|
+
for await (const chunk of req) bufs.push(chunk);
|
|
1104
|
+
const body = bufs.length > 0 ? Buffer.concat(bufs).toString() : void 0;
|
|
1105
|
+
const url = `http://${req.headers.host || "localhost"}${req.url}`;
|
|
1106
|
+
const headers = new Headers();
|
|
1107
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
1108
|
+
if (v) {
|
|
1109
|
+
if (Array.isArray(v)) v.forEach((vv) => headers.append(k, vv));
|
|
1110
|
+
else headers.set(k, v);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
if (body && !headers.has("content-type")) headers.set("content-type", "application/json");
|
|
1114
|
+
const webReq = new Request(url, {
|
|
1115
|
+
method: req.method || "GET",
|
|
1116
|
+
headers,
|
|
1117
|
+
body: req.method !== "GET" && req.method !== "HEAD" ? body : void 0
|
|
1118
|
+
});
|
|
1119
|
+
try {
|
|
1120
|
+
const webRes = await app.fetch(webReq);
|
|
1121
|
+
res.writeHead(webRes.status, Object.fromEntries(webRes.headers.entries()));
|
|
1122
|
+
if (webRes.body) {
|
|
1123
|
+
const reader = webRes.body.getReader();
|
|
1124
|
+
while (true) {
|
|
1125
|
+
const { done, value } = await reader.read();
|
|
1126
|
+
if (done) break;
|
|
1127
|
+
res.write(value);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
res.end();
|
|
1131
|
+
} catch (err) {
|
|
1132
|
+
if (!res.headersSent) {
|
|
1133
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1134
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : "Internal server error" }));
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
});
|
|
1138
|
+
attachWsHandler(server, pool);
|
|
1139
|
+
server.listen(port, "127.0.0.1", () => {
|
|
1140
|
+
console.error(`[web] Ordewell API server: http://localhost:${port}`);
|
|
1141
|
+
});
|
|
1142
|
+
async function shutdown() {
|
|
1143
|
+
pool.destroyAll();
|
|
1144
|
+
if (tmuxRunner) await tmuxRunner.killSession().catch(() => {
|
|
1145
|
+
});
|
|
1146
|
+
server.close();
|
|
1147
|
+
process.exit(0);
|
|
1148
|
+
}
|
|
1149
|
+
process.on("SIGINT", () => {
|
|
1150
|
+
shutdown();
|
|
1151
|
+
});
|
|
1152
|
+
process.on("SIGTERM", () => {
|
|
1153
|
+
shutdown();
|
|
1154
|
+
});
|
|
1155
|
+
process.on("SIGHUP", () => {
|
|
1156
|
+
shutdown();
|
|
1157
|
+
});
|
|
1158
|
+
//# sourceMappingURL=main.js.map
|
|
1159
|
+
//# sourceMappingURL=main.js.map
|